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