refactor: extract workflow crate

This commit is contained in:
2026-05-11 22:49:07 +09:00
parent 520895f1c9
commit f70975789e
25 changed files with 686 additions and 311 deletions
-5
View File
@@ -69,11 +69,6 @@ pub enum LintError {
#[error("body exceeds the size limit for this record kind: {actual} chars > {limit}")]
BodyTooLong { actual: usize, limit: usize },
#[error(
"write to a Workflow path is forbidden via the memory tool — Workflows are human-edited"
)]
WorkflowWriteForbidden,
#[error("slug `{0}` already exists; use the edit tool instead of creating a new record")]
SlugAlreadyExists(String),
-9
View File
@@ -13,11 +13,9 @@ pub mod linter;
pub mod resident;
pub mod schema;
pub mod scope;
pub mod skill;
pub mod slug;
pub mod tool;
pub mod usage;
pub mod workflow;
pub mod workspace;
pub use error::{LintError, LintWarning, MemoryError};
@@ -25,17 +23,10 @@ pub use extract::ExtractPointerPayload;
pub use linter::{LintReport, Linter};
pub use resident::{ResidentKnowledgeEntry, collect_resident_knowledge};
pub use scope::deny_write_rules;
pub use skill::{
SKILL_FILENAME, SkillParseError, SkillRecord, load_skills_from_dir, parse_skill_md,
};
pub use slug::Slug;
pub use usage::{
UsageEvent, UsageEventKind, UsageRecordSnapshot, UsageReport, UsageReportRecord, UsageSource,
append_resident_exposure_event, append_usage_event, append_use_event, build_usage_report,
snapshot_record_from_bytes, snapshot_record_from_layout,
};
pub use workflow::{
ResidentWorkflowEntry, ShadowedSkill, WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowLoadError,
WorkflowRecord, WorkflowRegistry, WorkflowSource, load_workflows,
};
pub use workspace::WorkspaceLayout;
+4 -11
View File
@@ -1,5 +1,4 @@
//! Walks `<workspace>/memory/{decisions,requests}/`,
//! `<workspace>/workflow/`, and `<workspace>/knowledge/` to collect
//! Walks `<workspace>/memory/{decisions,requests}/` and `<workspace>/knowledge/` to collect
//! the slug set the linter needs for reference-integrity and
//! same-slug-duplication checks.
//!
@@ -11,8 +10,7 @@ use std::io;
use std::path::Path;
use crate::schema::{
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, WorkflowFrontmatter,
split_frontmatter,
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, split_frontmatter,
};
use crate::slug::Slug;
use crate::workspace::{RecordKind, WorkspaceLayout};
@@ -28,7 +26,6 @@ pub struct ExistingRecords {
decisions: HashMap<Slug, DecisionMeta>,
requests: HashSet<Slug>,
knowledge: HashSet<Slug>,
workflow: HashSet<Slug>,
}
#[derive(Debug, Clone)]
@@ -42,7 +39,7 @@ impl ExistingRecords {
RecordKind::Decision => self.decisions.contains_key(slug),
RecordKind::Request => self.requests.contains(slug),
RecordKind::Knowledge => self.knowledge.contains(slug),
RecordKind::Workflow => self.workflow.contains(slug),
RecordKind::Workflow => false,
RecordKind::Summary => false,
}
}
@@ -56,7 +53,7 @@ impl ExistingRecords {
RecordKind::Decision => self.decisions.keys().collect(),
RecordKind::Request => self.requests.iter().collect(),
RecordKind::Knowledge => self.knowledge.iter().collect(),
RecordKind::Workflow => self.workflow.iter().collect(),
RecordKind::Workflow => Vec::new(),
RecordKind::Summary => Vec::new(),
}
}
@@ -82,10 +79,6 @@ pub fn scan_existing(layout: &WorkspaceLayout) -> io::Result<ExistingRecords> {
let _ = parse_silent::<KnowledgeFrontmatter>(path);
out.knowledge.insert(slug);
})?;
scan_dir(&layout.workflow_dir(), |path, slug| {
let _ = parse_silent::<WorkflowFrontmatter>(path);
out.workflow.insert(slug);
})?;
Ok(out)
}
+4 -155
View File
@@ -23,9 +23,8 @@ use serde::de::DeserializeOwned;
use crate::error::{LintError, LintWarning};
use crate::schema::{
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, SummaryFrontmatter,
WorkflowFrontmatter, split_frontmatter,
split_frontmatter,
};
use crate::workflow::WORKFLOW_DESCRIPTION_HARD_CAP;
use crate::workspace::{ClassifiedPath, RecordKind, WorkspaceLayout};
pub use existing::{ExistingRecords, scan_existing};
@@ -99,12 +98,6 @@ impl Linter {
}
};
// 2. Workflow paths are sub-Worker-forbidden at the tool layer.
if classified.kind == RecordKind::Workflow {
report.push_error(LintError::WorkflowWriteForbidden);
return report;
}
// 3. Frontmatter parse + kind-specific structural checks +
// size limits. Reference-integrity needs the existing
// record set, fetched once below.
@@ -146,7 +139,9 @@ impl Linter {
RecordKind::Summary => {
self.check_kind::<SummaryFrontmatter>(content, &classified, &mut report);
}
RecordKind::Workflow => unreachable!("guarded above"),
RecordKind::Workflow => {
unreachable!("workflow paths are not classified by memory linter")
}
}
report
@@ -240,59 +235,6 @@ impl Linter {
}
}
impl Linter {
/// Workflow record validator exposed for human-edit paths
/// (CLI / pre-commit). Not used by the memory tool, which rejects
/// workflow writes outright.
///
/// Verifies frontmatter shape, body size, and that every slug in
/// `requires` points at an existing Knowledge record under the
/// workspace's `knowledge/` directory.
pub fn lint_workflow(&self, content: &str) -> LintReport {
let mut report = LintReport::default();
let parsed = match parse_frontmatter::<WorkflowFrontmatter>(content) {
Ok(p) => p,
Err(e) => {
report.push_error(e);
return report;
}
};
size::check_body::<WorkflowFrontmatter>(parsed.body, &mut report);
// Mirror the loader's cap so human-edit paths fail fast instead
// of surfacing the same error only at Pod startup.
if parsed.frontmatter.model_invokation {
let actual = parsed.frontmatter.description.chars().count();
if actual > WORKFLOW_DESCRIPTION_HARD_CAP {
report.push_error(LintError::DescriptionTooLong {
actual,
limit: WORKFLOW_DESCRIPTION_HARD_CAP,
});
}
}
let existing = match existing::scan_existing(&self.layout) {
Ok(e) => e,
Err(e) => {
report.push_error(LintError::MalformedFrontmatter(format!(
"failed to scan existing records: {e}"
)));
return report;
}
};
for slug in &parsed.frontmatter.requires {
if !existing.contains(crate::workspace::RecordKind::Knowledge, slug) {
report.push_error(LintError::UnknownReference {
field: "requires",
kind: "knowledge",
slug: slug.to_string(),
});
}
}
report
}
}
struct Parsed<'a, F> {
frontmatter: F,
body: &'a str,
@@ -332,22 +274,6 @@ mod tests {
(dir, linter)
}
#[test]
fn workflow_write_rejected() {
let (dir, linter) = workspace();
let path = dir.path().join(".insomnia/workflow/wf.md");
let content =
"---\ndescription: x\nmodel_invokation: false\nuser_invocable: true\n---\nbody"
.to_string();
let report = linter.lint(&path, &content, WriteMode::Create);
assert!(
report
.errors
.iter()
.any(|e| matches!(e, LintError::WorkflowWriteForbidden))
);
}
#[test]
fn outside_memory_tree_rejected() {
let (dir, linter) = workspace();
@@ -499,83 +425,6 @@ mod tests {
);
}
#[test]
fn workflow_lint_accepts_valid_record() {
let (dir, linter) = workspace();
// Place a Knowledge record that the workflow will reference.
let kn = dir.path().join(".insomnia/knowledge/foo.md");
write(
&kn,
&format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nkind: rule\ndescription: x\nmodel_invokation: false\nuser_invocable: true\nlast_sources: []\n---\n",
n = iso_now()
),
);
let wf = "---\ndescription: do thing\nmodel_invokation: false\nuser_invocable: true\nrequires: [foo]\n---\nstep 1\n".to_string();
let report = linter.lint_workflow(&wf);
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
}
#[test]
fn workflow_lint_flags_unknown_requires() {
let (_dir, linter) = workspace();
let wf = "---\ndescription: x\nmodel_invokation: false\nuser_invocable: true\nrequires: [missing-knowledge]\n---\n".to_string();
let report = linter.lint_workflow(&wf);
assert!(report.errors.iter().any(|e| matches!(
e,
LintError::UnknownReference {
field: "requires",
kind: "knowledge",
..
}
)));
}
#[test]
fn workflow_lint_flags_long_description_when_model_invokation() {
let (_dir, linter) = workspace();
let desc = "x".repeat(crate::workflow::WORKFLOW_DESCRIPTION_HARD_CAP + 1);
let wf = format!(
"---\ndescription: {desc}\nmodel_invokation: true\nuser_invocable: true\n---\n"
);
let report = linter.lint_workflow(&wf);
assert!(
report
.errors
.iter()
.any(|e| matches!(e, LintError::DescriptionTooLong { .. })),
);
}
#[test]
fn workflow_lint_allows_long_description_when_not_model_invokation() {
let (_dir, linter) = workspace();
let desc = "x".repeat(crate::workflow::WORKFLOW_DESCRIPTION_HARD_CAP + 1);
let wf = format!(
"---\ndescription: {desc}\nmodel_invokation: false\nuser_invocable: true\n---\n"
);
let report = linter.lint_workflow(&wf);
assert!(
!report
.errors
.iter()
.any(|e| matches!(e, LintError::DescriptionTooLong { .. })),
);
}
#[test]
fn workflow_lint_collects_multiple_unknown_requires() {
let (_dir, linter) = workspace();
let wf = "---\ndescription: x\nmodel_invokation: false\nuser_invocable: true\nrequires: [a, b, c]\n---\n".to_string();
let report = linter.lint_workflow(&wf);
let unknown_count = report
.errors
.iter()
.filter(|e| matches!(e, LintError::UnknownReference { .. }))
.count();
assert_eq!(unknown_count, 3);
}
#[test]
fn similar_slugs_warns_on_cluster() {
let (dir, linter) = workspace();
-4
View File
@@ -1,8 +1,4 @@
//! Reference-integrity checks: `replaced_by` existence + cycle detection.
//!
//! `requires` (Workflow) is checked symmetrically when/if the Workflow
//! linter is invoked from a human-edit path; the memory tool itself
//! never writes Workflow records.
use std::collections::HashSet;
-2
View File
@@ -10,11 +10,9 @@ mod decision;
mod knowledge;
mod request;
mod summary;
mod workflow;
pub use common::{Frontmatter, SourceRef, split_frontmatter};
pub use decision::{DecisionFrontmatter, DecisionStatus};
pub use knowledge::{KNOWLEDGE_DESCRIPTION_HARD_CAP, KnowledgeFrontmatter};
pub use request::RequestFrontmatter;
pub use summary::SummaryFrontmatter;
pub use workflow::WorkflowFrontmatter;
-50
View File
@@ -1,50 +0,0 @@
//! Workflow frontmatter schema.
//!
//! NOTE: Workflows are written by humans, not by the memory tool. The
//! linter only validates frontmatter when invoked directly (e.g. by a
//! future CLI / pre-commit hook). The memory write/edit tool rejects
//! `.insomnia/workflow/` paths outright via
//! [`LintError::WorkflowWriteForbidden`].
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::schema::common::Frontmatter;
use crate::slug::Slug;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WorkflowFrontmatter {
/// Workflows do not require timestamps in the MVP. Human-authored files
/// may carry them; when absent the linter uses Unix epoch as a neutral
/// placeholder for the shared `Frontmatter` trait.
#[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
}
fn epoch() -> DateTime<Utc> {
DateTime::<Utc>::from_timestamp(0, 0).expect("Unix epoch timestamp is valid")
}
impl Frontmatter for WorkflowFrontmatter {
const BODY_LIMIT: usize = 8000;
fn created_at(&self) -> DateTime<Utc> {
self.created_at.or(self.updated_at).unwrap_or_else(epoch)
}
fn updated_at(&self) -> DateTime<Utc> {
self.updated_at.unwrap_or_else(epoch)
}
}
+5 -10
View File
@@ -13,17 +13,13 @@ use manifest::{Permission, ScopeRule};
use crate::workspace::WorkspaceLayout;
/// Build deny rules that strip Write permission from `<workspace>/memory/`,
/// `<workspace>/knowledge/`, and `<workspace>/workflow/`. Recursive —
/// every descendant is capped at Read for the generic tools.
///
/// Workflow files are human-edited on the host side; the generic CRUD
/// tools must not touch them.
/// Build deny rules that strip Write permission from `<workspace>/memory/`
/// and `<workspace>/knowledge/`. Recursive — every descendant is capped at
/// Read for the generic tools.
pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
vec![
deny_write(layout.memory_dir().as_path()),
deny_write(layout.knowledge_dir().as_path()),
deny_write(layout.workflow_dir().as_path()),
]
}
@@ -41,14 +37,13 @@ mod tests {
use std::path::PathBuf;
#[test]
fn deny_targets_memory_knowledge_and_workflow() {
fn deny_targets_memory_and_knowledge() {
let layout = WorkspaceLayout::new(PathBuf::from("/ws"));
let rules = deny_write_rules(&layout);
assert_eq!(rules.len(), 3);
assert_eq!(rules.len(), 2);
assert_eq!(rules[0].target, PathBuf::from("/ws/.insomnia/memory"));
assert_eq!(rules[0].permission, Permission::Write);
assert!(rules[0].recursive);
assert_eq!(rules[1].target, PathBuf::from("/ws/.insomnia/knowledge"));
assert_eq!(rules[2].target, PathBuf::from("/ws/.insomnia/workflow"));
}
}
-447
View File
@@ -1,447 +0,0 @@
//! 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::error::LintError;
use crate::schema::split_frontmatter;
use crate::slug::Slug;
use crate::workflow::{WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowRecord, WorkflowSource};
/// 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)]
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::workflow`].
#[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: LintError,
},
#[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: LintError,
},
#[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: LintError::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"]);
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
//!
//! Creates or overwrites a memory or knowledge record by `(kind, slug)`.
//! Pre-write Linter validates frontmatter, slug uniqueness (Create only),
//! reference integrity, size limits, and the workflow-write ban. On any
//! reference integrity, size limits. On any
//! Linter error the tool returns `ToolError::InvalidArgument` with all
//! violations aggregated and the file is **not** written.
-515
View File
@@ -1,515 +0,0 @@
//! 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::error::LintError;
use crate::schema::{WorkflowFrontmatter, split_frontmatter};
use crate::slug::Slug;
use crate::workspace::WorkspaceLayout;
/// 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: LintError },
#[error("invalid workflow frontmatter in {}: {source}", .path.display())]
Frontmatter { path: PathBuf, source: LintError },
#[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) -> LintError {
let msg = err.to_string();
if let Some(field) = parse_missing_field(&msg) {
return LintError::MissingField(field);
}
LintError::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 { .. }));
}
}
+2 -16
View File
@@ -153,8 +153,8 @@ impl WorkspaceLayout {
}
/// Classify a path under the memory tree. Returns `None` if the
/// path is not under `.insomnia/memory/`, `.insomnia/knowledge/`,
/// or `.insomnia/workflow/` of this workspace, or if it lives in
/// path is not under `.insomnia/memory/` or `.insomnia/knowledge/`
/// of this workspace, or if it lives in
/// `_staging/` / `_usage/` (opaque subsystem-owned trees).
///
/// On a conventional path that's *almost* a record but malformed
@@ -164,14 +164,10 @@ impl WorkspaceLayout {
pub fn classify(&self, path: &Path) -> Result<Option<ClassifiedPath>, LintError> {
let memory = self.memory_dir();
let knowledge = self.knowledge_dir();
let workflow = self.workflow_dir();
if let Ok(rel) = path.strip_prefix(&knowledge) {
return Ok(Some(classify_kinded_md(rel, RecordKind::Knowledge, path)?));
}
if let Ok(rel) = path.strip_prefix(&workflow) {
return Ok(Some(classify_kinded_md(rel, RecordKind::Workflow, path)?));
}
let rel = match path.strip_prefix(&memory) {
Ok(r) => r,
Err(_) => return Ok(None),
@@ -277,16 +273,6 @@ mod tests {
assert_eq!(cp.kind, RecordKind::Knowledge);
}
#[test]
fn classifies_workflow() {
let cp = layout()
.classify(&PathBuf::from("/ws/.insomnia/workflow/wf.md"))
.unwrap()
.unwrap();
assert_eq!(cp.kind, RecordKind::Workflow);
assert_eq!(cp.slug.unwrap().as_str(), "wf");
}
#[test]
fn workflow_under_memory_is_invalid_path() {
let err = layout()