Implement workflow MVP

This commit is contained in:
2026-05-02 00:46:47 +09:00
parent 573501e37c
commit e2d1fa120f
17 changed files with 773 additions and 64 deletions
+17 -8
View File
@@ -251,19 +251,15 @@ impl PodController {
permission: manifest::Permission::Read,
recursive: true,
});
let scope_with_bash = manifest::Scope::from_config(&scope_config)
.map_err(std::io::Error::other)?;
let scope_with_bash =
manifest::Scope::from_config(&scope_config).map_err(std::io::Error::other)?;
let fs = tools::ScopedFs::new(scope_with_bash, pwd_for_tools.clone());
let tracker = tools::Tracker::new();
// The same ScopedFs also powers the IPC `ListCompletions`
// query — keep a clone for the FS view we attach below,
// since the tools consume `fs` itself.
fs_for_view = fs.clone();
worker.register_tools(tools::builtin_tools(
fs,
tracker.clone(),
bash_output_dir,
));
worker.register_tools(tools::builtin_tools(fs, tracker.clone(), bash_output_dir));
// Memory subsystem opt-in. When `[memory]` is present in
// the manifest, register the memory-specific Read/Write/Edit
@@ -316,6 +312,12 @@ impl PodController {
shared_state.update_history(pod.worker().history().to_vec());
shared_state.set_user_segments(pod.user_segments().to_vec());
shared_state.set_fs_view(crate::fs_view::PodFsView::new(fs_for_view));
shared_state.set_workflows(
pod.workflow_completions()
.into_iter()
.map(|slug| crate::shared_state::WorkflowCandidate { slug })
.collect(),
);
runtime_dir.write_manifest(&manifest_toml).await?;
runtime_dir.write_status(&shared_state).await?;
runtime_dir.write_history(&shared_state).await?;
@@ -360,6 +362,14 @@ impl PodController {
});
continue;
}
let was_paused = status_before == PodStatus::Paused;
if let Err(e) = pod.validate_workflow_invocations(&input) {
let _ = event_tx.send(Event::Error {
code: ErrorCode::InvalidRequest,
message: e.to_string(),
});
continue;
}
// Broadcast the accepted user message so every
// subscriber (including the submitter) can
// render the turn header + user line from a
@@ -369,7 +379,6 @@ impl PodController {
let _ = event_tx.send(Event::UserMessage {
segments: input.clone(),
});
let was_paused = status_before == PodStatus::Paused;
shared_state.set_status(PodStatus::Running);
let _ = runtime_dir.write_status(&shared_state).await;
+1
View File
@@ -647,6 +647,7 @@ permission = "write"
tool_names: Vec::new(),
agents_md: None,
resident_knowledge: None,
resident_workflows: None,
prompts: &catalog,
};
let rendered = tmpl.render(&ctx).unwrap();
+10 -5
View File
@@ -102,11 +102,16 @@ async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) {
is_dir: c.is_dir,
})
.collect(),
// Knowledge / Workflow resolvers are not wired
// up yet — reply empty so the TUI sees a
// consistent shape regardless of kind.
protocol::CompletionKind::Knowledge
| protocol::CompletionKind::Workflow => Vec::new(),
protocol::CompletionKind::Knowledge => Vec::new(),
protocol::CompletionKind::Workflow => handle
.shared_state
.list_workflow_completions(&prefix)
.into_iter()
.map(|c| protocol::CompletionEntry {
value: c.slug,
is_dir: false,
})
.collect(),
};
if writer
.write(&Event::Completions { kind, entries })
+1
View File
@@ -7,6 +7,7 @@ pub mod prompt;
pub mod runtime;
pub mod shared_state;
pub mod spawn;
pub mod workflow;
mod factory;
mod interrupt_and_run;
+117 -23
View File
@@ -27,6 +27,7 @@ use crate::prompt::loader::PromptLoader;
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
use crate::runtime::dir;
use crate::runtime::pod_registry::{self, ScopeAllocationGuard, ScopeLockError};
use crate::workflow::WorkflowResolveError;
use async_trait::async_trait;
use llm_worker::interceptor::PreRequestAction;
use protocol::{AlertLevel, AlertSource, Event, Segment};
@@ -124,6 +125,12 @@ pub struct Pod<C: LlmClient, St: Store> {
/// [`Self::from_manifest`], or defaults to the builtin pack when a
/// Pod is constructed through lower-level paths that have no loader.
prompts: Arc<PromptCatalog>,
/// Registry loaded from `<workspace>/.insomnia/memory/workflow/*.md`
/// when memory is enabled. Missing memory config keeps this empty.
workflow_registry: memory::WorkflowRegistry,
/// Memory workspace layout used by the workflow resolver to load required
/// Knowledge records by exact slug.
memory_layout: Option<memory::WorkspaceLayout>,
/// When true (default), the system-prompt assembler walks
/// `<workspace>/knowledge/*` and appends a `## Resident knowledge`
/// section listing records with `model_invokation: true`.
@@ -200,6 +207,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
scope_allocation: None,
callback_socket: None,
prompts,
workflow_registry: memory::WorkflowRegistry::empty(),
memory_layout: None,
inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)),
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
@@ -565,23 +574,31 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
// Owned `Vec` lives for the duration of `render` below; the
// context borrows a slice into it.
let resident: Vec<memory::ResidentKnowledgeEntry> = if self.inject_resident_knowledge {
self.manifest
.memory
self.memory_layout
.as_ref()
.map(|mem| {
let layout = memory::WorkspaceLayout::resolve(mem, &self.pwd);
memory::collect_resident_knowledge(&layout)
})
.map(memory::collect_resident_knowledge)
.unwrap_or_default()
} else {
Vec::new()
};
let resident_slice: Option<&[memory::ResidentKnowledgeEntry]> =
if self.inject_resident_knowledge && self.manifest.memory.is_some() {
if self.inject_resident_knowledge && self.memory_layout.is_some() {
Some(&resident)
} else {
None
};
let resident_workflows: Vec<memory::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]> =
if self.inject_resident_knowledge && self.memory_layout.is_some() {
Some(&resident_workflows)
} else {
None
};
let ctx = SystemPromptContext {
now: chrono::Utc::now(),
cwd: &self.pwd,
@@ -589,6 +606,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
tool_names,
agents_md: agents_md_read.body,
resident_knowledge: resident_slice,
resident_workflows: resident_workflow_slice,
prompts: &self.prompts,
};
let rendered = template
@@ -636,11 +654,12 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
.await?;
self.user_segments.push(input.clone());
// Resolve `@<path>` refs to system messages stashed for the
// PodInterceptor to attach right after the user message. Failures
// surface as user-facing Alerts and the placeholder remains in
// the flattened text so the LLM sees the unresolved intent.
let attachments = self.resolve_file_refs(&input);
// Resolve `@<path>` refs and `/<slug>` workflow invocations to
// system messages stashed for the PodInterceptor to attach right
// after the user message. File failures are non-fatal alerts; explicit
// workflow invocation failures abort before the Worker sees the turn.
let mut attachments = self.resolve_file_refs(&input);
attachments.extend(self.resolve_workflow_invocations(&input)?);
if !attachments.is_empty() {
*self
.pending_attachments
@@ -690,6 +709,63 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
out
}
fn resolve_workflow_invocations(
&self,
segments: &[Segment],
) -> Result<Vec<Item>, WorkflowResolveError> {
let Some(layout) = self.memory_layout.as_ref() else {
if let Some(slug) = segments.iter().find_map(|seg| match seg {
Segment::WorkflowInvoke { slug } => Some(slug.clone()),
_ => None,
}) {
return Err(WorkflowResolveError::NotFound { slug });
}
return Ok(Vec::new());
};
let mut out = Vec::new();
for seg in segments {
let Segment::WorkflowInvoke { slug } = seg else {
continue;
};
let items = crate::workflow::resolve_workflow_invocation(
&self.workflow_registry,
layout,
slug,
)?;
out.extend(items);
}
Ok(out)
}
/// Validate explicit workflow invocations without reading dependency
/// bodies. Used by the controller before broadcasting `UserMessage` so
/// user-invocation errors are returned immediately and never reach the
/// Worker or client history.
pub fn validate_workflow_invocations(
&self,
segments: &[Segment],
) -> Result<(), WorkflowResolveError> {
for seg in segments {
let Segment::WorkflowInvoke { slug } = seg else {
continue;
};
let parsed =
memory::Slug::parse(slug.clone()).map_err(WorkflowResolveError::InvalidSlug)?;
let record = self
.workflow_registry
.get(&parsed)
.ok_or_else(|| WorkflowResolveError::NotFound { slug: slug.clone() })?;
if !record.user_invocable {
return Err(WorkflowResolveError::NotUserInvocable { slug: slug.clone() });
}
}
Ok(())
}
pub fn workflow_completions(&self) -> Vec<String> {
self.workflow_registry.list_user_invocable("")
}
/// Flatten a typed segment list into the single string the Worker
/// receives as the user message, and emit user-facing alerts for
/// segments that fall through to placeholder (knowledge / workflow
@@ -711,16 +787,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
),
);
}
Segment::WorkflowInvoke { slug } => {
self.alert(
AlertLevel::Warn,
AlertSource::Pod,
format!(
"workflow /{slug} cannot be resolved \
(resolver not yet implemented); passed to LLM as placeholder"
),
);
}
Segment::WorkflowInvoke { .. } => {}
Segment::Unknown => {
self.alert(
AlertLevel::Warn,
@@ -1652,7 +1719,10 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
worker.register_tool(memory::tool::write_tool(layout.clone()));
worker.register_tool(memory::tool::edit_tool(layout.clone()));
worker.register_tool(memory::tool::memory_query_tool(layout.clone(), query_cfg));
worker.register_tool(memory::tool::knowledge_query_tool(layout.clone(), query_cfg));
worker.register_tool(memory::tool::knowledge_query_tool(
layout.clone(),
query_cfg,
));
let tidy = consolidate::collect_tidy_hints(&layout);
let candidates = consolidate::KnowledgeCandidateReport::empty();
@@ -1809,6 +1879,8 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
scope_allocation: Some(scope_allocation),
callback_socket: None,
prompts: common.prompts,
workflow_registry: common.workflow_registry,
memory_layout: common.memory_layout,
inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)),
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
@@ -1867,6 +1939,8 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
scope_allocation: Some(scope_allocation),
callback_socket: Some(callback_socket),
prompts: common.prompts,
workflow_registry: common.workflow_registry,
memory_layout: common.memory_layout,
inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)),
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
@@ -1977,6 +2051,8 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
scope_allocation: Some(scope_allocation),
callback_socket: None,
prompts: common.prompts,
workflow_registry: common.workflow_registry,
memory_layout: common.memory_layout,
inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)),
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
@@ -2184,6 +2260,12 @@ pub enum PodError {
#[error("memory Phase 2 lock acquisition failed: {0}")]
ConsolidationLock(#[source] memory::consolidate::LockError),
#[error("workflow load failed: {0}")]
WorkflowLoad(#[source] memory::WorkflowLoadError),
#[error("workflow invocation failed: {0}")]
WorkflowResolve(#[from] WorkflowResolveError),
#[error("session {session_id} has no entries to restore")]
SessionEmpty { session_id: SessionId },
}
@@ -2197,6 +2279,8 @@ struct PodCommon {
scope: Scope,
client: Box<dyn LlmClient>,
prompts: Arc<PromptCatalog>,
workflow_registry: memory::WorkflowRegistry,
memory_layout: Option<memory::WorkspaceLayout>,
system_prompt_template: Option<SystemPromptTemplate>,
}
@@ -2223,6 +2307,14 @@ fn prepare_pod_common(
let client = provider::build_client(&manifest.model)?;
let prompts = PromptCatalog::load(loader, manifest.pod.prompt_pack.as_deref())?;
let memory_layout = manifest
.memory
.as_ref()
.map(|mem| memory::WorkspaceLayout::resolve(mem, &pwd));
let workflow_registry = match memory_layout.as_ref() {
Some(layout) => memory::load_workflows(layout).map_err(PodError::WorkflowLoad)?,
None => memory::WorkflowRegistry::empty(),
};
let system_prompt_template = if parse_template {
Some(
@@ -2238,6 +2330,8 @@ fn prepare_pod_common(
scope,
client,
prompts,
workflow_registry,
memory_layout,
system_prompt_template,
})
}
+16
View File
@@ -79,6 +79,10 @@ pub enum PodPrompt {
/// AGENTS.md section when memory is enabled and at least one
/// `knowledge/*` record advertises `model_invokation: true`.
ResidentKnowledgeSection,
/// Trailing `## Resident workflows` section, appended after resident
/// knowledge when memory is enabled and at least one workflow advertises
/// `model_invokation: true`.
ResidentWorkflowsSection,
}
impl PodPrompt {
@@ -91,6 +95,7 @@ impl PodPrompt {
Self::WorkingBoundariesSection => "working_boundaries_section",
Self::AgentsMdSection => "agents_md_section",
Self::ResidentKnowledgeSection => "resident_knowledge_section",
Self::ResidentWorkflowsSection => "resident_workflows_section",
}
}
@@ -105,6 +110,7 @@ impl PodPrompt {
PodPrompt::WorkingBoundariesSection,
PodPrompt::AgentsMdSection,
PodPrompt::ResidentKnowledgeSection,
PodPrompt::ResidentWorkflowsSection,
];
pub const KEYS: &'static [&'static str] = &[
@@ -115,6 +121,7 @@ impl PodPrompt {
"working_boundaries_section",
"agents_md_section",
"resident_knowledge_section",
"resident_workflows_section",
];
}
@@ -330,6 +337,15 @@ impl PromptCatalog {
single("entries", entries),
)
}
/// Render `PodPrompt::ResidentWorkflowsSection` with `{{ entries }}`
/// (a pre-formatted list block authored by the caller).
pub fn resident_workflows_section(&self, entries: &str) -> Result<String, CatalogError> {
self.render(
PodPrompt::ResidentWorkflowsSection,
single("entries", entries),
)
}
}
fn single(key: &'static str, value: &str) -> Value {
+37 -4
View File
@@ -18,7 +18,7 @@ use std::sync::Arc;
use chrono::{DateTime, SecondsFormat, Utc};
use manifest::Scope;
use memory::ResidentKnowledgeEntry;
use memory::{ResidentKnowledgeEntry, ResidentWorkflowEntry};
use minijinja::value::Value;
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
use thiserror::Error;
@@ -122,6 +122,7 @@ impl SystemPromptTemplate {
ctx.scope,
ctx.agents_md.as_deref(),
ctx.resident_knowledge,
ctx.resident_workflows,
)
}
}
@@ -153,6 +154,10 @@ pub struct SystemPromptContext<'a> {
/// section entirely (memory disabled, or a Phase 2 worker that opts
/// out); `Some(&[])` also yields no section.
pub resident_knowledge: Option<&'a [ResidentKnowledgeEntry]>,
/// Resident workflow descriptions from `<workspace>/memory/workflow/*`
/// whose frontmatter has `model_invokation: true`. `None` disables the
/// section; Phase 2 workers opt out together with resident Knowledge.
pub resident_workflows: Option<&'a [ResidentWorkflowEntry]>,
/// Catalog used to render the fixed trailing section headers.
/// Passed by reference so callers do not give up ownership across
/// the short-lived render borrow.
@@ -201,6 +206,7 @@ pub fn append_trailing_section(
scope: &Scope,
agents_md: Option<&str>,
resident_knowledge: Option<&[ResidentKnowledgeEntry]>,
resident_workflows: Option<&[ResidentWorkflowEntry]>,
) -> Result<String, SystemPromptError> {
let mut out = String::with_capacity(body.len() + 256);
out.push_str(body);
@@ -227,6 +233,15 @@ pub fn append_trailing_section(
out.push('\n');
}
}
if let Some(entries) = resident_workflows {
if !entries.is_empty() {
out.push('\n');
let formatted = format_resident_workflow_entries(entries);
let section = prompts.resident_workflows_section(&formatted)?;
out.push_str(section.trim_end_matches(&['\n', ' '][..]));
out.push('\n');
}
}
// Canonicalise the tail so the emitted prompt has a single form
// regardless of how individual templates chose to end.
while out.ends_with('\n') || out.ends_with(' ') {
@@ -238,15 +253,31 @@ pub fn append_trailing_section(
/// `- <slug>: <description>` per line. Description newlines are folded
/// to spaces so a single entry stays on one row in the rendered prompt.
fn format_resident_knowledge_entries(entries: &[ResidentKnowledgeEntry]) -> String {
format_resident_entries(
entries
.iter()
.map(|e| (e.slug.as_str(), e.description.as_str())),
)
}
fn format_resident_workflow_entries(entries: &[ResidentWorkflowEntry]) -> String {
format_resident_entries(
entries
.iter()
.map(|e| (e.slug.as_str(), e.description.as_str())),
)
}
fn format_resident_entries<'a>(entries: impl Iterator<Item = (&'a str, &'a str)>) -> String {
let mut out = String::new();
for (i, e) in entries.iter().enumerate() {
for (i, (slug, description)) in entries.enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str("- ");
out.push_str(&e.slug);
out.push_str(slug);
out.push_str(": ");
for ch in e.description.chars() {
for ch in description.chars() {
if ch == '\n' || ch == '\r' {
out.push(' ');
} else {
@@ -300,6 +331,7 @@ mod tests {
tool_names: tools,
agents_md,
resident_knowledge: None,
resident_workflows: None,
prompts: test_prompts(),
}
}
@@ -316,6 +348,7 @@ mod tests {
tool_names: Vec::new(),
agents_md: None,
resident_knowledge: Some(resident),
resident_workflows: None,
prompts: test_prompts(),
}
}
+24
View File
@@ -7,6 +7,11 @@ use session_store::SessionId;
use crate::fs_view::PodFsView;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkflowCandidate {
pub slug: String,
}
/// Shared state between PodController and runtime directory.
///
/// Controller updates this in-memory; RuntimeDir writes it to disk.
@@ -31,6 +36,7 @@ pub struct PodSharedState {
/// (only relevant for unit tests that build a `PodSharedState`
/// directly without spinning up a controller).
fs_view: OnceLock<PodFsView>,
workflows: OnceLock<Vec<WorkflowCandidate>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -57,6 +63,7 @@ impl PodSharedState {
history: RwLock::new(Vec::new()),
user_segments: RwLock::new(Vec::new()),
fs_view: OnceLock::new(),
workflows: OnceLock::new(),
}
}
@@ -72,6 +79,23 @@ impl PodSharedState {
self.fs_view.get()
}
pub fn set_workflows(&self, workflows: Vec<WorkflowCandidate>) {
let _ = self.workflows.set(workflows);
}
pub fn list_workflow_completions(&self, prefix: &str) -> Vec<WorkflowCandidate> {
self.workflows
.get()
.map(|items| {
items
.iter()
.filter(|candidate| candidate.slug.starts_with(prefix))
.cloned()
.collect()
})
.unwrap_or_default()
}
pub fn user_segments(&self) -> Vec<Vec<Segment>> {
self.user_segments
.read()
+197
View File
@@ -0,0 +1,197 @@
//! Pod-side Workflow resolver.
//!
//! Turns `Segment::WorkflowInvoke { slug }` into system-message attachments:
//! dependency Knowledge bodies first, then the Workflow body. Resolution is
//! strict for explicit user invocations: missing workflows, non-user-invocable
//! workflows, and missing Knowledge requirements are returned as errors before
//! the turn is handed to the Worker.
use std::fmt;
use llm_worker::Item;
use memory::schema::split_frontmatter;
use memory::{Slug, WorkflowRegistry, WorkspaceLayout};
#[derive(Debug)]
pub enum WorkflowResolveError {
InvalidSlug(memory::LintError),
NotFound {
slug: String,
},
NotUserInvocable {
slug: String,
},
KnowledgeNotFound {
workflow: String,
slug: String,
},
KnowledgeRead {
workflow: String,
slug: String,
source: std::io::Error,
},
KnowledgeFrontmatter {
workflow: String,
slug: String,
source: memory::LintError,
},
}
impl fmt::Display for WorkflowResolveError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidSlug(e) => write!(f, "invalid workflow slug: {e}"),
Self::NotFound { slug } => write!(f, "workflow /{slug} is not registered"),
Self::NotUserInvocable { slug } => {
write!(f, "workflow /{slug} is not user-invocable")
}
Self::KnowledgeNotFound { workflow, slug } => write!(
f,
"workflow /{workflow} requires missing Knowledge slug `{slug}`"
),
Self::KnowledgeRead {
workflow,
slug,
source,
} => write!(
f,
"workflow /{workflow} could not read required Knowledge `{slug}`: {source}"
),
Self::KnowledgeFrontmatter {
workflow,
slug,
source,
} => write!(
f,
"workflow /{workflow} required Knowledge `{slug}` has invalid frontmatter: {source}"
),
}
}
}
impl std::error::Error for WorkflowResolveError {}
pub fn resolve_workflow_invocation(
registry: &WorkflowRegistry,
layout: &WorkspaceLayout,
raw_slug: &str,
) -> Result<Vec<Item>, WorkflowResolveError> {
let slug = Slug::parse(raw_slug.to_string()).map_err(WorkflowResolveError::InvalidSlug)?;
let record = registry
.get(&slug)
.ok_or_else(|| WorkflowResolveError::NotFound {
slug: raw_slug.to_string(),
})?;
if !record.user_invocable {
return Err(WorkflowResolveError::NotUserInvocable {
slug: raw_slug.to_string(),
});
}
let mut out = Vec::new();
for req in &record.requires {
let path = layout.knowledge_path(req);
let raw = std::fs::read_to_string(&path).map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
WorkflowResolveError::KnowledgeNotFound {
workflow: slug.to_string(),
slug: req.to_string(),
}
} else {
WorkflowResolveError::KnowledgeRead {
workflow: slug.to_string(),
slug: req.to_string(),
source,
}
}
})?;
let (_yaml, body) = split_frontmatter(&raw).map_err(|source| {
WorkflowResolveError::KnowledgeFrontmatter {
workflow: slug.to_string(),
slug: req.to_string(),
source,
}
})?;
out.push(Item::system_message(format!(
"[Workflow /{} requires Knowledge #{}]\n{}",
slug,
req,
body.trim_end()
)));
}
out.push(Item::system_message(format!(
"[Workflow /{}]\n{}",
slug,
record.body.trim_end()
)));
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 setup() -> (TempDir, WorkspaceLayout, WorkflowRegistry) {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
write(
&dir.path().join(".insomnia/knowledge/policy.md"),
"---\ncreated_at: 2026-01-01T00:00:00Z\nupdated_at: 2026-01-01T00:00:00Z\nkind: policy\ndescription: p\nmodel_invokation: false\nuser_invocable: true\nlast_sources: []\n---\npolicy body\n",
);
write(
&dir.path().join(".insomnia/memory/workflow/run-it.md"),
"---\ndescription: run\nrequires: [policy]\n---\nworkflow body\n",
);
let registry = memory::load_workflows(&layout).unwrap();
(dir, layout, registry)
}
#[test]
fn resolves_requires_before_workflow_body() {
let (_dir, layout, registry) = setup();
let items = resolve_workflow_invocation(&registry, &layout, "run-it").unwrap();
assert_eq!(items.len(), 2);
let first = format!("{:?}", items[0]);
let second = format!("{:?}", items[1]);
assert!(first.contains("Knowledge #policy"));
assert!(first.contains("policy body"));
assert!(second.contains("[Workflow /run-it]"));
assert!(second.contains("workflow body"));
}
#[test]
fn user_invocable_false_errors() {
let (dir, layout, _registry) = setup();
write(
&dir.path().join(".insomnia/memory/workflow/hidden.md"),
"---\ndescription: hidden\nuser_invocable: false\n---\nbody\n",
);
let registry = memory::load_workflows(&layout).unwrap();
let err = resolve_workflow_invocation(&registry, &layout, "hidden").unwrap_err();
assert!(matches!(err, WorkflowResolveError::NotUserInvocable { .. }));
}
#[test]
fn missing_required_knowledge_errors() {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
write(
&dir.path().join(".insomnia/memory/workflow/bad.md"),
"---\ndescription: bad\nrequires: [ghost]\n---\nbody\n",
);
let registry = memory::load_workflows(&layout).unwrap();
let err = resolve_workflow_invocation(&registry, &layout, "bad").unwrap_err();
assert!(matches!(
err,
WorkflowResolveError::KnowledgeNotFound { .. }
));
}
}