feat: add builtin workflow resources

This commit is contained in:
2026-06-11 17:44:42 +09:00
parent bf5b5bef48
commit 2418ad330e
6 changed files with 252 additions and 60 deletions
+80 -18
View File
@@ -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(&registry, &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(&registry, &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
View File
@@ -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 {