refactor: extract workflow crate
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "workflow"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
manifest = { workspace = true }
|
||||
memory = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_yaml = "0.9.34"
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Errors raised by Workflow loading and linting.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// A single Workflow linter violation.
|
||||
#[derive(Debug, Clone, Error, PartialEq, Eq)]
|
||||
pub enum WorkflowLintError {
|
||||
#[error("invalid slug `{0}`: must match ^[a-z0-9](?:[a-z0-9-]{{0,62}}[a-z0-9])?$")]
|
||||
InvalidSlug(String),
|
||||
|
||||
#[error("malformed frontmatter: {0}")]
|
||||
MalformedFrontmatter(String),
|
||||
|
||||
#[error("frontmatter is missing or document is empty")]
|
||||
MissingFrontmatter,
|
||||
|
||||
#[error("missing required frontmatter field: `{0}`")]
|
||||
MissingField(&'static str),
|
||||
|
||||
#[error(
|
||||
"Workflow with model_invokation: true cannot have description longer than {limit} chars (got {actual})"
|
||||
)]
|
||||
DescriptionTooLong { actual: usize, limit: usize },
|
||||
|
||||
#[error("body exceeds the Workflow size limit: {actual} chars > {limit}")]
|
||||
BodyTooLong { actual: usize, limit: usize },
|
||||
|
||||
#[error("`{field}` references unknown {kind} slug `{slug}`")]
|
||||
UnknownReference {
|
||||
field: &'static str,
|
||||
kind: &'static str,
|
||||
slug: String,
|
||||
},
|
||||
|
||||
#[error("path is not a valid Workflow location: {}", .0.display())]
|
||||
InvalidPath(PathBuf),
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Workflow records, loading, Agent Skill ingestion, and human-edit linting.
|
||||
|
||||
mod error;
|
||||
mod linter;
|
||||
mod schema;
|
||||
mod scope;
|
||||
mod skill;
|
||||
mod slug;
|
||||
mod workflow;
|
||||
|
||||
pub use error::WorkflowLintError;
|
||||
pub use linter::{WorkflowLintReport, WorkflowLinter};
|
||||
pub use schema::{WorkflowFrontmatter, split_frontmatter};
|
||||
pub use scope::deny_write_rules;
|
||||
pub use skill::{
|
||||
SKILL_FILENAME, SkillParseError, SkillRecord, load_skills_from_dir, parse_skill_md,
|
||||
};
|
||||
pub use slug::{Slug, is_valid_slug};
|
||||
pub use workflow::{
|
||||
ResidentWorkflowEntry, ShadowedSkill, WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowLoadError,
|
||||
WorkflowRecord, WorkflowRegistry, WorkflowSource, load_workflows,
|
||||
};
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Human-edit linter for Workflow files.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use memory::WorkspaceLayout;
|
||||
|
||||
use crate::{Slug, WorkflowLintError};
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::schema::{WORKFLOW_BODY_LIMIT, WorkflowFrontmatter, split_frontmatter};
|
||||
use crate::workflow::WORKFLOW_DESCRIPTION_HARD_CAP;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct WorkflowLintReport {
|
||||
pub errors: Vec<WorkflowLintError>,
|
||||
}
|
||||
|
||||
impl WorkflowLintReport {
|
||||
pub fn has_errors(&self) -> bool {
|
||||
!self.errors.is_empty()
|
||||
}
|
||||
|
||||
pub fn push_error(&mut self, err: WorkflowLintError) {
|
||||
self.errors.push(err);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkflowLinter {
|
||||
layout: WorkspaceLayout,
|
||||
}
|
||||
|
||||
impl WorkflowLinter {
|
||||
pub fn new(layout: WorkspaceLayout) -> Self {
|
||||
Self { layout }
|
||||
}
|
||||
|
||||
pub fn layout(&self) -> &WorkspaceLayout {
|
||||
&self.layout
|
||||
}
|
||||
|
||||
/// Validate a human-authored Workflow document.
|
||||
///
|
||||
/// Verifies frontmatter shape, body size, resident description size, and
|
||||
/// that every `requires` slug points at an existing Knowledge record.
|
||||
pub fn lint(&self, content: &str) -> WorkflowLintReport {
|
||||
let mut report = WorkflowLintReport::default();
|
||||
let parsed = match parse_frontmatter::<WorkflowFrontmatter>(content) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => {
|
||||
report.push_error(err);
|
||||
return report;
|
||||
}
|
||||
};
|
||||
|
||||
let body_chars = parsed.body.chars().count();
|
||||
if body_chars > WORKFLOW_BODY_LIMIT {
|
||||
report.push_error(WorkflowLintError::BodyTooLong {
|
||||
actual: body_chars,
|
||||
limit: WORKFLOW_BODY_LIMIT,
|
||||
});
|
||||
}
|
||||
|
||||
if parsed.frontmatter.model_invokation {
|
||||
let actual = parsed.frontmatter.description.chars().count();
|
||||
if actual > WORKFLOW_DESCRIPTION_HARD_CAP {
|
||||
report.push_error(WorkflowLintError::DescriptionTooLong {
|
||||
actual,
|
||||
limit: WORKFLOW_DESCRIPTION_HARD_CAP,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let knowledge = match scan_knowledge_slugs(&self.layout) {
|
||||
Ok(knowledge) => knowledge,
|
||||
Err(err) => {
|
||||
report.push_error(WorkflowLintError::MalformedFrontmatter(format!(
|
||||
"failed to scan existing Knowledge records: {err}"
|
||||
)));
|
||||
return report;
|
||||
}
|
||||
};
|
||||
|
||||
for slug in &parsed.frontmatter.requires {
|
||||
if !knowledge.contains(slug) {
|
||||
report.push_error(WorkflowLintError::UnknownReference {
|
||||
field: "requires",
|
||||
kind: "knowledge",
|
||||
slug: slug.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
report
|
||||
}
|
||||
}
|
||||
|
||||
struct Parsed<'a, F> {
|
||||
frontmatter: F,
|
||||
body: &'a str,
|
||||
}
|
||||
|
||||
fn parse_frontmatter<F: DeserializeOwned>(
|
||||
content: &str,
|
||||
) -> Result<Parsed<'_, F>, WorkflowLintError> {
|
||||
let (yaml, body) = split_frontmatter(content)?;
|
||||
let frontmatter = serde_yaml::from_str::<F>(yaml).map_err(|err| {
|
||||
let msg = err.to_string();
|
||||
if let Some(field) = parse_missing_field(&msg) {
|
||||
WorkflowLintError::MissingField(field)
|
||||
} else {
|
||||
WorkflowLintError::MalformedFrontmatter(msg)
|
||||
}
|
||||
})?;
|
||||
Ok(Parsed { frontmatter, body })
|
||||
}
|
||||
|
||||
fn parse_missing_field(msg: &str) -> Option<&'static str> {
|
||||
let needle = "missing field `";
|
||||
let start = msg.find(needle)? + needle.len();
|
||||
let end = msg[start..].find('`')? + start;
|
||||
match &msg[start..end] {
|
||||
"description" => Some("description"),
|
||||
"model_invokation" => Some("model_invokation"),
|
||||
"user_invocable" => Some("user_invocable"),
|
||||
"requires" => Some("requires"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_knowledge_slugs(layout: &WorkspaceLayout) -> std::io::Result<HashSet<Slug>> {
|
||||
let mut out = HashSet::new();
|
||||
let entries = match std::fs::read_dir(layout.knowledge_dir()) {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(out),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if !path.is_file() || path.extension().and_then(|s| s.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(slug) = Slug::parse(stem) {
|
||||
out.insert(slug);
|
||||
}
|
||||
}
|
||||
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 workspace() -> (TempDir, WorkflowLinter) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
(dir, WorkflowLinter::new(layout))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_lint_accepts_valid_file() {
|
||||
let (dir, linter) = workspace();
|
||||
write(
|
||||
&dir.path().join(".insomnia/knowledge/policy.md"),
|
||||
"---\ndescription: p\n---\nbody",
|
||||
);
|
||||
let wf = "---\ndescription: run\nrequires: [policy]\n---\nbody";
|
||||
let report = linter.lint(wf);
|
||||
assert!(!report.has_errors(), "{:?}", report.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_lint_rejects_missing_required_knowledge() {
|
||||
let (_dir, linter) = workspace();
|
||||
let wf = "---\ndescription: run\nrequires: [ghost]\n---\nbody";
|
||||
let report = linter.lint(wf);
|
||||
assert!(report.errors.iter().any(|err| matches!(
|
||||
err,
|
||||
WorkflowLintError::UnknownReference { field: "requires", kind: "knowledge", slug }
|
||||
if slug == "ghost"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_lint_enforces_resident_description_cap() {
|
||||
let (_dir, linter) = workspace();
|
||||
let desc = "x".repeat(WORKFLOW_DESCRIPTION_HARD_CAP + 1);
|
||||
let wf = format!("---\ndescription: {desc}\nmodel_invokation: true\n---\nbody");
|
||||
let report = linter.lint(&wf);
|
||||
assert!(
|
||||
report
|
||||
.errors
|
||||
.iter()
|
||||
.any(|err| matches!(err, WorkflowLintError::DescriptionTooLong { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_lint_enforces_body_limit() {
|
||||
let (_dir, linter) = workspace();
|
||||
let body = "x".repeat(WORKFLOW_BODY_LIMIT + 1);
|
||||
let wf = format!("---\ndescription: run\n---\n{body}");
|
||||
let report = linter.lint(&wf);
|
||||
assert!(
|
||||
report
|
||||
.errors
|
||||
.iter()
|
||||
.any(|err| matches!(err, WorkflowLintError::BodyTooLong { .. }))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Workflow frontmatter schema and frontmatter splitting helpers.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Slug, WorkflowLintError};
|
||||
|
||||
pub const WORKFLOW_BODY_LIMIT: usize = 8000;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct WorkflowFrontmatter {
|
||||
/// Workflows do not require timestamps in the MVP. Human-authored files
|
||||
/// may carry them.
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub model_invokation: bool,
|
||||
#[serde(default = "default_user_invocable")]
|
||||
pub user_invocable: bool,
|
||||
#[serde(default)]
|
||||
pub requires: Vec<Slug>,
|
||||
}
|
||||
|
||||
fn default_user_invocable() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
const FRONTMATTER_DELIM: &str = "---";
|
||||
|
||||
/// Split a markdown document into `(yaml_frontmatter, body)`.
|
||||
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), WorkflowLintError> {
|
||||
let after_open = content
|
||||
.strip_prefix(FRONTMATTER_DELIM)
|
||||
.and_then(|s| s.strip_prefix('\n').or(Some(s)))
|
||||
.ok_or(WorkflowLintError::MissingFrontmatter)?;
|
||||
|
||||
let mut yaml_end = None;
|
||||
let mut byte_offset = 0usize;
|
||||
for line in after_open.split_inclusive('\n') {
|
||||
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
|
||||
if trimmed == FRONTMATTER_DELIM {
|
||||
yaml_end = Some((byte_offset, byte_offset + line.len()));
|
||||
break;
|
||||
}
|
||||
byte_offset += line.len();
|
||||
}
|
||||
|
||||
let (yaml_end_excl, body_start) = yaml_end.ok_or_else(|| {
|
||||
WorkflowLintError::MalformedFrontmatter("missing closing `---` line".to_string())
|
||||
})?;
|
||||
|
||||
let yaml = &after_open[..yaml_end_excl];
|
||||
let body = &after_open[body_start..];
|
||||
Ok((yaml, body))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn splits_simple() {
|
||||
let doc = "---\nfoo: 1\n---\nbody here\n";
|
||||
let (y, b) = split_frontmatter(doc).unwrap();
|
||||
assert_eq!(y, "foo: 1\n");
|
||||
assert_eq!(b, "body here\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_leading_delim_errors() {
|
||||
let err = split_frontmatter("hello").unwrap_err();
|
||||
assert!(matches!(err, WorkflowLintError::MissingFrontmatter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_closing_delim_errors() {
|
||||
let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err();
|
||||
assert!(matches!(err, WorkflowLintError::MalformedFrontmatter(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_empty_body() {
|
||||
let doc = "---\nfoo: 1\n---\n";
|
||||
let (_, b) = split_frontmatter(doc).unwrap();
|
||||
assert_eq!(b, "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Scope deny helpers for human-authored Workflow files.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use manifest::{Permission, ScopeRule};
|
||||
use memory::WorkspaceLayout;
|
||||
|
||||
/// Build deny rules that strip Write permission from
|
||||
/// `<workspace>/.insomnia/workflow/` for generic CRUD tools.
|
||||
pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
|
||||
vec![deny_write(layout.workflow_dir().as_path())]
|
||||
}
|
||||
|
||||
fn deny_write(target: &Path) -> ScopeRule {
|
||||
ScopeRule {
|
||||
target: target.to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn deny_targets_workflow() {
|
||||
let layout = WorkspaceLayout::new(PathBuf::from("/ws"));
|
||||
let rules = deny_write_rules(&layout);
|
||||
assert_eq!(rules.len(), 1);
|
||||
assert_eq!(rules[0].target, PathBuf::from("/ws/.insomnia/workflow"));
|
||||
assert_eq!(rules[0].permission, Permission::Write);
|
||||
assert!(rules[0].recursive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
//! Agent Skills (`SKILL.md`) parser.
|
||||
//!
|
||||
//! Skills follow the [agentskills.io](https://agentskills.io/specification)
|
||||
//! spec: a directory `<root>/<name>/` containing `SKILL.md` (YAML frontmatter
|
||||
//! + Markdown body) and optional `scripts/` / `references/` / `assets/`
|
||||
//! subdirectories. The body is procedural agent guidance; insomnia ingests
|
||||
//! it as a Workflow so `/<name>` resolves to it just like an internal
|
||||
//! Workflow.
|
||||
//!
|
||||
//! Parsing is intentionally lenient at the directory-scan level — one
|
||||
//! malformed SKILL.md emits `tracing::warn!` and is skipped, leaving sibling
|
||||
//! skills loadable. Internal Workflows (`.insomnia/workflow/<slug>.md`) keep
|
||||
//! their hard-error semantics.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::schema::split_frontmatter;
|
||||
use crate::workflow::{WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowRecord, WorkflowSource};
|
||||
use crate::{Slug, WorkflowLintError};
|
||||
|
||||
/// Filename within a skill directory carrying the frontmatter + body.
|
||||
pub const SKILL_FILENAME: &str = "SKILL.md";
|
||||
|
||||
/// SKILL.md frontmatter as defined by the agent-skills spec.
|
||||
///
|
||||
/// Fields beyond `name` / `description` are accepted to be spec-compatible
|
||||
/// but not used by insomnia today: `license`, `compatibility`, and
|
||||
/// `metadata` are documentary, while `allowed-tools` is recognised and
|
||||
/// emits a warning until [`permission-extension-point.md`] lands.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct SkillFrontmatter {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub license: Option<String>,
|
||||
#[serde(default)]
|
||||
pub compatibility: Option<String>,
|
||||
#[serde(default)]
|
||||
pub metadata: Option<serde_yaml::Value>,
|
||||
#[serde(default, rename = "allowed-tools")]
|
||||
pub allowed_tools: Option<serde_yaml::Value>,
|
||||
}
|
||||
|
||||
/// Validated skill record. Constructed by [`parse_skill_md`] and converted
|
||||
/// to a `WorkflowRecord` by the caller via the `Skill → Workflow`
|
||||
/// projection in [`crate::WorkflowRecord`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SkillRecord {
|
||||
pub slug: Slug,
|
||||
pub description: String,
|
||||
pub body: String,
|
||||
/// The skill directory (parent of `SKILL.md`). Carried so callers can
|
||||
/// register `scripts/` / `references/` / `assets/` against the Pod's
|
||||
/// scope.
|
||||
pub dir: PathBuf,
|
||||
/// Path to the `SKILL.md` file itself. Used as the resolved path on
|
||||
/// the resulting `WorkflowRecord`.
|
||||
pub skill_md_path: PathBuf,
|
||||
}
|
||||
|
||||
impl SkillRecord {
|
||||
/// Project this skill into a [`WorkflowRecord`]. Skill-sourced
|
||||
/// Workflows are advertised resident (`model_invokation: true`,
|
||||
/// matching the agentskills progressive-disclosure model), are
|
||||
/// invocable as `/<slug>`, and carry no `requires` since the SKILL
|
||||
/// spec has no Knowledge-dependency concept.
|
||||
pub fn into_workflow_record(self, source: WorkflowSource) -> WorkflowRecord {
|
||||
WorkflowRecord {
|
||||
slug: self.slug,
|
||||
description: self.description,
|
||||
model_invokation: true,
|
||||
user_invocable: true,
|
||||
requires: Vec::new(),
|
||||
body: self.body,
|
||||
path: self.skill_md_path,
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SkillParseError {
|
||||
#[error("skill path has no parent directory: {}", .0.display())]
|
||||
NoParentDir(PathBuf),
|
||||
#[error("failed to read SKILL.md at {}: {source}", .path.display())]
|
||||
ReadFile { path: PathBuf, source: io::Error },
|
||||
#[error("invalid frontmatter in {}: {source}", .path.display())]
|
||||
Frontmatter {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: WorkflowLintError,
|
||||
},
|
||||
#[error(
|
||||
"SKILL.md `name` `{name}` does not match its directory name `{dir_name}` (at {})",
|
||||
.skill_md_path.display()
|
||||
)]
|
||||
NameDirMismatch {
|
||||
name: String,
|
||||
dir_name: String,
|
||||
skill_md_path: PathBuf,
|
||||
},
|
||||
#[error("SKILL.md `name` is not a valid slug at {}: {source}", .skill_md_path.display())]
|
||||
InvalidName {
|
||||
skill_md_path: PathBuf,
|
||||
#[source]
|
||||
source: WorkflowLintError,
|
||||
},
|
||||
#[error("SKILL.md `description` must be non-empty (at {})", .skill_md_path.display())]
|
||||
DescriptionEmpty { skill_md_path: PathBuf },
|
||||
#[error(
|
||||
"SKILL.md `description` length {actual} exceeds limit {limit} (at {})",
|
||||
.skill_md_path.display()
|
||||
)]
|
||||
DescriptionTooLong {
|
||||
skill_md_path: PathBuf,
|
||||
actual: usize,
|
||||
limit: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// Parse a single `SKILL.md`. The directory name is taken from the parent
|
||||
/// of `skill_md_path` and validated against the frontmatter `name`.
|
||||
pub fn parse_skill_md(skill_md_path: &Path) -> Result<SkillRecord, SkillParseError> {
|
||||
let dir = skill_md_path
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.ok_or_else(|| SkillParseError::NoParentDir(skill_md_path.to_path_buf()))?;
|
||||
let dir_name = dir
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| SkillParseError::NoParentDir(skill_md_path.to_path_buf()))?;
|
||||
|
||||
let raw =
|
||||
std::fs::read_to_string(skill_md_path).map_err(|source| SkillParseError::ReadFile {
|
||||
path: skill_md_path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
let (yaml, body) = split_frontmatter(&raw).map_err(|source| SkillParseError::Frontmatter {
|
||||
path: skill_md_path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
warn_unknown_skill_fields(skill_md_path, yaml);
|
||||
let frontmatter: SkillFrontmatter =
|
||||
serde_yaml::from_str(yaml).map_err(|err| SkillParseError::Frontmatter {
|
||||
path: skill_md_path.to_path_buf(),
|
||||
source: WorkflowLintError::MalformedFrontmatter(err.to_string()),
|
||||
})?;
|
||||
|
||||
if frontmatter.allowed_tools.is_some() {
|
||||
warn!(
|
||||
path = %skill_md_path.display(),
|
||||
"SKILL.md `allowed-tools` is recognised but not yet enforced; ignoring"
|
||||
);
|
||||
}
|
||||
|
||||
let desc_chars = frontmatter.description.chars().count();
|
||||
if desc_chars == 0 {
|
||||
return Err(SkillParseError::DescriptionEmpty {
|
||||
skill_md_path: skill_md_path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
if desc_chars > WORKFLOW_DESCRIPTION_HARD_CAP {
|
||||
return Err(SkillParseError::DescriptionTooLong {
|
||||
skill_md_path: skill_md_path.to_path_buf(),
|
||||
actual: desc_chars,
|
||||
limit: WORKFLOW_DESCRIPTION_HARD_CAP,
|
||||
});
|
||||
}
|
||||
|
||||
if frontmatter.name != dir_name {
|
||||
return Err(SkillParseError::NameDirMismatch {
|
||||
name: frontmatter.name,
|
||||
dir_name,
|
||||
skill_md_path: skill_md_path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
let slug = Slug::parse(frontmatter.name).map_err(|source| SkillParseError::InvalidName {
|
||||
skill_md_path: skill_md_path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(SkillRecord {
|
||||
slug,
|
||||
description: frontmatter.description,
|
||||
body: body.to_string(),
|
||||
dir,
|
||||
skill_md_path: skill_md_path.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Scan a skills root for `<root>/<name>/SKILL.md`. Returns successfully
|
||||
/// parsed skills; per-skill errors emit a `tracing::warn!` and are
|
||||
/// skipped. A missing root is treated as zero skills, not an error —
|
||||
/// callers can probe optional directories without pre-checking.
|
||||
pub fn load_skills_from_dir(root: &Path) -> Vec<SkillRecord> {
|
||||
let entries = match std::fs::read_dir(root) {
|
||||
Ok(it) => it,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Vec::new(),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
dir = %root.display(),
|
||||
error = %err,
|
||||
"failed to read skills directory; treating as empty"
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
let mut paths: Vec<PathBuf> = Vec::new();
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
dir = %root.display(),
|
||||
error = %err,
|
||||
"skill directory entry read error; skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let skill_md = path.join(SKILL_FILENAME);
|
||||
if skill_md.is_file() {
|
||||
paths.push(skill_md);
|
||||
}
|
||||
}
|
||||
paths.sort();
|
||||
|
||||
let mut out = Vec::new();
|
||||
for path in paths {
|
||||
match parse_skill_md(&path) {
|
||||
Ok(record) => out.push(record),
|
||||
Err(err) => warn!(path = %path.display(), error = %err, "SKILL.md skipped"),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn warn_unknown_skill_fields(path: &Path, yaml: &str) {
|
||||
let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(yaml) else {
|
||||
return;
|
||||
};
|
||||
let Some(map) = value.as_mapping() else {
|
||||
return;
|
||||
};
|
||||
for key in map.keys().filter_map(|k| k.as_str()) {
|
||||
if !matches!(
|
||||
key,
|
||||
"name" | "description" | "license" | "compatibility" | "metadata" | "allowed-tools"
|
||||
) {
|
||||
warn!(path = %path.display(), field = key, "unknown SKILL.md frontmatter field ignored");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_skill(root: &Path, name: &str, frontmatter: &str, body: &str) -> PathBuf {
|
||||
let dir = root.join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join(SKILL_FILENAME);
|
||||
std::fs::write(&path, format!("---\n{frontmatter}\n---\n{body}")).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_minimal_skill() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = write_skill(
|
||||
dir.path(),
|
||||
"do-thing",
|
||||
"name: do-thing\ndescription: Do the thing",
|
||||
"Step 1\nStep 2\n",
|
||||
);
|
||||
let record = parse_skill_md(&path).unwrap();
|
||||
assert_eq!(record.slug.as_str(), "do-thing");
|
||||
assert_eq!(record.description, "Do the thing");
|
||||
assert_eq!(record.body, "Step 1\nStep 2\n");
|
||||
assert_eq!(record.dir, dir.path().join("do-thing"));
|
||||
assert_eq!(record.skill_md_path, path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_dir_mismatch_is_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = write_skill(
|
||||
dir.path(),
|
||||
"actual-dir",
|
||||
"name: declared-name\ndescription: x",
|
||||
"body",
|
||||
);
|
||||
let err = parse_skill_md(&path).unwrap_err();
|
||||
assert!(matches!(err, SkillParseError::NameDirMismatch { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_slug_name_is_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = write_skill(
|
||||
dir.path(),
|
||||
"BAD-Caps",
|
||||
"name: BAD-Caps\ndescription: x",
|
||||
"body",
|
||||
);
|
||||
// Slug::parse rejects uppercase before the dir match check fires;
|
||||
// either way the parse is rejected.
|
||||
let err = parse_skill_md(&path).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
SkillParseError::InvalidName { .. } | SkillParseError::NameDirMismatch { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_description_is_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = write_skill(dir.path(), "x", "name: x\ndescription: \"\"", "body");
|
||||
let err = parse_skill_md(&path).unwrap_err();
|
||||
assert!(matches!(err, SkillParseError::DescriptionEmpty { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_at_cap_is_accepted() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let desc = "x".repeat(WORKFLOW_DESCRIPTION_HARD_CAP);
|
||||
let path = write_skill(
|
||||
dir.path(),
|
||||
"x",
|
||||
&format!("name: x\ndescription: {desc}"),
|
||||
"body",
|
||||
);
|
||||
let record = parse_skill_md(&path).unwrap();
|
||||
assert_eq!(
|
||||
record.description.chars().count(),
|
||||
WORKFLOW_DESCRIPTION_HARD_CAP
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_over_cap_is_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let desc = "x".repeat(WORKFLOW_DESCRIPTION_HARD_CAP + 1);
|
||||
let path = write_skill(
|
||||
dir.path(),
|
||||
"x",
|
||||
&format!("name: x\ndescription: {desc}"),
|
||||
"body",
|
||||
);
|
||||
let err = parse_skill_md(&path).unwrap_err();
|
||||
assert!(matches!(err, SkillParseError::DescriptionTooLong { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_frontmatter_is_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("x").join(SKILL_FILENAME);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, "no frontmatter at all\n").unwrap();
|
||||
let err = parse_skill_md(&path).unwrap_err();
|
||||
assert!(matches!(err, SkillParseError::Frontmatter { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_frontmatter_fields_are_kept() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = write_skill(
|
||||
dir.path(),
|
||||
"x",
|
||||
"name: x\ndescription: ok\nlicense: MIT\ncompatibility: claude-4\n\
|
||||
metadata:\n team: foo\nallowed-tools:\n - Read",
|
||||
"body",
|
||||
);
|
||||
let record = parse_skill_md(&path).unwrap();
|
||||
assert_eq!(record.slug.as_str(), "x");
|
||||
// allowed-tools triggers a warn, but parse succeeds.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_skills_from_dir_skips_broken_and_keeps_good() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_skill(dir.path(), "good", "name: good\ndescription: ok", "body");
|
||||
// Mismatch — should be skipped, not abort the scan.
|
||||
write_skill(
|
||||
dir.path(),
|
||||
"bad-dir",
|
||||
"name: declared-different\ndescription: ok",
|
||||
"body",
|
||||
);
|
||||
// A bare file at the root (not a directory) is ignored.
|
||||
std::fs::write(dir.path().join("stray.md"), "not a skill").unwrap();
|
||||
|
||||
let records = load_skills_from_dir(dir.path());
|
||||
let slugs: Vec<&str> = records.iter().map(|r| r.slug.as_str()).collect();
|
||||
assert_eq!(slugs, vec!["good"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_skills_from_dir_missing_root_is_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let records = load_skills_from_dir(&dir.path().join("does-not-exist"));
|
||||
assert!(records.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_workflow_record_uses_skill_defaults() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = write_skill(
|
||||
dir.path(),
|
||||
"x",
|
||||
"name: x\ndescription: Project X",
|
||||
"Steps\n",
|
||||
);
|
||||
let record = parse_skill_md(&path).unwrap();
|
||||
let wf = record.into_workflow_record(WorkflowSource::Skill {
|
||||
dir: dir.path().to_path_buf(),
|
||||
});
|
||||
assert_eq!(wf.slug.as_str(), "x");
|
||||
assert_eq!(wf.description, "Project X");
|
||||
assert!(wf.model_invokation);
|
||||
assert!(wf.user_invocable);
|
||||
assert!(wf.requires.is_empty());
|
||||
assert_eq!(wf.body, "Steps\n");
|
||||
assert!(matches!(wf.source, WorkflowSource::Skill { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_skills_from_dir_orders_deterministically() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
write_skill(dir.path(), "b", "name: b\ndescription: b", "");
|
||||
write_skill(dir.path(), "a", "name: a\ndescription: a", "");
|
||||
write_skill(dir.path(), "c", "name: c\ndescription: c", "");
|
||||
let records = load_skills_from_dir(dir.path());
|
||||
let slugs: Vec<&str> = records.iter().map(|r| r.slug.as_str()).collect();
|
||||
assert_eq!(slugs, vec!["a", "b", "c"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Slug type and validation.
|
||||
//!
|
||||
//! Syntax (agent-skills compatible):
|
||||
//! ^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$
|
||||
//! - 1–64 chars
|
||||
//! - lowercase ASCII alphanumerics and `-`
|
||||
//! - cannot start or end with `-`
|
||||
//! - no consecutive `--`
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::WorkflowLintError;
|
||||
|
||||
const MIN_LEN: usize = 1;
|
||||
const MAX_LEN: usize = 64;
|
||||
|
||||
/// Validated slug. Constructible only via [`Slug::parse`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Slug(String);
|
||||
|
||||
impl Slug {
|
||||
/// Parse and validate. Returns [`WorkflowLintError::InvalidSlug`] on rejection.
|
||||
pub fn parse(s: impl Into<String>) -> Result<Self, WorkflowLintError> {
|
||||
let s = s.into();
|
||||
if is_valid_slug(&s) {
|
||||
Ok(Self(s))
|
||||
} else {
|
||||
Err(WorkflowLintError::InvalidSlug(s))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Slug {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for Slug {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Slug {
|
||||
type Err = WorkflowLintError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::parse(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Slug {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let raw = String::deserialize(deserializer)?;
|
||||
Self::parse(raw).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure-fn predicate matching the agent-skills slug regex without
|
||||
/// pulling in the `regex` crate.
|
||||
pub fn is_valid_slug(s: &str) -> bool {
|
||||
let bytes = s.as_bytes();
|
||||
let len = bytes.len();
|
||||
if len < MIN_LEN || len > MAX_LEN {
|
||||
return false;
|
||||
}
|
||||
if !is_alnum_lower(bytes[0]) || !is_alnum_lower(bytes[len - 1]) {
|
||||
return false;
|
||||
}
|
||||
let mut prev_dash = false;
|
||||
for &b in bytes {
|
||||
if b == b'-' {
|
||||
if prev_dash {
|
||||
return false;
|
||||
}
|
||||
prev_dash = true;
|
||||
} else if is_alnum_lower(b) {
|
||||
prev_dash = false;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn is_alnum_lower(b: u8) -> bool {
|
||||
b.is_ascii_digit() || b.is_ascii_lowercase()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_basic_slugs() {
|
||||
for s in ["a", "ab", "abc-def", "x9", "a-b-c", "123", "a-1"] {
|
||||
assert!(is_valid_slug(s), "expected `{s}` valid");
|
||||
assert!(Slug::parse(s).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_slugs() {
|
||||
for s in [
|
||||
"", "-", "-foo", "foo-", "Foo", "foo_bar", "foo bar", "foo--bar", "foo.bar", "ä",
|
||||
] {
|
||||
assert!(!is_valid_slug(s), "expected `{s}` invalid");
|
||||
assert!(Slug::parse(s).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforces_length_bounds() {
|
||||
let too_long = "a".repeat(MAX_LEN + 1);
|
||||
assert!(!is_valid_slug(&too_long));
|
||||
let max = "a".repeat(MAX_LEN);
|
||||
assert!(is_valid_slug(&max));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_via_serde() {
|
||||
let json = "\"valid-slug\"";
|
||||
let slug: Slug = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(slug.as_str(), "valid-slug");
|
||||
|
||||
let bad = "\"BAD\"";
|
||||
let err: Result<Slug, _> = serde_json::from_str(bad);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
//! Workflow loader and registry.
|
||||
//!
|
||||
//! Workflows live under `<workspace>/.insomnia/workflow/<slug>.md`. They are
|
||||
//! human-authored Markdown documents with YAML frontmatter. The loader is
|
||||
//! intentionally strict about malformed records because Pod startup should
|
||||
//! fail rather than silently ignoring a broken procedural instruction.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use thiserror::Error;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::schema::{WorkflowFrontmatter, split_frontmatter};
|
||||
use memory::WorkspaceLayout;
|
||||
|
||||
use crate::{Slug, WorkflowLintError};
|
||||
|
||||
/// Hard cap on Workflow descriptions that are advertised resident.
|
||||
/// Mirrors agent-skills and resident Knowledge descriptions.
|
||||
pub const WORKFLOW_DESCRIPTION_HARD_CAP: usize = 1024;
|
||||
|
||||
/// Origin of a [`WorkflowRecord`]. Used to break ties when the same slug
|
||||
/// is provided by multiple sources: workspace-authored Workflows always
|
||||
/// win over external skills.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WorkflowSource {
|
||||
/// `<workspace>/.insomnia/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 },
|
||||
}
|
||||
|
||||
impl WorkflowSource {
|
||||
/// Human-readable label used in shadow-notification messages.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::WorkspaceWorkflow => "workspace workflow",
|
||||
Self::Skill { .. } => "skill",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkflowRecord {
|
||||
pub slug: Slug,
|
||||
pub description: String,
|
||||
pub model_invokation: bool,
|
||||
pub user_invocable: bool,
|
||||
pub requires: Vec<Slug>,
|
||||
/// Markdown body after the closing frontmatter delimiter.
|
||||
pub body: String,
|
||||
pub path: PathBuf,
|
||||
/// Where this record was loaded from. Determines shadowing priority
|
||||
/// when [`WorkflowRegistry::merge_skill`] encounters a slug
|
||||
/// collision.
|
||||
pub source: WorkflowSource,
|
||||
}
|
||||
|
||||
/// Returned by [`WorkflowRegistry::merge_skill`] when an incoming skill is
|
||||
/// shadowed by an existing record (either an internal Workflow or a
|
||||
/// higher-priority skill). Carries enough context for a `Notification` to
|
||||
/// explain which side won.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ShadowedSkill {
|
||||
pub slug: Slug,
|
||||
pub kept_source: WorkflowSource,
|
||||
pub kept_path: PathBuf,
|
||||
pub shadowed_source: WorkflowSource,
|
||||
pub shadowed_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ShadowedSkill {
|
||||
/// One-line message for `Notification` payloads.
|
||||
pub fn message(&self) -> String {
|
||||
format!(
|
||||
"skill /{slug} from {shadowed_label} ({shadowed_path}) was shadowed by existing {kept_label} ({kept_path})",
|
||||
slug = self.slug,
|
||||
shadowed_label = self.shadowed_source.label(),
|
||||
shadowed_path = self.shadowed_path.display(),
|
||||
kept_label = self.kept_source.label(),
|
||||
kept_path = self.kept_path.display(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WorkflowRegistry {
|
||||
records: BTreeMap<Slug, WorkflowRecord>,
|
||||
}
|
||||
|
||||
impl WorkflowRegistry {
|
||||
pub fn empty() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.records.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.records.is_empty()
|
||||
}
|
||||
|
||||
pub fn get(&self, slug: &Slug) -> Option<&WorkflowRecord> {
|
||||
self.records.get(slug)
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &WorkflowRecord> {
|
||||
self.records.values()
|
||||
}
|
||||
|
||||
pub fn resident_entries(&self) -> Vec<ResidentWorkflowEntry> {
|
||||
self.records
|
||||
.values()
|
||||
.filter(|record| record.model_invokation)
|
||||
.map(|record| ResidentWorkflowEntry {
|
||||
slug: record.slug.to_string(),
|
||||
description: record.description.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn list_user_invocable(&self, prefix: &str) -> Vec<String> {
|
||||
self.records
|
||||
.values()
|
||||
.filter(|record| record.user_invocable && record.slug.as_str().starts_with(prefix))
|
||||
.map(|record| record.slug.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Insert a skill-derived record. If an existing record (internal
|
||||
/// Workflow or earlier-fed skill) already owns the slug, the
|
||||
/// incoming record is dropped and a [`ShadowedSkill`] describing the
|
||||
/// collision is returned. Callers feed records in priority order
|
||||
/// (highest first); the registry is "first-insert wins" and does
|
||||
/// not re-rank.
|
||||
pub fn merge_skill(&mut self, record: WorkflowRecord) -> Option<ShadowedSkill> {
|
||||
if let Some(existing) = self.records.get(&record.slug) {
|
||||
return Some(ShadowedSkill {
|
||||
slug: record.slug.clone(),
|
||||
kept_source: existing.source.clone(),
|
||||
kept_path: existing.path.clone(),
|
||||
shadowed_source: record.source,
|
||||
shadowed_path: record.path,
|
||||
});
|
||||
}
|
||||
self.records.insert(record.slug.clone(), record);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResidentWorkflowEntry {
|
||||
pub slug: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WorkflowLoadError {
|
||||
#[error("failed to read workflow directory {}: {source}", .dir.display())]
|
||||
ReadDir { dir: PathBuf, source: io::Error },
|
||||
#[error("failed to read workflow file {}: {source}", .path.display())]
|
||||
ReadFile { path: PathBuf, source: io::Error },
|
||||
#[error("invalid workflow file name {}: {source}", .path.display())]
|
||||
InvalidSlug {
|
||||
path: PathBuf,
|
||||
source: WorkflowLintError,
|
||||
},
|
||||
#[error("invalid workflow frontmatter in {}: {source}", .path.display())]
|
||||
Frontmatter {
|
||||
path: PathBuf,
|
||||
source: WorkflowLintError,
|
||||
},
|
||||
#[error(
|
||||
"Workflow {} with model_invokation: true cannot have description longer than {limit} chars (got {actual})",
|
||||
.path.display()
|
||||
)]
|
||||
DescriptionTooLong {
|
||||
path: PathBuf,
|
||||
actual: usize,
|
||||
limit: usize,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn load_workflows(layout: &WorkspaceLayout) -> Result<WorkflowRegistry, WorkflowLoadError> {
|
||||
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());
|
||||
}
|
||||
Err(source) => return Err(WorkflowLoadError::ReadDir { dir, source }),
|
||||
};
|
||||
|
||||
let mut paths = Vec::new();
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|source| WorkflowLoadError::ReadDir {
|
||||
dir: dir.clone(),
|
||||
source,
|
||||
})?;
|
||||
let path = entry.path();
|
||||
if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("md") {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
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;
|
||||
};
|
||||
let slug =
|
||||
Slug::parse(stem.to_string()).map_err(|source| WorkflowLoadError::InvalidSlug {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
if records.contains_key(&slug) {
|
||||
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,
|
||||
};
|
||||
records.insert(slug.clone(), record);
|
||||
}
|
||||
|
||||
Ok(WorkflowRegistry { records })
|
||||
}
|
||||
|
||||
fn warn_unknown_workflow_fields(path: &Path, yaml: &str) {
|
||||
let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(yaml) else {
|
||||
return;
|
||||
};
|
||||
let Some(map) = value.as_mapping() else {
|
||||
return;
|
||||
};
|
||||
for key in map.keys().filter_map(|k| k.as_str()) {
|
||||
if !matches!(
|
||||
key,
|
||||
"description"
|
||||
| "model_invokation"
|
||||
| "user_invocable"
|
||||
| "requires"
|
||||
| "created_at"
|
||||
| "updated_at"
|
||||
) {
|
||||
warn!(path = %path.display(), field = key, "unknown workflow frontmatter field ignored");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_serde_workflow_error(err: serde_yaml::Error) -> WorkflowLintError {
|
||||
let msg = err.to_string();
|
||||
if let Some(field) = parse_missing_field(&msg) {
|
||||
return WorkflowLintError::MissingField(field);
|
||||
}
|
||||
WorkflowLintError::MalformedFrontmatter(msg)
|
||||
}
|
||||
|
||||
fn parse_missing_field(msg: &str) -> Option<&'static str> {
|
||||
let needle = "missing field `";
|
||||
let start = msg.find(needle)? + needle.len();
|
||||
let end = msg[start..].find('`')? + start;
|
||||
match &msg[start..end] {
|
||||
"description" => Some("description"),
|
||||
"model_invokation" => Some("model_invokation"),
|
||||
"user_invocable" => Some("user_invocable"),
|
||||
"requires" => Some("requires"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (TempDir, WorkspaceLayout) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join(".insomnia/workflow")).unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
(dir, layout)
|
||||
}
|
||||
|
||||
fn write_workflow(root: &Path, slug: &str, frontmatter: &str, body: &str) {
|
||||
let path = root.join(".insomnia/workflow").join(format!("{slug}.md"));
|
||||
std::fs::write(path, format!("---\n{frontmatter}\n---\n{body}")).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_directory_loads_empty_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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_valid_workflow_with_default_flags() {
|
||||
let (dir, layout) = setup();
|
||||
write_workflow(dir.path(), "do-thing", "description: Do thing", "Step 1");
|
||||
let got = load_workflows(&layout).unwrap();
|
||||
let slug = Slug::parse("do-thing").unwrap();
|
||||
let record = got.get(&slug).unwrap();
|
||||
assert_eq!(record.description, "Do thing");
|
||||
assert!(!record.model_invokation);
|
||||
assert!(record.user_invocable);
|
||||
assert!(record.requires.is_empty());
|
||||
assert_eq!(record.body, "Step 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_invokation_uses_typo_field() {
|
||||
let (dir, layout) = setup();
|
||||
write_workflow(
|
||||
dir.path(),
|
||||
"auto",
|
||||
"description: Auto\nmodel_invokation: true\nuser_invocable: false",
|
||||
"Body",
|
||||
);
|
||||
let got = load_workflows(&layout).unwrap();
|
||||
assert_eq!(got.resident_entries()[0].slug, "auto");
|
||||
assert!(got.list_user_invocable("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_filename_is_hard_error() {
|
||||
let (dir, layout) = setup();
|
||||
write_workflow(dir.path(), "Bad", "description: Bad", "Body");
|
||||
let err = load_workflows(&layout).unwrap_err();
|
||||
assert!(matches!(err, WorkflowLoadError::InvalidSlug { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_description_is_hard_error() {
|
||||
let (dir, layout) = setup();
|
||||
write_workflow(dir.path(), "bad", "model_invokation: false", "Body");
|
||||
let err = load_workflows(&layout).unwrap_err();
|
||||
assert!(matches!(err, WorkflowLoadError::Frontmatter { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_under_memory_is_ignored() {
|
||||
// The legacy `.insomnia/memory/workflow/` location is no longer
|
||||
// a Workflow source. Files placed there must be ignored (the
|
||||
// loader is rooted at `.insomnia/workflow/` only).
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
let legacy = dir.path().join(".insomnia/memory/workflow");
|
||||
std::fs::create_dir_all(&legacy).unwrap();
|
||||
std::fs::write(
|
||||
legacy.join("ghost.md"),
|
||||
"---\ndescription: ghost\n---\nbody\n",
|
||||
)
|
||||
.unwrap();
|
||||
let got = load_workflows(&layout).unwrap();
|
||||
assert!(got.is_empty());
|
||||
}
|
||||
|
||||
fn skill_record(slug: &str, path: &Path) -> WorkflowRecord {
|
||||
WorkflowRecord {
|
||||
slug: Slug::parse(slug).unwrap(),
|
||||
description: format!("desc {slug}"),
|
||||
model_invokation: true,
|
||||
user_invocable: true,
|
||||
requires: Vec::new(),
|
||||
body: format!("body for {slug}"),
|
||||
path: path.to_path_buf(),
|
||||
source: WorkflowSource::Skill {
|
||||
dir: path.parent().unwrap().parent().unwrap().to_path_buf(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_skill_inserts_when_no_collision() {
|
||||
let mut reg = WorkflowRegistry::empty();
|
||||
let path = std::path::PathBuf::from("/tmp/skills/x/SKILL.md");
|
||||
let shadow = reg.merge_skill(skill_record("x", &path));
|
||||
assert!(shadow.is_none());
|
||||
assert_eq!(reg.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_skill_shadows_existing_workflow() {
|
||||
let (dir, layout) = setup();
|
||||
write_workflow(
|
||||
dir.path(),
|
||||
"shared",
|
||||
"description: Internal",
|
||||
"internal body",
|
||||
);
|
||||
let mut reg = load_workflows(&layout).unwrap();
|
||||
let skill_path = dir
|
||||
.path()
|
||||
.join("user-skills")
|
||||
.join("shared")
|
||||
.join("SKILL.md");
|
||||
std::fs::create_dir_all(skill_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&skill_path, "ignored").unwrap();
|
||||
let incoming = WorkflowRecord {
|
||||
slug: Slug::parse("shared").unwrap(),
|
||||
description: "From skill".into(),
|
||||
model_invokation: true,
|
||||
user_invocable: true,
|
||||
requires: Vec::new(),
|
||||
body: "skill body".into(),
|
||||
path: skill_path.clone(),
|
||||
source: WorkflowSource::Skill {
|
||||
dir: dir.path().join("user-skills"),
|
||||
},
|
||||
};
|
||||
let shadow = reg.merge_skill(incoming).expect("expected shadow");
|
||||
assert_eq!(shadow.slug.as_str(), "shared");
|
||||
assert!(matches!(
|
||||
shadow.kept_source,
|
||||
WorkflowSource::WorkspaceWorkflow
|
||||
));
|
||||
assert!(matches!(
|
||||
shadow.shadowed_source,
|
||||
WorkflowSource::Skill { .. }
|
||||
));
|
||||
// The kept record is still the workspace workflow.
|
||||
let kept = reg.get(&Slug::parse("shared").unwrap()).unwrap();
|
||||
assert!(matches!(kept.source, WorkflowSource::WorkspaceWorkflow));
|
||||
assert_eq!(kept.body, "internal body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_skill_first_fed_wins_on_collision() {
|
||||
let mut reg = WorkflowRegistry::empty();
|
||||
let first_path = std::path::PathBuf::from("/a/skills/x/SKILL.md");
|
||||
let second_path = std::path::PathBuf::from("/b/skills/x/SKILL.md");
|
||||
let first = WorkflowRecord {
|
||||
slug: Slug::parse("x").unwrap(),
|
||||
description: "first".into(),
|
||||
model_invokation: true,
|
||||
user_invocable: true,
|
||||
requires: Vec::new(),
|
||||
body: "first body".into(),
|
||||
path: first_path.clone(),
|
||||
source: WorkflowSource::Skill {
|
||||
dir: std::path::PathBuf::from("/a/skills"),
|
||||
},
|
||||
};
|
||||
let second = WorkflowRecord {
|
||||
slug: Slug::parse("x").unwrap(),
|
||||
description: "second".into(),
|
||||
model_invokation: true,
|
||||
user_invocable: true,
|
||||
requires: Vec::new(),
|
||||
body: "second body".into(),
|
||||
path: second_path.clone(),
|
||||
source: WorkflowSource::Skill {
|
||||
dir: std::path::PathBuf::from("/b/skills"),
|
||||
},
|
||||
};
|
||||
// Caller is responsible for feeding in priority order; the
|
||||
// registry just keeps whichever arrives first.
|
||||
assert!(reg.merge_skill(first).is_none());
|
||||
let shadow = reg
|
||||
.merge_skill(second)
|
||||
.expect("later-fed skill must shadow");
|
||||
assert_eq!(shadow.kept_path, first_path);
|
||||
assert!(matches!(shadow.kept_source, WorkflowSource::Skill { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_message_is_human_readable() {
|
||||
let s = ShadowedSkill {
|
||||
slug: Slug::parse("x").unwrap(),
|
||||
kept_source: WorkflowSource::WorkspaceWorkflow,
|
||||
kept_path: std::path::PathBuf::from("/ws/.insomnia/workflow/x.md"),
|
||||
shadowed_source: WorkflowSource::Skill {
|
||||
dir: std::path::PathBuf::from("/skills"),
|
||||
},
|
||||
shadowed_path: std::path::PathBuf::from("/skills/x/SKILL.md"),
|
||||
};
|
||||
let msg = s.message();
|
||||
assert!(msg.contains("/x"));
|
||||
assert!(msg.contains("workspace workflow"));
|
||||
assert!(msg.contains("skill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resident_description_cap_is_enforced() {
|
||||
let (dir, layout) = setup();
|
||||
let desc = "x".repeat(WORKFLOW_DESCRIPTION_HARD_CAP + 1);
|
||||
write_workflow(
|
||||
dir.path(),
|
||||
"bad",
|
||||
&format!("description: {desc}\nmodel_invokation: true"),
|
||||
"Body",
|
||||
);
|
||||
let err = load_workflows(&layout).unwrap_err();
|
||||
assert!(matches!(err, WorkflowLoadError::DescriptionTooLong { .. }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user