feat: add builtin workflow resources
This commit is contained in:
@@ -72,6 +72,49 @@ impl fmt::Display for WorkflowResolveError {
|
||||
|
||||
impl std::error::Error for WorkflowResolveError {}
|
||||
|
||||
struct BuiltinKnowledgeResource {
|
||||
slug: &'static str,
|
||||
content: &'static str,
|
||||
}
|
||||
|
||||
const BUILTIN_KNOWLEDGE: &[BuiltinKnowledgeResource] = &[BuiltinKnowledgeResource {
|
||||
slug: "workflow-resource-boundary",
|
||||
content: include_str!("../../../../resources/knowledge/workflow-resource-boundary.md"),
|
||||
}];
|
||||
|
||||
fn builtin_knowledge(slug: &Slug) -> Option<&'static str> {
|
||||
BUILTIN_KNOWLEDGE
|
||||
.iter()
|
||||
.find(|resource| resource.slug == slug.as_str())
|
||||
.map(|resource| resource.content)
|
||||
}
|
||||
|
||||
fn read_required_knowledge(
|
||||
workflow: &Slug,
|
||||
layout: &WorkspaceLayout,
|
||||
req: &Slug,
|
||||
) -> Result<(String, &'static str), WorkflowResolveError> {
|
||||
let path = layout.knowledge_dir().join(format!("{req}.md"));
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(raw) => Ok((raw, "workspace")),
|
||||
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
|
||||
if let Some(raw) = builtin_knowledge(req) {
|
||||
Ok((raw.to_string(), "builtin"))
|
||||
} else {
|
||||
Err(WorkflowResolveError::KnowledgeNotFound {
|
||||
workflow: workflow.to_string(),
|
||||
slug: req.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
Err(source) => Err(WorkflowResolveError::KnowledgeRead {
|
||||
workflow: workflow.to_string(),
|
||||
slug: req.to_string(),
|
||||
source,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_workflow_invocation(
|
||||
registry: &WorkflowRegistry,
|
||||
layout: &WorkspaceLayout,
|
||||
@@ -92,21 +135,7 @@ pub fn resolve_workflow_invocation(
|
||||
|
||||
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 (raw, knowledge_source) = read_required_knowledge(&slug, layout, req)?;
|
||||
let (_yaml, body) = split_frontmatter(&raw).map_err(|source| {
|
||||
WorkflowResolveError::KnowledgeFrontmatter {
|
||||
workflow: slug.to_string(),
|
||||
@@ -115,15 +144,17 @@ pub fn resolve_workflow_invocation(
|
||||
}
|
||||
})?;
|
||||
out.push(Item::system_message(format!(
|
||||
"[Workflow /{} requires Knowledge #{}]\n{}",
|
||||
"[Workflow /{} requires Knowledge #{} from {}]\n{}",
|
||||
slug,
|
||||
req,
|
||||
knowledge_source,
|
||||
body.trim_end()
|
||||
)));
|
||||
}
|
||||
out.push(Item::system_message(format!(
|
||||
"[Workflow /{}]\n{}",
|
||||
"[Workflow /{} from {}]\n{}",
|
||||
slug,
|
||||
record.source.label(),
|
||||
record.body.trim_end()
|
||||
)));
|
||||
Ok(out)
|
||||
@@ -165,10 +196,41 @@ mod tests {
|
||||
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 /run-it from workspace workflow]"));
|
||||
assert!(second.contains("workflow body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_workflow_uses_builtin_required_knowledge_when_workspace_missing() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
let registry = workflow_crate::load_workflows(&layout).unwrap();
|
||||
let items =
|
||||
resolve_workflow_invocation(®istry, &layout, "ticket-intake-workflow").unwrap();
|
||||
let first = format!("{:?}", items[0]);
|
||||
let second = format!("{:?}", items[1]);
|
||||
assert!(first.contains("Knowledge #workflow-resource-boundary from builtin"));
|
||||
assert!(first.contains("Builtin workflow resources live under"));
|
||||
assert!(second.contains("[Workflow /ticket-intake-workflow from builtin workflow]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_knowledge_overrides_builtin_required_knowledge() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
write(
|
||||
&dir.path()
|
||||
.join(".yoi/knowledge/workflow-resource-boundary.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---\nworkspace override knowledge\n",
|
||||
);
|
||||
let registry = workflow_crate::load_workflows(&layout).unwrap();
|
||||
let items =
|
||||
resolve_workflow_invocation(®istry, &layout, "ticket-intake-workflow").unwrap();
|
||||
let first = format!("{:?}", items[0]);
|
||||
assert!(first.contains("Knowledge #workflow-resource-boundary from workspace"));
|
||||
assert!(first.contains("workspace override knowledge"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_invocable_false_errors() {
|
||||
let (dir, layout, _registry) = setup();
|
||||
|
||||
+131
-42
@@ -27,19 +27,23 @@ pub const WORKFLOW_DESCRIPTION_HARD_CAP: usize = 1024;
|
||||
/// win over external skills.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WorkflowSource {
|
||||
Builtin,
|
||||
/// `<workspace>/.yoi/workflow/<slug>.md`. Authored in-tree by
|
||||
/// the project.
|
||||
WorkspaceWorkflow,
|
||||
/// SKILL.md ingested from a `[skills] directories` entry in the
|
||||
/// manifest. `dir` is the skills root that contained
|
||||
/// `<slug>/SKILL.md`.
|
||||
Skill { dir: PathBuf },
|
||||
Skill {
|
||||
dir: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
impl WorkflowSource {
|
||||
/// Human-readable label used in shadow-notification messages.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Builtin => "builtin workflow",
|
||||
Self::WorkspaceWorkflow => "workspace workflow",
|
||||
Self::Skill { .. } => "skill",
|
||||
}
|
||||
@@ -188,12 +192,93 @@ pub enum WorkflowLoadError {
|
||||
},
|
||||
}
|
||||
|
||||
struct BuiltinWorkflowResource {
|
||||
slug: &'static str,
|
||||
content: &'static str,
|
||||
}
|
||||
|
||||
const BUILTIN_WORKFLOWS: &[BuiltinWorkflowResource] = &[
|
||||
BuiltinWorkflowResource {
|
||||
slug: "ticket-intake-workflow",
|
||||
content: include_str!("../../../resources/workflows/ticket-intake-workflow.md"),
|
||||
},
|
||||
BuiltinWorkflowResource {
|
||||
slug: "ticket-orchestrator-routing",
|
||||
content: include_str!("../../../resources/workflows/ticket-orchestrator-routing.md"),
|
||||
},
|
||||
BuiltinWorkflowResource {
|
||||
slug: "multi-agent-workflow",
|
||||
content: include_str!("../../../resources/workflows/multi-agent-workflow.md"),
|
||||
},
|
||||
];
|
||||
|
||||
fn builtin_workflow_records() -> Result<BTreeMap<Slug, WorkflowRecord>, WorkflowLoadError> {
|
||||
let mut records = BTreeMap::new();
|
||||
for resource in BUILTIN_WORKFLOWS {
|
||||
let path = PathBuf::from(format!("builtin:{}", resource.slug));
|
||||
records.insert(
|
||||
Slug::parse(resource.slug).map_err(|source| WorkflowLoadError::InvalidSlug {
|
||||
path: path.clone(),
|
||||
source: source.into(),
|
||||
})?,
|
||||
parse_workflow_record(
|
||||
Slug::parse(resource.slug).map_err(|source| WorkflowLoadError::InvalidSlug {
|
||||
path: path.clone(),
|
||||
source: source.into(),
|
||||
})?,
|
||||
path,
|
||||
WorkflowSource::Builtin,
|
||||
resource.content,
|
||||
)?,
|
||||
);
|
||||
}
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
fn parse_workflow_record(
|
||||
slug: Slug,
|
||||
path: PathBuf,
|
||||
source: WorkflowSource,
|
||||
raw: &str,
|
||||
) -> Result<WorkflowRecord, WorkflowLoadError> {
|
||||
let (yaml, body) = split_frontmatter(raw).map_err(|source| WorkflowLoadError::Frontmatter {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
warn_unknown_workflow_fields(&path, yaml);
|
||||
let frontmatter: WorkflowFrontmatter =
|
||||
serde_yaml::from_str(yaml).map_err(|err| WorkflowLoadError::Frontmatter {
|
||||
path: path.clone(),
|
||||
source: map_serde_workflow_error(err),
|
||||
})?;
|
||||
if frontmatter.model_invokation
|
||||
&& frontmatter.description.chars().count() > WORKFLOW_DESCRIPTION_HARD_CAP
|
||||
{
|
||||
return Err(WorkflowLoadError::DescriptionTooLong {
|
||||
path,
|
||||
actual: frontmatter.description.chars().count(),
|
||||
limit: WORKFLOW_DESCRIPTION_HARD_CAP,
|
||||
});
|
||||
}
|
||||
Ok(WorkflowRecord {
|
||||
slug,
|
||||
description: frontmatter.description,
|
||||
model_invokation: frontmatter.model_invokation,
|
||||
user_invocable: frontmatter.user_invocable,
|
||||
requires: frontmatter.requires,
|
||||
body: body.to_string(),
|
||||
path,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_workflows(layout: &WorkspaceLayout) -> Result<WorkflowRegistry, WorkflowLoadError> {
|
||||
let mut records = builtin_workflow_records()?;
|
||||
let dir = layout.workflow_dir();
|
||||
let entries = match std::fs::read_dir(&dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||
return Ok(WorkflowRegistry::empty());
|
||||
return Ok(WorkflowRegistry { records });
|
||||
}
|
||||
Err(source) => return Err(WorkflowLoadError::ReadDir { dir, source }),
|
||||
};
|
||||
@@ -211,7 +296,6 @@ pub fn load_workflows(layout: &WorkspaceLayout) -> Result<WorkflowRegistry, Work
|
||||
}
|
||||
paths.sort();
|
||||
|
||||
let mut records = BTreeMap::new();
|
||||
for path in paths {
|
||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
@@ -221,45 +305,18 @@ pub fn load_workflows(layout: &WorkspaceLayout) -> Result<WorkflowRegistry, Work
|
||||
path: path.clone(),
|
||||
source: source.into(),
|
||||
})?;
|
||||
if records.contains_key(&slug) {
|
||||
warn!(slug = %slug, path = %path.display(), "duplicate workflow slug encountered; keeping first record");
|
||||
continue;
|
||||
if let Some(existing) = records.get(&slug) {
|
||||
if !matches!(existing.source, WorkflowSource::Builtin) {
|
||||
warn!(slug = %slug, path = %path.display(), "duplicate workflow slug encountered; keeping first record");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let raw = std::fs::read_to_string(&path).map_err(|source| WorkflowLoadError::ReadFile {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
let (yaml, body) =
|
||||
split_frontmatter(&raw).map_err(|source| WorkflowLoadError::Frontmatter {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
warn_unknown_workflow_fields(&path, yaml);
|
||||
let frontmatter: WorkflowFrontmatter =
|
||||
serde_yaml::from_str(yaml).map_err(|err| WorkflowLoadError::Frontmatter {
|
||||
path: path.clone(),
|
||||
source: map_serde_workflow_error(err),
|
||||
})?;
|
||||
if frontmatter.model_invokation
|
||||
&& frontmatter.description.chars().count() > WORKFLOW_DESCRIPTION_HARD_CAP
|
||||
{
|
||||
return Err(WorkflowLoadError::DescriptionTooLong {
|
||||
path,
|
||||
actual: frontmatter.description.chars().count(),
|
||||
limit: WORKFLOW_DESCRIPTION_HARD_CAP,
|
||||
});
|
||||
}
|
||||
|
||||
let record = WorkflowRecord {
|
||||
slug: slug.clone(),
|
||||
description: frontmatter.description,
|
||||
model_invokation: frontmatter.model_invokation,
|
||||
user_invocable: frontmatter.user_invocable,
|
||||
requires: frontmatter.requires,
|
||||
body: body.to_string(),
|
||||
path: path.clone(),
|
||||
source: WorkflowSource::WorkspaceWorkflow,
|
||||
};
|
||||
let record =
|
||||
parse_workflow_record(slug.clone(), path, WorkflowSource::WorkspaceWorkflow, &raw)?;
|
||||
records.insert(slug.clone(), record);
|
||||
}
|
||||
|
||||
@@ -327,11 +384,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_directory_loads_empty_registry() {
|
||||
fn missing_directory_loads_builtin_registry() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
let got = load_workflows(&layout).unwrap();
|
||||
assert!(got.is_empty());
|
||||
assert!(got.get(&Slug::parse("ghost").unwrap()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -358,8 +415,40 @@ mod tests {
|
||||
"Body",
|
||||
);
|
||||
let got = load_workflows(&layout).unwrap();
|
||||
assert_eq!(got.resident_entries()[0].slug, "auto");
|
||||
assert!(got.list_user_invocable("").is_empty());
|
||||
assert!(
|
||||
got.resident_entries()
|
||||
.iter()
|
||||
.any(|entry| entry.slug == "auto")
|
||||
);
|
||||
assert!(!got.list_user_invocable("").contains(&"auto".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_workflow_overrides_builtin_by_slug() {
|
||||
let (dir, layout) = setup();
|
||||
write_workflow(
|
||||
dir.path(),
|
||||
"ticket-intake-workflow",
|
||||
"description: Workspace intake\nmodel_invokation: false",
|
||||
"workspace override body",
|
||||
);
|
||||
let got = load_workflows(&layout).unwrap();
|
||||
let slug = Slug::parse("ticket-intake-workflow").unwrap();
|
||||
let record = got.get(&slug).unwrap();
|
||||
assert_eq!(record.source, WorkflowSource::WorkspaceWorkflow);
|
||||
assert_eq!(record.description, "Workspace intake");
|
||||
assert_eq!(record.body, "workspace override body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_workflow_records_have_visible_provenance() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
let got = load_workflows(&layout).unwrap();
|
||||
let slug = Slug::parse("multi-agent-workflow").unwrap();
|
||||
let record = got.get(&slug).unwrap();
|
||||
assert_eq!(record.source, WorkflowSource::Builtin);
|
||||
assert_eq!(record.path, PathBuf::from("builtin:multi-agent-workflow"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -393,7 +482,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let got = load_workflows(&layout).unwrap();
|
||||
assert!(got.is_empty());
|
||||
assert!(got.get(&Slug::parse("ghost").unwrap()).is_none());
|
||||
}
|
||||
|
||||
fn skill_record(slug: &str, path: &Path) -> WorkflowRecord {
|
||||
|
||||
Reference in New Issue
Block a user