merge: workflow crate extraction

This commit is contained in:
Keisuke Hirata 2026-05-11 22:50:19 +09:00
commit 18b0f8b19f
27 changed files with 686 additions and 380 deletions

16
Cargo.lock generated
View File

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

View File

@ -15,6 +15,7 @@ members = [
"crates/tools",
"crates/tui",
"crates/memory",
"crates/workflow",
]
[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" }
manifest = { path = "crates/manifest" }
memory = { path = "crates/memory" }
workflow = { path = "crates/workflow" }
pod-registry = { path = "crates/pod-registry" }
protocol = { path = "crates/protocol" }
provider = { path = "crates/provider" }

View File

@ -1,7 +1,6 @@
- Workflow / Skills
- 内部 Worker / 内部 Pod の Workflow 化 → [tickets/internal-worker-workflow.md](tickets/internal-worker-workflow.md)
- 半自動開発運用 Workflow → [tickets/auto-maintain-workflow.md](tickets/auto-maintain-workflow.md)
- Workflow を memory crate から独立させる → [tickets/workflow-crate-extraction.md](tickets/workflow-crate-extraction.md)
- Prompt / Workflow 評価メトリクスと改善 Offer → [tickets/prompt-eval-metrics.md](tickets/prompt-eval-metrics.md)
- Permission: allow-all 既定 policy への整理 → [tickets/permission-default-policy.md](tickets/permission-default-policy.md)
- Pod CLI: マニフェスト関連フラグの整理 → [tickets/pod-cli-manifest-flags.md](tickets/pod-cli-manifest-flags.md)

View File

@ -69,11 +69,6 @@ pub enum LintError {
#[error("body exceeds the size limit for this record kind: {actual} chars > {limit}")]
BodyTooLong { actual: usize, limit: usize },
#[error(
"write to a Workflow path is forbidden via the memory tool — Workflows are human-edited"
)]
WorkflowWriteForbidden,
#[error("slug `{0}` already exists; use the edit tool instead of creating a new record")]
SlugAlreadyExists(String),

View File

@ -13,11 +13,9 @@ pub mod linter;
pub mod resident;
pub mod schema;
pub mod scope;
pub mod skill;
pub mod slug;
pub mod tool;
pub mod usage;
pub mod workflow;
pub mod workspace;
pub use error::{LintError, LintWarning, MemoryError};
@ -25,17 +23,10 @@ pub use extract::ExtractPointerPayload;
pub use linter::{LintReport, Linter};
pub use resident::{ResidentKnowledgeEntry, collect_resident_knowledge};
pub use scope::deny_write_rules;
pub use skill::{
SKILL_FILENAME, SkillParseError, SkillRecord, load_skills_from_dir, parse_skill_md,
};
pub use slug::Slug;
pub use usage::{
UsageEvent, UsageEventKind, UsageRecordSnapshot, UsageReport, UsageReportRecord, UsageSource,
append_resident_exposure_event, append_usage_event, append_use_event, build_usage_report,
snapshot_record_from_bytes, snapshot_record_from_layout,
};
pub use workflow::{
ResidentWorkflowEntry, ShadowedSkill, WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowLoadError,
WorkflowRecord, WorkflowRegistry, WorkflowSource, load_workflows,
};
pub use workspace::WorkspaceLayout;

View File

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

View File

@ -23,9 +23,8 @@ use serde::de::DeserializeOwned;
use crate::error::{LintError, LintWarning};
use crate::schema::{
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, SummaryFrontmatter,
WorkflowFrontmatter, split_frontmatter,
split_frontmatter,
};
use crate::workflow::WORKFLOW_DESCRIPTION_HARD_CAP;
use crate::workspace::{ClassifiedPath, RecordKind, WorkspaceLayout};
pub use existing::{ExistingRecords, scan_existing};
@ -99,12 +98,6 @@ impl Linter {
}
};
// 2. Workflow paths are sub-Worker-forbidden at the tool layer.
if classified.kind == RecordKind::Workflow {
report.push_error(LintError::WorkflowWriteForbidden);
return report;
}
// 3. Frontmatter parse + kind-specific structural checks +
// size limits. Reference-integrity needs the existing
// record set, fetched once below.
@ -146,7 +139,9 @@ impl Linter {
RecordKind::Summary => {
self.check_kind::<SummaryFrontmatter>(content, &classified, &mut report);
}
RecordKind::Workflow => unreachable!("guarded above"),
RecordKind::Workflow => {
unreachable!("workflow paths are not classified by memory linter")
}
}
report
@ -240,59 +235,6 @@ impl Linter {
}
}
impl Linter {
/// Workflow record validator exposed for human-edit paths
/// (CLI / pre-commit). Not used by the memory tool, which rejects
/// workflow writes outright.
///
/// Verifies frontmatter shape, body size, and that every slug in
/// `requires` points at an existing Knowledge record under the
/// workspace's `knowledge/` directory.
pub fn lint_workflow(&self, content: &str) -> LintReport {
let mut report = LintReport::default();
let parsed = match parse_frontmatter::<WorkflowFrontmatter>(content) {
Ok(p) => p,
Err(e) => {
report.push_error(e);
return report;
}
};
size::check_body::<WorkflowFrontmatter>(parsed.body, &mut report);
// Mirror the loader's cap so human-edit paths fail fast instead
// of surfacing the same error only at Pod startup.
if parsed.frontmatter.model_invokation {
let actual = parsed.frontmatter.description.chars().count();
if actual > WORKFLOW_DESCRIPTION_HARD_CAP {
report.push_error(LintError::DescriptionTooLong {
actual,
limit: WORKFLOW_DESCRIPTION_HARD_CAP,
});
}
}
let existing = match existing::scan_existing(&self.layout) {
Ok(e) => e,
Err(e) => {
report.push_error(LintError::MalformedFrontmatter(format!(
"failed to scan existing records: {e}"
)));
return report;
}
};
for slug in &parsed.frontmatter.requires {
if !existing.contains(crate::workspace::RecordKind::Knowledge, slug) {
report.push_error(LintError::UnknownReference {
field: "requires",
kind: "knowledge",
slug: slug.to_string(),
});
}
}
report
}
}
struct Parsed<'a, F> {
frontmatter: F,
body: &'a str,
@ -332,22 +274,6 @@ mod tests {
(dir, linter)
}
#[test]
fn workflow_write_rejected() {
let (dir, linter) = workspace();
let path = dir.path().join(".insomnia/workflow/wf.md");
let content =
"---\ndescription: x\nmodel_invokation: false\nuser_invocable: true\n---\nbody"
.to_string();
let report = linter.lint(&path, &content, WriteMode::Create);
assert!(
report
.errors
.iter()
.any(|e| matches!(e, LintError::WorkflowWriteForbidden))
);
}
#[test]
fn outside_memory_tree_rejected() {
let (dir, linter) = workspace();
@ -499,83 +425,6 @@ mod tests {
);
}
#[test]
fn workflow_lint_accepts_valid_record() {
let (dir, linter) = workspace();
// Place a Knowledge record that the workflow will reference.
let kn = dir.path().join(".insomnia/knowledge/foo.md");
write(
&kn,
&format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nkind: rule\ndescription: x\nmodel_invokation: false\nuser_invocable: true\nlast_sources: []\n---\n",
n = iso_now()
),
);
let wf = "---\ndescription: do thing\nmodel_invokation: false\nuser_invocable: true\nrequires: [foo]\n---\nstep 1\n".to_string();
let report = linter.lint_workflow(&wf);
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
}
#[test]
fn workflow_lint_flags_unknown_requires() {
let (_dir, linter) = workspace();
let wf = "---\ndescription: x\nmodel_invokation: false\nuser_invocable: true\nrequires: [missing-knowledge]\n---\n".to_string();
let report = linter.lint_workflow(&wf);
assert!(report.errors.iter().any(|e| matches!(
e,
LintError::UnknownReference {
field: "requires",
kind: "knowledge",
..
}
)));
}
#[test]
fn workflow_lint_flags_long_description_when_model_invokation() {
let (_dir, linter) = workspace();
let desc = "x".repeat(crate::workflow::WORKFLOW_DESCRIPTION_HARD_CAP + 1);
let wf = format!(
"---\ndescription: {desc}\nmodel_invokation: true\nuser_invocable: true\n---\n"
);
let report = linter.lint_workflow(&wf);
assert!(
report
.errors
.iter()
.any(|e| matches!(e, LintError::DescriptionTooLong { .. })),
);
}
#[test]
fn workflow_lint_allows_long_description_when_not_model_invokation() {
let (_dir, linter) = workspace();
let desc = "x".repeat(crate::workflow::WORKFLOW_DESCRIPTION_HARD_CAP + 1);
let wf = format!(
"---\ndescription: {desc}\nmodel_invokation: false\nuser_invocable: true\n---\n"
);
let report = linter.lint_workflow(&wf);
assert!(
!report
.errors
.iter()
.any(|e| matches!(e, LintError::DescriptionTooLong { .. })),
);
}
#[test]
fn workflow_lint_collects_multiple_unknown_requires() {
let (_dir, linter) = workspace();
let wf = "---\ndescription: x\nmodel_invokation: false\nuser_invocable: true\nrequires: [a, b, c]\n---\n".to_string();
let report = linter.lint_workflow(&wf);
let unknown_count = report
.errors
.iter()
.filter(|e| matches!(e, LintError::UnknownReference { .. }))
.count();
assert_eq!(unknown_count, 3);
}
#[test]
fn similar_slugs_warns_on_cluster() {
let (dir, linter) = workspace();

View File

@ -1,8 +1,4 @@
//! Reference-integrity checks: `replaced_by` existence + cycle detection.
//!
//! `requires` (Workflow) is checked symmetrically when/if the Workflow
//! linter is invoked from a human-edit path; the memory tool itself
//! never writes Workflow records.
use std::collections::HashSet;

View File

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

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

View File

@ -2,7 +2,7 @@
//!
//! Creates or overwrites a memory or knowledge record by `(kind, slug)`.
//! Pre-write Linter validates frontmatter, slug uniqueness (Create only),
//! reference integrity, size limits, and the workflow-write ban. On any
//! reference integrity, size limits. On any
//! Linter error the tool returns `ToolError::InvalidArgument` with all
//! violations aggregated and the file is **not** written.

View File

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

View File

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

View File

@ -150,7 +150,7 @@ pub struct Pod<C: LlmClient, St: Store> {
prompts: Arc<PromptCatalog>,
/// Registry loaded from `<workspace>/.insomnia/workflow/*.md` when
/// 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
/// Knowledge records by exact slug.
memory_layout: Option<memory::WorkspaceLayout>,
@ -323,7 +323,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
scope_allocation: None,
callback_socket: None,
prompts,
workflow_registry: memory::WorkflowRegistry::empty(),
workflow_registry: workflow_crate::WorkflowRegistry::empty(),
memory_layout: None,
inject_resident_knowledge: true,
pending_scope_snapshot: Arc::new(Mutex::new(None)),
@ -865,13 +865,13 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
} else {
None
};
let resident_workflows: Vec<memory::ResidentWorkflowEntry> =
let resident_workflows: Vec<workflow_crate::ResidentWorkflowEntry> =
if self.inject_resident_knowledge && self.memory_layout.is_some() {
self.workflow_registry.resident_entries()
} else {
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() {
Some(&resident_workflows)
} else {
@ -1106,7 +1106,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
fn resident_exposure_snapshots(
&self,
knowledge: &[memory::ResidentKnowledgeEntry],
workflows: &[memory::ResidentWorkflowEntry],
workflows: &[workflow_crate::ResidentWorkflowEntry],
) -> Vec<memory::UsageRecordSnapshot> {
let Some(layout) = self.memory_layout.as_ref() else {
return Vec::new();
@ -1220,8 +1220,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
let Segment::WorkflowInvoke { slug } = seg else {
continue;
};
let parsed =
memory::Slug::parse(slug.clone()).map_err(WorkflowResolveError::InvalidSlug)?;
let parsed = workflow_crate::Slug::parse(slug.clone())
.map_err(WorkflowResolveError::InvalidSlug)?;
let record = self
.workflow_registry
.get(&parsed)
@ -2886,7 +2886,7 @@ pub enum PodError {
ConsolidationLock(#[source] memory::consolidate::LockError),
#[error("workflow load failed: {0}")]
WorkflowLoad(#[source] memory::WorkflowLoadError),
WorkflowLoad(#[source] workflow_crate::WorkflowLoadError),
#[error("workflow invocation failed: {0}")]
WorkflowResolve(#[from] WorkflowResolveError),
@ -2909,14 +2909,14 @@ struct PodCommon {
scope: Scope,
client: Box<dyn LlmClient>,
prompts: Arc<PromptCatalog>,
workflow_registry: memory::WorkflowRegistry,
workflow_registry: workflow_crate::WorkflowRegistry,
memory_layout: Option<memory::WorkspaceLayout>,
system_prompt_template: Option<SystemPromptTemplate>,
/// SKILL.md shadow events surfaced during workflow-registry build.
/// The Pod constructor drains these into the notify buffer right
/// after the Pod is materialised so the first LLM request observes
/// 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
@ -2968,8 +2968,8 @@ fn prepare_pod_common_from_scope(
.as_ref()
.map(|mem| memory::WorkspaceLayout::resolve(mem, &pwd));
let mut workflow_registry = match memory_layout.as_ref() {
Some(layout) => memory::load_workflows(layout).map_err(PodError::WorkflowLoad)?,
None => memory::WorkflowRegistry::empty(),
Some(layout) => workflow_crate::load_workflows(layout).map_err(PodError::WorkflowLoad)?,
None => workflow_crate::WorkflowRegistry::empty(),
};
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`
/// 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
/// [`memory::ShadowedSkill`] events that the caller pushes onto the
/// [`workflow_crate::ShadowedSkill`] events that the caller pushes onto the
/// Pod's notification buffer.
fn ingest_skills(
registry: &mut memory::WorkflowRegistry,
registry: &mut workflow_crate::WorkflowRegistry,
manifest: &PodManifest,
) -> Vec<memory::ShadowedSkill> {
) -> Vec<workflow_crate::ShadowedSkill> {
let mut shadows = Vec::new();
let Some(skills_cfg) = manifest.skills.as_ref() else {
return shadows;
};
for dir in &skills_cfg.directories {
for skill in memory::load_skills_from_dir(dir) {
let source = memory::WorkflowSource::Skill { dir: dir.clone() };
for skill in workflow_crate::load_skills_from_dir(dir) {
let source = workflow_crate::WorkflowSource::Skill { dir: dir.clone() };
let record = skill.into_workflow_record(source);
if let Some(shadow) = registry.merge_skill(record) {
shadows.push(shadow);
@ -3024,7 +3024,7 @@ fn ingest_skills(
/// Drain skill-ingest shadow events into the Pod's notify buffer so the
/// 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
C: LlmClient,
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() {
let layout = memory::WorkspaceLayout::resolve(mem, pwd);
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::from_config(&scope_config).map_err(PodError::Scope)
@ -3215,7 +3218,7 @@ permission = "write"
#[test]
fn ingest_skills_returns_empty_when_skills_section_missing() {
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);
assert!(shadows.is_empty());
assert!(registry.is_empty());
@ -3233,13 +3236,13 @@ permission = "write"
.unwrap();
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);
// workspace skill `alpha` should be registered (no collision).
assert!(
registry
.get(&memory::Slug::parse("alpha").unwrap())
.get(&workflow_crate::Slug::parse("alpha").unwrap())
.is_some()
);
// 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 manifest::Scope;
use memory::{ResidentKnowledgeEntry, ResidentWorkflowEntry};
use memory::ResidentKnowledgeEntry;
use minijinja::value::Value;
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
use thiserror::Error;
use workflow_crate::ResidentWorkflowEntry;
use crate::prompt::catalog::{CatalogError, PromptCatalog};
use crate::prompt::loader::{LoaderError, PromptLoader, PromptRef};

View File

@ -9,12 +9,13 @@
use std::fmt;
use llm_worker::Item;
use memory::WorkspaceLayout;
use memory::schema::split_frontmatter;
use memory::{Slug, WorkflowRegistry, WorkspaceLayout};
use workflow_crate::{Slug, WorkflowRegistry};
#[derive(Debug)]
pub enum WorkflowResolveError {
InvalidSlug(memory::LintError),
InvalidSlug(workflow_crate::WorkflowLintError),
NotFound {
slug: String,
},
@ -90,7 +91,7 @@ pub fn resolve_workflow_invocation(
let mut out = Vec::new();
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| {
if source.kind() == std::io::ErrorKind::NotFound {
WorkflowResolveError::KnowledgeNotFound {
@ -150,7 +151,7 @@ mod tests {
&dir.path().join(".insomnia/workflow/run-it.md"),
"---\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)
}
@ -174,7 +175,7 @@ mod tests {
&dir.path().join(".insomnia/workflow/hidden.md"),
"---\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();
assert!(matches!(err, WorkflowResolveError::NotUserInvocable { .. }));
}
@ -187,7 +188,7 @@ mod tests {
&dir.path().join(".insomnia/workflow/bad.md"),
"---\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();
assert!(matches!(
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 tracing::warn;
use crate::error::LintError;
use crate::schema::split_frontmatter;
use crate::slug::Slug;
use crate::workflow::{WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowRecord, WorkflowSource};
use crate::{Slug, WorkflowLintError};
/// Filename within a skill directory carrying the frontmatter + body.
pub const SKILL_FILENAME: &str = "SKILL.md";
@ -34,6 +33,7 @@ pub const SKILL_FILENAME: &str = "SKILL.md";
/// `metadata` are documentary, while `allowed-tools` is recognised and
/// emits a warning until [`permission-extension-point.md`] lands.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct SkillFrontmatter {
pub name: String,
pub description: String,
@ -49,7 +49,7 @@ pub struct SkillFrontmatter {
/// Validated skill record. Constructed by [`parse_skill_md`] and converted
/// to a `WorkflowRecord` by the caller via the `Skill → Workflow`
/// projection in [`crate::workflow`].
/// projection in [`crate::WorkflowRecord`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillRecord {
pub slug: Slug,
@ -94,7 +94,7 @@ pub enum SkillParseError {
Frontmatter {
path: PathBuf,
#[source]
source: LintError,
source: WorkflowLintError,
},
#[error(
"SKILL.md `name` `{name}` does not match its directory name `{dir_name}` (at {})",
@ -109,7 +109,7 @@ pub enum SkillParseError {
InvalidName {
skill_md_path: PathBuf,
#[source]
source: LintError,
source: WorkflowLintError,
},
#[error("SKILL.md `description` must be non-empty (at {})", .skill_md_path.display())]
DescriptionEmpty { skill_md_path: PathBuf },
@ -150,7 +150,7 @@ pub fn parse_skill_md(skill_md_path: &Path) -> Result<SkillRecord, SkillParseErr
let frontmatter: SkillFrontmatter =
serde_yaml::from_str(yaml).map_err(|err| SkillParseError::Frontmatter {
path: skill_md_path.to_path_buf(),
source: LintError::MalformedFrontmatter(err.to_string()),
source: WorkflowLintError::MalformedFrontmatter(err.to_string()),
})?;
if frontmatter.allowed_tools.is_some() {
@ -344,7 +344,10 @@ mod tests {
"body",
);
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]

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 tracing::warn;
use crate::error::LintError;
use crate::schema::{WorkflowFrontmatter, split_frontmatter};
use crate::slug::Slug;
use crate::workspace::WorkspaceLayout;
use memory::WorkspaceLayout;
use crate::{Slug, WorkflowLintError};
/// Hard cap on Workflow descriptions that are advertised resident.
/// Mirrors agent-skills and resident Knowledge descriptions.
@ -167,9 +167,15 @@ pub enum WorkflowLoadError {
#[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 },
InvalidSlug {
path: PathBuf,
source: WorkflowLintError,
},
#[error("invalid workflow frontmatter in {}: {source}", .path.display())]
Frontmatter { path: PathBuf, source: LintError },
Frontmatter {
path: PathBuf,
source: WorkflowLintError,
},
#[error(
"Workflow {} with model_invokation: true cannot have description longer than {limit} chars (got {actual})",
.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();
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> {
@ -416,9 +422,18 @@ mod tests {
#[test]
fn merge_skill_shadows_existing_workflow() {
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 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::write(&skill_path, "ignored").unwrap();
let incoming = WorkflowRecord {
@ -435,8 +450,14 @@ mod tests {
};
let shadow = reg.merge_skill(incoming).expect("expected shadow");
assert_eq!(shadow.slug.as_str(), "shared");
assert!(matches!(shadow.kept_source, WorkflowSource::WorkspaceWorkflow));
assert!(matches!(shadow.shadowed_source, WorkflowSource::Skill { .. }));
assert!(matches!(
shadow.kept_source,
WorkflowSource::WorkspaceWorkflow
));
assert!(matches!(
shadow.shadowed_source,
WorkflowSource::Skill { .. }
));
// The kept record is still the workspace workflow.
let kept = reg.get(&Slug::parse("shared").unwrap()).unwrap();
assert!(matches!(kept.source, WorkflowSource::WorkspaceWorkflow));

View File

@ -1,68 +0,0 @@
# Workflow を memory crate から独立させる
## 背景
`tickets/workflow-directory-layout.md` で Workflow の物理配置を `.insomnia/workflow/` に分離した。これにより Workflow は概念上 memory statesession-derived / generatedと別物として整理されたが、ソースコード上は依然として `crates/memory/` 配下に同居している:
- `crates/memory/src/workflow.rs``WorkflowRecord` / `WorkflowRegistry` / `WorkflowSource` / `load_workflows` / `WorkflowLoadError` / `WORKFLOW_DESCRIPTION_HARD_CAP` / `ResidentWorkflowEntry` / `ShadowedSkill`
- `crates/memory/src/schema/workflow.rs``WorkflowFrontmatter`
- `crates/memory/src/skill.rs`Skill → Workflow projection
- `crates/memory/src/linter/mod.rs::lint_workflow`(人間編集向けの workflow linter
- `crates/memory/src/error.rs::LintError::WorkflowWriteForbidden`
memory crate のドメインは「decisions / requests / summary / knowledge / staging / consolidation」に絞り、Workflow は独立した crate に出す。`tickets/internal-worker-workflow.md` で内部 Worker の Workflow 化が予定されており、bundled default や `internal_role` 追加の置き場として独立 crate がある方が自然。
## 要件
### crate の分離
`crates/workflow/` を新設し、上記の Workflow 関連型 / 関数 / スキーマ / Skill projection / human-edit linter を移す。
- 新 crate からは memory crate に依存しないか、`WorkspaceLayout` 経由で薄く依存するに留める
- `crates/memory/` から workflow 関連の `pub use` 再エクスポートは削除(呼び出し側が新 crate を直接 import する)
- Workflow 用の linter は memory crate の `Linter` を共有しないでよい場合は単独で持つ。共有が必要なら共通部分を別 crate例: `crates/lint-common/`)に切る判断を行う
### `WorkspaceLayout` の扱い
`workflow_dir()` / `workflow_path()` が memory crate に残るかは設計判断:
- memory crate に残し、workflow crate がそれを利用する形でよい
- 別 crate例: `crates/workspace-layout/`)に切り出す場合は memory / workflow 両方が参照する形にする
どちらでもよいが、結果として循環依存を生まないこと。
### 既存 use site の更新
- `crates/pod/``pod.rs` / `prompt/system.rs` / `workflow/mod.rs`
- `crates/tui/`
- その他 `memory::Workflow*` を import している箇所
これらが新 crate を import する形に書き換わる。
### Skill ingestion の所属
`SKILL.md` パーサと `WorkflowRecord` への projection は workflow crate に同居する。Skills は外部入力だが最終的に Workflow registry に流れるので、workflow crate を窓口にする方がレイヤとして自然。
### scope deny の整理
`crates/memory/src/scope.rs::deny_write_rules` は memory / knowledge / workflow の 3 ディレクトリを deny している。workflow crate 側で `.insomnia/workflow/` の deny を表明し、Pod 起動時に両方を合成する形にするか、あるいは scope deny は呼び出し側podで集約する形に再設計する。
## 範囲外
- Workflow の機能変更frontmatter schema 変更、resolver 改修等)
- bundled default Workflow 機構(`tickets/internal-worker-workflow.md` の対象)
- memory crate 内部の他モジュール再編
## 完了条件
- `crates/workflow/` crate が独立して存在し、`WorkflowRecord` / `WorkflowRegistry` / `load_workflows` / `WorkflowFrontmatter` / Skill projection / human-edit linter がそこに住む
- memory crate に workflow / skill 関連のソースが残っていないreexport も無し)
- 既存テストが新構造で通る
- 既存呼び出し側pod / tui 等)が新 crate を import する形に更新されている
- scope deny が memory / workflow を矛盾なく合成できる構成になっている
## 参照
- 直前: `tickets/workflow-directory-layout.md`git log
- 後続: `tickets/internal-worker-workflow.md`
- 関連: `docs/plan/workflow.md`