Skip to main content

stasis/dashboard/
service.rs

1use std::collections::HashSet;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use chrono::Utc;
6
7use crate::application::dto::{
8    ClusterNodeHealthRow, EndpointDiagnosticsReadModelRow,
9    ListClusterNodeHealthRequest, ListEndpointDiagnosticsReadModelRequest,
10    ListEndpointFailureRateTrendsRequest,
11};
12use crate::application::runtime::runtime_factory::{RuntimeComposition, RuntimeFactory};
13use crate::application::runtime::in_memory_runtime::InMemoryRuntime;
14use crate::dashboard::dto::{
15    AttemptInspectorDto, ClusterMapDto, DashboardDto, EndpointRowDto, EventInspectorDto,
16    InspectorView, JobInspectorDto, JobRowDto, OutboxEventRowDto, RecurringDefinitionRowDto,
17    SystemKpiDto, UiListPanel,
18};
19use crate::dashboard::mappers::{
20    map_cluster_health_row, map_endpoint_inspector, map_endpoint_row, map_job_to_row,
21    map_node_inspector, map_outbox_to_row, map_recurring_definition_row,
22};
23use crate::domain::errors::{Result, StasisError};
24use crate::domain::runtime::job::JobState;
25use crate::domain::runtime::outbox::OutboxEvent;
26use crate::domain::runtime::workflow_definition::{WorkflowDefinition, WorkflowRevision};
27use crate::infrastructure::runtime::grapheme_sdk_workflow_reflection::GraphemeSdkWorkflowReflection;
28use crate::infrastructure::runtime::composite_control_plane_store::CompositeControlPlaneStore;
29use crate::infrastructure::runtime::in_memory_cluster_node_store::InMemoryClusterNodeStore;
30use crate::infrastructure::runtime::in_memory_delivery_endpoint_store::InMemoryDeliveryEndpointStore;
31use crate::infrastructure::runtime::in_memory_endpoint_delivery_status_store::InMemoryEndpointDeliveryStatusStore;
32use crate::infrastructure::runtime::in_memory_workflow_definition_store::InMemoryWorkflowDefinitionStore;
33use crate::infrastructure::runtime::surreal_cluster_node_store::SurrealClusterNodeStore;
34use crate::infrastructure::runtime::surreal_delivery_endpoint_store::SurrealDeliveryEndpointStore;
35use crate::infrastructure::runtime::surreal_endpoint_delivery_status_store::SurrealEndpointDeliveryStatusStore;
36use crate::infrastructure::runtime::surreal_workflow_definition_store::SurrealWorkflowDefinitionStore;
37use crate::ports::outbound::runtime::job_store::JobStore;
38use crate::ports::outbound::runtime::recurring_store::RecurringStore;
39use crate::ports::outbound::runtime::workflow_definition_store::WorkflowDefinitionStore;
40use crate::ports::outbound::runtime::workflow_engine::WorkflowEngine;
41use crate::ports::outbound::runtime::workflow_reflection::{
42    WorkflowModuleInfoReflection, WorkflowModuleSearchReflection,
43    WorkflowModuleTypesReflection, WorkflowReflectionPort, WorkflowSourceReflection,
44};
45use crate::sdk::control_plane_sdk::ControlPlaneSdk;
46
47type DashboardControlStore =
48    CompositeControlPlaneStore<InMemoryDeliveryEndpointStore, InMemoryClusterNodeStore>;
49type DashboardControlPlane = ControlPlaneSdk<DashboardControlStore>;
50type DashboardSurrealControlStore =
51    CompositeControlPlaneStore<SurrealDeliveryEndpointStore, SurrealClusterNodeStore>;
52type DashboardSurrealControlPlane = ControlPlaneSdk<DashboardSurrealControlStore>;
53
54#[derive(Clone)]
55enum DashboardControlPlaneKind {
56    InMemory(DashboardControlPlane),
57    Surreal(DashboardSurrealControlPlane),
58}
59
60#[derive(Clone, Debug)]
61pub enum InspectEntity {
62    Job(String),
63    Attempt(String),
64    Node(String),
65    Endpoint(String),
66    Event(String),
67}
68
69#[derive(Clone, Debug)]
70pub struct WorkflowSaveRequest {
71    pub workflow_id: String,
72    pub queue: String,
73    pub source: String,
74    pub compile_mode_hint: Option<String>,
75    pub graph_state_json: Option<String>,
76    pub graph_modules_csv: Option<String>,
77    pub graph_function_steps_csv: Option<String>,
78    pub graph_function_inputs_json: Option<String>,
79}
80
81#[derive(Clone, Debug)]
82pub struct WorkflowSaveResult {
83    pub workflow_id: String,
84    pub queue: String,
85    pub revision_id: String,
86    pub executable_count: usize,
87}
88
89#[derive(Clone, Debug)]
90pub struct WorkflowExecuteResult {
91    pub workflow_id: String,
92    pub queue: String,
93    pub revision_id: String,
94    pub executable_count: usize,
95    pub graph_function_steps_csv: String,
96    pub graph_function_inputs_json: String,
97    pub source_bytes: usize,
98    pub reflected_at_utc: String,
99    pub leased_job_id: Option<String>,
100}
101
102#[derive(Clone, Debug)]
103pub struct WorkflowRunDraftRequest {
104    pub workflow_id: String,
105    pub queue: String,
106    pub source: String,
107    pub graph_state_json: Option<String>,
108    pub graph_modules_csv: Option<String>,
109    pub graph_function_steps_csv: Option<String>,
110    pub graph_function_inputs_json: Option<String>,
111}
112
113#[derive(Clone, Debug)]
114pub struct WorkflowRunDraftResult {
115    pub workflow_id: String,
116    pub queue: String,
117    pub executable_count: usize,
118    pub graph_modules_csv: String,
119    pub graph_function_steps_csv: String,
120    pub graph_function_inputs_json: String,
121    pub source_bytes: usize,
122    pub run_id: String,
123    pub execution_json: String,
124    pub final_state_json: String,
125}
126
127#[derive(Clone, Debug)]
128pub struct WorkflowSavedRevisionSummary {
129    pub workflow_id: String,
130    pub revision_id: String,
131    pub executable_count: usize,
132    pub reflected_at_utc: String,
133    pub compile_mode: String,
134    pub source: String,
135    pub source_bytes: usize,
136    pub graph_state_json: String,
137    pub graph_modules_csv: String,
138    pub graph_function_steps_csv: String,
139    pub graph_function_inputs_json: String,
140}
141
142#[derive(Clone, Debug, PartialEq, Eq)]
143pub enum WorkflowDiagnosticSeverity {
144    Error,
145    Warning,
146    Info,
147}
148
149#[derive(Clone, Debug, PartialEq, Eq)]
150pub struct WorkflowDiagnostic {
151    pub severity: WorkflowDiagnosticSeverity,
152    pub message: String,
153    pub code: Option<String>,
154    pub line: Option<usize>,
155    pub column: Option<usize>,
156}
157
158#[derive(Clone, Debug, PartialEq, Eq)]
159pub struct WorkflowDiagnosticsResult {
160    pub enabled: bool,
161    pub provider: String,
162    pub summary: String,
163    pub diagnostics: Vec<WorkflowDiagnostic>,
164}
165
166fn parse_leading_usize(input: &str) -> Option<(usize, &str)> {
167    let digits_len = input.chars().take_while(|ch| ch.is_ascii_digit()).count();
168    if digits_len == 0 {
169        return None;
170    }
171
172    let (digits, tail) = input.split_at(digits_len);
173    Some((digits.parse().ok()?, tail))
174}
175
176fn extract_line_column(message: &str) -> (Option<usize>, Option<usize>) {
177    if let Some(anchor_index) = message.find("-->") {
178        let tail = message[(anchor_index + 3)..].trim_start();
179        if let Some((line, rest)) = parse_leading_usize(tail) {
180            let rest = rest.trim_start();
181            if let Some(stripped) = rest.strip_prefix(':') {
182                let stripped = stripped.trim_start();
183                if let Some((column, _)) = parse_leading_usize(stripped) {
184                    return (Some(line), Some(column));
185                }
186            }
187        }
188    }
189
190    for marker in ["line ", "Line "] {
191        if let Some(anchor_index) = message.find(marker) {
192            let tail = &message[(anchor_index + marker.len())..];
193            if let Some((line, rest)) = parse_leading_usize(tail) {
194                let rest_lower = rest.to_ascii_lowercase();
195                if let Some(column_anchor) = rest_lower.find("column ") {
196                    let col_tail = &rest[(column_anchor + "column ".len())..];
197                    if let Some((column, _)) = parse_leading_usize(col_tail) {
198                        return (Some(line), Some(column));
199                    }
200                }
201
202                return (Some(line), None);
203            }
204        }
205    }
206
207    (None, None)
208}
209
210fn reflection_code_for_error(message: &str) -> &'static str {
211    let lowered = message.to_ascii_lowercase();
212    if lowered.contains("capability") {
213        "REFLECTION_CAPABILITY"
214    } else if lowered.contains("schema")
215        || lowered.contains("type")
216        || lowered.contains("state machine")
217    {
218        "REFLECTION_SCHEMA"
219    } else {
220        "REFLECTION"
221    }
222}
223
224fn normalize_graph_modules_csv(raw: Option<&str>) -> String {
225    let Some(raw) = raw else {
226        return String::new();
227    };
228
229    let mut seen = HashSet::new();
230    let mut normalized = Vec::new();
231
232    for part in raw.split(',') {
233        let module = part.trim().to_ascii_lowercase();
234        let is_allowed = matches!(
235            module.as_str(),
236            "core"
237                | "html"
238                | "json"
239                | "csv"
240                | "yaml"
241                | "docs"
242                | "io"
243                | "http"
244                | "web"
245                | "websearch"
246                | "tcp"
247                | "smtp"
248                | "sql"
249                | "surreal"
250                | "memory"
251                | "runtime"
252                | "secrets"
253                | "textops"
254                | "healthcheck"
255        );
256
257        if is_allowed && seen.insert(module.clone()) {
258            normalized.push(module);
259        }
260    }
261
262    normalized.join(",")
263}
264
265fn normalize_graph_function_steps_csv(raw: Option<&str>) -> String {
266    let Some(raw) = raw else {
267        return String::new();
268    };
269
270    let mut seen = HashSet::new();
271    let mut normalized = Vec::new();
272
273    for part in raw.split(',') {
274        let token = part.trim().to_ascii_lowercase();
275        let Some((module_id, function_id)) = token.split_once('.') else {
276            continue;
277        };
278        let module_id = module_id.trim();
279        let function_id = function_id.trim();
280
281        if !matches!(
282            module_id,
283            "core"
284                | "html"
285                | "json"
286                | "csv"
287                | "yaml"
288                | "docs"
289                | "io"
290                | "http"
291                | "web"
292                | "websearch"
293                | "tcp"
294                | "smtp"
295                | "sql"
296                | "surreal"
297                | "memory"
298                | "runtime"
299                | "secrets"
300                | "textops"
301                | "healthcheck"
302        ) {
303            continue;
304        }
305        if function_id.is_empty()
306            || !function_id
307                .chars()
308                .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
309        {
310            continue;
311        }
312
313        let normalized_token = format!("{module_id}.{function_id}");
314        if seen.insert(normalized_token.clone()) {
315            normalized.push(normalized_token);
316        }
317    }
318
319    normalized.join(",")
320}
321
322fn normalize_graph_function_inputs_json(raw: Option<&str>) -> String {
323    let Some(raw) = raw else {
324        return "{}".to_string();
325    };
326
327    let parsed = serde_json::from_str::<serde_json::Value>(raw);
328    let Ok(value) = parsed else {
329        return "{}".to_string();
330    };
331    let Some(obj) = value.as_object() else {
332        return "{}".to_string();
333    };
334
335    let mut normalized = serde_json::Map::new();
336    for (key, value) in obj {
337        if key.trim().is_empty() {
338            continue;
339        }
340        if let Some(payload) = value.as_str() {
341            normalized.insert(key.clone(), serde_json::Value::String(payload.to_string()));
342        }
343    }
344
345    serde_json::Value::Object(normalized).to_string()
346}
347
348fn normalize_graph_state_json(raw: Option<&str>) -> String {
349    let Some(raw) = raw else {
350        return "{}".to_string();
351    };
352
353    let parsed = serde_json::from_str::<serde_json::Value>(raw);
354    let Ok(value) = parsed else {
355        return "{}".to_string();
356    };
357
358    value.to_string()
359}
360
361fn validate_compile_graph_state_contract(
362    graph: &serde_json::Map<String, serde_json::Value>,
363) -> Result<()> {
364    let query = graph
365        .get("query")
366        .and_then(|value| value.as_object())
367        .ok_or_else(|| {
368            StasisError::PortFailure(
369                "graph_state compile contract requires query.steps".to_string(),
370            )
371        })?;
372    let steps = query
373        .get("steps")
374        .and_then(|value| value.as_array())
375        .ok_or_else(|| {
376            StasisError::PortFailure(
377                "graph_state compile contract requires query.steps".to_string(),
378            )
379        })?;
380
381    if steps.is_empty() {
382        return Err(StasisError::PortFailure(
383            "graph_state compile contract requires at least one query step".to_string(),
384        ));
385    }
386
387    if let Some(iterators) = graph.get("iterators") {
388        let iterators = iterators.as_array().ok_or_else(|| {
389            StasisError::PortFailure(
390                "graph_state compile contract requires iterators to be an array"
391                    .to_string(),
392            )
393        })?;
394
395        for (index, iterator) in iterators.iter().enumerate() {
396            let iterator_obj = iterator.as_object().ok_or_else(|| {
397                StasisError::PortFailure(format!(
398                    "graph_state compile contract iterator[{index}] must be an object"
399                ))
400            })?;
401            let loop_obj = iterator_obj
402                .get("loop")
403                .and_then(|value| value.as_object())
404                .ok_or_else(|| {
405                    StasisError::PortFailure(format!(
406                        "graph_state compile contract iterator[{index}] requires loop"
407                    ))
408                })?;
409
410            let max = loop_obj
411                .get("max")
412                .and_then(|value| value.as_u64())
413                .ok_or_else(|| {
414                    StasisError::PortFailure(format!(
415                        "graph_state compile contract iterator[{index}] requires bounded loop.max"
416                    ))
417                })?;
418            if max == 0 {
419                return Err(StasisError::PortFailure(format!(
420                    "graph_state compile contract iterator[{index}] requires bounded loop.max"
421                )));
422            }
423
424            let each = loop_obj
425                .get("each")
426                .and_then(|value| value.as_str())
427                .map(str::trim)
428                .unwrap_or("");
429            if each.is_empty() || !each.starts_with('$') {
430                return Err(StasisError::PortFailure(format!(
431                    "graph_state compile contract iterator[{index}] requires loop.each path"
432                )));
433            }
434        }
435    }
436
437    Ok(())
438}
439
440fn validate_topology_graph_state_contract(
441    graph: &serde_json::Map<String, serde_json::Value>,
442) -> Result<()> {
443    let nodes = graph
444        .get("nodes")
445        .and_then(|value| value.as_array())
446        .ok_or_else(|| {
447            StasisError::PortFailure(
448                "graph_state topology contract requires nodes array".to_string(),
449            )
450        })?;
451    let _edges = graph
452        .get("edges")
453        .and_then(|value| value.as_array())
454        .ok_or_else(|| {
455            StasisError::PortFailure(
456                "graph_state topology contract requires edges array".to_string(),
457            )
458        })?;
459
460    for (index, node) in nodes.iter().enumerate() {
461        let node_id = node
462            .as_object()
463            .and_then(|value| value.get("id"))
464            .and_then(|value| value.as_str())
465            .map(str::trim)
466            .unwrap_or("");
467        if node_id.is_empty() {
468            return Err(StasisError::PortFailure(format!(
469                "graph_state topology contract node[{index}] requires id"
470            )));
471        }
472    }
473
474    Ok(())
475}
476
477fn validate_and_normalize_graph_state_json(raw: Option<&str>) -> Result<String> {
478    let normalized = normalize_graph_state_json(raw);
479    if normalized == "{}" {
480        return Ok(normalized);
481    }
482
483    let value = serde_json::from_str::<serde_json::Value>(&normalized).map_err(|err| {
484        StasisError::PortFailure(format!(
485            "graph_state must be valid JSON object: {err}"
486        ))
487    })?;
488    let graph = value.as_object().ok_or_else(|| {
489        StasisError::PortFailure("graph_state must be a JSON object".to_string())
490    })?;
491
492    let has_compile_shape = graph.contains_key("query") || graph.contains_key("iterators");
493    let has_topology_shape =
494        graph.contains_key("nodes") || graph.contains_key("edges") || graph.contains_key("version");
495
496    if has_compile_shape {
497        validate_compile_graph_state_contract(graph)?;
498    }
499    if has_topology_shape {
500        validate_topology_graph_state_contract(graph)?;
501    }
502
503    Ok(normalized)
504}
505
506fn graph_state_contains_compile_shape(graph_state_json: &str) -> bool {
507    let parsed = serde_json::from_str::<serde_json::Value>(graph_state_json);
508    let Ok(value) = parsed else {
509        return false;
510    };
511    let Some(graph) = value.as_object() else {
512        return false;
513    };
514
515    graph.contains_key("query") || graph.contains_key("iterators")
516}
517
518fn normalize_compile_mode_hint(raw: Option<&str>) -> Option<String> {
519    let value = raw.map(str::trim).unwrap_or_default();
520    match value {
521        "graph_compiled" | "legacy_function_steps" | "source_passthrough" => {
522            Some(value.to_string())
523        }
524        _ => None,
525    }
526}
527
528#[async_trait]
529pub trait DashboardQueryService: Send + Sync {
530    async fn dashboard(&self, inspect: Option<InspectEntity>) -> Result<DashboardDto>;
531    async fn jobs_stream(&self) -> Result<UiListPanel<JobRowDto>>;
532    async fn outbox_stream(&self) -> Result<UiListPanel<OutboxEventRowDto>>;
533    async fn endpoint_stream(&self) -> Result<UiListPanel<EndpointRowDto>>;
534    async fn recurring_stream(&self) -> Result<UiListPanel<RecurringDefinitionRowDto>>;
535    async fn cluster_stream(&self) -> Result<ClusterMapDto>;
536    async fn scheduler_materialize_now(&self, scheduler_id: &str) -> Result<usize>;
537    async fn scheduler_process_queue_once(
538        &self,
539        queue: &str,
540        worker_id: &str,
541    ) -> Result<Option<String>>;
542    async fn scheduler_publish_pending_now(&self, limit: usize) -> Result<usize>;
543    async fn scheduler_replay_dead_letter_now(&self, job_id: &str) -> Result<bool>;
544    async fn workflow_save(&self, request: WorkflowSaveRequest) -> Result<WorkflowSaveResult>;
545    async fn workflow_execute(
546        &self,
547        workflow_id: &str,
548        queue: &str,
549        worker_id: &str,
550    ) -> Result<WorkflowExecuteResult>;
551    async fn workflow_run_draft(
552        &self,
553        request: WorkflowRunDraftRequest,
554    ) -> Result<WorkflowRunDraftResult>;
555    async fn workflow_reflect_source(&self, source: &str) -> Result<WorkflowSourceReflection>;
556    async fn workflow_modules_search(&self, query: &str) -> Result<WorkflowModuleSearchReflection>;
557    async fn workflow_module_info(&self, module_id: &str) -> Result<Option<WorkflowModuleInfoReflection>>;
558    async fn workflow_module_types(&self, module_id: &str) -> Result<Option<WorkflowModuleTypesReflection>>;
559    async fn workflow_saved_revision_summary(
560        &self,
561        workflow_id: &str,
562    ) -> Result<Option<WorkflowSavedRevisionSummary>>;
563    async fn workflow_lsp_diagnostics(&self, source: &str) -> Result<WorkflowDiagnosticsResult>;
564    async fn inspect(&self, entity: InspectEntity) -> Result<InspectorView>;
565}
566
567#[derive(Clone)]
568pub struct RuntimeDashboardQueryService {
569    runtime: RuntimeComposition,
570    control_plane: DashboardControlPlaneKind,
571    workflow_reflection: Arc<dyn WorkflowReflectionPort>,
572    workflow_store: Arc<dyn WorkflowDefinitionStore>,
573    workflow_engine: Arc<dyn WorkflowEngine>,
574}
575
576impl RuntimeDashboardQueryService {
577    pub fn new(runtime: Arc<InMemoryRuntime>, control_plane: DashboardControlPlane) -> Self {
578        Self {
579            runtime: RuntimeComposition::InMemory(runtime.as_ref().clone()),
580            control_plane: DashboardControlPlaneKind::InMemory(control_plane),
581            workflow_reflection: Arc::new(GraphemeSdkWorkflowReflection::new()),
582            workflow_store: Arc::new(InMemoryWorkflowDefinitionStore::default()),
583            workflow_engine: RuntimeFactory::default_workflow_engine(),
584        }
585    }
586
587    pub fn from_runtime_composition(runtime: RuntimeComposition) -> Self {
588        match runtime {
589            RuntimeComposition::InMemory(rt) => {
590                let endpoint_store = InMemoryDeliveryEndpointStore::default();
591                let cluster_store = InMemoryClusterNodeStore::default();
592                let status_store = Arc::new(InMemoryEndpointDeliveryStatusStore::default());
593                let control_store = CompositeControlPlaneStore::new(endpoint_store, cluster_store);
594                let control_plane =
595                    ControlPlaneSdk::new_with_status_store(control_store, status_store);
596
597                Self {
598                    runtime: RuntimeComposition::InMemory(rt),
599                    control_plane: DashboardControlPlaneKind::InMemory(control_plane),
600                    workflow_reflection: Arc::new(GraphemeSdkWorkflowReflection::new()),
601                    workflow_store: Arc::new(InMemoryWorkflowDefinitionStore::default()),
602                    workflow_engine: RuntimeFactory::default_workflow_engine(),
603                }
604            }
605            RuntimeComposition::Surreal(rt) => {
606                let endpoint_store = SurrealDeliveryEndpointStore::new(rt.job_store.db());
607                let cluster_store = SurrealClusterNodeStore::new(rt.job_store.db());
608                let status_store = Arc::new(SurrealEndpointDeliveryStatusStore::new(rt.job_store.db()));
609                let control_store = CompositeControlPlaneStore::new(endpoint_store, cluster_store);
610                let control_plane =
611                    ControlPlaneSdk::new_with_status_store(control_store, status_store);
612
613                let workflow_store: Arc<dyn WorkflowDefinitionStore> =
614                    Arc::new(SurrealWorkflowDefinitionStore::new(rt.job_store.db()));
615
616                Self {
617                    runtime: RuntimeComposition::Surreal(rt),
618                    control_plane: DashboardControlPlaneKind::Surreal(control_plane),
619                    workflow_reflection: Arc::new(GraphemeSdkWorkflowReflection::new()),
620                    workflow_store,
621                    workflow_engine: RuntimeFactory::default_workflow_engine(),
622                }
623            }
624        }
625    }
626
627    async fn load_workflow_definition(&self, workflow_id: &str) -> Result<WorkflowDefinition> {
628        self.workflow_store
629            .get_definition(workflow_id)
630            .await?
631            .ok_or_else(|| {
632                StasisError::PortFailure(format!("workflow definition not found: {workflow_id}"))
633            })
634    }
635
636    async fn list_cluster_node_health_rows(&self) -> Result<Vec<ClusterNodeHealthRow>> {
637        let request = ListClusterNodeHealthRequest {
638            role: None,
639            region: None,
640            capability_tag: None,
641            queue: None,
642            health: None,
643            offset: 0,
644            limit: Some(200),
645        };
646
647        match &self.control_plane {
648            DashboardControlPlaneKind::InMemory(control_plane) => {
649                control_plane.list_cluster_node_health(request).await
650            }
651            DashboardControlPlaneKind::Surreal(control_plane) => {
652                control_plane.list_cluster_node_health(request).await
653            }
654        }
655    }
656
657    async fn list_endpoint_diagnostics_rows(
658        &self,
659        endpoint_ids: Option<Vec<String>>,
660        limit: Option<usize>,
661    ) -> Result<Vec<EndpointDiagnosticsReadModelRow>> {
662        let request = ListEndpointDiagnosticsReadModelRequest {
663            endpoint_ids,
664            protocol: None,
665            min_failure_count: None,
666            stale_after_seconds: None,
667            unhealthy_only: false,
668            include_disabled: true,
669            offset: 0,
670            limit,
671        };
672
673        match &self.control_plane {
674            DashboardControlPlaneKind::InMemory(control_plane) => {
675                control_plane.list_endpoint_diagnostics_read_model(request).await
676            }
677            DashboardControlPlaneKind::Surreal(control_plane) => {
678                control_plane.list_endpoint_diagnostics_read_model(request).await
679            }
680        }
681    }
682
683    async fn list_endpoint_failure_rate_trends(&self) -> Vec<crate::application::dto::EndpointFailureRateTrendRow> {
684        let request = ListEndpointFailureRateTrendsRequest {
685            protocol: None,
686            include_disabled: true,
687            min_total_attempts: None,
688            limit: 100,
689        };
690
691        match &self.control_plane {
692            DashboardControlPlaneKind::InMemory(control_plane) => control_plane
693                .list_endpoint_failure_rate_trends(request)
694                .await
695                .unwrap_or_default(),
696            DashboardControlPlaneKind::Surreal(control_plane) => control_plane
697                .list_endpoint_failure_rate_trends(request)
698                .await
699                .unwrap_or_default(),
700        }
701    }
702
703    async fn list_job_attempts(&self, job_id: &str) -> Result<Vec<crate::domain::runtime::job_attempt::JobAttempt>> {
704        match &self.runtime {
705            RuntimeComposition::InMemory(rt) => rt.list_job_attempts(job_id).await,
706            RuntimeComposition::Surreal(rt) => rt.list_job_attempts(job_id).await,
707        }
708    }
709
710    async fn list_lineage_events_by_job(
711        &self,
712        job_id: &str,
713    ) -> Result<Vec<OutboxEvent>> {
714        match &self.runtime {
715            RuntimeComposition::InMemory(rt) => rt.list_lineage_events(job_id).await,
716            RuntimeComposition::Surreal(rt) => rt.list_lineage_events(job_id).await,
717        }
718    }
719
720    async fn list_all_jobs(&self) -> Result<Vec<crate::domain::runtime::job::Job>> {
721        let states = [
722            JobState::Enqueued,
723            JobState::Leased,
724            JobState::Running,
725            JobState::Succeeded,
726            JobState::Failed,
727            JobState::DeadLetter,
728            JobState::Canceled,
729        ];
730
731        let mut jobs = Vec::new();
732        for state in states {
733            let mut by_state = match &self.runtime {
734                RuntimeComposition::InMemory(rt) => rt.job_store.list_by_state(state).await?,
735                RuntimeComposition::Surreal(rt) => rt.job_store.list_by_state(state).await?,
736            };
737            jobs.append(&mut by_state);
738        }
739
740        jobs.sort_by(|left, right| {
741            right
742                .scheduled_at
743                .cmp(&left.scheduled_at)
744                .then_with(|| left.id.cmp(&right.id))
745        });
746        Ok(jobs)
747    }
748
749    async fn list_all_outbox_events(&self) -> Result<Vec<OutboxEvent>> {
750        let jobs = self.list_all_jobs().await?;
751        let mut seen = HashSet::new();
752        let mut out = Vec::new();
753
754        for job in jobs {
755            for event in self.list_lineage_events_by_job(&job.id).await? {
756                if seen.insert(event.event_id.clone()) {
757                    out.push(event);
758                }
759            }
760        }
761
762        out.sort_by(|left, right| {
763            right
764                .event
765                .occurred_at
766                .cmp(&left.event.occurred_at)
767                .then_with(|| left.event_id.cmp(&right.event_id))
768        });
769        Ok(out)
770    }
771}
772
773#[async_trait]
774impl DashboardQueryService for RuntimeDashboardQueryService {
775    async fn dashboard(&self, inspect: Option<InspectEntity>) -> Result<DashboardDto> {
776        let jobs = self.jobs_stream().await?;
777        let outbox = self.outbox_stream().await?;
778        let cluster = self.cluster_stream().await?;
779        let healthy_nodes = cluster
780            .nodes
781            .iter()
782            .filter(|node| node.health == "Healthy")
783            .count();
784        let degraded_nodes = cluster
785            .nodes
786            .iter()
787            .filter(|node| node.health == "Degraded")
788            .count();
789        let offline_nodes = cluster
790            .nodes
791            .iter()
792            .filter(|node| node.health == "Offline")
793            .count();
794
795        let running_jobs = jobs
796            .items
797            .iter()
798            .filter(|job| job.status == "running")
799            .count();
800        let enqueued_jobs = jobs
801            .items
802            .iter()
803            .filter(|job| job.status == "enqueued")
804            .count();
805        let succeeded_jobs = jobs
806            .items
807            .iter()
808            .filter(|job| job.status == "succeeded")
809            .count();
810        let failed_jobs = jobs
811            .items
812            .iter()
813            .filter(|job| job.status == "failed" || job.status == "dead_letter")
814            .count();
815
816        let pending_outbox = outbox
817            .items
818            .iter()
819            .filter(|event| event.delivery_state == "pending")
820            .count();
821        let failed_outbox = outbox
822            .items
823            .iter()
824            .filter(|event| event.delivery_state == "failed")
825            .count();
826
827        let endpoint_trends = self.list_endpoint_failure_rate_trends().await;
828
829        let avg_failure_rate = if endpoint_trends.is_empty() {
830            0.0
831        } else {
832            endpoint_trends
833                .iter()
834                .map(|row| row.failure_rate)
835                .sum::<f64>()
836                / endpoint_trends.len() as f64
837        };
838
839        let inspector = match inspect {
840            Some(entity) => self.inspect(entity).await?,
841            None => InspectorView::None,
842        };
843
844        Ok(DashboardDto {
845            kpis: SystemKpiDto {
846                succeeded_jobs,
847                failed_jobs,
848                enqueued_jobs,
849                running_jobs,
850                pending_outbox,
851                failed_outbox,
852                healthy_nodes,
853                degraded_nodes,
854                offline_nodes,
855                endpoint_failure_rate: format!("{:.1}%", avg_failure_rate * 100.0),
856            },
857            job_stream: jobs,
858            outbox_stream: outbox,
859            cluster_map: cluster,
860            inspector,
861        })
862    }
863
864    async fn jobs_stream(&self) -> Result<UiListPanel<JobRowDto>> {
865        let jobs = self.list_all_jobs().await?;
866        let mapped = jobs.iter().map(map_job_to_row).collect::<Vec<_>>();
867
868        Ok(UiListPanel {
869            items: mapped.clone(),
870            total: Some(mapped.len() as u64),
871            cursor: None,
872        })
873    }
874
875    async fn outbox_stream(&self) -> Result<UiListPanel<OutboxEventRowDto>> {
876        let events = self.list_all_outbox_events().await?;
877        let mapped = events
878            .iter()
879            .take(200)
880            .map(map_outbox_to_row)
881            .collect::<Vec<_>>();
882
883        Ok(UiListPanel {
884            items: mapped.clone(),
885            total: Some(mapped.len() as u64),
886            cursor: None,
887        })
888    }
889
890    async fn endpoint_stream(&self) -> Result<UiListPanel<EndpointRowDto>> {
891        let rows = self.list_endpoint_diagnostics_rows(None, Some(200)).await?;
892
893        let mapped = rows.iter().map(map_endpoint_row).collect::<Vec<_>>();
894
895        Ok(UiListPanel {
896            items: mapped.clone(),
897            total: Some(mapped.len() as u64),
898            cursor: None,
899        })
900    }
901
902    async fn recurring_stream(&self) -> Result<UiListPanel<RecurringDefinitionRowDto>> {
903        let definitions = match &self.runtime {
904            RuntimeComposition::InMemory(rt) => rt.recurring_store.list().await?,
905            RuntimeComposition::Surreal(rt) => rt.recurring_store.list().await?,
906        };
907        let mut rows = definitions
908            .iter()
909            .map(map_recurring_definition_row)
910            .collect::<Vec<_>>();
911
912        rows.sort_by(|left, right| {
913            left.next_run_at
914                .cmp(&right.next_run_at)
915                .then_with(|| left.id.cmp(&right.id))
916        });
917
918        Ok(UiListPanel {
919            items: rows.clone(),
920            total: Some(rows.len() as u64),
921            cursor: None,
922        })
923    }
924
925    async fn cluster_stream(&self) -> Result<ClusterMapDto> {
926        let rows = self.list_cluster_node_health_rows().await?;
927
928        let nodes = rows.iter().map(map_cluster_health_row).collect();
929
930        Ok(ClusterMapDto { nodes })
931    }
932
933    async fn scheduler_materialize_now(&self, scheduler_id: &str) -> Result<usize> {
934        match &self.runtime {
935            RuntimeComposition::InMemory(rt) => rt.materialize_recurring_now(scheduler_id).await,
936            RuntimeComposition::Surreal(rt) => rt.materialize_recurring_now(scheduler_id).await,
937        }
938    }
939
940    async fn scheduler_process_queue_once(
941        &self,
942        queue: &str,
943        worker_id: &str,
944    ) -> Result<Option<String>> {
945        match &self.runtime {
946            RuntimeComposition::InMemory(rt) => rt.process_once_now(queue, worker_id).await,
947            RuntimeComposition::Surreal(rt) => rt.process_once_now(queue, worker_id).await,
948        }
949    }
950
951    async fn scheduler_publish_pending_now(&self, limit: usize) -> Result<usize> {
952        match &self.runtime {
953            RuntimeComposition::InMemory(rt) => rt.publish_pending_events_now(limit).await,
954            RuntimeComposition::Surreal(rt) => rt.publish_pending_events_now(limit).await,
955        }
956    }
957
958    async fn scheduler_replay_dead_letter_now(&self, job_id: &str) -> Result<bool> {
959        match &self.runtime {
960            RuntimeComposition::InMemory(rt) => rt.replay_dead_letter_now(job_id).await,
961            RuntimeComposition::Surreal(rt) => rt.replay_dead_letter_now(job_id).await,
962        }
963    }
964
965    async fn workflow_save(&self, request: WorkflowSaveRequest) -> Result<WorkflowSaveResult> {
966        let workflow_id = request.workflow_id.trim();
967        let queue = request.queue.trim();
968        let source = request.source.trim();
969        let graph_modules_csv = normalize_graph_modules_csv(request.graph_modules_csv.as_deref());
970        let graph_function_steps_csv =
971            normalize_graph_function_steps_csv(request.graph_function_steps_csv.as_deref());
972        let graph_function_inputs_json =
973            normalize_graph_function_inputs_json(request.graph_function_inputs_json.as_deref());
974        let graph_state_json =
975            validate_and_normalize_graph_state_json(request.graph_state_json.as_deref())?;
976        let compile_mode = normalize_compile_mode_hint(request.compile_mode_hint.as_deref())
977            .unwrap_or_else(|| {
978                if graph_state_contains_compile_shape(graph_state_json.as_str()) {
979                    "graph_compiled".to_string()
980                } else if !graph_function_steps_csv.is_empty() {
981                    "legacy_function_steps".to_string()
982                } else {
983                    "source_passthrough".to_string()
984                }
985            });
986
987        if workflow_id.is_empty() {
988            return Err(StasisError::PortFailure("workflow_id is required".to_string()));
989        }
990        if queue.is_empty() {
991            return Err(StasisError::PortFailure("queue is required".to_string()));
992        }
993        if source.is_empty() {
994            return Err(StasisError::PortFailure("source is required".to_string()));
995        }
996
997        let reflection = self
998            .workflow_reflection
999            .reflect_executables_from_source(source)?;
1000        let reflection_receipt_json = serde_json::to_string(&reflection).map_err(|err| {
1001            StasisError::PortFailure(format!("encode workflow reflection receipt: {err}"))
1002        })?;
1003        let now = Utc::now();
1004        let revision_id = format!(
1005            "rev-{}-{}",
1006            now.timestamp_millis(),
1007            reflection.count.max(1)
1008        );
1009
1010        let created_at = self
1011            .workflow_store
1012            .get_definition(workflow_id)
1013            .await?
1014            .map(|existing| existing.created_at)
1015            .unwrap_or(now);
1016
1017        let definition = WorkflowDefinition {
1018            workflow_id: workflow_id.to_string(),
1019            queue: queue.to_string(),
1020            latest_revision_id: revision_id.clone(),
1021            created_at,
1022            updated_at: now,
1023        };
1024
1025        let revision = WorkflowRevision {
1026            workflow_id: workflow_id.to_string(),
1027            revision_id: revision_id.clone(),
1028            source: source.to_string(),
1029            graph_state_json,
1030            compiler_metadata_json: serde_json::json!({
1031                "compiler_version": "workflow-graph-v1",
1032                "compile_mode": compile_mode,
1033                "compiled_at_utc": now.to_rfc3339(),
1034            })
1035            .to_string(),
1036            graph_modules_csv,
1037            graph_function_steps_csv,
1038            graph_function_inputs_json,
1039            reflected_at_utc: now,
1040            executable_count: reflection.count,
1041            reflection_receipt_json,
1042        };
1043
1044        self.workflow_store.upsert_definition(definition).await?;
1045        self.workflow_store.insert_revision(revision).await?;
1046
1047        Ok(WorkflowSaveResult {
1048            workflow_id: workflow_id.to_string(),
1049            queue: queue.to_string(),
1050            revision_id,
1051            executable_count: reflection.count,
1052        })
1053    }
1054
1055    async fn workflow_execute(
1056        &self,
1057        workflow_id: &str,
1058        queue: &str,
1059        worker_id: &str,
1060    ) -> Result<WorkflowExecuteResult> {
1061        let workflow = self.load_workflow_definition(workflow_id.trim()).await?;
1062        let revisions = self
1063            .workflow_store
1064            .list_revisions(workflow.workflow_id.as_str())
1065            .await?;
1066        let latest = revisions
1067            .iter()
1068            .find(|revision| revision.revision_id == workflow.latest_revision_id)
1069            .ok_or_else(|| {
1070                StasisError::PortFailure(format!(
1071                    "workflow revision not found: {}",
1072                    workflow.latest_revision_id
1073                ))
1074            })?;
1075        let resolved_queue = if queue.trim().is_empty() {
1076            workflow.queue.clone()
1077        } else {
1078            queue.trim().to_string()
1079        };
1080
1081        let leased_job_id = match &self.runtime {
1082            RuntimeComposition::InMemory(rt) => {
1083                rt.process_once_now(resolved_queue.as_str(), worker_id).await?
1084            }
1085            RuntimeComposition::Surreal(rt) => {
1086                rt.process_once_now(resolved_queue.as_str(), worker_id).await?
1087            }
1088        };
1089
1090        Ok(WorkflowExecuteResult {
1091            workflow_id: workflow.workflow_id,
1092            queue: resolved_queue,
1093            revision_id: workflow.latest_revision_id,
1094            executable_count: latest.executable_count,
1095            graph_function_steps_csv: latest.graph_function_steps_csv.clone(),
1096            graph_function_inputs_json: latest.graph_function_inputs_json.clone(),
1097            source_bytes: latest.source.len(),
1098            reflected_at_utc: latest.reflected_at_utc.to_rfc3339(),
1099            leased_job_id,
1100        })
1101    }
1102
1103    async fn workflow_run_draft(
1104        &self,
1105        request: WorkflowRunDraftRequest,
1106    ) -> Result<WorkflowRunDraftResult> {
1107        let workflow_id = request.workflow_id.trim();
1108        let queue = request.queue.trim();
1109        let source = request.source.trim();
1110        let graph_modules_csv = normalize_graph_modules_csv(request.graph_modules_csv.as_deref());
1111        let graph_function_steps_csv =
1112            normalize_graph_function_steps_csv(request.graph_function_steps_csv.as_deref());
1113        let graph_function_inputs_json =
1114            normalize_graph_function_inputs_json(request.graph_function_inputs_json.as_deref());
1115        let _graph_state_json =
1116            validate_and_normalize_graph_state_json(request.graph_state_json.as_deref())?;
1117
1118        if workflow_id.is_empty() {
1119            return Err(StasisError::PortFailure("workflow_id is required".to_string()));
1120        }
1121        if queue.is_empty() {
1122            return Err(StasisError::PortFailure("queue is required".to_string()));
1123        }
1124        if source.is_empty() {
1125            return Err(StasisError::PortFailure("source is required".to_string()));
1126        }
1127
1128        let reflection = self
1129            .workflow_reflection
1130            .reflect_executables_from_source(source)?;
1131        let output = self
1132            .workflow_engine
1133            .execute_grapheme_source(source, None)
1134            .await?;
1135
1136        Ok(WorkflowRunDraftResult {
1137            workflow_id: workflow_id.to_string(),
1138            queue: queue.to_string(),
1139            executable_count: reflection.count,
1140            graph_modules_csv,
1141            graph_function_steps_csv,
1142            graph_function_inputs_json,
1143            source_bytes: source.len(),
1144            run_id: output.run_id,
1145            execution_json: output.execution.to_string(),
1146            final_state_json: output.final_state.to_string(),
1147        })
1148    }
1149
1150    async fn workflow_reflect_source(&self, source: &str) -> Result<WorkflowSourceReflection> {
1151        self.workflow_reflection
1152            .reflect_executables_from_source(source)
1153    }
1154
1155    async fn workflow_modules_search(&self, query: &str) -> Result<WorkflowModuleSearchReflection> {
1156        self.workflow_reflection.modules_search(query)
1157    }
1158
1159    async fn workflow_module_info(&self, module_id: &str) -> Result<Option<WorkflowModuleInfoReflection>> {
1160        self.workflow_reflection.module_info(module_id)
1161    }
1162
1163    async fn workflow_module_types(&self, module_id: &str) -> Result<Option<WorkflowModuleTypesReflection>> {
1164        self.workflow_reflection.module_types(module_id)
1165    }
1166
1167    async fn workflow_saved_revision_summary(
1168        &self,
1169        workflow_id: &str,
1170    ) -> Result<Option<WorkflowSavedRevisionSummary>> {
1171        let workflow_id = workflow_id.trim();
1172        if workflow_id.is_empty() {
1173            return Ok(None);
1174        }
1175
1176        let Some(definition) = self.workflow_store.get_definition(workflow_id).await? else {
1177            return Ok(None);
1178        };
1179        let revisions = self.workflow_store.list_revisions(workflow_id).await?;
1180        let Some(latest) = revisions
1181            .into_iter()
1182            .find(|revision| revision.revision_id == definition.latest_revision_id)
1183        else {
1184            return Ok(None);
1185        };
1186        let compile_mode = serde_json::from_str::<serde_json::Value>(
1187            latest.compiler_metadata_json.as_str(),
1188        )
1189        .ok()
1190        .and_then(|value| {
1191            value
1192                .get("compile_mode")
1193                .and_then(serde_json::Value::as_str)
1194                .map(|text| text.to_string())
1195        })
1196        .unwrap_or_else(|| "unknown".to_string());
1197
1198        Ok(Some(WorkflowSavedRevisionSummary {
1199            workflow_id: definition.workflow_id,
1200            revision_id: latest.revision_id,
1201            executable_count: latest.executable_count,
1202            reflected_at_utc: latest.reflected_at_utc.to_rfc3339(),
1203            compile_mode,
1204            source_bytes: latest.source.len(),
1205            source: latest.source,
1206            graph_state_json: latest.graph_state_json,
1207            graph_modules_csv: latest.graph_modules_csv,
1208            graph_function_steps_csv: latest.graph_function_steps_csv,
1209            graph_function_inputs_json: latest.graph_function_inputs_json,
1210        }))
1211    }
1212
1213    async fn workflow_lsp_diagnostics(&self, source: &str) -> Result<WorkflowDiagnosticsResult> {
1214        let source = source.trim();
1215        let mut diagnostics = Vec::new();
1216
1217        if source.is_empty() {
1218            diagnostics.push(WorkflowDiagnostic {
1219                severity: WorkflowDiagnosticSeverity::Warning,
1220                message: "Source is empty. Add a query/mutation/subscription to reflect."
1221                    .to_string(),
1222                code: Some("EMPTY_SOURCE".to_string()),
1223                line: None,
1224                column: None,
1225            });
1226        } else if let Err(parse_err) = grapheme_compiler::parse(source) {
1227            let parse_message = parse_err.to_string();
1228            let (line, column) = extract_line_column(parse_message.as_str());
1229
1230            diagnostics.push(WorkflowDiagnostic {
1231                severity: WorkflowDiagnosticSeverity::Error,
1232                message: parse_message,
1233                code: Some("PARSE".to_string()),
1234                line,
1235                column,
1236            });
1237        } else if let Err(reflect_err) = self
1238            .workflow_reflection
1239            .reflect_executables_from_source(source)
1240        {
1241            let reflect_message = reflect_err.to_string();
1242            let (line, column) = extract_line_column(reflect_message.as_str());
1243
1244            diagnostics.push(WorkflowDiagnostic {
1245                severity: WorkflowDiagnosticSeverity::Error,
1246                message: reflect_message.clone(),
1247                code: Some(reflection_code_for_error(reflect_message.as_str()).to_string()),
1248                line,
1249                column,
1250            });
1251        }
1252
1253        let enabled = cfg!(feature = "dashboard-lsp");
1254        let provider = if enabled {
1255            "grapheme-lsp"
1256        } else {
1257            "fallback-parser"
1258        };
1259        let summary = if diagnostics.is_empty() {
1260            "No diagnostics from current source snapshot.".to_string()
1261        } else {
1262            format!(
1263                "Diagnostics reported {} issue(s) in the current source snapshot.",
1264                diagnostics.len()
1265            )
1266        };
1267
1268        Ok(WorkflowDiagnosticsResult {
1269            enabled,
1270            provider: provider.to_string(),
1271            summary,
1272            diagnostics,
1273        })
1274    }
1275
1276    async fn inspect(&self, entity: InspectEntity) -> Result<InspectorView> {
1277        let inspector = match entity {
1278            InspectEntity::Job(id) => {
1279                let jobs = self.list_all_jobs().await?;
1280                let Some(job) = jobs.iter().find(|job| job.id == id) else {
1281                    return Ok(InspectorView::None);
1282                };
1283
1284                InspectorView::Job(JobInspectorDto {
1285                    id: job.id.clone(),
1286                    status: format!("{:?}", job.state),
1287                    queue: job.queue.clone(),
1288                    trace_id: job.trace_id.clone(),
1289                    correlation_id: job.correlation_id.clone(),
1290                    causation_id: job.causation_id.clone(),
1291                    last_error: job.last_error.clone(),
1292                })
1293            }
1294            InspectEntity::Attempt(id) => {
1295                let jobs = self.list_all_jobs().await?;
1296                let mut found = None;
1297                for job in jobs {
1298                    for attempt in self.list_job_attempts(&job.id).await? {
1299                        if attempt.attempt_id == id {
1300                            found = Some(attempt);
1301                            break;
1302                        }
1303                    }
1304                    if found.is_some() {
1305                        break;
1306                    }
1307                }
1308
1309                let Some(attempt) = found else {
1310                    return Ok(InspectorView::None);
1311                };
1312
1313                InspectorView::Attempt(AttemptInspectorDto {
1314                    attempt_id: attempt.attempt_id,
1315                    job_id: attempt.job_id,
1316                    outcome: format!("{:?}", attempt.outcome),
1317                    worker_id: attempt.worker_id,
1318                    duration_ms: attempt.duration_ms,
1319                    guardrail_code: attempt.guardrail_code,
1320                    policy_reason: attempt.policy_reason,
1321                })
1322            }
1323            InspectEntity::Node(id) => {
1324                let rows = self.list_cluster_node_health_rows().await?;
1325
1326                let Some(node) = rows.iter().find(|row| row.snapshot.node.node_id == id) else {
1327                    return Ok(InspectorView::None);
1328                };
1329                InspectorView::Node(map_node_inspector(node))
1330            }
1331            InspectEntity::Endpoint(id) => {
1332                let rows = self
1333                    .list_endpoint_diagnostics_rows(Some(vec![id.clone()]), Some(1))
1334                    .await?;
1335
1336                let Some(endpoint) = rows.first() else {
1337                    return Ok(InspectorView::None);
1338                };
1339                InspectorView::Endpoint(map_endpoint_inspector(endpoint))
1340            }
1341            InspectEntity::Event(id) => {
1342                let events = self.list_all_outbox_events().await?;
1343                let Some(event) = events.iter().find(|event| event.event_id == id) else {
1344                    return Ok(InspectorView::None);
1345                };
1346
1347                InspectorView::Event(EventInspectorDto {
1348                    event_id: event.event_id.clone(),
1349                    event_type: format!("{:?}", event.event.event_type),
1350                    job_id: event.event.job_id.clone(),
1351                    correlation_id: event.event.correlation_id.clone(),
1352                    trace_id: event.event.trace_id.clone(),
1353                    status: format!("{:?}", event.status),
1354                })
1355            }
1356        };
1357
1358        Ok(inspector)
1359    }
1360}
1361
1362/// Backward-compatible alias retained for existing callers while naming transitions to runtime-agnostic service.
1363pub type InMemoryDashboardQueryService = RuntimeDashboardQueryService;
1364
1365#[cfg(test)]
1366mod tests {
1367    use chrono::DateTime;
1368
1369    use crate::application::runtime::runtime_factory::{RuntimeBackend, RuntimeFactory};
1370
1371    use super::{
1372        DashboardQueryService, RuntimeDashboardQueryService, WorkflowDiagnosticSeverity,
1373        WorkflowRunDraftRequest, WorkflowSaveRequest,
1374    };
1375    use crate::application::runtime::in_memory_runtime::InMemoryRuntime;
1376    use crate::application::runtime::runtime_factory::RuntimeComposition;
1377
1378    fn valid_workflow_source() -> &'static str {
1379        r#"
1380import core from "grapheme/core"
1381
1382query Echo {
1383  core.echo(message: "ping") {
1384    state {
1385      current
1386    }
1387  }
1388}
1389"#
1390    }
1391
1392    #[tokio::test]
1393    async fn workflow_save_persists_definition_and_revision() {
1394        let service = RuntimeDashboardQueryService::from_runtime_composition(
1395            RuntimeComposition::InMemory(InMemoryRuntime::new()),
1396        );
1397
1398        let saved = service
1399            .workflow_save(WorkflowSaveRequest {
1400                workflow_id: "wf.phase2".to_string(),
1401                queue: "queue.phase2".to_string(),
1402                source: valid_workflow_source().to_string(),
1403                compile_mode_hint: None,
1404                graph_state_json: Some(
1405                    r#"{"query":{"name":"Q","steps":[{"op":"core.echo","args":{"message":"hello"}}]}}"#
1406                        .to_string(),
1407                ),
1408                graph_modules_csv: Some(" core, textops,core,invalid,healthcheck ".to_string()),
1409                graph_function_steps_csv: Some(
1410                    " core.echo,textops.to_markdown,core.echo,invalid.step,healthcheck.runtime_ready "
1411                        .to_string(),
1412                ),
1413                graph_function_inputs_json: Some(
1414                    r#"{"node-fn-core-echo-1":"{\"message\":\"hello\"}"}"#.to_string(),
1415                ),
1416            })
1417            .await
1418            .expect("workflow save should succeed");
1419
1420        let definition = service
1421            .workflow_store
1422            .get_definition("wf.phase2")
1423            .await
1424            .expect("definition load should succeed")
1425            .expect("definition should exist");
1426        assert_eq!(definition.workflow_id, "wf.phase2");
1427        assert_eq!(definition.queue, "queue.phase2");
1428        assert_eq!(definition.latest_revision_id, saved.revision_id);
1429
1430        let revisions = service
1431            .workflow_store
1432            .list_revisions("wf.phase2")
1433            .await
1434            .expect("revisions load should succeed");
1435        assert_eq!(revisions.len(), 1);
1436
1437        let latest = &revisions[0];
1438        assert_eq!(latest.workflow_id, "wf.phase2");
1439        assert_eq!(latest.revision_id, saved.revision_id);
1440        assert_eq!(latest.executable_count, saved.executable_count);
1441        assert_eq!(latest.graph_modules_csv, "core,textops,healthcheck");
1442        assert_eq!(
1443            latest.graph_function_steps_csv,
1444            "core.echo,textops.to_markdown,healthcheck.runtime_ready"
1445        );
1446        assert_eq!(
1447            latest.graph_function_inputs_json,
1448            r#"{"node-fn-core-echo-1":"{\"message\":\"hello\"}"}"#
1449        );
1450        assert_eq!(
1451            latest.graph_state_json,
1452            r#"{"query":{"name":"Q","steps":[{"args":{"message":"hello"},"op":"core.echo"}]}}"#
1453        );
1454
1455        let compiler_metadata: serde_json::Value =
1456            serde_json::from_str(&latest.compiler_metadata_json)
1457                .expect("compiler metadata should be valid json");
1458        assert_eq!(
1459            compiler_metadata["compile_mode"].as_str(),
1460            Some("graph_compiled")
1461        );
1462
1463        let receipt: serde_json::Value = serde_json::from_str(&latest.reflection_receipt_json)
1464            .expect("reflection receipt should be valid json");
1465        assert_eq!(receipt["count"].as_u64(), Some(saved.executable_count as u64));
1466    }
1467
1468    #[tokio::test]
1469    async fn workflow_save_rejects_compile_graph_state_without_query_steps() {
1470        let service = RuntimeDashboardQueryService::from_runtime_composition(
1471            RuntimeComposition::InMemory(InMemoryRuntime::new()),
1472        );
1473
1474        let error = service
1475            .workflow_save(WorkflowSaveRequest {
1476                workflow_id: "wf.invalid.graph.contract".to_string(),
1477                queue: "queue.invalid.graph.contract".to_string(),
1478                source: valid_workflow_source().to_string(),
1479                compile_mode_hint: None,
1480                graph_state_json: Some(
1481                    r#"{"query":{"name":"Q","steps":[]}}"#.to_string(),
1482                ),
1483                graph_modules_csv: None,
1484                graph_function_steps_csv: Some("core.echo".to_string()),
1485                graph_function_inputs_json: None,
1486            })
1487            .await
1488            .expect_err("workflow save should reject invalid compile graph contract");
1489
1490        let error_text = error.to_string();
1491        assert!(error_text.contains("requires at least one query step"));
1492    }
1493
1494    #[tokio::test]
1495    async fn workflow_save_accepts_topology_graph_state_contract() {
1496        let service = RuntimeDashboardQueryService::from_runtime_composition(
1497            RuntimeComposition::InMemory(InMemoryRuntime::new()),
1498        );
1499
1500        let saved = service
1501            .workflow_save(WorkflowSaveRequest {
1502                workflow_id: "wf.topology.graph.contract".to_string(),
1503                queue: "queue.topology.graph.contract".to_string(),
1504                source: valid_workflow_source().to_string(),
1505                compile_mode_hint: None,
1506                graph_state_json: Some(
1507                    r#"{"version":1,"nodes":[{"id":"node-fn-core-echo-1"}],"edges":[]}"#
1508                        .to_string(),
1509                ),
1510                graph_modules_csv: Some("core".to_string()),
1511                graph_function_steps_csv: Some("core.echo".to_string()),
1512                graph_function_inputs_json: None,
1513            })
1514            .await
1515            .expect("workflow save should accept topology graph contract");
1516
1517        let summary = service
1518            .workflow_saved_revision_summary("wf.topology.graph.contract")
1519            .await
1520            .expect("summary lookup should succeed")
1521            .expect("summary should exist");
1522        assert_eq!(summary.revision_id, saved.revision_id);
1523        assert_eq!(
1524            summary.graph_state_json,
1525            r#"{"edges":[],"nodes":[{"id":"node-fn-core-echo-1"}],"version":1}"#
1526        );
1527
1528        let revisions = service
1529            .workflow_store
1530            .list_revisions("wf.topology.graph.contract")
1531            .await
1532            .expect("revisions load should succeed");
1533        let latest = revisions
1534            .first()
1535            .expect("one revision should exist for topology contract test");
1536        let compiler_metadata: serde_json::Value =
1537            serde_json::from_str(&latest.compiler_metadata_json)
1538                .expect("compiler metadata should be valid json");
1539        assert_eq!(
1540            compiler_metadata["compile_mode"].as_str(),
1541            Some("legacy_function_steps")
1542        );
1543    }
1544
1545    #[tokio::test]
1546    async fn workflow_execute_uses_latest_persisted_revision_metadata() {
1547        let service = RuntimeDashboardQueryService::from_runtime_composition(
1548            RuntimeComposition::InMemory(InMemoryRuntime::new()),
1549        );
1550        let source = valid_workflow_source();
1551
1552        let saved = service
1553            .workflow_save(WorkflowSaveRequest {
1554                workflow_id: "wf.exec.meta".to_string(),
1555                queue: "queue.exec.meta".to_string(),
1556                source: source.to_string(),
1557                compile_mode_hint: None,
1558                graph_state_json: None,
1559                graph_modules_csv: None,
1560                graph_function_steps_csv: Some("core.echo,textops.to_markdown".to_string()),
1561                graph_function_inputs_json: Some(
1562                    r#"{"node-fn-core-echo-1":"{\"message\":\"ping\"}"}"#.to_string(),
1563                ),
1564            })
1565            .await
1566            .expect("workflow save should succeed");
1567
1568        let executed = service
1569            .workflow_execute("wf.exec.meta", "", "workflow-test")
1570            .await
1571            .expect("workflow execute should succeed");
1572
1573        assert_eq!(executed.workflow_id, "wf.exec.meta");
1574        assert_eq!(executed.queue, "queue.exec.meta");
1575        assert_eq!(executed.revision_id, saved.revision_id);
1576        assert_eq!(executed.executable_count, saved.executable_count);
1577        assert_eq!(executed.graph_function_steps_csv, "core.echo,textops.to_markdown");
1578        assert_eq!(
1579            executed.graph_function_inputs_json,
1580            r#"{"node-fn-core-echo-1":"{\"message\":\"ping\"}"}"#
1581        );
1582        assert_eq!(executed.source_bytes, source.trim().len());
1583        assert!(executed.leased_job_id.is_none());
1584        assert!(
1585            DateTime::parse_from_rfc3339(&executed.reflected_at_utc).is_ok(),
1586            "reflected_at_utc should be RFC3339"
1587        );
1588    }
1589
1590    #[tokio::test]
1591    async fn workflow_run_draft_executes_without_persisting_revision() {
1592        let service = RuntimeDashboardQueryService::from_runtime_composition(
1593            RuntimeComposition::InMemory(InMemoryRuntime::new()),
1594        );
1595        let source = valid_workflow_source();
1596
1597        let run = service
1598            .workflow_run_draft(WorkflowRunDraftRequest {
1599                workflow_id: "wf.draft.run".to_string(),
1600                queue: "queue.draft.run".to_string(),
1601                source: source.to_string(),
1602                graph_state_json: Some(
1603                    r#"{"query":{"name":"Draft","steps":[{"op":"core.echo","args":{"message":"ping"}}]}}"#
1604                        .to_string(),
1605                ),
1606                graph_modules_csv: Some("core".to_string()),
1607                graph_function_steps_csv: Some("core.echo".to_string()),
1608                graph_function_inputs_json: Some(
1609                    r#"{"node-fn-core-echo-1":"{\"message\":\"ping\"}"}"#.to_string(),
1610                ),
1611            })
1612            .await
1613            .expect("draft run should succeed");
1614
1615        assert_eq!(run.workflow_id, "wf.draft.run");
1616        assert_eq!(run.queue, "queue.draft.run");
1617        assert_eq!(run.executable_count, 1);
1618        assert_eq!(run.graph_modules_csv, "core");
1619        assert_eq!(run.graph_function_steps_csv, "core.echo");
1620        assert!(run.source_bytes > 0);
1621        assert!(!run.run_id.is_empty());
1622        assert!(
1623            serde_json::from_str::<serde_json::Value>(&run.execution_json)
1624                .ok()
1625                .and_then(|value| value.as_object().cloned())
1626                .is_some(),
1627            "execution_json should be a JSON object"
1628        );
1629        assert!(
1630            serde_json::from_str::<serde_json::Value>(&run.final_state_json)
1631                .ok()
1632                .and_then(|value| value.as_object().cloned())
1633                .is_some(),
1634            "final_state_json should be a JSON object"
1635        );
1636
1637        let definition = service
1638            .workflow_store
1639            .get_definition("wf.draft.run")
1640            .await
1641            .expect("definition load should succeed");
1642        assert!(definition.is_none(), "draft run should not persist definition");
1643    }
1644
1645    #[tokio::test]
1646    async fn workflow_reflection_queries_match_between_inmemory_and_surrealmem() {
1647        let in_memory = RuntimeDashboardQueryService::from_runtime_composition(
1648            RuntimeComposition::InMemory(InMemoryRuntime::new()),
1649        );
1650        let surreal_runtime = RuntimeFactory::build(RuntimeBackend::surreal_mem(
1651            "stasis",
1652            "dashboard_phase3_reflection_parity",
1653        ))
1654        .await
1655        .expect("surreal mem runtime should build");
1656        let surreal = RuntimeDashboardQueryService::from_runtime_composition(surreal_runtime);
1657
1658        let in_memory_search = in_memory
1659            .workflow_modules_search("core")
1660            .await
1661            .expect("in-memory module search should succeed");
1662        let surreal_search = surreal
1663            .workflow_modules_search("core")
1664            .await
1665            .expect("surreal module search should succeed");
1666
1667        assert!(!in_memory_search.matches.is_empty());
1668        assert_eq!(
1669            in_memory_search
1670                .matches
1671                .iter()
1672                .map(|row| row.module_id.clone())
1673                .collect::<Vec<_>>(),
1674            surreal_search
1675                .matches
1676                .iter()
1677                .map(|row| row.module_id.clone())
1678                .collect::<Vec<_>>()
1679        );
1680
1681        let module_id = in_memory_search.matches[0].module_id.clone();
1682        let in_memory_info = in_memory
1683            .workflow_module_info(module_id.as_str())
1684            .await
1685            .expect("in-memory module info should succeed")
1686            .expect("module info should exist");
1687        let surreal_info = surreal
1688            .workflow_module_info(module_id.as_str())
1689            .await
1690            .expect("surreal module info should succeed")
1691            .expect("module info should exist");
1692        assert_eq!(in_memory_info.module_id, surreal_info.module_id);
1693        assert_eq!(in_memory_info.total_ops, surreal_info.total_ops);
1694
1695        let in_memory_types = in_memory
1696            .workflow_module_types(module_id.as_str())
1697            .await
1698            .expect("in-memory module types should succeed")
1699            .expect("module types should exist");
1700        let surreal_types = surreal
1701            .workflow_module_types(module_id.as_str())
1702            .await
1703            .expect("surreal module types should succeed")
1704            .expect("module types should exist");
1705        assert_eq!(in_memory_types.module_id, surreal_types.module_id);
1706        assert_eq!(in_memory_types.total_types, surreal_types.total_types);
1707    }
1708
1709    #[tokio::test]
1710    async fn workflow_saved_revision_summary_returns_latest_saved_revision() {
1711        let service = RuntimeDashboardQueryService::from_runtime_composition(
1712            RuntimeComposition::InMemory(InMemoryRuntime::new()),
1713        );
1714        let source = valid_workflow_source();
1715
1716        let saved = service
1717            .workflow_save(WorkflowSaveRequest {
1718                workflow_id: "wf.summary".to_string(),
1719                queue: "queue.summary".to_string(),
1720                source: source.to_string(),
1721                compile_mode_hint: None,
1722                graph_state_json: Some(
1723                    r#"{"query":{"name":"Summary","steps":[{"op":"core.echo","args":{"message":"summary"}}]}}"#
1724                        .to_string(),
1725                ),
1726                graph_modules_csv: Some("core, branch, healthcheck, core".to_string()),
1727                graph_function_steps_csv: Some(
1728                    "core.echo,healthcheck.runtime_ready,unknown.bad,core.echo".to_string(),
1729                ),
1730                graph_function_inputs_json: Some(
1731                    r#"{"node-fn-core-echo-1":"{\"message\":\"summary\"}"}"#.to_string(),
1732                ),
1733            })
1734            .await
1735            .expect("workflow save should succeed");
1736
1737        let summary = service
1738            .workflow_saved_revision_summary("wf.summary")
1739            .await
1740            .expect("summary lookup should succeed")
1741            .expect("summary should exist");
1742
1743        assert_eq!(summary.workflow_id, "wf.summary");
1744        assert_eq!(summary.revision_id, saved.revision_id);
1745        assert_eq!(summary.executable_count, saved.executable_count);
1746        assert_eq!(summary.compile_mode, "graph_compiled");
1747        assert_eq!(summary.source_bytes, source.trim().len());
1748        assert_eq!(summary.graph_modules_csv, "core,healthcheck");
1749        assert_eq!(summary.graph_function_steps_csv, "core.echo,healthcheck.runtime_ready");
1750        assert_eq!(
1751            summary.graph_function_inputs_json,
1752            r#"{"node-fn-core-echo-1":"{\"message\":\"summary\"}"}"#
1753        );
1754        assert_eq!(
1755            summary.graph_state_json,
1756            r#"{"query":{"name":"Summary","steps":[{"args":{"message":"summary"},"op":"core.echo"}]}}"#
1757        );
1758        assert!(summary.source.contains("query Echo"));
1759    }
1760
1761    #[tokio::test]
1762    async fn workflow_lsp_diagnostics_reports_disabled_without_feature() {
1763        let service = RuntimeDashboardQueryService::from_runtime_composition(
1764            RuntimeComposition::InMemory(InMemoryRuntime::new()),
1765        );
1766
1767        let diagnostics = service
1768            .workflow_lsp_diagnostics(valid_workflow_source())
1769            .await
1770            .expect("diagnostics call should succeed");
1771
1772        #[cfg(not(feature = "dashboard-lsp"))]
1773        {
1774            assert!(!diagnostics.enabled);
1775            assert_eq!(diagnostics.provider, "fallback-parser");
1776            assert!(diagnostics.diagnostics.is_empty());
1777        }
1778    }
1779
1780    #[tokio::test]
1781    async fn workflow_lsp_diagnostics_marks_parse_errors_with_parse_code() {
1782        let service = RuntimeDashboardQueryService::from_runtime_composition(
1783            RuntimeComposition::InMemory(InMemoryRuntime::new()),
1784        );
1785
1786        let diagnostics = service
1787            .workflow_lsp_diagnostics("query Broken {")
1788            .await
1789            .expect("diagnostics call should succeed");
1790
1791        assert!(!diagnostics.diagnostics.is_empty());
1792        assert_eq!(diagnostics.diagnostics[0].severity, WorkflowDiagnosticSeverity::Error);
1793        assert_eq!(diagnostics.diagnostics[0].code.as_deref(), Some("PARSE"));
1794    }
1795
1796    #[tokio::test]
1797    async fn workflow_lsp_diagnostics_marks_reflection_errors_with_reflection_code() {
1798        let service = RuntimeDashboardQueryService::from_runtime_composition(
1799            RuntimeComposition::InMemory(InMemoryRuntime::new()),
1800        );
1801
1802        let source = r#"
1803import core from "grapheme/core"
1804
1805query Broken {
1806  core.not_real(message: "ping")
1807}
1808"#;
1809
1810        let diagnostics = service
1811            .workflow_lsp_diagnostics(source)
1812            .await
1813            .expect("diagnostics call should succeed");
1814
1815        assert!(!diagnostics.diagnostics.is_empty());
1816        assert_eq!(diagnostics.diagnostics[0].severity, WorkflowDiagnosticSeverity::Error);
1817        assert_ne!(diagnostics.diagnostics[0].code.as_deref(), Some("PARSE"));
1818    }
1819}