stasis-rs 0.1.0

Durable AI orchestration framework with runtime jobs, lineage, and memory integration
Documentation
use std::collections::HashMap;
use std::sync::Arc;

use async_trait::async_trait;
use serde::Deserialize;
use serde_json::json;

use crate::application::runtime::grapheme_job_handler::GraphemeJobHandler;
use crate::application::runtime::in_memory_runtime::{JobExecutionOutcome, JobHandler};
use crate::domain::errors::Result;
use crate::domain::runtime::job::Job;
use crate::ports::outbound::runtime::workflow_engine::WorkflowEngine;

const MAX_TEXT_LEN: usize = 4096;
const DEFAULT_MAX_ITEMS: usize = 3;
const MAX_ITEMS_LIMIT: usize = 10;

#[derive(Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
enum TextOpsMode {
    Summarize,
    ExtractKeywords,
}

#[derive(Deserialize)]
struct TextOpsPayload {
    mode: TextOpsMode,
    text: String,
    max_items: Option<usize>,
}

pub struct GraphemeTextOpsJobHandler {
    delegate: GraphemeJobHandler,
}

impl GraphemeTextOpsJobHandler {
    pub fn new(engine: Arc<dyn WorkflowEngine>) -> Self {
        Self {
            delegate: GraphemeJobHandler::new(engine),
        }
    }

    fn build_failure(message: String) -> JobExecutionOutcome {
        let diagnostics = json!({
            "provider": "grapheme-sdk",
            "status": "failure",
            "guardrail_code": "POLICY_VIOLATION",
            "policy_reason": &message,
        })
        .to_string();

        JobExecutionOutcome::FatalFailure {
            message,
            execution_id: None,
            diagnostics: Some(diagnostics),
        }
    }

    fn parse_payload(raw: &str) -> std::result::Result<TextOpsPayload, String> {
        let payload: TextOpsPayload = serde_json::from_str(raw)
            .map_err(|err| format!("policy violation: invalid textops payload json: {err}"))?;

        if payload.text.trim().is_empty() {
            return Err("policy violation: textops payload.text must be non-empty".to_string());
        }

        if payload.text.len() > MAX_TEXT_LEN {
            return Err(format!(
                "policy violation: textops payload.text exceeds max length {}",
                MAX_TEXT_LEN
            ));
        }

        let max_items = payload.max_items.unwrap_or(DEFAULT_MAX_ITEMS);
        if !(1..=MAX_ITEMS_LIMIT).contains(&max_items) {
            return Err(format!(
                "policy violation: textops payload.max_items must be between 1 and {}",
                MAX_ITEMS_LIMIT
            ));
        }

        Ok(TextOpsPayload {
            mode: payload.mode,
            text: payload.text,
            max_items: Some(max_items),
        })
    }

    fn summarize(text: &str, max_items: usize) -> String {
        let mut sentences = Vec::new();
        let mut current = String::new();

        for ch in text.chars() {
            current.push(ch);
            if matches!(ch, '.' | '!' | '?') {
                let sentence = current.trim();
                if !sentence.is_empty() {
                    sentences.push(sentence.to_string());
                }
                current.clear();
            }
        }

        if sentences.is_empty() {
            let fallback = text
                .split_whitespace()
                .take(24)
                .collect::<Vec<_>>()
                .join(" ");
            return fallback;
        }

        sentences
            .into_iter()
            .take(max_items)
            .collect::<Vec<_>>()
            .join(" ")
    }

    fn extract_keywords(text: &str, max_items: usize) -> String {
        let stop_words = [
            "the", "and", "for", "with", "that", "this", "from", "into", "have", "are", "was",
            "were", "you", "your", "our", "not", "but", "can", "will", "all",
        ];

        let mut counts: HashMap<String, usize> = HashMap::new();
        for token in text
            .split(|c: char| !c.is_alphanumeric())
            .map(str::to_lowercase)
            .filter(|word| word.len() >= 4)
        {
            if stop_words.contains(&token.as_str()) {
                continue;
            }
            *counts.entry(token).or_insert(0) += 1;
        }

        let mut ranked = counts.into_iter().collect::<Vec<_>>();
        ranked.sort_by(|(a_word, a_count), (b_word, b_count)| {
            b_count.cmp(a_count).then_with(|| a_word.cmp(b_word))
        });

        ranked
            .into_iter()
            .take(max_items)
            .map(|(word, _)| word)
            .collect::<Vec<_>>()
            .join(", ")
    }

    fn transform(mode: TextOpsMode, text: &str, max_items: usize) -> String {
        match mode {
            TextOpsMode::Summarize => Self::summarize(text, max_items),
            TextOpsMode::ExtractKeywords => Self::extract_keywords(text, max_items),
        }
    }

    fn build_inline_source(message: &str) -> String {
        let cleaned = message
            .replace('"', "'")
            .replace(['\n', '\r'], " ");

        format!(
            "import core from \"grapheme/core\"\n\nquery TextOps {{\n  core.echo(message: \"{}\") {{\n    state {{ current }}\n  }}\n}}\n",
            cleaned
        )
    }
}

#[async_trait]
impl JobHandler for GraphemeTextOpsJobHandler {
    fn job_type(&self) -> &'static str {
        "workflow.grapheme.textops"
    }

    async fn execute(&self, job: &Job) -> Result<JobExecutionOutcome> {
        let payload = match Self::parse_payload(&job.payload_ref) {
            Ok(payload) => payload,
            Err(message) => return Ok(Self::build_failure(message)),
        };

        let transformed = Self::transform(
            payload.mode,
            &payload.text,
            payload.max_items.unwrap_or(DEFAULT_MAX_ITEMS),
        );
        if transformed.trim().is_empty() {
            return Ok(Self::build_failure(
                "policy violation: textops transform produced empty output".to_string(),
            ));
        }

        let source = Self::build_inline_source(&transformed);
        let synthetic_job = Job {
            payload_ref: format!("grapheme:inline:{}", source),
            ..job.clone()
        };

        self.delegate.execute(&synthetic_job).await
    }
}