yoi/crates/pod/src/workflow/mod.rs
2026-06-01 18:49:23 +09:00

200 lines
6.6 KiB
Rust

//! Pod-side Workflow resolver.
//!
//! Turns `Segment::WorkflowInvoke { slug }` into system-message attachments:
//! dependency Knowledge bodies first, then the Workflow body. Resolution is
//! strict for explicit user invocations: missing workflows, non-user-invocable
//! workflows, and missing Knowledge requirements are returned as errors before
//! the turn is handed to the Worker.
use std::fmt;
use llm_worker::Item;
use memory::WorkspaceLayout;
use memory::schema::split_frontmatter;
use workflow_crate::{Slug, WorkflowRegistry};
#[derive(Debug)]
pub enum WorkflowResolveError {
InvalidSlug(workflow_crate::WorkflowLintError),
NotFound {
slug: String,
},
NotUserInvocable {
slug: String,
},
KnowledgeNotFound {
workflow: String,
slug: String,
},
KnowledgeRead {
workflow: String,
slug: String,
source: std::io::Error,
},
KnowledgeFrontmatter {
workflow: String,
slug: String,
source: memory::LintError,
},
}
impl fmt::Display for WorkflowResolveError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidSlug(e) => write!(f, "invalid workflow slug: {e}"),
Self::NotFound { slug } => write!(f, "workflow /{slug} is not registered"),
Self::NotUserInvocable { slug } => {
write!(f, "workflow /{slug} is not user-invocable")
}
Self::KnowledgeNotFound { workflow, slug } => write!(
f,
"workflow /{workflow} requires missing Knowledge slug `{slug}`"
),
Self::KnowledgeRead {
workflow,
slug,
source,
} => write!(
f,
"workflow /{workflow} could not read required Knowledge `{slug}`: {source}"
),
Self::KnowledgeFrontmatter {
workflow,
slug,
source,
} => write!(
f,
"workflow /{workflow} required Knowledge `{slug}` has invalid frontmatter: {source}"
),
}
}
}
impl std::error::Error for WorkflowResolveError {}
pub fn resolve_workflow_invocation(
registry: &WorkflowRegistry,
layout: &WorkspaceLayout,
raw_slug: &str,
) -> Result<Vec<Item>, WorkflowResolveError> {
let slug = Slug::parse(raw_slug.to_string())
.map_err(|source| WorkflowResolveError::InvalidSlug(source.into()))?;
let record = registry
.get(&slug)
.ok_or_else(|| WorkflowResolveError::NotFound {
slug: raw_slug.to_string(),
})?;
if !record.user_invocable {
return Err(WorkflowResolveError::NotUserInvocable {
slug: raw_slug.to_string(),
});
}
let mut out = Vec::new();
for req in &record.requires {
let path = layout.knowledge_dir().join(format!("{req}.md"));
let raw = std::fs::read_to_string(&path).map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
WorkflowResolveError::KnowledgeNotFound {
workflow: slug.to_string(),
slug: req.to_string(),
}
} else {
WorkflowResolveError::KnowledgeRead {
workflow: slug.to_string(),
slug: req.to_string(),
source,
}
}
})?;
let (_yaml, body) = split_frontmatter(&raw).map_err(|source| {
WorkflowResolveError::KnowledgeFrontmatter {
workflow: slug.to_string(),
slug: req.to_string(),
source,
}
})?;
out.push(Item::system_message(format!(
"[Workflow /{} requires Knowledge #{}]\n{}",
slug,
req,
body.trim_end()
)));
}
out.push(Item::system_message(format!(
"[Workflow /{}]\n{}",
slug,
record.body.trim_end()
)));
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write(path: &std::path::Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, content).unwrap();
}
fn setup() -> (TempDir, WorkspaceLayout, WorkflowRegistry) {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
write(
&dir.path().join(".yoi/knowledge/policy.md"),
"---\ncreated_at: 2026-01-01T00:00:00Z\nupdated_at: 2026-01-01T00:00:00Z\nkind: policy\ndescription: p\nmodel_invokation: false\nuser_invocable: true\nlast_sources: []\n---\npolicy body\n",
);
write(
&dir.path().join(".yoi/workflow/run-it.md"),
"---\ndescription: run\nrequires: [policy]\n---\nworkflow body\n",
);
let registry = workflow_crate::load_workflows(&layout).unwrap();
(dir, layout, registry)
}
#[test]
fn resolves_requires_before_workflow_body() {
let (_dir, layout, registry) = setup();
let items = resolve_workflow_invocation(&registry, &layout, "run-it").unwrap();
assert_eq!(items.len(), 2);
let first = format!("{:?}", items[0]);
let second = format!("{:?}", items[1]);
assert!(first.contains("Knowledge #policy"));
assert!(first.contains("policy body"));
assert!(second.contains("[Workflow /run-it]"));
assert!(second.contains("workflow body"));
}
#[test]
fn user_invocable_false_errors() {
let (dir, layout, _registry) = setup();
write(
&dir.path().join(".yoi/workflow/hidden.md"),
"---\ndescription: hidden\nuser_invocable: false\n---\nbody\n",
);
let registry = workflow_crate::load_workflows(&layout).unwrap();
let err = resolve_workflow_invocation(&registry, &layout, "hidden").unwrap_err();
assert!(matches!(err, WorkflowResolveError::NotUserInvocable { .. }));
}
#[test]
fn missing_required_knowledge_errors() {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
write(
&dir.path().join(".yoi/workflow/bad.md"),
"---\ndescription: bad\nrequires: [ghost]\n---\nbody\n",
);
let registry = workflow_crate::load_workflows(&layout).unwrap();
let err = resolve_workflow_invocation(&registry, &layout, "bad").unwrap_err();
assert!(matches!(
err,
WorkflowResolveError::KnowledgeNotFound { .. }
));
}
}