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
1411pub 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}