refactor: extract workflow crate
This commit is contained in:
parent
520895f1c9
commit
f70975789e
16
Cargo.lock
generated
16
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
|
|
@ -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(®istry, &layout, "hidden").unwrap_err();
|
let err = resolve_workflow_invocation(®istry, &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(®istry, &layout, "bad").unwrap_err();
|
let err = resolve_workflow_invocation(®istry, &layout, "bad").unwrap_err();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
err,
|
err,
|
||||||
|
|
|
||||||
18
crates/workflow/Cargo.toml
Normal file
18
crates/workflow/Cargo.toml
Normal 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 }
|
||||||
39
crates/workflow/src/error.rs
Normal file
39
crates/workflow/src/error.rs
Normal 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),
|
||||||
|
}
|
||||||
22
crates/workflow/src/lib.rs
Normal file
22
crates/workflow/src/lib.rs
Normal 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,
|
||||||
|
};
|
||||||
223
crates/workflow/src/linter.rs
Normal file
223
crates/workflow/src/linter.rs
Normal 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 { .. }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
crates/workflow/src/schema.rs
Normal file
90
crates/workflow/src/schema.rs
Normal 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, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
36
crates/workflow/src/scope.rs
Normal file
36
crates/workflow/src/scope.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
146
crates/workflow/src/slug.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
//! Slug type and validation.
|
||||||
|
//!
|
||||||
|
//! Syntax (agent-skills compatible):
|
||||||
|
//! ^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$
|
||||||
|
//! - 1–64 chars
|
||||||
|
//! - lowercase ASCII alphanumerics and `-`
|
||||||
|
//! - cannot start or end with `-`
|
||||||
|
//! - no consecutive `--`
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
|
|
||||||
|
use crate::WorkflowLintError;
|
||||||
|
|
||||||
|
const MIN_LEN: usize = 1;
|
||||||
|
const MAX_LEN: usize = 64;
|
||||||
|
|
||||||
|
/// Validated slug. Constructible only via [`Slug::parse`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct Slug(String);
|
||||||
|
|
||||||
|
impl Slug {
|
||||||
|
/// Parse and validate. Returns [`WorkflowLintError::InvalidSlug`] on rejection.
|
||||||
|
pub fn parse(s: impl Into<String>) -> Result<Self, WorkflowLintError> {
|
||||||
|
let s = s.into();
|
||||||
|
if is_valid_slug(&s) {
|
||||||
|
Ok(Self(s))
|
||||||
|
} else {
|
||||||
|
Err(WorkflowLintError::InvalidSlug(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_string(self) -> String {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Slug {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<str> for Slug {
|
||||||
|
fn as_ref(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for Slug {
|
||||||
|
type Err = WorkflowLintError;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
Self::parse(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for Slug {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let raw = String::deserialize(deserializer)?;
|
||||||
|
Self::parse(raw).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure-fn predicate matching the agent-skills slug regex without
|
||||||
|
/// pulling in the `regex` crate.
|
||||||
|
pub fn is_valid_slug(s: &str) -> bool {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let len = bytes.len();
|
||||||
|
if len < MIN_LEN || len > MAX_LEN {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !is_alnum_lower(bytes[0]) || !is_alnum_lower(bytes[len - 1]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut prev_dash = false;
|
||||||
|
for &b in bytes {
|
||||||
|
if b == b'-' {
|
||||||
|
if prev_dash {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
prev_dash = true;
|
||||||
|
} else if is_alnum_lower(b) {
|
||||||
|
prev_dash = false;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_alnum_lower(b: u8) -> bool {
|
||||||
|
b.is_ascii_digit() || b.is_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_basic_slugs() {
|
||||||
|
for s in ["a", "ab", "abc-def", "x9", "a-b-c", "123", "a-1"] {
|
||||||
|
assert!(is_valid_slug(s), "expected `{s}` valid");
|
||||||
|
assert!(Slug::parse(s).is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_bad_slugs() {
|
||||||
|
for s in [
|
||||||
|
"", "-", "-foo", "foo-", "Foo", "foo_bar", "foo bar", "foo--bar", "foo.bar", "ä",
|
||||||
|
] {
|
||||||
|
assert!(!is_valid_slug(s), "expected `{s}` invalid");
|
||||||
|
assert!(Slug::parse(s).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enforces_length_bounds() {
|
||||||
|
let too_long = "a".repeat(MAX_LEN + 1);
|
||||||
|
assert!(!is_valid_slug(&too_long));
|
||||||
|
let max = "a".repeat(MAX_LEN);
|
||||||
|
assert!(is_valid_slug(&max));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserializes_via_serde() {
|
||||||
|
let json = "\"valid-slug\"";
|
||||||
|
let slug: Slug = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(slug.as_str(), "valid-slug");
|
||||||
|
|
||||||
|
let bad = "\"BAD\"";
|
||||||
|
let err: Result<Slug, _> = serde_json::from_str(bad);
|
||||||
|
assert!(err.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
Loading…
Reference in New Issue
Block a user