refactor: extract workflow crate

This commit is contained in:
Keisuke Hirata 2026-05-11 22:46:18 +09:00
parent 520895f1c9
commit f70975789e
25 changed files with 686 additions and 311 deletions

16
Cargo.lock generated
View File

@ -2164,6 +2164,7 @@ dependencies = [
"tools", "tools",
"tracing", "tracing",
"uuid", "uuid",
"workflow",
] ]
[[package]] [[package]]
@ -4398,6 +4399,21 @@ dependencies = [
"wasmparser", "wasmparser",
] ]
[[package]]
name = "workflow"
version = "0.1.0"
dependencies = [
"chrono",
"manifest",
"memory",
"serde",
"serde_json",
"serde_yaml",
"tempfile",
"thiserror 2.0.18",
"tracing",
]
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.3" version = "0.6.3"

View File

@ -15,6 +15,7 @@ members = [
"crates/tools", "crates/tools",
"crates/tui", "crates/tui",
"crates/memory", "crates/memory",
"crates/workflow",
] ]
[workspace.package] [workspace.package]
@ -28,6 +29,7 @@ llm-worker = { path = "crates/llm-worker", version = "0.2" }
llm-worker-macros = { path = "crates/llm-worker-macros", version = "0.2" } llm-worker-macros = { path = "crates/llm-worker-macros", version = "0.2" }
manifest = { path = "crates/manifest" } manifest = { path = "crates/manifest" }
memory = { path = "crates/memory" } memory = { path = "crates/memory" }
workflow = { path = "crates/workflow" }
pod-registry = { path = "crates/pod-registry" } pod-registry = { path = "crates/pod-registry" }
protocol = { path = "crates/protocol" } protocol = { path = "crates/protocol" }
provider = { path = "crates/provider" } provider = { path = "crates/provider" }

View File

@ -69,11 +69,6 @@ pub enum LintError {
#[error("body exceeds the size limit for this record kind: {actual} chars > {limit}")] #[error("body exceeds the size limit for this record kind: {actual} chars > {limit}")]
BodyTooLong { actual: usize, limit: usize }, 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")] #[error("slug `{0}` already exists; use the edit tool instead of creating a new record")]
SlugAlreadyExists(String), SlugAlreadyExists(String),

View File

@ -13,11 +13,9 @@ pub mod linter;
pub mod resident; pub mod resident;
pub mod schema; pub mod schema;
pub mod scope; pub mod scope;
pub mod skill;
pub mod slug; pub mod slug;
pub mod tool; pub mod tool;
pub mod usage; pub mod usage;
pub mod workflow;
pub mod workspace; pub mod workspace;
pub use error::{LintError, LintWarning, MemoryError}; pub use error::{LintError, LintWarning, MemoryError};
@ -25,17 +23,10 @@ pub use extract::ExtractPointerPayload;
pub use linter::{LintReport, Linter}; pub use linter::{LintReport, Linter};
pub use resident::{ResidentKnowledgeEntry, collect_resident_knowledge}; pub use resident::{ResidentKnowledgeEntry, collect_resident_knowledge};
pub use scope::deny_write_rules; 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 slug::Slug;
pub use usage::{ pub use usage::{
UsageEvent, UsageEventKind, UsageRecordSnapshot, UsageReport, UsageReportRecord, UsageSource, UsageEvent, UsageEventKind, UsageRecordSnapshot, UsageReport, UsageReportRecord, UsageSource,
append_resident_exposure_event, append_usage_event, append_use_event, build_usage_report, append_resident_exposure_event, append_usage_event, append_use_event, build_usage_report,
snapshot_record_from_bytes, snapshot_record_from_layout, 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; pub use workspace::WorkspaceLayout;

View File

@ -1,5 +1,4 @@
//! Walks `<workspace>/memory/{decisions,requests}/`, //! Walks `<workspace>/memory/{decisions,requests}/` and `<workspace>/knowledge/` to collect
//! `<workspace>/workflow/`, and `<workspace>/knowledge/` to collect
//! the slug set the linter needs for reference-integrity and //! the slug set the linter needs for reference-integrity and
//! same-slug-duplication checks. //! same-slug-duplication checks.
//! //!
@ -11,8 +10,7 @@ use std::io;
use std::path::Path; use std::path::Path;
use crate::schema::{ use crate::schema::{
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, WorkflowFrontmatter, DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, split_frontmatter,
split_frontmatter,
}; };
use crate::slug::Slug; use crate::slug::Slug;
use crate::workspace::{RecordKind, WorkspaceLayout}; use crate::workspace::{RecordKind, WorkspaceLayout};
@ -28,7 +26,6 @@ pub struct ExistingRecords {
decisions: HashMap<Slug, DecisionMeta>, decisions: HashMap<Slug, DecisionMeta>,
requests: HashSet<Slug>, requests: HashSet<Slug>,
knowledge: HashSet<Slug>, knowledge: HashSet<Slug>,
workflow: HashSet<Slug>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -42,7 +39,7 @@ impl ExistingRecords {
RecordKind::Decision => self.decisions.contains_key(slug), RecordKind::Decision => self.decisions.contains_key(slug),
RecordKind::Request => self.requests.contains(slug), RecordKind::Request => self.requests.contains(slug),
RecordKind::Knowledge => self.knowledge.contains(slug), RecordKind::Knowledge => self.knowledge.contains(slug),
RecordKind::Workflow => self.workflow.contains(slug), RecordKind::Workflow => false,
RecordKind::Summary => false, RecordKind::Summary => false,
} }
} }
@ -56,7 +53,7 @@ impl ExistingRecords {
RecordKind::Decision => self.decisions.keys().collect(), RecordKind::Decision => self.decisions.keys().collect(),
RecordKind::Request => self.requests.iter().collect(), RecordKind::Request => self.requests.iter().collect(),
RecordKind::Knowledge => self.knowledge.iter().collect(), RecordKind::Knowledge => self.knowledge.iter().collect(),
RecordKind::Workflow => self.workflow.iter().collect(), RecordKind::Workflow => Vec::new(),
RecordKind::Summary => Vec::new(), RecordKind::Summary => Vec::new(),
} }
} }
@ -82,10 +79,6 @@ pub fn scan_existing(layout: &WorkspaceLayout) -> io::Result<ExistingRecords> {
let _ = parse_silent::<KnowledgeFrontmatter>(path); let _ = parse_silent::<KnowledgeFrontmatter>(path);
out.knowledge.insert(slug); out.knowledge.insert(slug);
})?; })?;
scan_dir(&layout.workflow_dir(), |path, slug| {
let _ = parse_silent::<WorkflowFrontmatter>(path);
out.workflow.insert(slug);
})?;
Ok(out) Ok(out)
} }

View File

@ -23,9 +23,8 @@ use serde::de::DeserializeOwned;
use crate::error::{LintError, LintWarning}; use crate::error::{LintError, LintWarning};
use crate::schema::{ use crate::schema::{
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, SummaryFrontmatter, DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, SummaryFrontmatter,
WorkflowFrontmatter, split_frontmatter, split_frontmatter,
}; };
use crate::workflow::WORKFLOW_DESCRIPTION_HARD_CAP;
use crate::workspace::{ClassifiedPath, RecordKind, WorkspaceLayout}; use crate::workspace::{ClassifiedPath, RecordKind, WorkspaceLayout};
pub use existing::{ExistingRecords, scan_existing}; 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 + // 3. Frontmatter parse + kind-specific structural checks +
// size limits. Reference-integrity needs the existing // size limits. Reference-integrity needs the existing
// record set, fetched once below. // record set, fetched once below.
@ -146,7 +139,9 @@ impl Linter {
RecordKind::Summary => { RecordKind::Summary => {
self.check_kind::<SummaryFrontmatter>(content, &classified, &mut report); 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 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> { struct Parsed<'a, F> {
frontmatter: F, frontmatter: F,
body: &'a str, body: &'a str,
@ -332,22 +274,6 @@ mod tests {
(dir, linter) (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] #[test]
fn outside_memory_tree_rejected() { fn outside_memory_tree_rejected() {
let (dir, linter) = workspace(); 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] #[test]
fn similar_slugs_warns_on_cluster() { fn similar_slugs_warns_on_cluster() {
let (dir, linter) = workspace(); let (dir, linter) = workspace();

View File

@ -1,8 +1,4 @@
//! Reference-integrity checks: `replaced_by` existence + cycle detection. //! 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; use std::collections::HashSet;

View File

@ -10,11 +10,9 @@ mod decision;
mod knowledge; mod knowledge;
mod request; mod request;
mod summary; mod summary;
mod workflow;
pub use common::{Frontmatter, SourceRef, split_frontmatter}; pub use common::{Frontmatter, SourceRef, split_frontmatter};
pub use decision::{DecisionFrontmatter, DecisionStatus}; pub use decision::{DecisionFrontmatter, DecisionStatus};
pub use knowledge::{KNOWLEDGE_DESCRIPTION_HARD_CAP, KnowledgeFrontmatter}; pub use knowledge::{KNOWLEDGE_DESCRIPTION_HARD_CAP, KnowledgeFrontmatter};
pub use request::RequestFrontmatter; pub use request::RequestFrontmatter;
pub use summary::SummaryFrontmatter; pub use summary::SummaryFrontmatter;
pub use workflow::WorkflowFrontmatter;

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)
}
}

View File

@ -13,17 +13,13 @@ use manifest::{Permission, ScopeRule};
use crate::workspace::WorkspaceLayout; use crate::workspace::WorkspaceLayout;
/// Build deny rules that strip Write permission from `<workspace>/memory/`, /// Build deny rules that strip Write permission from `<workspace>/memory/`
/// `<workspace>/knowledge/`, and `<workspace>/workflow/`. Recursive — /// and `<workspace>/knowledge/`. Recursive — every descendant is capped at
/// every descendant is capped at Read for the generic tools. /// Read for the generic tools.
///
/// Workflow files are human-edited on the host side; the generic CRUD
/// tools must not touch them.
pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> { pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
vec![ vec![
deny_write(layout.memory_dir().as_path()), deny_write(layout.memory_dir().as_path()),
deny_write(layout.knowledge_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; use std::path::PathBuf;
#[test] #[test]
fn deny_targets_memory_knowledge_and_workflow() { fn deny_targets_memory_and_knowledge() {
let layout = WorkspaceLayout::new(PathBuf::from("/ws")); let layout = WorkspaceLayout::new(PathBuf::from("/ws"));
let rules = deny_write_rules(&layout); 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].target, PathBuf::from("/ws/.insomnia/memory"));
assert_eq!(rules[0].permission, Permission::Write); assert_eq!(rules[0].permission, Permission::Write);
assert!(rules[0].recursive); assert!(rules[0].recursive);
assert_eq!(rules[1].target, PathBuf::from("/ws/.insomnia/knowledge")); assert_eq!(rules[1].target, PathBuf::from("/ws/.insomnia/knowledge"));
assert_eq!(rules[2].target, PathBuf::from("/ws/.insomnia/workflow"));
} }
} }

View File

@ -2,7 +2,7 @@
//! //!
//! Creates or overwrites a memory or knowledge record by `(kind, slug)`. //! Creates or overwrites a memory or knowledge record by `(kind, slug)`.
//! Pre-write Linter validates frontmatter, slug uniqueness (Create only), //! 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 //! Linter error the tool returns `ToolError::InvalidArgument` with all
//! violations aggregated and the file is **not** written. //! violations aggregated and the file is **not** written.

View File

@ -153,8 +153,8 @@ impl WorkspaceLayout {
} }
/// Classify a path under the memory tree. Returns `None` if the /// Classify a path under the memory tree. Returns `None` if the
/// path is not under `.insomnia/memory/`, `.insomnia/knowledge/`, /// path is not under `.insomnia/memory/` or `.insomnia/knowledge/`
/// or `.insomnia/workflow/` of this workspace, or if it lives in /// of this workspace, or if it lives in
/// `_staging/` / `_usage/` (opaque subsystem-owned trees). /// `_staging/` / `_usage/` (opaque subsystem-owned trees).
/// ///
/// On a conventional path that's *almost* a record but malformed /// 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> { pub fn classify(&self, path: &Path) -> Result<Option<ClassifiedPath>, LintError> {
let memory = self.memory_dir(); let memory = self.memory_dir();
let knowledge = self.knowledge_dir(); let knowledge = self.knowledge_dir();
let workflow = self.workflow_dir();
if let Ok(rel) = path.strip_prefix(&knowledge) { if let Ok(rel) = path.strip_prefix(&knowledge) {
return Ok(Some(classify_kinded_md(rel, RecordKind::Knowledge, path)?)); 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) { let rel = match path.strip_prefix(&memory) {
Ok(r) => r, Ok(r) => r,
Err(_) => return Ok(None), Err(_) => return Ok(None),
@ -277,16 +273,6 @@ mod tests {
assert_eq!(cp.kind, RecordKind::Knowledge); 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] #[test]
fn workflow_under_memory_is_invalid_path() { fn workflow_under_memory_is_invalid_path() {
let err = layout() let err = layout()

View File

@ -27,6 +27,7 @@ fs4 = { workspace = true, features = ["sync"] }
libc = { workspace = true } libc = { workspace = true }
schemars = { workspace = true } schemars = { workspace = true }
memory = { workspace = true } memory = { workspace = true }
workflow-crate = { package = "workflow", path = "../workflow" }
uuid = { workspace = true, features = ["v7"] } uuid = { workspace = true, features = ["v7"] }
session-metrics = { workspace = true } session-metrics = { workspace = true }

View File

@ -150,7 +150,7 @@ pub struct Pod<C: LlmClient, St: Store> {
prompts: Arc<PromptCatalog>, prompts: Arc<PromptCatalog>,
/// Registry loaded from `<workspace>/.insomnia/workflow/*.md` when /// Registry loaded from `<workspace>/.insomnia/workflow/*.md` when
/// memory is enabled. Missing memory config keeps this empty. /// memory is enabled. Missing memory config keeps this empty.
workflow_registry: memory::WorkflowRegistry, workflow_registry: workflow_crate::WorkflowRegistry,
/// Memory workspace layout used by the workflow resolver to load required /// Memory workspace layout used by the workflow resolver to load required
/// Knowledge records by exact slug. /// Knowledge records by exact slug.
memory_layout: Option<memory::WorkspaceLayout>, memory_layout: Option<memory::WorkspaceLayout>,
@ -323,7 +323,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
scope_allocation: None, scope_allocation: None,
callback_socket: None, callback_socket: None,
prompts, prompts,
workflow_registry: memory::WorkflowRegistry::empty(), workflow_registry: workflow_crate::WorkflowRegistry::empty(),
memory_layout: None, memory_layout: None,
inject_resident_knowledge: true, inject_resident_knowledge: true,
pending_scope_snapshot: Arc::new(Mutex::new(None)), pending_scope_snapshot: Arc::new(Mutex::new(None)),
@ -865,13 +865,13 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
} else { } else {
None None
}; };
let resident_workflows: Vec<memory::ResidentWorkflowEntry> = let resident_workflows: Vec<workflow_crate::ResidentWorkflowEntry> =
if self.inject_resident_knowledge && self.memory_layout.is_some() { if self.inject_resident_knowledge && self.memory_layout.is_some() {
self.workflow_registry.resident_entries() self.workflow_registry.resident_entries()
} else { } else {
Vec::new() Vec::new()
}; };
let resident_workflow_slice: Option<&[memory::ResidentWorkflowEntry]> = let resident_workflow_slice: Option<&[workflow_crate::ResidentWorkflowEntry]> =
if self.inject_resident_knowledge && self.memory_layout.is_some() { if self.inject_resident_knowledge && self.memory_layout.is_some() {
Some(&resident_workflows) Some(&resident_workflows)
} else { } else {
@ -1106,7 +1106,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
fn resident_exposure_snapshots( fn resident_exposure_snapshots(
&self, &self,
knowledge: &[memory::ResidentKnowledgeEntry], knowledge: &[memory::ResidentKnowledgeEntry],
workflows: &[memory::ResidentWorkflowEntry], workflows: &[workflow_crate::ResidentWorkflowEntry],
) -> Vec<memory::UsageRecordSnapshot> { ) -> Vec<memory::UsageRecordSnapshot> {
let Some(layout) = self.memory_layout.as_ref() else { let Some(layout) = self.memory_layout.as_ref() else {
return Vec::new(); return Vec::new();
@ -1220,8 +1220,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
let Segment::WorkflowInvoke { slug } = seg else { let Segment::WorkflowInvoke { slug } = seg else {
continue; continue;
}; };
let parsed = let parsed = workflow_crate::Slug::parse(slug.clone())
memory::Slug::parse(slug.clone()).map_err(WorkflowResolveError::InvalidSlug)?; .map_err(WorkflowResolveError::InvalidSlug)?;
let record = self let record = self
.workflow_registry .workflow_registry
.get(&parsed) .get(&parsed)
@ -2886,7 +2886,7 @@ pub enum PodError {
ConsolidationLock(#[source] memory::consolidate::LockError), ConsolidationLock(#[source] memory::consolidate::LockError),
#[error("workflow load failed: {0}")] #[error("workflow load failed: {0}")]
WorkflowLoad(#[source] memory::WorkflowLoadError), WorkflowLoad(#[source] workflow_crate::WorkflowLoadError),
#[error("workflow invocation failed: {0}")] #[error("workflow invocation failed: {0}")]
WorkflowResolve(#[from] WorkflowResolveError), WorkflowResolve(#[from] WorkflowResolveError),
@ -2909,14 +2909,14 @@ struct PodCommon {
scope: Scope, scope: Scope,
client: Box<dyn LlmClient>, client: Box<dyn LlmClient>,
prompts: Arc<PromptCatalog>, prompts: Arc<PromptCatalog>,
workflow_registry: memory::WorkflowRegistry, workflow_registry: workflow_crate::WorkflowRegistry,
memory_layout: Option<memory::WorkspaceLayout>, memory_layout: Option<memory::WorkspaceLayout>,
system_prompt_template: Option<SystemPromptTemplate>, system_prompt_template: Option<SystemPromptTemplate>,
/// SKILL.md shadow events surfaced during workflow-registry build. /// SKILL.md shadow events surfaced during workflow-registry build.
/// The Pod constructor drains these into the notify buffer right /// The Pod constructor drains these into the notify buffer right
/// after the Pod is materialised so the first LLM request observes /// after the Pod is materialised so the first LLM request observes
/// any skill ↔ workflow collisions. /// any skill ↔ workflow collisions.
skill_shadows: Vec<memory::ShadowedSkill>, skill_shadows: Vec<workflow_crate::ShadowedSkill>,
} }
/// Resolve pwd / scope / LLM client / prompt catalog from a validated /// Resolve pwd / scope / LLM client / prompt catalog from a validated
@ -2968,8 +2968,8 @@ fn prepare_pod_common_from_scope(
.as_ref() .as_ref()
.map(|mem| memory::WorkspaceLayout::resolve(mem, &pwd)); .map(|mem| memory::WorkspaceLayout::resolve(mem, &pwd));
let mut workflow_registry = match memory_layout.as_ref() { let mut workflow_registry = match memory_layout.as_ref() {
Some(layout) => memory::load_workflows(layout).map_err(PodError::WorkflowLoad)?, Some(layout) => workflow_crate::load_workflows(layout).map_err(PodError::WorkflowLoad)?,
None => memory::WorkflowRegistry::empty(), None => workflow_crate::WorkflowRegistry::empty(),
}; };
let skill_shadows = ingest_skills(&mut workflow_registry, manifest); let skill_shadows = ingest_skills(&mut workflow_registry, manifest);
@ -2998,21 +2998,21 @@ fn prepare_pod_common_from_scope(
/// ///
/// Skills come exclusively from the manifest's `[skills] directories` /// Skills come exclusively from the manifest's `[skills] directories`
/// list (resolved against the manifest base directory). Internal /// list (resolved against the manifest base directory). Internal
/// Workflows already loaded via [`memory::load_workflows`] take priority /// Workflows already loaded via [`workflow_crate::load_workflows`] take priority
/// over skills sharing the same slug; collisions are surfaced as /// over skills sharing the same slug; collisions are surfaced as
/// [`memory::ShadowedSkill`] events that the caller pushes onto the /// [`workflow_crate::ShadowedSkill`] events that the caller pushes onto the
/// Pod's notification buffer. /// Pod's notification buffer.
fn ingest_skills( fn ingest_skills(
registry: &mut memory::WorkflowRegistry, registry: &mut workflow_crate::WorkflowRegistry,
manifest: &PodManifest, manifest: &PodManifest,
) -> Vec<memory::ShadowedSkill> { ) -> Vec<workflow_crate::ShadowedSkill> {
let mut shadows = Vec::new(); let mut shadows = Vec::new();
let Some(skills_cfg) = manifest.skills.as_ref() else { let Some(skills_cfg) = manifest.skills.as_ref() else {
return shadows; return shadows;
}; };
for dir in &skills_cfg.directories { for dir in &skills_cfg.directories {
for skill in memory::load_skills_from_dir(dir) { for skill in workflow_crate::load_skills_from_dir(dir) {
let source = memory::WorkflowSource::Skill { dir: dir.clone() }; let source = workflow_crate::WorkflowSource::Skill { dir: dir.clone() };
let record = skill.into_workflow_record(source); let record = skill.into_workflow_record(source);
if let Some(shadow) = registry.merge_skill(record) { if let Some(shadow) = registry.merge_skill(record) {
shadows.push(shadow); shadows.push(shadow);
@ -3024,7 +3024,7 @@ fn ingest_skills(
/// Drain skill-ingest shadow events into the Pod's notify buffer so the /// Drain skill-ingest shadow events into the Pod's notify buffer so the
/// first LLM request renders them as system-message attachments. /// first LLM request renders them as system-message attachments.
fn drain_skill_shadows<C, S>(pod: &Pod<C, S>, shadows: Vec<memory::ShadowedSkill>) fn drain_skill_shadows<C, S>(pod: &Pod<C, S>, shadows: Vec<workflow_crate::ShadowedSkill>)
where where
C: LlmClient, C: LlmClient,
S: Store, S: Store,
@ -3048,6 +3048,9 @@ fn build_scope_with_memory(manifest: &PodManifest, pwd: &Path) -> Result<Scope,
if let Some(mem) = manifest.memory.as_ref() { if let Some(mem) = manifest.memory.as_ref() {
let layout = memory::WorkspaceLayout::resolve(mem, pwd); let layout = memory::WorkspaceLayout::resolve(mem, pwd);
scope_config.deny.extend(memory::deny_write_rules(&layout)); scope_config.deny.extend(memory::deny_write_rules(&layout));
scope_config
.deny
.extend(workflow_crate::deny_write_rules(&layout));
} }
scope_config.allow.extend(skill_dir_read_rules(manifest)); scope_config.allow.extend(skill_dir_read_rules(manifest));
Scope::from_config(&scope_config).map_err(PodError::Scope) Scope::from_config(&scope_config).map_err(PodError::Scope)
@ -3215,7 +3218,7 @@ permission = "write"
#[test] #[test]
fn ingest_skills_returns_empty_when_skills_section_missing() { fn ingest_skills_returns_empty_when_skills_section_missing() {
let manifest = minimal_manifest_with_skills(vec![]); let manifest = minimal_manifest_with_skills(vec![]);
let mut registry = memory::WorkflowRegistry::empty(); let mut registry = workflow_crate::WorkflowRegistry::empty();
let shadows = ingest_skills(&mut registry, &manifest); let shadows = ingest_skills(&mut registry, &manifest);
assert!(shadows.is_empty()); assert!(shadows.is_empty());
assert!(registry.is_empty()); assert!(registry.is_empty());
@ -3233,13 +3236,13 @@ permission = "write"
.unwrap(); .unwrap();
let manifest = minimal_manifest_with_skills(vec![skills_root.clone()]); let manifest = minimal_manifest_with_skills(vec![skills_root.clone()]);
let mut registry = memory::WorkflowRegistry::empty(); let mut registry = workflow_crate::WorkflowRegistry::empty();
let shadows = ingest_skills(&mut registry, &manifest); let shadows = ingest_skills(&mut registry, &manifest);
// workspace skill `alpha` should be registered (no collision). // workspace skill `alpha` should be registered (no collision).
assert!( assert!(
registry registry
.get(&memory::Slug::parse("alpha").unwrap()) .get(&workflow_crate::Slug::parse("alpha").unwrap())
.is_some() .is_some()
); );
// No workflow exists to shadow `alpha`, so no shadow event for it. // No workflow exists to shadow `alpha`, so no shadow event for it.

View File

@ -18,10 +18,11 @@ use std::sync::Arc;
use chrono::{DateTime, SecondsFormat, Utc}; use chrono::{DateTime, SecondsFormat, Utc};
use manifest::Scope; use manifest::Scope;
use memory::{ResidentKnowledgeEntry, ResidentWorkflowEntry}; use memory::ResidentKnowledgeEntry;
use minijinja::value::Value; use minijinja::value::Value;
use minijinja::{Environment, ErrorKind, UndefinedBehavior}; use minijinja::{Environment, ErrorKind, UndefinedBehavior};
use thiserror::Error; use thiserror::Error;
use workflow_crate::ResidentWorkflowEntry;
use crate::prompt::catalog::{CatalogError, PromptCatalog}; use crate::prompt::catalog::{CatalogError, PromptCatalog};
use crate::prompt::loader::{LoaderError, PromptLoader, PromptRef}; use crate::prompt::loader::{LoaderError, PromptLoader, PromptRef};

View File

@ -9,12 +9,13 @@
use std::fmt; use std::fmt;
use llm_worker::Item; use llm_worker::Item;
use memory::WorkspaceLayout;
use memory::schema::split_frontmatter; use memory::schema::split_frontmatter;
use memory::{Slug, WorkflowRegistry, WorkspaceLayout}; use workflow_crate::{Slug, WorkflowRegistry};
#[derive(Debug)] #[derive(Debug)]
pub enum WorkflowResolveError { pub enum WorkflowResolveError {
InvalidSlug(memory::LintError), InvalidSlug(workflow_crate::WorkflowLintError),
NotFound { NotFound {
slug: String, slug: String,
}, },
@ -90,7 +91,7 @@ pub fn resolve_workflow_invocation(
let mut out = Vec::new(); let mut out = Vec::new();
for req in &record.requires { for req in &record.requires {
let path = layout.knowledge_path(req); let path = layout.knowledge_dir().join(format!("{req}.md"));
let raw = std::fs::read_to_string(&path).map_err(|source| { let raw = std::fs::read_to_string(&path).map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound { if source.kind() == std::io::ErrorKind::NotFound {
WorkflowResolveError::KnowledgeNotFound { WorkflowResolveError::KnowledgeNotFound {
@ -150,7 +151,7 @@ mod tests {
&dir.path().join(".insomnia/workflow/run-it.md"), &dir.path().join(".insomnia/workflow/run-it.md"),
"---\ndescription: run\nrequires: [policy]\n---\nworkflow body\n", "---\ndescription: run\nrequires: [policy]\n---\nworkflow body\n",
); );
let registry = memory::load_workflows(&layout).unwrap(); let registry = workflow_crate::load_workflows(&layout).unwrap();
(dir, layout, registry) (dir, layout, registry)
} }
@ -174,7 +175,7 @@ mod tests {
&dir.path().join(".insomnia/workflow/hidden.md"), &dir.path().join(".insomnia/workflow/hidden.md"),
"---\ndescription: hidden\nuser_invocable: false\n---\nbody\n", "---\ndescription: hidden\nuser_invocable: false\n---\nbody\n",
); );
let registry = memory::load_workflows(&layout).unwrap(); let registry = workflow_crate::load_workflows(&layout).unwrap();
let err = resolve_workflow_invocation(&registry, &layout, "hidden").unwrap_err(); let err = resolve_workflow_invocation(&registry, &layout, "hidden").unwrap_err();
assert!(matches!(err, WorkflowResolveError::NotUserInvocable { .. })); assert!(matches!(err, WorkflowResolveError::NotUserInvocable { .. }));
} }
@ -187,7 +188,7 @@ mod tests {
&dir.path().join(".insomnia/workflow/bad.md"), &dir.path().join(".insomnia/workflow/bad.md"),
"---\ndescription: bad\nrequires: [ghost]\n---\nbody\n", "---\ndescription: bad\nrequires: [ghost]\n---\nbody\n",
); );
let registry = memory::load_workflows(&layout).unwrap(); let registry = workflow_crate::load_workflows(&layout).unwrap();
let err = resolve_workflow_invocation(&registry, &layout, "bad").unwrap_err(); let err = resolve_workflow_invocation(&registry, &layout, "bad").unwrap_err();
assert!(matches!( assert!(matches!(
err, err,

View File

@ -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 }

View File

@ -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),
}

View File

@ -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,
};

View File

@ -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 { .. }))
);
}
}

View File

@ -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, "");
}
}

View File

@ -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);
}
}

View File

@ -19,10 +19,9 @@ use serde::Deserialize;
use thiserror::Error; use thiserror::Error;
use tracing::warn; use tracing::warn;
use crate::error::LintError;
use crate::schema::split_frontmatter; use crate::schema::split_frontmatter;
use crate::slug::Slug;
use crate::workflow::{WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowRecord, WorkflowSource}; use crate::workflow::{WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowRecord, WorkflowSource};
use crate::{Slug, WorkflowLintError};
/// Filename within a skill directory carrying the frontmatter + body. /// Filename within a skill directory carrying the frontmatter + body.
pub const SKILL_FILENAME: &str = "SKILL.md"; pub const SKILL_FILENAME: &str = "SKILL.md";
@ -34,6 +33,7 @@ pub const SKILL_FILENAME: &str = "SKILL.md";
/// `metadata` are documentary, while `allowed-tools` is recognised and /// `metadata` are documentary, while `allowed-tools` is recognised and
/// emits a warning until [`permission-extension-point.md`] lands. /// emits a warning until [`permission-extension-point.md`] lands.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct SkillFrontmatter { pub struct SkillFrontmatter {
pub name: String, pub name: String,
pub description: String, pub description: String,
@ -49,7 +49,7 @@ pub struct SkillFrontmatter {
/// Validated skill record. Constructed by [`parse_skill_md`] and converted /// Validated skill record. Constructed by [`parse_skill_md`] and converted
/// to a `WorkflowRecord` by the caller via the `Skill → Workflow` /// to a `WorkflowRecord` by the caller via the `Skill → Workflow`
/// projection in [`crate::workflow`]. /// projection in [`crate::WorkflowRecord`].
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillRecord { pub struct SkillRecord {
pub slug: Slug, pub slug: Slug,
@ -94,7 +94,7 @@ pub enum SkillParseError {
Frontmatter { Frontmatter {
path: PathBuf, path: PathBuf,
#[source] #[source]
source: LintError, source: WorkflowLintError,
}, },
#[error( #[error(
"SKILL.md `name` `{name}` does not match its directory name `{dir_name}` (at {})", "SKILL.md `name` `{name}` does not match its directory name `{dir_name}` (at {})",
@ -109,7 +109,7 @@ pub enum SkillParseError {
InvalidName { InvalidName {
skill_md_path: PathBuf, skill_md_path: PathBuf,
#[source] #[source]
source: LintError, source: WorkflowLintError,
}, },
#[error("SKILL.md `description` must be non-empty (at {})", .skill_md_path.display())] #[error("SKILL.md `description` must be non-empty (at {})", .skill_md_path.display())]
DescriptionEmpty { skill_md_path: PathBuf }, DescriptionEmpty { skill_md_path: PathBuf },
@ -150,7 +150,7 @@ pub fn parse_skill_md(skill_md_path: &Path) -> Result<SkillRecord, SkillParseErr
let frontmatter: SkillFrontmatter = let frontmatter: SkillFrontmatter =
serde_yaml::from_str(yaml).map_err(|err| SkillParseError::Frontmatter { serde_yaml::from_str(yaml).map_err(|err| SkillParseError::Frontmatter {
path: skill_md_path.to_path_buf(), path: skill_md_path.to_path_buf(),
source: LintError::MalformedFrontmatter(err.to_string()), source: WorkflowLintError::MalformedFrontmatter(err.to_string()),
})?; })?;
if frontmatter.allowed_tools.is_some() { if frontmatter.allowed_tools.is_some() {
@ -344,7 +344,10 @@ mod tests {
"body", "body",
); );
let record = parse_skill_md(&path).unwrap(); let record = parse_skill_md(&path).unwrap();
assert_eq!(record.description.chars().count(), WORKFLOW_DESCRIPTION_HARD_CAP); assert_eq!(
record.description.chars().count(),
WORKFLOW_DESCRIPTION_HARD_CAP
);
} }
#[test] #[test]

146
crates/workflow/src/slug.rs Normal file
View File

@ -0,0 +1,146 @@
//! Slug type and validation.
//!
//! Syntax (agent-skills compatible):
//! ^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$
//! - 164 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());
}
}

View File

@ -12,10 +12,10 @@ use std::path::{Path, PathBuf};
use thiserror::Error; use thiserror::Error;
use tracing::warn; use tracing::warn;
use crate::error::LintError;
use crate::schema::{WorkflowFrontmatter, split_frontmatter}; use crate::schema::{WorkflowFrontmatter, split_frontmatter};
use crate::slug::Slug; use memory::WorkspaceLayout;
use crate::workspace::WorkspaceLayout;
use crate::{Slug, WorkflowLintError};
/// Hard cap on Workflow descriptions that are advertised resident. /// Hard cap on Workflow descriptions that are advertised resident.
/// Mirrors agent-skills and resident Knowledge descriptions. /// Mirrors agent-skills and resident Knowledge descriptions.
@ -167,9 +167,15 @@ pub enum WorkflowLoadError {
#[error("failed to read workflow file {}: {source}", .path.display())] #[error("failed to read workflow file {}: {source}", .path.display())]
ReadFile { path: PathBuf, source: io::Error }, ReadFile { path: PathBuf, source: io::Error },
#[error("invalid workflow file name {}: {source}", .path.display())] #[error("invalid workflow file name {}: {source}", .path.display())]
InvalidSlug { path: PathBuf, source: LintError }, InvalidSlug {
path: PathBuf,
source: WorkflowLintError,
},
#[error("invalid workflow frontmatter in {}: {source}", .path.display())] #[error("invalid workflow frontmatter in {}: {source}", .path.display())]
Frontmatter { path: PathBuf, source: LintError }, Frontmatter {
path: PathBuf,
source: WorkflowLintError,
},
#[error( #[error(
"Workflow {} with model_invokation: true cannot have description longer than {limit} chars (got {actual})", "Workflow {} with model_invokation: true cannot have description longer than {limit} chars (got {actual})",
.path.display() .path.display()
@ -281,12 +287,12 @@ fn warn_unknown_workflow_fields(path: &Path, yaml: &str) {
} }
} }
fn map_serde_workflow_error(err: serde_yaml::Error) -> LintError { fn map_serde_workflow_error(err: serde_yaml::Error) -> WorkflowLintError {
let msg = err.to_string(); let msg = err.to_string();
if let Some(field) = parse_missing_field(&msg) { if let Some(field) = parse_missing_field(&msg) {
return LintError::MissingField(field); return WorkflowLintError::MissingField(field);
} }
LintError::MalformedFrontmatter(msg) WorkflowLintError::MalformedFrontmatter(msg)
} }
fn parse_missing_field(msg: &str) -> Option<&'static str> { fn parse_missing_field(msg: &str) -> Option<&'static str> {
@ -416,9 +422,18 @@ mod tests {
#[test] #[test]
fn merge_skill_shadows_existing_workflow() { fn merge_skill_shadows_existing_workflow() {
let (dir, layout) = setup(); let (dir, layout) = setup();
write_workflow(dir.path(), "shared", "description: Internal", "internal body"); write_workflow(
dir.path(),
"shared",
"description: Internal",
"internal body",
);
let mut reg = load_workflows(&layout).unwrap(); let mut reg = load_workflows(&layout).unwrap();
let skill_path = dir.path().join("user-skills").join("shared").join("SKILL.md"); 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::create_dir_all(skill_path.parent().unwrap()).unwrap();
std::fs::write(&skill_path, "ignored").unwrap(); std::fs::write(&skill_path, "ignored").unwrap();
let incoming = WorkflowRecord { let incoming = WorkflowRecord {
@ -435,8 +450,14 @@ mod tests {
}; };
let shadow = reg.merge_skill(incoming).expect("expected shadow"); let shadow = reg.merge_skill(incoming).expect("expected shadow");
assert_eq!(shadow.slug.as_str(), "shared"); assert_eq!(shadow.slug.as_str(), "shared");
assert!(matches!(shadow.kept_source, WorkflowSource::WorkspaceWorkflow)); assert!(matches!(
assert!(matches!(shadow.shadowed_source, WorkflowSource::Skill { .. })); shadow.kept_source,
WorkflowSource::WorkspaceWorkflow
));
assert!(matches!(
shadow.shadowed_source,
WorkflowSource::Skill { .. }
));
// The kept record is still the workspace workflow. // The kept record is still the workspace workflow.
let kept = reg.get(&Slug::parse("shared").unwrap()).unwrap(); let kept = reg.get(&Slug::parse("shared").unwrap()).unwrap();
assert!(matches!(kept.source, WorkflowSource::WorkspaceWorkflow)); assert!(matches!(kept.source, WorkflowSource::WorkspaceWorkflow));