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