Implement workflow MVP
This commit is contained in:
@@ -15,6 +15,7 @@ pub mod schema;
|
||||
pub mod scope;
|
||||
pub mod slug;
|
||||
pub mod tool;
|
||||
pub mod workflow;
|
||||
pub mod workspace;
|
||||
|
||||
pub use error::{LintError, LintWarning, MemoryError};
|
||||
@@ -23,4 +24,8 @@ pub use linter::{LintReport, Linter};
|
||||
pub use resident::{ResidentKnowledgeEntry, collect_resident_knowledge};
|
||||
pub use scope::deny_write_rules;
|
||||
pub use slug::Slug;
|
||||
pub use workflow::{
|
||||
ResidentWorkflowEntry, WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowLoadError, WorkflowRecord,
|
||||
WorkflowRegistry, load_workflows,
|
||||
};
|
||||
pub use workspace::WorkspaceLayout;
|
||||
|
||||
@@ -44,7 +44,6 @@ fn parse_missing_field(msg: &str) -> Option<&'static str> {
|
||||
"model_invokation",
|
||||
"user_invocable",
|
||||
"last_sources",
|
||||
"auto_invoke",
|
||||
"requires",
|
||||
];
|
||||
FIELDS.iter().copied().find(|n| *n == field_name)
|
||||
|
||||
@@ -323,10 +323,9 @@ mod tests {
|
||||
fn workflow_write_rejected() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join(".insomnia/memory/workflow/wf.md");
|
||||
let content = format!(
|
||||
"---\nupdated_at: {now}\ndescription: x\nauto_invoke: false\nuser_invocable: true\n---\nbody",
|
||||
now = iso_now()
|
||||
);
|
||||
let content =
|
||||
"---\ndescription: x\nmodel_invokation: false\nuser_invocable: true\n---\nbody"
|
||||
.to_string();
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(
|
||||
report
|
||||
@@ -499,10 +498,7 @@ mod tests {
|
||||
n = iso_now()
|
||||
),
|
||||
);
|
||||
let wf = format!(
|
||||
"---\nupdated_at: {n}\ndescription: do thing\nauto_invoke: false\nuser_invocable: true\nrequires: [foo]\n---\nstep 1\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);
|
||||
}
|
||||
@@ -510,10 +506,7 @@ mod tests {
|
||||
#[test]
|
||||
fn workflow_lint_flags_unknown_requires() {
|
||||
let (_dir, linter) = workspace();
|
||||
let wf = format!(
|
||||
"---\nupdated_at: {n}\ndescription: x\nauto_invoke: false\nuser_invocable: true\nrequires: [missing-knowledge]\n---\n",
|
||||
n = iso_now()
|
||||
);
|
||||
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,
|
||||
@@ -528,10 +521,7 @@ mod tests {
|
||||
#[test]
|
||||
fn workflow_lint_collects_multiple_unknown_requires() {
|
||||
let (_dir, linter) = workspace();
|
||||
let wf = format!(
|
||||
"---\nupdated_at: {n}\ndescription: x\nauto_invoke: false\nuser_invocable: true\nrequires: [a, b, c]\n---\n",
|
||||
n = iso_now()
|
||||
);
|
||||
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
|
||||
|
||||
@@ -13,25 +13,37 @@ use crate::slug::Slug;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct WorkflowFrontmatter {
|
||||
/// Workflows don't carry sources/created_at requirements in the
|
||||
/// plan doc; only `updated_at` is required at the schema level.
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// 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,
|
||||
pub auto_invoke: bool,
|
||||
#[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.unwrap_or(self.updated_at)
|
||||
self.created_at.or(self.updated_at).unwrap_or_else(epoch)
|
||||
}
|
||||
fn updated_at(&self) -> DateTime<Utc> {
|
||||
self.updated_at
|
||||
self.updated_at.unwrap_or_else(epoch)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::workspace::WorkspaceLayout;
|
||||
|
||||
/// Build deny rules that strip Write permission from `<workspace>/memory/`
|
||||
/// and `<workspace>/knowledge/`. Recursive — every descendant is capped
|
||||
/// at Read for the generic tools.
|
||||
/// at Read for the generic tools, including `memory/workflow/`.
|
||||
pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
|
||||
vec![
|
||||
deny_write(layout.memory_dir().as_path()),
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Workflow loader and registry.
|
||||
//!
|
||||
//! Workflows live under `<workspace>/.insomnia/memory/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;
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[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()
|
||||
}
|
||||
}
|
||||
|
||||
#[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(),
|
||||
};
|
||||
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/memory/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/memory/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 resident_description_cap_is_enforced() {
|
||||
let (dir, layout) = setup();
|
||||
let desc = "x".repeat(WORKFLOW_DESCRIPTION_HARD_CAP + 1);
|
||||
write_workflow(
|
||||
dir.path(),
|
||||
"bad",
|
||||
&format!("description: {desc}\nmodel_invokation: true"),
|
||||
"Body",
|
||||
);
|
||||
let err = load_workflows(&layout).unwrap_err();
|
||||
assert!(matches!(err, WorkflowLoadError::DescriptionTooLong { .. }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user