refactor: remove workflow machinery

This commit is contained in:
2026-07-16 05:45:24 +09:00
parent 83ad7506d7
commit d801b2698b
64 changed files with 141 additions and 4055 deletions
+4 -24
View File
@@ -165,7 +165,6 @@ pub struct TicketRoleLaunchPlan {
pub role: TicketRole,
pub worker_name: String,
pub profile: String,
pub workflow: String,
pub launch_prompt_ref: Option<String>,
pub run_segments: Vec<Segment>,
}
@@ -305,7 +304,6 @@ pub fn plan_ticket_role_launch_with_config(
}
let role_config = config.role_launch_config(context.role)?;
let profile = role_config.profile.as_str().to_string();
let workflow = role_config.workflow.as_str().to_string();
let launch_prompt_ref = role_config
.launch_prompt
.as_ref()
@@ -336,14 +334,10 @@ pub fn plan_ticket_role_launch_with_config(
role: context.role,
worker_name,
profile,
workflow: workflow.clone(),
launch_prompt_ref,
run_segments: vec![
Segment::WorkflowInvoke { slug: workflow },
Segment::Text {
content: format!("\n\n{prompt}"),
},
],
run_segments: vec![Segment::Text {
content: format!("\n\n{prompt}"),
}],
})
}
@@ -759,7 +753,6 @@ mod tests {
role: TicketRole::Intake,
worker_name: "ticket-intake".to_string(),
profile: "project:intake".to_string(),
workflow: "ticket-intake-workflow".to_string(),
launch_prompt_ref: None,
run_segments: vec![Segment::Text {
content: "intake request".to_string(),
@@ -994,7 +987,6 @@ profile = "builtin:default"
.unwrap();
assert_eq!(intake.role, TicketRole::Intake);
assert_eq!(intake.profile, TicketRole::Intake.default_profile());
assert_eq!(intake.workflow, TicketRole::Intake.default_workflow());
let orchestrator = plan_ticket_role_launch(TicketRoleLaunchContext::new(
temp.path(),
@@ -1006,10 +998,6 @@ profile = "builtin:default"
orchestrator.profile,
TicketRole::Orchestrator.default_profile()
);
assert_eq!(
orchestrator.workflow,
TicketRole::Orchestrator.default_workflow()
);
}
#[test]
@@ -1038,7 +1026,6 @@ profile = "builtin:default"
[ticket.roles.reviewer]
profile = "builtin:default"
launch_prompt = "$workspace/ticket/reviewer/launch"
workflow = "ticket-review-workflow"
"#,
);
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer);
@@ -1051,18 +1038,13 @@ workflow = "ticket-review-workflow"
assert_eq!(plan.worker_name, "reviewer-fixed");
assert_eq!(plan.profile, "builtin:default");
assert_eq!(plan.workflow, "ticket-review-workflow");
assert_eq!(
plan.launch_prompt_ref.as_deref(),
Some("$workspace/ticket/reviewer/launch")
);
assert!(matches!(
&plan.run_segments[0],
Segment::WorkflowInvoke { slug } if slug == "ticket-review-workflow"
));
assert!(matches!(&plan.run_segments[0], Segment::Text { .. }));
assert!(!text.contains("Configured launch_prompt"));
assert!(!text.contains("$workspace/ticket/reviewer/launch"));
assert!(!text.contains("Workflow: ticket-review-workflow"));
assert!(!text.contains("Profile selector: builtin:default"));
assert!(!text.contains("Role: reviewer"));
assert!(!text.contains("system_instruction"));
@@ -1133,8 +1115,6 @@ workflow = "ticket-review-workflow"
assert!(orchestrator_text.contains("Route to implementation after planning sync."));
assert!(orchestrator_text.contains("cargo check --workspace --all-targets"));
assert!(!orchestrator_text.contains("state = inprogress"));
assert!(!orchestrator_text.contains("worktree-workflow"));
assert!(!orchestrator_text.contains("multi-agent-workflow"));
assert!(!orchestrator_text.contains("root/original workspace reads"));
assert!(!orchestrator_text.contains("role_workspace_root"));
assert!(!orchestrator_text.contains("role_cwd"));
+1 -1
View File
@@ -100,7 +100,7 @@ fn push_kind_records(out: &mut String, layout: &WorkspaceLayout, kind: RecordKin
let dir = match kind {
RecordKind::Decision => layout.decisions_dir(),
RecordKind::Request => layout.requests_dir(),
RecordKind::Knowledge | RecordKind::Summary | RecordKind::Workflow => return,
RecordKind::Knowledge | RecordKind::Summary => return,
};
let entries = match std::fs::read_dir(&dir) {
Ok(it) => it,
+1 -1
View File
@@ -141,7 +141,7 @@ fn read_kind_records(layout: &WorkspaceLayout, kind: RecordKind) -> BTreeMap<Str
RecordKind::Decision => layout.decisions_dir(),
RecordKind::Request => layout.requests_dir(),
RecordKind::Knowledge => layout.knowledge_dir(),
RecordKind::Summary | RecordKind::Workflow => return BTreeMap::new(),
RecordKind::Summary => return BTreeMap::new(),
};
let mut out: BTreeMap<String, String> = BTreeMap::new();
let entries = match std::fs::read_dir(&dir) {
-2
View File
@@ -39,7 +39,6 @@ impl ExistingRecords {
RecordKind::Decision => self.decisions.contains_key(slug),
RecordKind::Request => self.requests.contains(slug),
RecordKind::Knowledge => self.knowledge.contains(slug),
RecordKind::Workflow => false,
RecordKind::Summary => false,
}
}
@@ -53,7 +52,6 @@ impl ExistingRecords {
RecordKind::Decision => self.decisions.keys().collect(),
RecordKind::Request => self.requests.iter().collect(),
RecordKind::Knowledge => self.knowledge.iter().collect(),
RecordKind::Workflow => Vec::new(),
RecordKind::Summary => Vec::new(),
}
}
-3
View File
@@ -140,9 +140,6 @@ impl Linter {
RecordKind::Summary => {
self.check_kind::<SummaryFrontmatter>(content, &classified, &mut report);
}
RecordKind::Workflow => {
unreachable!("workflow paths are not classified by memory linter")
}
}
report
+1 -20
View File
@@ -7,7 +7,7 @@
//! enumerate what records exist without knowing what's inside them.
//!
//! - `MemoryQuery` walks `.yoi/memory/{summary.md,decisions/,
//! requests/}`. `.yoi/workflow/`, `.yoi/memory/_staging/`,
//! requests/}`. `.yoi/memory/_staging/`,
//! `.yoi/memory/_usage/`, and `.yoi/memory/_logs/` are excluded
//! by construction.
//! - `KnowledgeQuery` walks `.yoi/knowledge/*.md` and supports a
@@ -524,7 +524,6 @@ mod tests {
std::fs::create_dir_all(dir.path().join(".yoi/memory/decisions")).unwrap();
std::fs::create_dir_all(dir.path().join(".yoi/memory/requests")).unwrap();
std::fs::create_dir_all(dir.path().join(".yoi/memory/_staging")).unwrap();
std::fs::create_dir_all(dir.path().join(".yoi/workflow")).unwrap();
std::fs::create_dir_all(dir.path().join(".yoi/knowledge")).unwrap();
(dir, layout)
}
@@ -637,24 +636,6 @@ mod tests {
assert_eq!(records[0].kind, "summary");
}
#[tokio::test]
async fn memory_query_excludes_workflow_and_staging() {
let (dir, layout) = setup();
let wf = dir.path().join(".yoi/workflow/wf.md");
std::fs::write(&wf, "needle in workflow\n").unwrap();
let stg = dir.path().join(".yoi/memory/_staging/abc.json");
std::fs::write(&stg, "needle in staging\n").unwrap();
let (_, tool) = memory_query_tool(layout, QueryConfig::default())();
let inp = serde_json::json!({ "query": "needle" });
let out = tool
.execute(&inp.to_string(), Default::default())
.await
.unwrap();
let records: Vec<OwnedMemoryRecord> = parse_records(&out);
assert!(records.is_empty(), "got records: {:?}", out.content);
}
#[tokio::test]
async fn query_hits_do_not_log_usage() {
let (dir, layout) = setup();
+1 -7
View File
@@ -1,4 +1,4 @@
//! Workspace-local usage event log for memory / knowledge / workflow records.
//! Workspace-local usage event log for memory / knowledge records.
//!
//! The log is append-only JSONL under the workspace's `.yoi/` tree. It is
//! intentionally evidence-only: aggregation reports explicit context reads and
@@ -26,7 +26,6 @@ pub enum UsageEventKind {
pub enum UsageSource {
MemoryRead,
KnowledgeRef,
WorkflowInvoke,
ResidentInjection,
}
@@ -35,7 +34,6 @@ impl UsageSource {
match self {
Self::MemoryRead => "MemoryRead",
Self::KnowledgeRef => "KnowledgeRef",
Self::WorkflowInvoke => "WorkflowInvoke",
Self::ResidentInjection => "ResidentInjection",
}
}
@@ -216,10 +214,6 @@ fn record_path(
let slug = crate::Slug::parse(slug.to_string()).map_err(invalid_slug_error)?;
Ok(layout.request_path(&slug))
}
RecordKind::Workflow => {
let slug = crate::Slug::parse(slug.to_string()).map_err(invalid_slug_error)?;
Ok(layout.workflow_path(&slug))
}
RecordKind::Knowledge => {
let slug = crate::Slug::parse(slug.to_string()).map_err(invalid_slug_error)?;
Ok(layout.knowledge_path(&slug))
+2 -28
View File
@@ -3,9 +3,8 @@
//! `WorkspaceLayout` carries the root used by the memory subsystem.
//! All yoi-managed memory content lives under the conventional
//! `<root>/.yoi/` subdirectory — alongside workspace project records
//! such as workflow and generated durable memory. The trees inside it:
//! generated durable memory. The trees inside it:
//!
//! - `<root>/.yoi/workflow/<slug>.md`
//! - `<root>/.yoi/knowledge/<slug>.md`
//! - `<root>/.yoi/memory/summary.md`
//! - `<root>/.yoi/memory/decisions/<slug>.md`
@@ -14,9 +13,6 @@
//! - `<root>/.yoi/memory/_logs/current.log` (append-only audit log)
//!
//! `memory/` is reserved for session-derived / generated state;
//! Workflows are human-managed and live one level up under
//! `.yoi/workflow/`.
//!
//! `memory.workspace_root` pins this root explicitly. Without an explicit
//! root, resolution searches upward from the Worker pwd for a `.yoi/memory`
//! marker; `.yoi` project records alone are not a memory marker.
@@ -31,7 +27,6 @@ use lint_common::RecordLintError;
const YOI_DIR: &str = ".yoi";
const MEMORY_DIR: &str = "memory";
const KNOWLEDGE_DIR: &str = "knowledge";
const WORKFLOW_DIR: &str = "workflow";
const SUMMARY_FILE: &str = "summary.md";
const DECISIONS_DIR: &str = "decisions";
const REQUESTS_DIR: &str = "requests";
@@ -47,7 +42,6 @@ pub enum RecordKind {
Summary,
Decision,
Request,
Workflow,
Knowledge,
}
@@ -57,7 +51,6 @@ impl RecordKind {
Self::Summary => "summary",
Self::Decision => "decision",
Self::Request => "request",
Self::Workflow => "workflow",
Self::Knowledge => "knowledge",
}
}
@@ -86,7 +79,7 @@ impl WorkspaceLayout {
/// An explicit `memory.workspace_root` is honored exactly. Without an
/// explicit root, resolution searches `default_root` and its ancestors for
/// the nearest `.yoi/memory` directory. This keeps child worktrees that
/// contain `.yoi` project records such as tickets or workflows from
/// contain `.yoi` project records such as tickets from
/// becoming independent memory roots merely because they contain `.yoi`.
///
/// If no memory marker exists, this falls back to `default_root` because
@@ -133,11 +126,6 @@ impl WorkspaceLayout {
self.memory_dir().join(REQUESTS_DIR)
}
/// Workflow directory: `<root>/.yoi/workflow/`.
pub fn workflow_dir(&self) -> PathBuf {
self.yoi_dir().join(WORKFLOW_DIR)
}
pub fn staging_dir(&self) -> PathBuf {
self.memory_dir().join(STAGING_DIR)
}
@@ -170,10 +158,6 @@ impl WorkspaceLayout {
self.requests_dir().join(format!("{slug}.md"))
}
pub fn workflow_path(&self, slug: &Slug) -> PathBuf {
self.workflow_dir().join(format!("{slug}.md"))
}
pub fn knowledge_path(&self, slug: &Slug) -> PathBuf {
self.knowledge_dir().join(format!("{slug}.md"))
}
@@ -307,14 +291,6 @@ mod tests {
assert_eq!(cp.kind, RecordKind::Knowledge);
}
#[test]
fn workflow_under_memory_is_invalid_path() {
let err = layout()
.classify(&PathBuf::from("/ws/.yoi/memory/workflow/wf.md"))
.unwrap_err();
assert!(matches!(err, LintError::InvalidPath(_)));
}
#[test]
fn staging_returns_none() {
assert!(
@@ -414,7 +390,6 @@ mod tests {
let child = workspace.join(".worktree/child");
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
std::fs::create_dir_all(child.join(".yoi/tickets")).unwrap();
std::fs::create_dir_all(child.join(".yoi/workflow")).unwrap();
let cfg = manifest::MemoryConfig::default();
let layout = WorkspaceLayout::resolve(&cfg, &child);
@@ -427,7 +402,6 @@ mod tests {
let workspace = tmp.path().join("workspace");
let child = workspace.join("child");
std::fs::create_dir_all(workspace.join(".yoi/tickets")).unwrap();
std::fs::create_dir_all(workspace.join(".yoi/workflow")).unwrap();
std::fs::create_dir_all(&child).unwrap();
assert_eq!(find_memory_marker_root(&child), None);
+6 -13
View File
@@ -173,7 +173,7 @@ impl WorkerEvent {
/// `Method::Run` and `Event::UserMessage` carry `Vec<Segment>`. Dumb
/// clients (CLI piping, scripts) only need to produce a single
/// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms
/// (paste chips, file refs, knowledge refs, workflow invocations) and
/// (paste chips, file refs, knowledge refs, knowledge refs) and
/// send them through directly so the Worker side never has to re-parse a
/// flattened string.
///
@@ -205,8 +205,6 @@ pub enum Segment {
FileRef { path: String },
/// `#<slug>` Knowledge reference (see `docs/plan/memory.md`).
KnowledgeRef { slug: String },
/// `/<slug>` Workflow invocation (see `docs/plan/workflow.md`).
WorkflowInvoke { slug: String },
/// Unknown variant from a newer client. Worker treats this as an
/// unresolved input — surfaces an alert and inserts a placeholder.
/// Round-trip is lossy: re-serializing yields `{"kind":"unknown"}`.
@@ -225,9 +223,9 @@ impl Segment {
/// to surface user-visible alerts for unresolved refs should do so
/// alongside this call (Worker does so at submit time).
///
/// Sigil-prefixed variants (`FileRef` / `KnowledgeRef` / `WorkflowInvoke`)
/// Sigil-prefixed variants (`FileRef` / `KnowledgeRef`)
/// flatten back to their literal sigil form (`@<path>`, `#<slug>`,
/// `/<slug>`) — matching what the user originally typed. Resolved
/// ) — matching what the user originally typed. Resolved
/// content (e.g. file body or shallow directory listing for `FileRef`) is
/// delivered as separate `Item::system_message`s adjacent to the user
/// message; the resolution itself is the caller's job. `Unknown` falls back to
@@ -246,10 +244,6 @@ impl Segment {
out.push('#');
out.push_str(slug);
}
Segment::WorkflowInvoke { slug } => {
out.push('/');
out.push_str(slug);
}
Segment::Unknown => {
out.push_str("[unknown input segment]");
}
@@ -295,7 +289,7 @@ pub enum Event {
///
/// Carries the JSON form of `session_store::SystemItem`. Covers
/// `Method::Notify` echoes, child-Worker lifecycle events from
/// `Method::WorkerEvent`, `@<path>` / `#<slug>` / `/<slug>`
/// `Method::WorkerEvent`, `@<path>` / `#<slug>` /
/// resolution payloads, and any future agent-side injection kind.
/// Clients dispatch on the `kind` tag for typed rendering instead
/// of parsing free-text prefixes like `[Notification] …` or
@@ -618,7 +612,6 @@ pub enum AlertSource {
pub enum CompletionKind {
File,
Knowledge,
Workflow,
}
/// One candidate returned in `Event::Completions::entries`.
@@ -1190,7 +1183,7 @@ mod tests {
#[test]
fn event_completions_format_and_default_is_dir() {
let event = Event::Completions {
kind: CompletionKind::Workflow,
kind: CompletionKind::Knowledge,
entries: vec![CompletionEntry {
value: "clear".into(),
is_dir: false,
@@ -1199,7 +1192,7 @@ mod tests {
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "completions");
assert_eq!(parsed["data"]["kind"], "workflow");
assert_eq!(parsed["data"]["kind"], "knowledge");
assert_eq!(parsed["data"]["entries"][0]["value"], "clear");
// is_dir defaults to false on inbound payloads that omit it.
+1 -1
View File
@@ -74,7 +74,7 @@ pub enum LogEntry {
/// User input accepted at submit time. Carries the original typed
/// `Vec<Segment>` so clients can re-render typed atoms (paste chips,
/// file/knowledge refs, workflow invocations) on segment restore.
/// file/knowledge refs) on segment restore.
/// Replay flattens these into a `Item::user_message` for the worker
/// history; the worker layer never sees segments directly.
UserInput { ts: u64, segments: Vec<Segment> },
+22 -7
View File
@@ -2,7 +2,7 @@
//!
//! Items in worker history with `role:system` are never produced by the
//! LLM — they are always inserted by the Worker itself (notifications,
//! file/knowledge/workflow ref resolutions, child-worker lifecycle events,
//! file/knowledge ref resolutions, child-worker lifecycle events,
//! future `<system-reminder>` tags, …). [`SystemItem`] carries the
//! typed shape of each such injection so clients can dispatch on
//! `kind` instead of parsing text prefixes like `[Notification] …` or
@@ -106,7 +106,7 @@ fn render_system_reminder(body: &str) -> String {
///
/// Each variant carries the kind-specific raw data clients use for
/// typed rendering (`Notification.message`, `WorkerEvent.event`, file
/// path / knowledge slug / workflow slug / etc.), plus a pre-rendered
/// path / knowledge slug / etc.), plus a pre-rendered
/// `body` (where applicable) that is the exact `role:system` text the
/// LLM actually saw at commit time. `body` is denormalised so that
/// segment log replay reconstructs worker history byte-identical to
@@ -144,9 +144,11 @@ pub enum SystemItem {
/// header + body).
Knowledge { slug: String, body: String },
/// `/<slug>` Workflow invocation. `body` is the workflow's
/// prompt body materialized into the LLM context.
Workflow { slug: String, body: String },
/// Compatibility sink for pre-removal persisted `kind: "workflow"`
/// system items. These entries are intentionally not replayed as
/// authority-bearing context.
#[serde(rename = "workflow")]
LegacyIgnored { slug: String },
/// Task-management inactivity reminder inserted before an LLM request.
/// `source` is the policy that produced this durable reminder; `body` is
@@ -172,7 +174,9 @@ impl SystemItem {
SystemItem::WorkerEvent { body, .. } => body.clone(),
SystemItem::FileAttachment { body, .. } => body.clone(),
SystemItem::Knowledge { body, .. } => body.clone(),
SystemItem::Workflow { body, .. } => body.clone(),
SystemItem::LegacyIgnored { slug } => {
format!("Ignored legacy procedure item: /{slug}")
}
SystemItem::TaskReminder { body, .. } => body.clone(),
SystemItem::Interrupt { body } => body.clone(),
}
@@ -192,7 +196,7 @@ impl SystemItem {
SystemItem::WorkerEvent { .. } => "worker_event",
SystemItem::FileAttachment { .. } => "file_attachment",
SystemItem::Knowledge { .. } => "knowledge",
SystemItem::Workflow { .. } => "workflow",
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
SystemItem::TaskReminder { .. } => "task_reminder",
SystemItem::Interrupt { .. } => "interrupt",
}
@@ -306,6 +310,17 @@ mod tests {
}
}
#[test]
fn legacy_procedure_system_item_is_ignored_on_replay() {
let parsed: SystemItem =
serde_json::from_str(r#"{"kind":"workflow","slug":"old-flow","body":"legacy body"}"#)
.unwrap();
assert_eq!(
parsed.history_text(),
"Ignored legacy procedure item: /old-flow"
);
}
#[test]
fn round_trip_via_json() {
let item = SystemItem::FileAttachment {
+4 -77
View File
@@ -49,9 +49,8 @@ pub fn ticket_config_scaffold() -> String {
);
for role in TicketRole::ALL {
out.push_str(&format!(
"\n[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n",
"\n[ticket.roles.{role}]\nprofile = \"{}\"\n",
role.default_profile(),
role.default_workflow()
));
}
out
@@ -349,10 +348,6 @@ impl TicketConfig {
pub fn launch_prompt_for(&self, role: TicketRole) -> Option<&PromptRef> {
self.role(role).launch_prompt.as_ref()
}
pub fn workflow_for(&self, role: TicketRole) -> &WorkflowRef {
&self.role(role).workflow
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -437,14 +432,6 @@ impl TicketRole {
}
}
pub fn default_workflow(self) -> &'static str {
match self {
Self::Intake => "ticket-intake-workflow",
Self::Orchestrator => "ticket-orchestrator-routing",
Self::Coder | Self::Reviewer => "multi-agent-workflow",
}
}
pub fn default_profile(self) -> &'static str {
match self {
Self::Intake => "builtin:intake",
@@ -576,15 +563,13 @@ pub enum TicketRoleLaunchConfigError {
pub struct TicketRoleConfig {
pub profile: ProfileSelectorRef,
pub launch_prompt: Option<PromptRef>,
pub workflow: WorkflowRef,
}
impl TicketRoleConfig {
pub fn default_for_role(role: TicketRole) -> Self {
pub fn default_for_role(_role: TicketRole) -> Self {
Self {
profile: ProfileSelectorRef::inherit(),
launch_prompt: None,
workflow: WorkflowRef::from_static(role.default_workflow()),
}
}
}
@@ -685,45 +670,6 @@ impl AsRef<str> for PromptRef {
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub struct WorkflowRef(String);
impl WorkflowRef {
pub fn new(value: impl Into<String>) -> Result<Self, String> {
normalized_non_empty(value, "workflow ref").map(Self)
}
pub fn from_static(value: &'static str) -> Self {
Self(value.to_string())
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl<'de> Deserialize<'de> for WorkflowRef {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(value).map_err(serde::de::Error::custom)
}
}
impl fmt::Display for WorkflowRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl AsRef<str> for WorkflowRef {
fn as_ref(&self) -> &str {
self.as_str()
}
}
fn normalized_non_empty(value: impl Into<String>, label: &str) -> Result<String, String> {
let value = value.into();
let trimmed = value.trim();
@@ -930,18 +876,13 @@ struct RawTicketRoleConfig {
profile: Option<ProfileSelectorRef>,
#[serde(default)]
launch_prompt: Option<PromptRef>,
#[serde(default)]
workflow: Option<WorkflowRef>,
}
impl RawTicketRoleConfig {
fn resolve(self, role: TicketRole) -> TicketRoleConfig {
fn resolve(self, _role: TicketRole) -> TicketRoleConfig {
TicketRoleConfig {
profile: self.profile.unwrap_or_else(ProfileSelectorRef::inherit),
launch_prompt: self.launch_prompt,
workflow: self
.workflow
.unwrap_or_else(|| WorkflowRef::from_static(role.default_workflow())),
}
}
}
@@ -999,7 +940,6 @@ mod tests {
let role_config = config.role(role);
assert_eq!(role_config.profile.as_str(), "inherit");
assert!(role_config.launch_prompt.is_none());
assert_eq!(role_config.workflow.as_str(), role.default_workflow());
}
}
@@ -1108,22 +1048,18 @@ worktree_name = "custom-orchestrator"
[ticket.roles.intake]
profile = "project:intake"
launch_prompt = "$workspace/ticket/intake/launch"
workflow = "ticket-intake-workflow"
[ticket.roles.orchestrator]
profile = "project:orchestrator"
launch_prompt = "$workspace/ticket/orchestrator/launch"
workflow = "ticket-orchestrator-routing"
[ticket.roles.coder]
profile = "inherit"
launch_prompt = "$workspace/ticket/coder/launch"
workflow = "multi-agent-workflow"
[ticket.roles.reviewer]
profile = "project:reviewer"
launch_prompt = "$workspace/ticket/reviewer/launch"
workflow = "multi-agent-workflow"
"#,
);
@@ -1161,10 +1097,6 @@ workflow = "multi-agent-workflow"
.as_str(),
"$workspace/ticket/reviewer/launch"
);
assert_eq!(
config.workflow_for(TicketRole::Reviewer).as_str(),
"multi-agent-workflow"
);
}
#[test]
@@ -1184,8 +1116,7 @@ workflow = "multi-agent-workflow"
assert!(scaffold.contains(&format!("[ticket.roles.{role}]")));
assert!(scaffold.contains(&format!(
"[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
role.default_profile(),
role.default_workflow()
role.default_profile()
)));
}
assert!(!scaffold.contains("[ticket.roles.investigator]"));
@@ -1211,7 +1142,6 @@ workflow = "multi-agent-workflow"
for role in TicketRole::ALL {
let role_config = config.role_launch_config(role).unwrap();
assert_eq!(role_config.profile.as_str(), role.default_profile());
assert_eq!(role_config.workflow.as_str(), role.default_workflow());
}
}
@@ -1230,7 +1160,6 @@ profile = "project:coder"
let coder = config.role(TicketRole::Coder);
assert_eq!(coder.profile.as_str(), "project:coder");
assert!(coder.launch_prompt.is_none());
assert_eq!(coder.workflow.as_str(), "multi-agent-workflow");
assert_eq!(config.profile_for(TicketRole::Reviewer).as_str(), "inherit");
}
@@ -1319,7 +1248,6 @@ branch = "orchestration/panel:bad"
temp.path(),
r#"
[roles.orchestrator]
workflow = "ticket-orchestrator-routing"
"#,
);
@@ -1386,7 +1314,6 @@ profile = "inherit"
r#"
[roles.investigator]
profile = "builtin:default"
workflow = "ticket-orchestrator-routing"
"#,
);
+2 -10
View File
@@ -596,7 +596,6 @@ impl App {
match kind {
CompletionKind::File => self.input.replace_with_file_ref(start, value),
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value),
CompletionKind::Workflow => self.input.replace_with_workflow_invoke(start, value),
}
self.completion = None;
true
@@ -631,7 +630,6 @@ impl App {
match kind {
CompletionKind::File => self.input.replace_with_file_ref(start, value),
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value),
CompletionKind::Workflow => self.input.replace_with_workflow_invoke(start, value),
}
self.completion = None;
true
@@ -2170,12 +2168,12 @@ impl App {
}
session_store::SystemItem::FileAttachment { body, .. }
| session_store::SystemItem::Knowledge { body, .. }
| session_store::SystemItem::Workflow { body, .. }
| session_store::SystemItem::TaskReminder { body, .. }
| session_store::SystemItem::Interrupt { body } => {
self.task_store.apply_system_message_text(&body);
self.blocks.push(Block::SystemMessage { text: body });
}
session_store::SystemItem::LegacyIgnored { .. } => {}
}
}
@@ -2981,7 +2979,7 @@ mod completion_flow_tests {
let _ = app.refresh_completion();
// Reply for a different kind shouldn't overwrite state.
app.handle_worker_event(Event::Completions {
kind: CompletionKind::Workflow,
kind: CompletionKind::Knowledge,
entries: vec![CompletionEntry {
value: "stale".into(),
is_dir: false,
@@ -3707,12 +3705,6 @@ mod completion_flow_tests {
Segment::KnowledgeRef {
slug: "design-note".into(),
},
Segment::Text {
content: " then ".into(),
},
Segment::WorkflowInvoke {
slug: "review".into(),
},
Segment::Paste {
id: 1,
chars: 13,
+1 -1
View File
@@ -4949,7 +4949,7 @@ fn orchestrator_queue_notification_message(
) -> String {
let title = ticket.title.replace(['\r', '\n'], " ");
format!(
"Workspace Dashboard Queue for Ticket `{}`, title `{}`: human authorized Orchestrator routing; this is not an unattended scheduler. Read the Ticket and inspect current Orchestrator workspace state. If unblocked, record routing and transition state queued -> inprogress before any worktree/SpawnWorker implementation side effects. After inprogress acceptance, use worktree-workflow for `.worktree/<task-name>` creation with tracked `.yoi` project records visible and `.yoi/memory` plus local/runtime/log/lock/secret-like `.yoi` paths excluded, then use multi-agent-workflow to run sibling coder/reviewer Workers (coder narrow child-worktree write scope, reviewer read-only by default). After reviewer approval and blocker resolution, integrate the implementation branch into the orchestration branch automatically, validate in the Orchestrator worktree, record the outcome, and clean up only child implementation worktrees/branches. Do not read, write, validate, merge, clean up, or run git operations in the root/original workspace. If blocked, record a concise reason and leave the Ticket queued or return it to planning with the missing-information reason.",
"Workspace Dashboard Queue for Ticket `{}`, title `{}`: human authorized Orchestrator routing; this is not an unattended scheduler. Read the Ticket and inspect current Orchestrator workspace state. If unblocked, record routing and transition state queued -> inprogress before any worktree/SpawnWorker implementation side effects. After inprogress acceptance, create the delegated implementation worktree with tracked `.yoi` project records visible and generated/local/runtime/log/lock/secret-like `.yoi` paths excluded, then run sibling coder/reviewer Workers through typed Ticket role launch surfaces. After reviewer approval and blocker resolution, integrate the implementation branch into the orchestration branch automatically, validate in the Orchestrator worktree, record the outcome, and clean up only child implementation worktrees/branches. Do not read, write, validate, merge, clean up, or run git operations in the root/original workspace. If blocked, record a concise reason and leave the Ticket queued or return it to planning with the missing-information reason.",
ticket.id,
title.trim()
)
+2 -3
View File
@@ -825,7 +825,7 @@ fn ticket_queue_notification_message_carries_routing_contract() {
assert!(message.contains("transition state queued -> inprogress"));
assert!(message.contains("before any worktree/SpawnWorker implementation side effects"));
assert!(message.contains("After inprogress acceptance"));
assert!(message.contains("worktree-workflow"));
assert!(message.contains("implementation worktree"));
assert!(message.contains("`.worktree/<task-name>`"));
assert!(message.contains("tracked `.yoi` project records visible"));
assert!(
@@ -833,7 +833,7 @@ fn ticket_queue_notification_message_carries_routing_contract() {
"`.yoi/memory` plus local/runtime/log/lock/secret-like `.yoi` paths excluded"
)
);
assert!(message.contains("multi-agent-workflow"));
assert!(message.contains("sibling coder/reviewer Workers"));
assert!(message.contains("sibling coder/reviewer Workers"));
assert!(message.contains("coder narrow child-worktree write scope"));
assert!(message.contains("reviewer read-only by default"));
@@ -2791,7 +2791,6 @@ fn dashboard_ticket_intake_finish_success_clears_composer_and_reports_pod() {
role: TicketRole::Intake,
worker_name: "intake-worker".to_string(),
profile: "builtin:default".to_string(),
workflow: "ticket-intake-workflow".to_string(),
launch_prompt_ref: None,
run_segments: vec![],
},
+8 -84
View File
@@ -58,25 +58,12 @@ impl KnowledgeRefAtom {
}
}
/// `/<slug>` chip — confirmed completion of a Workflow invocation.
#[derive(Debug, Clone)]
pub struct WorkflowInvokeAtom {
pub slug: String,
}
impl WorkflowInvokeAtom {
pub fn label(&self) -> String {
format!("/{}", self.slug)
}
}
#[derive(Debug, Clone)]
pub enum Atom {
Char(char),
Paste(PasteRef),
FileRef(FileRefAtom),
KnowledgeRef(KnowledgeRefAtom),
WorkflowInvoke(WorkflowInvokeAtom),
}
impl Atom {
@@ -88,7 +75,6 @@ impl Atom {
Atom::Paste(p) => Some((Style::default().fg(Color::Magenta), p.label())),
Atom::FileRef(r) => Some((Style::default().fg(Color::Cyan), r.label())),
Atom::KnowledgeRef(r) => Some((Style::default().fg(Color::Green), r.label())),
Atom::WorkflowInvoke(r) => Some((Style::default().fg(Color::Yellow), r.label())),
}
}
}
@@ -97,8 +83,7 @@ impl Atom {
enum AtomClass {
Word(WordKind),
Sep,
/// Indivisible chip — paste / file ref / knowledge ref / workflow
/// invocation. Word motion treats one chip as one unit; deletion
/// Indivisible chip — paste / file ref / knowledge ref. Word motion treats one chip as one unit; deletion
/// removes the whole atom.
Chip,
}
@@ -118,9 +103,7 @@ enum WordKind {
fn atom_class(atom: &Atom) -> AtomClass {
match atom {
Atom::Char(c) => char_class(*c),
Atom::Paste(_) | Atom::FileRef(_) | Atom::KnowledgeRef(_) | Atom::WorkflowInvoke(_) => {
AtomClass::Chip
}
Atom::Paste(_) | Atom::FileRef(_) | Atom::KnowledgeRef(_) => AtomClass::Chip,
}
}
@@ -216,11 +199,6 @@ impl InputBuffer {
self.atoms
.push(Atom::KnowledgeRef(KnowledgeRefAtom { slug: slug.clone() }));
}
protocol::Segment::WorkflowInvoke { slug } => {
self.atoms.push(Atom::WorkflowInvoke(WorkflowInvokeAtom {
slug: slug.clone(),
}));
}
protocol::Segment::Unknown => {
self.atoms
.extend("[unknown input segment]".chars().map(Atom::Char));
@@ -249,7 +227,6 @@ impl InputBuffer {
Atom::Paste(paste) => text.push_str(&paste.content),
Atom::FileRef(file) => text.push_str(&file.path),
Atom::KnowledgeRef(knowledge) => text.push_str(&knowledge.slug),
Atom::WorkflowInvoke(workflow) => text.push_str(&workflow.slug),
}
}
text
@@ -277,7 +254,7 @@ impl InputBuffer {
}
/// Replace `atoms[start..self.cursor]` (the in-flight `@<typed>` /
/// `#<typed>` / `/<typed>` token) with the corresponding chip atom
/// `#<typed>` token) with the corresponding chip atom
/// and place the cursor right after the chip. Used by the completion
/// confirm path.
pub fn replace_with_file_ref(&mut self, start: usize, path: String) {
@@ -294,13 +271,6 @@ impl InputBuffer {
self.cursor = start + 1;
}
pub fn replace_with_workflow_invoke(&mut self, start: usize, slug: String) {
self.atoms.drain(start..self.cursor);
self.atoms
.insert(start, Atom::WorkflowInvoke(WorkflowInvokeAtom { slug }));
self.cursor = start + 1;
}
/// Replace `atoms[start..self.cursor]` with the chars of `text`,
/// leaving cursor at the end of the inserted run. Used by the Tab
/// completion path: the popup-selected entry is inserted as raw
@@ -322,9 +292,9 @@ impl InputBuffer {
/// after the sigil (sigil itself excluded).
///
/// Trigger rules:
/// - The sigil (`@` / `#` / `/`) must be preceded by start-of-input,
/// - The sigil (`@` / `#`) must be preceded by start-of-input,
/// whitespace, or another chip atom — otherwise this is normal
/// text (e.g. the `/` in `src/main.rs` is not a workflow trigger).
/// text (e.g. the `/` in `src/main.rs` is not a completion trigger).
/// - Whitespace, newlines and chip atoms invalidate an in-flight
/// token — `@foo /` closes the `@foo` candidate as soon as the
/// space lands.
@@ -342,7 +312,6 @@ impl InputBuffer {
let kind = match c {
'@' => Some(protocol::CompletionKind::File),
'#' => Some(protocol::CompletionKind::Knowledge),
'/' => Some(protocol::CompletionKind::Workflow),
_ => None,
};
if let Some(k) = kind {
@@ -511,7 +480,7 @@ impl InputBuffer {
/// Build the typed `Vec<Segment>` sent over the protocol. Adjacent
/// `Atom::Char`s are concatenated into a single `Segment::Text`; each
/// chip atom (`Paste` / `FileRef` / `KnowledgeRef` / `WorkflowInvoke`)
/// chip atom (`Paste` / `FileRef` / `KnowledgeRef` )
/// becomes a standalone `Segment` so that clients re-rendering an
/// `Event::UserMessage` see the same indivisible chip rather than a
/// flattened string.
@@ -547,12 +516,6 @@ impl InputBuffer {
slug: r.slug.clone(),
});
}
Atom::WorkflowInvoke(r) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::WorkflowInvoke {
slug: r.slug.clone(),
});
}
}
}
if !buf.is_empty() {
@@ -993,34 +956,6 @@ mod submit_segments_tests {
assert!(matches!(&segs[0], Segment::Text { content } if content == "see "));
assert!(matches!(&segs[1], Segment::FileRef { path } if path == "src/main.rs"));
}
#[test]
fn knowledge_and_workflow_chips_emit_typed_segments() {
let mut buf = InputBuffer::new();
for c in "#r".chars() {
buf.insert_char(c);
}
buf.replace_with_knowledge_ref(0, "rust-style".into());
buf.insert_char(' ');
for c in "/p".chars() {
buf.insert_char(c);
}
buf.replace_with_workflow_invoke(2, "plan".into());
let segs = buf.submit_segments();
assert_eq!(segs.len(), 3);
match &segs[0] {
Segment::KnowledgeRef { slug } => assert_eq!(slug, "rust-style"),
other => panic!("expected KnowledgeRef, got {other:?}"),
}
match &segs[1] {
Segment::Text { content } => assert_eq!(content, " "),
other => panic!("expected Text, got {other:?}"),
}
match &segs[2] {
Segment::WorkflowInvoke { slug } => assert_eq!(slug, "plan"),
other => panic!("expected WorkflowInvoke, got {other:?}"),
}
}
}
#[cfg(test)]
@@ -1055,7 +990,7 @@ mod completion_prefix_tests {
}
#[test]
fn slash_inside_path_is_not_a_workflow_trigger() {
fn slash_inside_path_is_not_a_completion_trigger() {
// After `@src/m`, the only valid trigger is `@`, not the `/`.
let buf = buf_from("@src/m");
let (kind, start, prefix) = buf.pending_completion_prefix().unwrap();
@@ -1101,14 +1036,6 @@ mod completion_prefix_tests {
assert_eq!(prefix, "abc");
}
#[test]
fn slash_at_start_triggers_workflow_completion() {
let buf = buf_from("/cl");
let (kind, _, prefix) = buf.pending_completion_prefix().unwrap();
assert_eq!(kind, CompletionKind::Workflow);
assert_eq!(prefix, "cl");
}
#[test]
fn newline_before_cursor_invalidates_trigger() {
let buf = buf_from("@a\nbc");
@@ -1307,10 +1234,7 @@ mod word_motion_tests {
for a in &buf.atoms {
match a {
Atom::Char(c) => out.push(*c),
Atom::Paste(_)
| Atom::FileRef(_)
| Atom::KnowledgeRef(_)
| Atom::WorkflowInvoke(_) => out.push_str("<P>"),
Atom::Paste(_) | Atom::FileRef(_) | Atom::KnowledgeRef(_) => out.push_str("<P>"),
}
}
out
-4
View File
@@ -1098,9 +1098,6 @@ fn chip_span_for(seg: &Segment, fallback: Style) -> (Style, String) {
),
Segment::FileRef { path } => (Style::default().fg(Color::Cyan), format!("@{path}")),
Segment::KnowledgeRef { slug } => (Style::default().fg(Color::Green), format!("#{slug}")),
Segment::WorkflowInvoke { slug } => {
(Style::default().fg(Color::Yellow), format!("/{slug}"))
}
Segment::Unknown => (fallback, "[unknown segment]".to_owned()),
}
}
@@ -1116,7 +1113,6 @@ fn segment_display_text(seg: &Segment) -> String {
} => format!("[Clipboard #{id} | {chars} chars, {lines} lines]"),
Segment::FileRef { path } => format!("@{path}"),
Segment::KnowledgeRef { slug } => format!("#{slug}"),
Segment::WorkflowInvoke { slug } => format!("/{slug}"),
Segment::Unknown => "[unknown segment]".to_owned(),
}
}
-1
View File
@@ -34,7 +34,6 @@ libc = { workspace = true }
schemars = { workspace = true }
ticket = { workspace = true }
memory = { workspace = true }
workflow-crate = { package = "workflow", path = "../workflow" }
uuid = { workspace = true, features = ["v7"] }
session-metrics = { workspace = true }
arc-swap = "1.9.1"
-736
View File
@@ -1,736 +0,0 @@
//! Durable active workflow invocation state.
//!
//! Workflow bodies are resolved at invocation time and snapshotted here. The
//! snapshot, not whatever resource version is installed later, is the procedural
//! authority that survives compaction for the currently governed task.
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use llm_engine::Item;
use llm_engine::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use session_store::{LogEntry, SystemItem, segment_log};
pub const DOMAIN: &str = "worker.active_workflows";
pub const REHYDRATION_MESSAGE_PREFIX: &str = "[Active workflow snapshot]";
pub const INACTIVE_MESSAGE_PREFIX: &str = "[Active workflow state]";
const SCHEMA_VERSION: u32 = 1;
pub type LogEntryCommitter = Arc<dyn Fn(LogEntry) + Send + Sync>;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActiveWorkflowSnapshot {
pub schema_version: u32,
pub workflows: Vec<ActiveWorkflowRecord>,
}
impl Default for ActiveWorkflowSnapshot {
fn default() -> Self {
Self {
schema_version: SCHEMA_VERSION,
workflows: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActiveWorkflowRecord {
pub slug: String,
pub status: ActiveWorkflowStatus,
pub invocation: WorkflowInvocationInfo,
pub task_scope: String,
pub body_snapshot_policy: WorkflowBodySnapshotPolicy,
pub guidance_snapshot: String,
pub obligations: Vec<String>,
pub checkpoints: Vec<WorkflowCheckpoint>,
pub updated_at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completion: Option<WorkflowCompletionInfo>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ActiveWorkflowStatus {
Active,
Completed,
Cancelled,
}
impl std::fmt::Display for ActiveWorkflowStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::Active => "active",
Self::Completed => "completed",
Self::Cancelled => "cancelled",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkflowInvocationInfo {
pub source: WorkflowInvocationSource,
pub invoked_at_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkflowInvocationSource {
UserWorkflowInvokeSegment,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkflowBodySnapshotPolicy {
SnapshottedAtInvocation,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkflowCheckpoint {
pub label: String,
pub status: WorkflowCheckpointStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkflowCheckpointStatus {
Open,
Done,
Cancelled,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkflowCompletionInfo {
pub completed_at_ms: u64,
pub reason: String,
}
#[derive(Debug, Clone, Default)]
pub struct ActiveWorkflowStore {
inner: Arc<Mutex<ActiveWorkflowSnapshot>>,
}
impl ActiveWorkflowStore {
pub fn new() -> Self {
Self::default()
}
pub fn snapshot(&self) -> ActiveWorkflowSnapshot {
self.inner.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
pub fn replace_with(&self, snapshot: ActiveWorkflowSnapshot) {
*self.inner.lock().unwrap_or_else(|e| e.into_inner()) = snapshot;
}
pub fn active_records(&self) -> Vec<ActiveWorkflowRecord> {
self.snapshot()
.workflows
.into_iter()
.filter(|record| record.status == ActiveWorkflowStatus::Active)
.collect()
}
pub fn activate_from_system_items(
&self,
items: &[SystemItem],
task_scope: String,
invoked_at_ms: u64,
) -> bool {
let mut grouped: BTreeMap<String, Vec<String>> = BTreeMap::new();
for item in items {
if let SystemItem::Workflow { slug, body } = item {
grouped.entry(slug.clone()).or_default().push(body.clone());
}
}
if grouped.is_empty() {
return false;
}
let mut snapshot = self.snapshot();
snapshot.schema_version = SCHEMA_VERSION;
for (slug, bodies) in grouped {
let guidance_snapshot = bodies.join("\n\n---\n\n");
let obligations = extract_obligations(&guidance_snapshot);
let checkpoints = obligations
.iter()
.take(32)
.map(|label| WorkflowCheckpoint {
label: label.clone(),
status: WorkflowCheckpointStatus::Open,
})
.collect();
let record = ActiveWorkflowRecord {
slug: slug.clone(),
status: ActiveWorkflowStatus::Active,
invocation: WorkflowInvocationInfo {
source: WorkflowInvocationSource::UserWorkflowInvokeSegment,
invoked_at_ms,
},
task_scope: truncate_chars(&task_scope, 2_000),
body_snapshot_policy: WorkflowBodySnapshotPolicy::SnapshottedAtInvocation,
guidance_snapshot,
obligations,
checkpoints,
updated_at_ms: invoked_at_ms,
completion: None,
};
upsert_record(&mut snapshot.workflows, record);
}
self.replace_with(snapshot);
true
}
pub fn set_status(
&self,
slug: &str,
status: ActiveWorkflowStatus,
reason: String,
now_ms: u64,
) -> Result<ActiveWorkflowRecord, String> {
let mut snapshot = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let record = snapshot
.workflows
.iter_mut()
.find(|record| record.slug == slug)
.ok_or_else(|| format!("active workflow `{slug}` not found"))?;
record.status = status;
record.updated_at_ms = now_ms;
record.completion = Some(WorkflowCompletionInfo {
completed_at_ms: now_ms,
reason,
});
for checkpoint in &mut record.checkpoints {
checkpoint.status = match status {
ActiveWorkflowStatus::Active => WorkflowCheckpointStatus::Open,
ActiveWorkflowStatus::Completed => WorkflowCheckpointStatus::Done,
ActiveWorkflowStatus::Cancelled => WorkflowCheckpointStatus::Cancelled,
};
}
Ok(record.clone())
}
pub fn snapshot_text(&self) -> Option<String> {
let active = self.active_records();
(!active.is_empty()).then(|| render_snapshot_text(&active))
}
pub fn rehydration_message(&self) -> Option<String> {
let active = self.active_records();
(!active.is_empty()).then(|| render_rehydration_message(&active))
}
pub fn sanitize_context(&self, context: &mut Vec<Item>) -> usize {
let removed = strip_rehydration_messages(context);
if let Some(message) = self.rehydration_message() {
context.push(Item::system_message(message));
} else if removed > 0 || context.iter().any(has_active_workflow_hint) {
context.push(Item::system_message(inactive_workflow_message()));
}
removed
}
pub fn extension_entry(&self) -> LogEntry {
LogEntry::Extension {
ts: segment_log::now_millis(),
domain: DOMAIN.into(),
payload: serde_json::to_value(self.snapshot())
.expect("ActiveWorkflowSnapshot is always JSON-serializable"),
}
}
pub fn restore_from_history_and_extensions(
&self,
_history: &[Item],
extensions: &[(String, serde_json::Value)],
) {
let (snapshot, diagnostics) = fold_extensions(extensions);
for diagnostic in diagnostics {
tracing::warn!(diagnostic, "failed to restore active workflow state");
}
self.replace_with(snapshot);
}
}
pub fn fold_extensions(
extensions: &[(String, serde_json::Value)],
) -> (ActiveWorkflowSnapshot, Vec<String>) {
let mut latest = None;
let mut diagnostics = Vec::new();
for (domain, payload) in extensions {
if domain != DOMAIN {
continue;
}
match serde_json::from_value::<ActiveWorkflowSnapshot>(payload.clone()) {
Ok(snapshot) if snapshot.schema_version == SCHEMA_VERSION => latest = Some(snapshot),
Ok(snapshot) => {
latest = None;
diagnostics.push(format!(
"unsupported active workflow schema_version {}",
snapshot.schema_version
));
}
Err(err) => {
latest = None;
diagnostics.push(format!("corrupt active workflow payload: {err}"));
}
}
}
(latest.unwrap_or_default(), diagnostics)
}
pub fn strip_rehydration_messages(items: &mut Vec<Item>) -> usize {
let before = items.len();
items.retain(|item| !is_rehydration_message(item));
before - items.len()
}
pub fn is_rehydration_message(item: &Item) -> bool {
item_system_text(item)
.map(|text| text.trim_start().starts_with(REHYDRATION_MESSAGE_PREFIX))
.unwrap_or(false)
}
fn has_active_workflow_hint(item: &Item) -> bool {
item_system_text(item)
.map(|text| {
text.contains("Active Workflow Invocation State")
|| text.contains("ActiveWorkflowStore:")
|| text.contains(REHYDRATION_MESSAGE_PREFIX)
})
.unwrap_or(false)
}
fn item_system_text(item: &Item) -> Option<String> {
match item {
Item::Message { role, content, .. } if *role == llm_engine::Role::System => Some(
content
.iter()
.map(|part| part.as_text())
.collect::<String>(),
),
_ => None,
}
}
fn inactive_workflow_message() -> String {
format!(
"{INACTIVE_MESSAGE_PREFIX}\n\n\
No currently valid active workflow invocation state is active. Ignore older compacted \
history or summaries that appear to describe active workflow obligations; only validated \
typed `{DOMAIN}` records with status `active` establish active workflow guidance."
)
}
pub fn active_workflow_tools(
store: ActiveWorkflowStore,
committer: Option<LogEntryCommitter>,
) -> Vec<ToolDefinition> {
vec![
list_tool(store.clone()),
status_tool(
store.clone(),
ActiveWorkflowStatus::Completed,
committer.clone(),
),
status_tool(store, ActiveWorkflowStatus::Cancelled, committer),
]
}
fn list_tool(store: ActiveWorkflowStore) -> ToolDefinition {
Arc::new(move || {
(
ToolMeta::new("ActiveWorkflowList")
.description("List durable active workflow invocations and their status")
.input_schema(
json!({"type":"object","properties":{},"additionalProperties":false}),
),
Arc::new(ActiveWorkflowListTool {
store: store.clone(),
}) as Arc<dyn Tool>,
)
})
}
fn status_tool(
store: ActiveWorkflowStore,
status: ActiveWorkflowStatus,
committer: Option<LogEntryCommitter>,
) -> ToolDefinition {
let name = match status {
ActiveWorkflowStatus::Completed => "ActiveWorkflowComplete",
ActiveWorkflowStatus::Cancelled => "ActiveWorkflowCancel",
ActiveWorkflowStatus::Active => unreachable!("active status tool is not exposed"),
};
let description = match status {
ActiveWorkflowStatus::Completed => {
"Mark an active workflow as completed when its governed task is finished"
}
ActiveWorkflowStatus::Cancelled => {
"Cancel an active workflow when the governed task is explicitly abandoned"
}
ActiveWorkflowStatus::Active => unreachable!("active status tool is not exposed"),
};
let store_for_tool = store.clone();
let committer_for_tool = committer.clone();
Arc::new(move || {
(
ToolMeta::new(name)
.description(description)
.input_schema(json!({
"type":"object",
"properties":{
"slug":{"type":"string","description":"Workflow slug to update"},
"reason":{"type":"string","description":"Brief completion/cancellation reason"}
},
"required":["slug"],
"additionalProperties":false
})),
Arc::new(ActiveWorkflowStatusTool {
store: store_for_tool.clone(),
status,
committer: committer_for_tool.clone(),
}) as Arc<dyn Tool>,
)
})
}
struct ActiveWorkflowListTool {
store: ActiveWorkflowStore,
}
#[async_trait]
impl Tool for ActiveWorkflowListTool {
async fn execute(
&self,
_input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let snapshot = self.store.snapshot();
let content = serde_json::to_string_pretty(&snapshot)
.map_err(|err| ToolError::Internal(err.to_string()))?;
let active = snapshot
.workflows
.iter()
.filter(|record| record.status == ActiveWorkflowStatus::Active)
.count();
Ok(ToolOutput {
summary: format!(
"ActiveWorkflowStore: {} workflow(s), {active} active",
snapshot.workflows.len()
),
content: Some(content),
})
}
}
struct ActiveWorkflowStatusTool {
store: ActiveWorkflowStore,
status: ActiveWorkflowStatus,
committer: Option<LogEntryCommitter>,
}
#[async_trait]
impl Tool for ActiveWorkflowStatusTool {
async fn execute(
&self,
input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: WorkflowStatusParams = serde_json::from_str(input_json)
.map_err(|err| ToolError::InvalidArgument(err.to_string()))?;
let reason = params.reason.unwrap_or_else(|| self.status.to_string());
let record = self
.store
.set_status(&params.slug, self.status, reason, segment_log::now_millis())
.map_err(ToolError::InvalidArgument)?;
if let Some(committer) = &self.committer {
committer(self.store.extension_entry());
}
let content = serde_json::to_string_pretty(&record)
.map_err(|err| ToolError::Internal(err.to_string()))?;
Ok(ToolOutput {
summary: format!("workflow {} marked {}", record.slug, record.status),
content: Some(content),
})
}
}
#[derive(Debug, Deserialize)]
struct WorkflowStatusParams {
slug: String,
#[serde(default)]
reason: Option<String>,
}
fn upsert_record(records: &mut Vec<ActiveWorkflowRecord>, record: ActiveWorkflowRecord) {
if let Some(existing) = records
.iter_mut()
.find(|existing| existing.slug == record.slug)
{
*existing = record;
} else {
records.push(record);
}
}
fn extract_obligations(body: &str) -> Vec<String> {
let mut obligations = Vec::new();
for line in body.lines() {
let trimmed = line.trim();
let candidate = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
.or_else(|| trimmed.strip_prefix(""))
.unwrap_or(trimmed);
let lower = candidate.to_ascii_lowercase();
let looks_obligating = lower.contains("must")
|| lower.contains("require")
|| lower.contains("obligation")
|| lower.contains("review")
|| lower.contains("merge")
|| lower.contains("close")
|| lower.contains("report")
|| lower.contains("handoff");
if looks_obligating && !candidate.is_empty() {
obligations.push(truncate_chars(candidate, 240));
}
if obligations.len() >= 32 {
break;
}
}
if obligations.is_empty() {
obligations
.push("Follow the snapshotted workflow body until completion or cancellation".into());
}
obligations
}
fn render_snapshot_text(records: &[ActiveWorkflowRecord]) -> String {
let json = serde_json::to_string_pretty(&ActiveWorkflowSnapshot {
schema_version: SCHEMA_VERSION,
workflows: records.to_vec(),
})
.unwrap_or_else(|_| String::from("{\"schema_version\":1,\"workflows\":[]}"));
format!(
"ActiveWorkflowStore: {} active workflow(s)\n\n```json\n{}\n```",
records.len(),
json
)
}
fn render_rehydration_message(records: &[ActiveWorkflowRecord]) -> String {
let mut out = format!(
"{REHYDRATION_MESSAGE_PREFIX}\n\n\
The following workflow invocation state is durable state carried across compaction. \
Continue to follow each active workflow's snapshotted guidance until the governed task \
is completed with ActiveWorkflowComplete or explicitly cancelled with ActiveWorkflowCancel. \
Missing or obsolete workflow resources must not replace these invocation snapshots.\n"
);
for record in records {
out.push_str(&format!(
"\n## /{} ({})\n- invoked_at_ms: {}\n- invocation_source: {:?}\n- body_snapshot_policy: {:?}\n- task_scope: {}\n\n### Current obligations/checkpoints\n",
record.slug,
record.status,
record.invocation.invoked_at_ms,
record.invocation.source,
record.body_snapshot_policy,
record.task_scope.replace('\n', " "),
));
for checkpoint in &record.checkpoints {
out.push_str(&format!(
"- [{}] {}\n",
checkpoint.status_label(),
checkpoint.label
));
}
out.push_str("\n### Snapshotted workflow guidance\n");
out.push_str(record.guidance_snapshot.trim_end());
out.push_str("\n");
}
out
}
impl WorkflowCheckpoint {
fn status_label(&self) -> &'static str {
match self.status {
WorkflowCheckpointStatus::Open => "open",
WorkflowCheckpointStatus::Done => "done",
WorkflowCheckpointStatus::Cancelled => "cancelled",
}
}
}
fn truncate_chars(text: &str, max_chars: usize) -> String {
let mut out = String::new();
for (idx, ch) in text.chars().enumerate() {
if idx >= max_chars {
out.push('…');
return out;
}
out.push(ch);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn store_with_active_workflow() -> ActiveWorkflowStore {
let store = ActiveWorkflowStore::new();
assert!(store.activate_from_system_items(
&[SystemItem::Workflow {
slug: "multi-agent-workflow".into(),
body: "# Multi-agent workflow\n- Delegate implementation to coder.\n- Require external review before merge.\n- Close the Ticket after merge and report evidence.\n".into(),
}],
"/multi-agent-workflow implement ticket".into(),
42,
));
store
}
fn active_extension(store: &ActiveWorkflowStore) -> (String, serde_json::Value) {
(
DOMAIN.to_string(),
serde_json::to_value(store.snapshot()).expect("snapshot json"),
)
}
#[test]
fn active_workflow_guidance_carries_merge_close_obligations() {
let store = store_with_active_workflow();
let msg = store.rehydration_message().unwrap();
assert!(msg.contains("multi-agent-workflow"));
assert!(msg.contains("external review before merge"));
assert!(msg.contains("Close the Ticket after merge"));
assert!(msg.contains("Snapshotted workflow guidance"));
}
#[test]
fn compacted_rehydration_message_is_removed_when_typed_state_missing_or_invalid() {
for extensions in [
Vec::new(),
vec![(DOMAIN.to_string(), json!({"schema_version":"bad"}))],
vec![(
DOMAIN.to_string(),
json!({"schema_version":999,"workflows":[]}),
)],
] {
let original = store_with_active_workflow();
let stale_message = original.rehydration_message().unwrap();
let mut context = vec![
Item::system_message(stale_message),
Item::user_message("continue"),
];
let restored = ActiveWorkflowStore::new();
restored.restore_from_history_and_extensions(&context, &extensions);
let removed = restored.sanitize_context(&mut context);
assert_eq!(removed, 1);
assert!(restored.active_records().is_empty());
assert!(!context.iter().any(is_rehydration_message));
}
}
#[test]
fn completion_or_cancellation_suppresses_old_compacted_guidance() {
for status in [
ActiveWorkflowStatus::Completed,
ActiveWorkflowStatus::Cancelled,
] {
let store = store_with_active_workflow();
let stale_message = store.rehydration_message().unwrap();
let mut context = vec![
Item::system_message(stale_message),
Item::user_message("continue"),
];
store
.set_status("multi-agent-workflow", status, status.to_string(), 84)
.expect("workflow exists");
let removed = store.sanitize_context(&mut context);
assert_eq!(removed, 1);
assert!(!context.iter().any(is_rehydration_message));
}
}
#[test]
fn unmatched_status_tool_calls_do_not_mutate_restored_state() {
let store = store_with_active_workflow();
let extensions = vec![active_extension(&store)];
let history = vec![
Item::tool_call(
"call-1",
"ActiveWorkflowCancel",
json!({"slug":"multi-agent-workflow","reason":"not durable"}).to_string(),
),
Item::tool_result_error("call-1", "error: failed"),
];
let restored = ActiveWorkflowStore::new();
restored.restore_from_history_and_extensions(&history, &extensions);
assert_eq!(restored.active_records().len(), 1);
assert_eq!(
restored.snapshot().workflows[0].status,
ActiveWorkflowStatus::Active
);
}
#[tokio::test]
async fn status_tool_persists_typed_extension_on_success() {
let store = store_with_active_workflow();
let committed = Arc::new(Mutex::new(Vec::<LogEntry>::new()));
let committed_for_tool = committed.clone();
let tools = active_workflow_tools(
store.clone(),
Some(Arc::new(move |entry| {
committed_for_tool
.lock()
.expect("committed entries mutex poisoned")
.push(entry);
})),
);
let (_, tool) = tools[1]();
tool.execute(
&json!({"slug":"multi-agent-workflow","reason":"review complete"}).to_string(),
ToolExecutionContext::default(),
)
.await
.expect("status tool succeeds");
let committed = committed.lock().expect("committed entries mutex poisoned");
let LogEntry::Extension {
domain, payload, ..
} = committed.last().expect("extension committed")
else {
panic!("expected typed active workflow extension");
};
assert_eq!(domain, DOMAIN);
let snapshot: ActiveWorkflowSnapshot = serde_json::from_value(payload.clone()).unwrap();
assert_eq!(
snapshot.workflows[0].status,
ActiveWorkflowStatus::Completed
);
}
#[test]
fn corrupt_extension_fails_closed_with_diagnostic() {
let entries = vec![(DOMAIN.to_string(), json!({"schema_version":"bad"}))];
let (snapshot, diagnostics) = fold_extensions(&entries);
assert!(snapshot.workflows.is_empty());
assert_eq!(diagnostics.len(), 1);
}
}
-17
View File
@@ -117,15 +117,6 @@ impl WorkerHandle {
is_dir: false,
})
.collect(),
protocol::CompletionKind::Workflow => self
.shared_state
.list_workflow_completions(prefix)
.into_iter()
.map(|c| protocol::CompletionEntry {
value: c.slug,
is_dir: false,
})
.collect(),
}
}
@@ -340,13 +331,6 @@ impl WorkerController {
if let Some(fs_for_view) = fs_for_view {
shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view));
}
shared_state.set_workflows(
worker
.workflow_completions()
.into_iter()
.map(|slug| crate::shared_state::WorkflowCandidate { slug })
.collect(),
);
shared_state.set_knowledge(
worker
.knowledge_completions()
@@ -1584,7 +1568,6 @@ fn worker_error_code(e: &WorkerError) -> ErrorCode {
_ => ErrorCode::Internal,
},
WorkerError::Provider(_) => ErrorCode::ProviderError,
WorkerError::WorkflowResolve(_) => ErrorCode::InvalidRequest,
_ => ErrorCode::Internal,
}
}
-25
View File
@@ -22,7 +22,6 @@ use llm_engine::tool::ToolOutput;
use tracing::info;
use tracing::warn;
use crate::active_workflow::ActiveWorkflowStore;
use crate::compact::state::CompactState;
use crate::compact::usage_tracker::UsageTracker;
use session_store::SystemItem;
@@ -72,10 +71,6 @@ pub(crate) struct WorkerInterceptor {
/// worker. `None` in tests / `Worker::new` paths where no writer is
/// attached.
log_writer: Option<Arc<dyn SystemItemCommitter>>,
/// Active workflow state is durable typed Worker state. The interceptor
/// regenerates request-local workflow guidance from this store and strips
/// any stale compacted-history copies before each model request.
active_workflows: ActiveWorkflowStore,
/// Next turn index assigned by `on_prompt_submit`.
next_turn_index: AtomicUsize,
/// Tool calls observed in the current turn (reset on each new prompt).
@@ -91,7 +86,6 @@ impl WorkerInterceptor {
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
prompts: Arc<PromptCatalog>,
log_writer: Option<Arc<dyn SystemItemCommitter>>,
active_workflows: ActiveWorkflowStore,
) -> Self {
Self {
registry,
@@ -102,7 +96,6 @@ impl WorkerInterceptor {
pending_attachments,
prompts,
log_writer,
active_workflows,
next_turn_index: AtomicUsize::new(0),
tool_calls_this_turn: AtomicUsize::new(0),
}
@@ -241,8 +234,6 @@ impl Interceptor for WorkerInterceptor {
}
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
self.active_workflows.sanitize_context(context);
let initial_tokens = self.estimated_tokens(context);
if self.request_threshold_exceeded(initial_tokens, context) {
return PreRequestAction::Yield;
@@ -536,7 +527,6 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@@ -569,7 +559,6 @@ mod tests {
Some(Arc::new(RecordingSystemItemCommitter {
committed: Arc::clone(&committed),
})),
ActiveWorkflowStore::new(),
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@@ -606,7 +595,6 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
)
.with_usage_tracker(usage_tracker);
let mut ctx = ctx_items;
@@ -632,7 +620,6 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@@ -674,7 +661,6 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@@ -702,7 +688,6 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx = ctx_items;
let action = interceptor.pre_llm_request(&mut ctx).await;
@@ -724,7 +709,6 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx: Vec<Item> = Vec::new();
let action = interceptor.pre_llm_request(&mut ctx).await;
@@ -753,7 +737,6 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
Some(committer),
ActiveWorkflowStore::new(),
);
let mut ctx: Vec<Item> = Vec::new();
@@ -801,7 +784,6 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx: Vec<Item> = Vec::new();
@@ -859,7 +841,6 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut info = task_tool_call_info("TaskList", serde_json::json!({"scope": "all"}));
@@ -907,7 +888,6 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let info = task_tool_call_info("TaskList", serde_json::json!({}));
let mut result_info = ToolResultInfo {
@@ -957,7 +937,6 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let history = vec![Item::user_message("hi"), Item::assistant_message("done")];
@@ -992,7 +971,6 @@ mod tests {
Some(Arc::new(RecordingSystemItemCommitter {
committed: Arc::clone(&committed),
})),
ActiveWorkflowStore::new(),
)
.with_usage_tracker(Arc::clone(&usage_tracker));
@@ -1052,7 +1030,6 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let items = interceptor.pending_history_appends().await;
@@ -1090,7 +1067,6 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
let action = interceptor.pre_llm_request(&mut ctx).await;
@@ -1121,7 +1097,6 @@ mod tests {
Arc::new(Mutex::new(Vec::new())),
PromptCatalog::builtins_only().unwrap(),
None,
ActiveWorkflowStore::new(),
);
let mut ctx: Vec<Item> = Vec::new();
let action = interceptor.pre_llm_request(&mut ctx).await;
-2
View File
@@ -1,4 +1,3 @@
pub mod active_workflow;
pub mod compact;
pub mod controller;
pub mod discovery;
@@ -15,7 +14,6 @@ pub mod segment_log_sink;
pub mod shared_state;
mod shutdown_after_idle;
pub mod spawn;
pub mod workflow;
mod interrupt_prep;
mod permission;
+1 -17
View File
@@ -88,10 +88,6 @@ pub enum WorkerPrompt {
/// injection is enabled, and at least one `knowledge/*` record advertises
/// `model_invokation: true`.
ResidentKnowledgeSection,
/// Trailing `## Resident workflows` section, appended after resident
/// knowledge when Workflow resident injection is enabled and at least one
/// workflow advertises `model_invokation: true`.
ResidentWorkflowsSection,
/// Trailing Worker orchestration guidance, appended when registered tools
/// include Worker-management capabilities.
WorkerOrchestrationGuidanceSection,
@@ -115,7 +111,6 @@ impl WorkerPrompt {
Self::AgentsMdSection => "agents_md_section",
Self::ResidentMemorySummarySection => "resident_memory_summary_section",
Self::ResidentKnowledgeSection => "resident_knowledge_section",
Self::ResidentWorkflowsSection => "resident_workflows_section",
Self::WorkerOrchestrationGuidanceSection => "worker_orchestration_guidance_section",
Self::TicketEventCompanionNotice => "ticket_event_companion_notice",
Self::SpawnWorkerToolDescription => "spawn_worker_tool_description",
@@ -136,7 +131,6 @@ impl WorkerPrompt {
WorkerPrompt::AgentsMdSection,
WorkerPrompt::ResidentMemorySummarySection,
WorkerPrompt::ResidentKnowledgeSection,
WorkerPrompt::ResidentWorkflowsSection,
WorkerPrompt::WorkerOrchestrationGuidanceSection,
WorkerPrompt::TicketEventCompanionNotice,
WorkerPrompt::SpawnWorkerToolDescription,
@@ -153,7 +147,6 @@ impl WorkerPrompt {
"agents_md_section",
"resident_memory_summary_section",
"resident_knowledge_section",
"resident_workflows_section",
"worker_orchestration_guidance_section",
"ticket_event_companion_notice",
"spawn_worker_tool_description",
@@ -410,15 +403,6 @@ impl PromptCatalog {
self.render(WorkerPrompt::ResidentKnowledgeSection, Value::from(m))
}
/// Render `WorkerPrompt::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(
WorkerPrompt::ResidentWorkflowsSection,
single("entries", entries),
)
}
/// Render `WorkerPrompt::WorkerOrchestrationGuidanceSection` (no inputs).
pub fn worker_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
self.render(
@@ -750,7 +734,7 @@ compact_system = "PREFIX\n{% include \"$yoi/internal/compact_system\" %}"
assert!(rendered.contains("Do not use `sleep` or polling loops"));
assert!(rendered.contains("worktree state, diff, and test results"));
assert!(rendered.contains("not scheduler or auto-maintain authorization"));
assert!(rendered.contains("bypass user/workflow authorization"));
assert!(rendered.contains("bypass user/Ticket authorization"));
}
#[test]
+1 -82
View File
@@ -25,7 +25,6 @@ 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};
@@ -127,7 +126,6 @@ impl SystemPromptTemplate {
ctx.agents_md.as_deref(),
ctx.resident_summary,
ctx.resident_knowledge,
ctx.resident_workflows,
ToolCapabilities::from_tool_names(&ctx.tool_names),
)
}
@@ -166,10 +164,6 @@ pub struct SystemPromptContext<'a> {
/// section entirely (memory disabled, or a consolidation worker that opts
/// out); `Some(&[])` also yields no section.
pub resident_knowledge: Option<&'a [ResidentKnowledgeEntry]>,
/// Resident workflow descriptions from `<workspace>/.yoi/workflow/*`
/// whose frontmatter has `model_invokation: true`. `None` disables the
/// section; consolidation 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.
@@ -304,7 +298,6 @@ fn append_trailing_section(
agents_md: Option<&str>,
resident_summary: Option<&str>,
resident_knowledge: Option<&[ResidentKnowledgeEntry]>,
resident_workflows: Option<&[ResidentWorkflowEntry]>,
tool_capabilities: ToolCapabilities,
) -> Result<String, SystemPromptError> {
let mut out = String::with_capacity(body.len() + 256);
@@ -345,15 +338,6 @@ 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');
}
}
if tool_capabilities.worker_management() {
out.push('\n');
let section = prompts.worker_orchestration_guidance_section()?;
@@ -378,14 +362,6 @@ fn format_resident_knowledge_entries(entries: &[ResidentKnowledgeEntry]) -> Stri
)
}
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, (slug, description)) in entries.enumerate() {
@@ -451,7 +427,6 @@ mod tests {
agents_md,
resident_summary: None,
resident_knowledge: None,
resident_workflows: None,
prompts: test_prompts(),
}
}
@@ -470,7 +445,6 @@ mod tests {
agents_md: None,
resident_summary: summary,
resident_knowledge: None,
resident_workflows: None,
prompts: test_prompts(),
}
}
@@ -489,26 +463,6 @@ mod tests {
agents_md: None,
resident_summary: None,
resident_knowledge: Some(resident),
resident_workflows: None,
prompts: test_prompts(),
}
}
fn ctx_with_resident_workflows<'a>(
cwd: &'a Path,
scope: &'a Scope,
resident: &'a [ResidentWorkflowEntry],
) -> SystemPromptContext<'a> {
SystemPromptContext {
now: fixed_now(),
cwd: cwd.display().to_string().into(),
language: manifest::defaults::WORKER_LANGUAGE,
scope,
tool_names: Vec::new(),
agents_md: None,
resident_summary: None,
resident_knowledge: None,
resident_workflows: Some(resident),
prompts: test_prompts(),
}
}
@@ -652,7 +606,7 @@ mod tests {
assert!(rendered.contains("Do not use `sleep` or polling loops"));
assert!(rendered.contains("worktree state, diff, and test results"));
assert!(rendered.contains("not scheduler or auto-maintain authorization"));
assert!(rendered.contains("bypass user/workflow authorization"));
assert!(rendered.contains("bypass user/Ticket authorization"));
}
#[test]
@@ -955,39 +909,4 @@ mod tests {
assert!(rendered.contains("## Resident knowledge"));
assert!(rendered.contains("KnowledgeQuery / MemoryRead"));
}
#[test]
fn trailing_section_renders_resident_workflows() {
let (_tmp, loader) = user_loader_with("body.md", "BODY");
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let workflows = [ResidentWorkflowEntry {
slug: "resident-flow".to_string(),
description: "workflow resident desc\nwith newline".to_string(),
}];
let rendered = tmpl
.render(&ctx_with_resident_workflows(dir.path(), &scope, &workflows))
.unwrap();
assert!(rendered.contains("## Resident workflows"));
assert!(rendered.contains("- resident-flow: workflow resident desc with newline"));
let pos_boundaries = rendered.find("## Working boundaries").unwrap();
let pos_resident = rendered.find("## Resident workflows").unwrap();
assert!(pos_resident > pos_boundaries);
}
#[test]
fn trailing_section_omits_empty_resident_workflows() {
let (_tmp, loader) = user_loader_with("body.md", "BODY");
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let workflows: [ResidentWorkflowEntry; 0] = [];
let rendered = tmpl
.render(&ctx_with_resident_workflows(dir.path(), &scope, &workflows))
.unwrap();
assert!(!rendered.contains("Resident workflows"));
}
}
+1 -25
View File
@@ -6,11 +6,6 @@ use session_store::SegmentId;
use crate::fs_view::WorkerFsView;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkflowCandidate {
pub slug: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KnowledgeCandidate {
pub slug: String,
@@ -24,7 +19,7 @@ pub struct KnowledgeCandidate {
/// History and typed user-segment mirrors used to live here so the
/// IPC layer could answer `Method::GetHistory`. Those reads now go
/// directly through the session-log sink (`Event::Snapshot` +
/// `Event::Entry`), so this struct holds only status, identity,
/// live events), so this struct holds only status, identity,
/// greeting, and completion lookup hubs.
pub struct WorkerSharedState {
pub worker_name: String,
@@ -39,7 +34,6 @@ pub struct WorkerSharedState {
/// (only relevant for unit tests that build a `WorkerSharedState`
/// directly without spinning up a controller).
fs_view: OnceLock<WorkerFsView>,
workflows: OnceLock<Vec<WorkflowCandidate>>,
knowledge: OnceLock<Vec<KnowledgeCandidate>>,
}
@@ -57,7 +51,6 @@ impl WorkerSharedState {
greeting,
status: RwLock::new(WorkerStatus::Idle),
fs_view: OnceLock::new(),
workflows: OnceLock::new(),
knowledge: OnceLock::new(),
}
}
@@ -74,23 +67,6 @@ impl WorkerSharedState {
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 set_knowledge(&self, knowledge: Vec<KnowledgeCandidate>) {
let _ = self.knowledge.set(knowledge);
}
File diff suppressed because it is too large Load Diff
-261
View File
@@ -1,261 +0,0 @@
//! Worker-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 Engine.
use std::fmt;
use llm_engine::Item;
use memory::WorkspaceLayout;
use memory::schema::split_frontmatter;
use workflow_crate::{Slug, WorkflowRegistry};
#[derive(Debug)]
pub enum WorkflowResolveError {
InvalidSlug(workflow_crate::WorkflowLintError),
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 {}
struct BuiltinKnowledgeResource {
slug: &'static str,
content: &'static str,
}
const BUILTIN_KNOWLEDGE: &[BuiltinKnowledgeResource] = &[BuiltinKnowledgeResource {
slug: "workflow-resource-boundary",
content: include_str!("../../../../resources/knowledge/workflow-resource-boundary.md"),
}];
fn builtin_knowledge(slug: &Slug) -> Option<&'static str> {
BUILTIN_KNOWLEDGE
.iter()
.find(|resource| resource.slug == slug.as_str())
.map(|resource| resource.content)
}
fn read_required_knowledge(
workflow: &Slug,
layout: &WorkspaceLayout,
req: &Slug,
) -> Result<(String, &'static str), WorkflowResolveError> {
let path = layout.knowledge_dir().join(format!("{req}.md"));
match std::fs::read_to_string(&path) {
Ok(raw) => Ok((raw, "workspace")),
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
if let Some(raw) = builtin_knowledge(req) {
Ok((raw.to_string(), "builtin"))
} else {
Err(WorkflowResolveError::KnowledgeNotFound {
workflow: workflow.to_string(),
slug: req.to_string(),
})
}
}
Err(source) => Err(WorkflowResolveError::KnowledgeRead {
workflow: workflow.to_string(),
slug: req.to_string(),
source,
}),
}
}
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(|source| WorkflowResolveError::InvalidSlug(source.into()))?;
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 (raw, knowledge_source) = read_required_knowledge(&slug, layout, req)?;
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 #{} from {}]\n{}",
slug,
req,
knowledge_source,
body.trim_end()
)));
}
out.push(Item::system_message(format!(
"[Workflow /{} from {}]\n{}",
slug,
record.source.label(),
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(".yoi/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(".yoi/workflow/run-it.md"),
"---\ndescription: run\nrequires: [policy]\n---\nworkflow body\n",
);
let registry = workflow_crate::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 from workspace workflow]"));
assert!(second.contains("workflow body"));
}
#[test]
fn builtin_workflow_uses_builtin_required_knowledge_when_workspace_missing() {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
let registry = workflow_crate::load_workflows(&layout).unwrap();
let items =
resolve_workflow_invocation(&registry, &layout, "ticket-intake-workflow").unwrap();
let first = format!("{:?}", items[0]);
let second = format!("{:?}", items[1]);
assert!(first.contains("Knowledge #workflow-resource-boundary from builtin"));
assert!(first.contains("Builtin workflow resources live under"));
assert!(second.contains("[Workflow /ticket-intake-workflow from builtin workflow]"));
}
#[test]
fn workspace_knowledge_overrides_builtin_required_knowledge() {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
write(
&dir.path()
.join(".yoi/knowledge/workflow-resource-boundary.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---\nworkspace override knowledge\n",
);
let registry = workflow_crate::load_workflows(&layout).unwrap();
let items =
resolve_workflow_invocation(&registry, &layout, "ticket-intake-workflow").unwrap();
let first = format!("{:?}", items[0]);
assert!(first.contains("Knowledge #workflow-resource-boundary from workspace"));
assert!(first.contains("workspace override knowledge"));
}
#[test]
fn user_invocable_false_errors() {
let (dir, layout, _registry) = setup();
write(
&dir.path().join(".yoi/workflow/hidden.md"),
"---\ndescription: hidden\nuser_invocable: false\n---\nbody\n",
);
let registry = workflow_crate::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(".yoi/workflow/bad.md"),
"---\ndescription: bad\nrequires: [ghost]\n---\nbody\n",
);
let registry = workflow_crate::load_workflows(&layout).unwrap();
let err = resolve_workflow_invocation(&registry, &layout, "bad").unwrap_err();
assert!(matches!(
err,
WorkflowResolveError::KnowledgeNotFound { .. }
));
}
}
+1 -14
View File
@@ -267,20 +267,7 @@ async fn feature_flags_default_to_core_tool_surface_only() {
let request = wait_for_captured_request(&client_for_assert).await;
let names = request_tool_names(&request);
assert_eq!(
names,
vec![
"ActiveWorkflowCancel",
"ActiveWorkflowComplete",
"ActiveWorkflowList",
"Bash",
"Edit",
"Glob",
"Grep",
"Read",
"Write"
]
);
assert_eq!(names, vec!["Bash", "Edit", "Glob", "Grep", "Read", "Write"]);
assert!(!names.iter().any(|name| name == "TaskCreate"));
assert!(!names.iter().any(|name| name == "WebSearch"));
assert!(!names.iter().any(|name| name == "SpawnWorker"));
-19
View File
@@ -1,19 +0,0 @@
[package]
name = "workflow"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
chrono = { version = "0.4", features = ["serde"] }
lint-common = { workspace = true }
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 }
-29
View File
@@ -1,29 +0,0 @@
# workflow
## Role
`workflow` owns project-authored workflow record parsing, validation, and invocation metadata.
## Boundaries
Owns:
- workflow file schema/linting
- workflow discovery from configured workflow locations
- typed workflow metadata used by runtime/tooling layers
Does not own:
- generated memory records (`memory`)
- Ticket file lifecycle (`crates/ticket`, `.yoi/tickets/`)
- Worker orchestration decisions (`worker`, workflows executed by agents)
- product CLI command shape (`yoi`)
## Design notes
Workflows are curated project assets. They should not be mixed with generated memory, and invoking a workflow should not bypass work item authority or scope policy.
## See also
- [`../../docs/development/workflows.md`](../../docs/development/workflows.md)
- [`../../docs/design/profiles-manifests-prompts.md`](../../docs/design/profiles-manifests-prompts.md)
-34
View File
@@ -1,34 +0,0 @@
//! Errors raised by Workflow loading and linting.
use std::path::PathBuf;
use lint_common::RecordLintError;
use thiserror::Error;
/// A single Workflow linter violation.
#[derive(Debug, Clone, Error, PartialEq, Eq)]
pub enum WorkflowLintError {
#[error(transparent)]
Record(#[from] RecordLintError),
#[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),
}
-21
View File
@@ -1,21 +0,0 @@
//! Workflow records, loading, Agent Skill ingestion, and human-edit linting.
mod error;
mod linter;
mod schema;
mod scope;
mod skill;
mod workflow;
pub use error::WorkflowLintError;
pub use lint_common::{RecordLintError, Slug, is_valid_slug};
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 workflow::{
ResidentWorkflowEntry, ShadowedSkill, WORKFLOW_DESCRIPTION_HARD_CAP, WorkflowLoadError,
WorkflowRecord, WorkflowRegistry, WorkflowSource, load_workflows,
};
-226
View File
@@ -1,226 +0,0 @@
//! Human-edit linter for Workflow files.
use std::collections::HashSet;
use memory::WorkspaceLayout;
use crate::{Slug, WorkflowLintError};
use lint_common::RecordLintError;
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::Record(
RecordLintError::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::Record(RecordLintError::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(".yoi/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 { .. }))
);
}
}
-86
View File
@@ -1,86 +0,0 @@
//! Workflow frontmatter schema and frontmatter splitting helpers.
use chrono::{DateTime, Utc};
use lint_common::Frontmatter;
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>,
}
impl Frontmatter for WorkflowFrontmatter {
const BODY_LIMIT: usize = WORKFLOW_BODY_LIMIT;
fn created_at(&self) -> Option<DateTime<Utc>> {
self.created_at
}
fn updated_at(&self) -> Option<DateTime<Utc>> {
self.updated_at
}
}
fn default_user_invocable() -> bool {
true
}
/// Split a markdown document into `(yaml_frontmatter, body)`.
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), WorkflowLintError> {
lint_common::split_frontmatter(content).map_err(Into::into)
}
#[cfg(test)]
mod tests {
use super::*;
use lint_common::RecordLintError;
#[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::Record(RecordLintError::MissingFrontmatter)
));
}
#[test]
fn no_closing_delim_errors() {
let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err();
assert!(matches!(
err,
WorkflowLintError::Record(RecordLintError::MalformedFrontmatter(_))
));
}
#[test]
fn handles_empty_body() {
let doc = "---\nfoo: 1\n---\n";
let (_, b) = split_frontmatter(doc).unwrap();
assert_eq!(b, "");
}
}
-36
View File
@@ -1,36 +0,0 @@
//! 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>/.yoi/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/.yoi/workflow"));
assert_eq!(rules[0].permission, Permission::Write);
assert!(rules[0].recursive);
}
}
-453
View File
@@ -1,453 +0,0 @@
//! Agent Skills (`SKILL.md`) parser.
//!
//! Skills follow the [agentskills.io](https://agentskills.io/specification)
//! spec: a directory `<root>/<name>/` containing `SKILL.md` (YAML frontmatter
//! + Markdown body) and optional `scripts/` / `references/` / `assets/`
//! subdirectories. The body is procedural agent guidance; yoi ingests
//! it as a Workflow so `/<name>` resolves to it just like an internal
//! Workflow.
//!
//! Parsing is intentionally lenient at the directory-scan level — one
//! malformed SKILL.md emits `tracing::warn!` and is skipped, leaving sibling
//! skills loadable. Internal Workflows (`.yoi/workflow/<slug>.md`) keep
//! their hard-error semantics.
use std::io;
use std::path::{Path, PathBuf};
use lint_common::RecordLintError;
use serde::Deserialize;
use thiserror::Error;
use tracing::warn;
use crate::schema::split_frontmatter;
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";
/// SKILL.md frontmatter as defined by the agent-skills spec.
///
/// Fields beyond `name` / `description` are accepted to be spec-compatible
/// but not used by yoi today: `license`, `compatibility`, and
/// `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,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub compatibility: Option<String>,
#[serde(default)]
pub metadata: Option<serde_yaml::Value>,
#[serde(default, rename = "allowed-tools")]
pub allowed_tools: Option<serde_yaml::Value>,
}
/// Validated skill record. Constructed by [`parse_skill_md`] and converted
/// to a `WorkflowRecord` by the caller via the `Skill → Workflow`
/// projection in [`crate::WorkflowRecord`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillRecord {
pub slug: Slug,
pub description: String,
pub body: String,
/// The skill directory (parent of `SKILL.md`). Carried so callers can
/// register `scripts/` / `references/` / `assets/` against the Worker's
/// scope.
pub dir: PathBuf,
/// Path to the `SKILL.md` file itself. Used as the resolved path on
/// the resulting `WorkflowRecord`.
pub skill_md_path: PathBuf,
}
impl SkillRecord {
/// Project this skill into a [`WorkflowRecord`]. Skill-sourced
/// Workflows are advertised resident (`model_invokation: true`,
/// matching the agentskills progressive-disclosure model), are
/// invocable as `/<slug>`, and carry no `requires` since the SKILL
/// spec has no Knowledge-dependency concept.
pub fn into_workflow_record(self, source: WorkflowSource) -> WorkflowRecord {
WorkflowRecord {
slug: self.slug,
description: self.description,
model_invokation: true,
user_invocable: true,
requires: Vec::new(),
body: self.body,
path: self.skill_md_path,
source,
}
}
}
#[derive(Debug, Error)]
pub enum SkillParseError {
#[error("skill path has no parent directory: {}", .0.display())]
NoParentDir(PathBuf),
#[error("failed to read SKILL.md at {}: {source}", .path.display())]
ReadFile { path: PathBuf, source: io::Error },
#[error("invalid frontmatter in {}: {source}", .path.display())]
Frontmatter {
path: PathBuf,
#[source]
source: WorkflowLintError,
},
#[error(
"SKILL.md `name` `{name}` does not match its directory name `{dir_name}` (at {})",
.skill_md_path.display()
)]
NameDirMismatch {
name: String,
dir_name: String,
skill_md_path: PathBuf,
},
#[error("SKILL.md `name` is not a valid slug at {}: {source}", .skill_md_path.display())]
InvalidName {
skill_md_path: PathBuf,
#[source]
source: WorkflowLintError,
},
#[error("SKILL.md `description` must be non-empty (at {})", .skill_md_path.display())]
DescriptionEmpty { skill_md_path: PathBuf },
#[error(
"SKILL.md `description` length {actual} exceeds limit {limit} (at {})",
.skill_md_path.display()
)]
DescriptionTooLong {
skill_md_path: PathBuf,
actual: usize,
limit: usize,
},
}
/// Parse a single `SKILL.md`. The directory name is taken from the parent
/// of `skill_md_path` and validated against the frontmatter `name`.
pub fn parse_skill_md(skill_md_path: &Path) -> Result<SkillRecord, SkillParseError> {
let dir = skill_md_path
.parent()
.map(|p| p.to_path_buf())
.ok_or_else(|| SkillParseError::NoParentDir(skill_md_path.to_path_buf()))?;
let dir_name = dir
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.ok_or_else(|| SkillParseError::NoParentDir(skill_md_path.to_path_buf()))?;
let raw =
std::fs::read_to_string(skill_md_path).map_err(|source| SkillParseError::ReadFile {
path: skill_md_path.to_path_buf(),
source,
})?;
let (yaml, body) = split_frontmatter(&raw).map_err(|source| SkillParseError::Frontmatter {
path: skill_md_path.to_path_buf(),
source,
})?;
warn_unknown_skill_fields(skill_md_path, yaml);
let frontmatter: SkillFrontmatter =
serde_yaml::from_str(yaml).map_err(|err| SkillParseError::Frontmatter {
path: skill_md_path.to_path_buf(),
source: WorkflowLintError::Record(RecordLintError::MalformedFrontmatter(
err.to_string(),
)),
})?;
if frontmatter.allowed_tools.is_some() {
warn!(
path = %skill_md_path.display(),
"SKILL.md `allowed-tools` is recognised but not yet enforced; ignoring"
);
}
let desc_chars = frontmatter.description.chars().count();
if desc_chars == 0 {
return Err(SkillParseError::DescriptionEmpty {
skill_md_path: skill_md_path.to_path_buf(),
});
}
if desc_chars > WORKFLOW_DESCRIPTION_HARD_CAP {
return Err(SkillParseError::DescriptionTooLong {
skill_md_path: skill_md_path.to_path_buf(),
actual: desc_chars,
limit: WORKFLOW_DESCRIPTION_HARD_CAP,
});
}
if frontmatter.name != dir_name {
return Err(SkillParseError::NameDirMismatch {
name: frontmatter.name,
dir_name,
skill_md_path: skill_md_path.to_path_buf(),
});
}
let slug = Slug::parse(frontmatter.name).map_err(|source| SkillParseError::InvalidName {
skill_md_path: skill_md_path.to_path_buf(),
source: source.into(),
})?;
Ok(SkillRecord {
slug,
description: frontmatter.description,
body: body.to_string(),
dir,
skill_md_path: skill_md_path.to_path_buf(),
})
}
/// Scan a skills root for `<root>/<name>/SKILL.md`. Returns successfully
/// parsed skills; per-skill errors emit a `tracing::warn!` and are
/// skipped. A missing root is treated as zero skills, not an error —
/// callers can probe optional directories without pre-checking.
pub fn load_skills_from_dir(root: &Path) -> Vec<SkillRecord> {
let entries = match std::fs::read_dir(root) {
Ok(it) => it,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Vec::new(),
Err(err) => {
warn!(
dir = %root.display(),
error = %err,
"failed to read skills directory; treating as empty"
);
return Vec::new();
}
};
let mut paths: Vec<PathBuf> = Vec::new();
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(err) => {
warn!(
dir = %root.display(),
error = %err,
"skill directory entry read error; skipping"
);
continue;
}
};
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join(SKILL_FILENAME);
if skill_md.is_file() {
paths.push(skill_md);
}
}
paths.sort();
let mut out = Vec::new();
for path in paths {
match parse_skill_md(&path) {
Ok(record) => out.push(record),
Err(err) => warn!(path = %path.display(), error = %err, "SKILL.md skipped"),
}
}
out
}
fn warn_unknown_skill_fields(path: &Path, yaml: &str) {
let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(yaml) else {
return;
};
let Some(map) = value.as_mapping() else {
return;
};
for key in map.keys().filter_map(|k| k.as_str()) {
if !matches!(
key,
"name" | "description" | "license" | "compatibility" | "metadata" | "allowed-tools"
) {
warn!(path = %path.display(), field = key, "unknown SKILL.md frontmatter field ignored");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write_skill(root: &Path, name: &str, frontmatter: &str, body: &str) -> PathBuf {
let dir = root.join(name);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(SKILL_FILENAME);
std::fs::write(&path, format!("---\n{frontmatter}\n---\n{body}")).unwrap();
path
}
#[test]
fn parses_minimal_skill() {
let dir = TempDir::new().unwrap();
let path = write_skill(
dir.path(),
"do-thing",
"name: do-thing\ndescription: Do the thing",
"Step 1\nStep 2\n",
);
let record = parse_skill_md(&path).unwrap();
assert_eq!(record.slug.as_str(), "do-thing");
assert_eq!(record.description, "Do the thing");
assert_eq!(record.body, "Step 1\nStep 2\n");
assert_eq!(record.dir, dir.path().join("do-thing"));
assert_eq!(record.skill_md_path, path);
}
#[test]
fn name_dir_mismatch_is_error() {
let dir = TempDir::new().unwrap();
let path = write_skill(
dir.path(),
"actual-dir",
"name: declared-name\ndescription: x",
"body",
);
let err = parse_skill_md(&path).unwrap_err();
assert!(matches!(err, SkillParseError::NameDirMismatch { .. }));
}
#[test]
fn invalid_slug_name_is_error() {
let dir = TempDir::new().unwrap();
let path = write_skill(
dir.path(),
"BAD-Caps",
"name: BAD-Caps\ndescription: x",
"body",
);
// Slug::parse rejects uppercase before the dir match check fires;
// either way the parse is rejected.
let err = parse_skill_md(&path).unwrap_err();
assert!(matches!(
err,
SkillParseError::InvalidName { .. } | SkillParseError::NameDirMismatch { .. }
));
}
#[test]
fn empty_description_is_error() {
let dir = TempDir::new().unwrap();
let path = write_skill(dir.path(), "x", "name: x\ndescription: \"\"", "body");
let err = parse_skill_md(&path).unwrap_err();
assert!(matches!(err, SkillParseError::DescriptionEmpty { .. }));
}
#[test]
fn description_at_cap_is_accepted() {
let dir = TempDir::new().unwrap();
let desc = "x".repeat(WORKFLOW_DESCRIPTION_HARD_CAP);
let path = write_skill(
dir.path(),
"x",
&format!("name: x\ndescription: {desc}"),
"body",
);
let record = parse_skill_md(&path).unwrap();
assert_eq!(
record.description.chars().count(),
WORKFLOW_DESCRIPTION_HARD_CAP
);
}
#[test]
fn description_over_cap_is_error() {
let dir = TempDir::new().unwrap();
let desc = "x".repeat(WORKFLOW_DESCRIPTION_HARD_CAP + 1);
let path = write_skill(
dir.path(),
"x",
&format!("name: x\ndescription: {desc}"),
"body",
);
let err = parse_skill_md(&path).unwrap_err();
assert!(matches!(err, SkillParseError::DescriptionTooLong { .. }));
}
#[test]
fn missing_frontmatter_is_error() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("x").join(SKILL_FILENAME);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "no frontmatter at all\n").unwrap();
let err = parse_skill_md(&path).unwrap_err();
assert!(matches!(err, SkillParseError::Frontmatter { .. }));
}
#[test]
fn extra_frontmatter_fields_are_kept() {
let dir = TempDir::new().unwrap();
let path = write_skill(
dir.path(),
"x",
"name: x\ndescription: ok\nlicense: MIT\ncompatibility: claude-4\n\
metadata:\n team: foo\nallowed-tools:\n - Read",
"body",
);
let record = parse_skill_md(&path).unwrap();
assert_eq!(record.slug.as_str(), "x");
// allowed-tools triggers a warn, but parse succeeds.
}
#[test]
fn load_skills_from_dir_skips_broken_and_keeps_good() {
let dir = TempDir::new().unwrap();
write_skill(dir.path(), "good", "name: good\ndescription: ok", "body");
// Mismatch — should be skipped, not abort the scan.
write_skill(
dir.path(),
"bad-dir",
"name: declared-different\ndescription: ok",
"body",
);
// A bare file at the root (not a directory) is ignored.
std::fs::write(dir.path().join("stray.md"), "not a skill").unwrap();
let records = load_skills_from_dir(dir.path());
let slugs: Vec<&str> = records.iter().map(|r| r.slug.as_str()).collect();
assert_eq!(slugs, vec!["good"]);
}
#[test]
fn load_skills_from_dir_missing_root_is_empty() {
let dir = TempDir::new().unwrap();
let records = load_skills_from_dir(&dir.path().join("does-not-exist"));
assert!(records.is_empty());
}
#[test]
fn into_workflow_record_uses_skill_defaults() {
let dir = TempDir::new().unwrap();
let path = write_skill(
dir.path(),
"x",
"name: x\ndescription: Project X",
"Steps\n",
);
let record = parse_skill_md(&path).unwrap();
let wf = record.into_workflow_record(WorkflowSource::Skill {
dir: dir.path().to_path_buf(),
});
assert_eq!(wf.slug.as_str(), "x");
assert_eq!(wf.description, "Project X");
assert!(wf.model_invokation);
assert!(wf.user_invocable);
assert!(wf.requires.is_empty());
assert_eq!(wf.body, "Steps\n");
assert!(matches!(wf.source, WorkflowSource::Skill { .. }));
}
#[test]
fn load_skills_from_dir_orders_deterministically() {
let dir = TempDir::new().unwrap();
write_skill(dir.path(), "b", "name: b\ndescription: b", "");
write_skill(dir.path(), "a", "name: a\ndescription: a", "");
write_skill(dir.path(), "c", "name: c\ndescription: c", "");
let records = load_skills_from_dir(dir.path());
let slugs: Vec<&str> = records.iter().map(|r| r.slug.as_str()).collect();
assert_eq!(slugs, vec!["a", "b", "c"]);
}
}
-626
View File
@@ -1,626 +0,0 @@
//! Workflow loader and registry.
//!
//! Workflows live under `<workspace>/.yoi/workflow/<slug>.md`. They are
//! human-authored Markdown documents with YAML frontmatter. The loader is
//! intentionally strict about malformed records because Worker startup should
//! fail rather than silently ignoring a broken procedural instruction.
use std::collections::BTreeMap;
use std::io;
use std::path::{Path, PathBuf};
use thiserror::Error;
use tracing::warn;
use crate::schema::{WorkflowFrontmatter, split_frontmatter};
use lint_common::RecordLintError;
use memory::WorkspaceLayout;
use crate::{Slug, WorkflowLintError};
/// Hard cap on Workflow descriptions that are advertised resident.
/// Mirrors agent-skills and resident Knowledge descriptions.
pub const WORKFLOW_DESCRIPTION_HARD_CAP: usize = 1024;
/// Origin of a [`WorkflowRecord`]. Used to break ties when the same slug
/// is provided by multiple sources: workspace-authored Workflows always
/// win over external skills.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkflowSource {
Builtin,
/// `<workspace>/.yoi/workflow/<slug>.md`. Authored in-tree by
/// the project.
WorkspaceWorkflow,
/// SKILL.md ingested from a `[skills] directories` entry in the
/// manifest. `dir` is the skills root that contained
/// `<slug>/SKILL.md`.
Skill {
dir: PathBuf,
},
}
impl WorkflowSource {
/// Human-readable label used in shadow-notification messages.
pub fn label(&self) -> &'static str {
match self {
Self::Builtin => "builtin workflow",
Self::WorkspaceWorkflow => "workspace workflow",
Self::Skill { .. } => "skill",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkflowRecord {
pub slug: Slug,
pub description: String,
pub model_invokation: bool,
pub user_invocable: bool,
pub requires: Vec<Slug>,
/// Markdown body after the closing frontmatter delimiter.
pub body: String,
pub path: PathBuf,
/// Where this record was loaded from. Determines shadowing priority
/// when [`WorkflowRegistry::merge_skill`] encounters a slug
/// collision.
pub source: WorkflowSource,
}
/// Returned by [`WorkflowRegistry::merge_skill`] when an incoming skill is
/// shadowed by an existing record (either an internal Workflow or a
/// higher-priority skill). Carries enough context for a `Notification` to
/// explain which side won.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShadowedSkill {
pub slug: Slug,
pub kept_source: WorkflowSource,
pub kept_path: PathBuf,
pub shadowed_source: WorkflowSource,
pub shadowed_path: PathBuf,
}
impl ShadowedSkill {
/// One-line message for `Notification` payloads.
pub fn message(&self) -> String {
format!(
"skill /{slug} from {shadowed_label} ({shadowed_path}) was shadowed by existing {kept_label} ({kept_path})",
slug = self.slug,
shadowed_label = self.shadowed_source.label(),
shadowed_path = self.shadowed_path.display(),
kept_label = self.kept_source.label(),
kept_path = self.kept_path.display(),
)
}
}
#[derive(Debug, Clone, Default)]
pub struct WorkflowRegistry {
records: BTreeMap<Slug, WorkflowRecord>,
}
impl WorkflowRegistry {
pub fn empty() -> Self {
Self::default()
}
pub fn len(&self) -> usize {
self.records.len()
}
pub fn is_empty(&self) -> bool {
self.records.is_empty()
}
pub fn get(&self, slug: &Slug) -> Option<&WorkflowRecord> {
self.records.get(slug)
}
pub fn iter(&self) -> impl Iterator<Item = &WorkflowRecord> {
self.records.values()
}
pub fn resident_entries(&self) -> Vec<ResidentWorkflowEntry> {
self.records
.values()
.filter(|record| record.model_invokation)
.map(|record| ResidentWorkflowEntry {
slug: record.slug.to_string(),
description: record.description.clone(),
})
.collect()
}
pub fn list_user_invocable(&self, prefix: &str) -> Vec<String> {
self.records
.values()
.filter(|record| record.user_invocable && record.slug.as_str().starts_with(prefix))
.map(|record| record.slug.to_string())
.collect()
}
/// Insert a skill-derived record. If an existing record (internal
/// Workflow or earlier-fed skill) already owns the slug, the
/// incoming record is dropped and a [`ShadowedSkill`] describing the
/// collision is returned. Callers feed records in priority order
/// (highest first); the registry is "first-insert wins" and does
/// not re-rank.
pub fn merge_skill(&mut self, record: WorkflowRecord) -> Option<ShadowedSkill> {
if let Some(existing) = self.records.get(&record.slug) {
return Some(ShadowedSkill {
slug: record.slug.clone(),
kept_source: existing.source.clone(),
kept_path: existing.path.clone(),
shadowed_source: record.source,
shadowed_path: record.path,
});
}
self.records.insert(record.slug.clone(), record);
None
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResidentWorkflowEntry {
pub slug: String,
pub description: String,
}
#[derive(Debug, Error)]
pub enum WorkflowLoadError {
#[error("failed to read workflow directory {}: {source}", .dir.display())]
ReadDir { dir: PathBuf, source: io::Error },
#[error("failed to read workflow file {}: {source}", .path.display())]
ReadFile { path: PathBuf, source: io::Error },
#[error("invalid workflow file name {}: {source}", .path.display())]
InvalidSlug {
path: PathBuf,
source: WorkflowLintError,
},
#[error("invalid workflow frontmatter in {}: {source}", .path.display())]
Frontmatter {
path: PathBuf,
source: WorkflowLintError,
},
#[error(
"Workflow {} with model_invokation: true cannot have description longer than {limit} chars (got {actual})",
.path.display()
)]
DescriptionTooLong {
path: PathBuf,
actual: usize,
limit: usize,
},
}
struct BuiltinWorkflowResource {
slug: &'static str,
content: &'static str,
}
const BUILTIN_WORKFLOWS: &[BuiltinWorkflowResource] = &[
BuiltinWorkflowResource {
slug: "ticket-intake-workflow",
content: include_str!("../../../resources/workflows/ticket-intake-workflow.md"),
},
BuiltinWorkflowResource {
slug: "ticket-orchestrator-routing",
content: include_str!("../../../resources/workflows/ticket-orchestrator-routing.md"),
},
BuiltinWorkflowResource {
slug: "multi-agent-workflow",
content: include_str!("../../../resources/workflows/multi-agent-workflow.md"),
},
];
fn builtin_workflow_records() -> Result<BTreeMap<Slug, WorkflowRecord>, WorkflowLoadError> {
let mut records = BTreeMap::new();
for resource in BUILTIN_WORKFLOWS {
let path = PathBuf::from(format!("builtin:{}", resource.slug));
records.insert(
Slug::parse(resource.slug).map_err(|source| WorkflowLoadError::InvalidSlug {
path: path.clone(),
source: source.into(),
})?,
parse_workflow_record(
Slug::parse(resource.slug).map_err(|source| WorkflowLoadError::InvalidSlug {
path: path.clone(),
source: source.into(),
})?,
path,
WorkflowSource::Builtin,
resource.content,
)?,
);
}
Ok(records)
}
fn parse_workflow_record(
slug: Slug,
path: PathBuf,
source: WorkflowSource,
raw: &str,
) -> Result<WorkflowRecord, WorkflowLoadError> {
let (yaml, body) = split_frontmatter(raw).map_err(|source| WorkflowLoadError::Frontmatter {
path: path.clone(),
source,
})?;
warn_unknown_workflow_fields(&path, yaml);
let frontmatter: WorkflowFrontmatter =
serde_yaml::from_str(yaml).map_err(|err| WorkflowLoadError::Frontmatter {
path: path.clone(),
source: map_serde_workflow_error(err),
})?;
if frontmatter.model_invokation
&& frontmatter.description.chars().count() > WORKFLOW_DESCRIPTION_HARD_CAP
{
return Err(WorkflowLoadError::DescriptionTooLong {
path,
actual: frontmatter.description.chars().count(),
limit: WORKFLOW_DESCRIPTION_HARD_CAP,
});
}
Ok(WorkflowRecord {
slug,
description: frontmatter.description,
model_invokation: frontmatter.model_invokation,
user_invocable: frontmatter.user_invocable,
requires: frontmatter.requires,
body: body.to_string(),
path,
source,
})
}
pub fn load_workflows(layout: &WorkspaceLayout) -> Result<WorkflowRegistry, WorkflowLoadError> {
let mut records = builtin_workflow_records()?;
let dir = layout.workflow_dir();
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(err) if err.kind() == io::ErrorKind::NotFound => {
return Ok(WorkflowRegistry { records });
}
Err(source) => return Err(WorkflowLoadError::ReadDir { dir, source }),
};
let mut paths = Vec::new();
for entry in entries {
let entry = entry.map_err(|source| WorkflowLoadError::ReadDir {
dir: dir.clone(),
source,
})?;
let path = entry.path();
if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("md") {
paths.push(path);
}
}
paths.sort();
for path in paths {
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let slug =
Slug::parse(stem.to_string()).map_err(|source| WorkflowLoadError::InvalidSlug {
path: path.clone(),
source: source.into(),
})?;
if let Some(existing) = records.get(&slug) {
if !matches!(existing.source, WorkflowSource::Builtin) {
warn!(slug = %slug, path = %path.display(), "duplicate workflow slug encountered; keeping first record");
continue;
}
}
let raw = std::fs::read_to_string(&path).map_err(|source| WorkflowLoadError::ReadFile {
path: path.clone(),
source,
})?;
let record =
parse_workflow_record(slug.clone(), path, WorkflowSource::WorkspaceWorkflow, &raw)?;
records.insert(slug.clone(), record);
}
Ok(WorkflowRegistry { records })
}
fn warn_unknown_workflow_fields(path: &Path, yaml: &str) {
let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(yaml) else {
return;
};
let Some(map) = value.as_mapping() else {
return;
};
for key in map.keys().filter_map(|k| k.as_str()) {
if !matches!(
key,
"description"
| "model_invokation"
| "user_invocable"
| "requires"
| "created_at"
| "updated_at"
) {
warn!(path = %path.display(), field = key, "unknown workflow frontmatter field ignored");
}
}
}
fn map_serde_workflow_error(err: serde_yaml::Error) -> WorkflowLintError {
let msg = err.to_string();
if let Some(field) = parse_missing_field(&msg) {
return WorkflowLintError::MissingField(field);
}
WorkflowLintError::Record(RecordLintError::MalformedFrontmatter(msg))
}
fn parse_missing_field(msg: &str) -> Option<&'static str> {
let needle = "missing field `";
let start = msg.find(needle)? + needle.len();
let end = msg[start..].find('`')? + start;
match &msg[start..end] {
"description" => Some("description"),
"model_invokation" => Some("model_invokation"),
"user_invocable" => Some("user_invocable"),
"requires" => Some("requires"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup() -> (TempDir, WorkspaceLayout) {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".yoi/workflow")).unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
(dir, layout)
}
fn write_workflow(root: &Path, slug: &str, frontmatter: &str, body: &str) {
let path = root.join(".yoi/workflow").join(format!("{slug}.md"));
std::fs::write(path, format!("---\n{frontmatter}\n---\n{body}")).unwrap();
}
#[test]
fn missing_directory_loads_builtin_registry() {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
let got = load_workflows(&layout).unwrap();
assert!(got.get(&Slug::parse("ghost").unwrap()).is_none());
}
#[test]
fn loads_valid_workflow_with_default_flags() {
let (dir, layout) = setup();
write_workflow(dir.path(), "do-thing", "description: Do thing", "Step 1");
let got = load_workflows(&layout).unwrap();
let slug = Slug::parse("do-thing").unwrap();
let record = got.get(&slug).unwrap();
assert_eq!(record.description, "Do thing");
assert!(!record.model_invokation);
assert!(record.user_invocable);
assert!(record.requires.is_empty());
assert_eq!(record.body, "Step 1");
}
#[test]
fn model_invokation_uses_typo_field() {
let (dir, layout) = setup();
write_workflow(
dir.path(),
"auto",
"description: Auto\nmodel_invokation: true\nuser_invocable: false",
"Body",
);
let got = load_workflows(&layout).unwrap();
assert!(
got.resident_entries()
.iter()
.any(|entry| entry.slug == "auto")
);
assert!(!got.list_user_invocable("").contains(&"auto".to_string()));
}
#[test]
fn workspace_workflow_overrides_builtin_by_slug() {
let (dir, layout) = setup();
write_workflow(
dir.path(),
"ticket-intake-workflow",
"description: Workspace intake\nmodel_invokation: false",
"workspace override body",
);
let got = load_workflows(&layout).unwrap();
let slug = Slug::parse("ticket-intake-workflow").unwrap();
let record = got.get(&slug).unwrap();
assert_eq!(record.source, WorkflowSource::WorkspaceWorkflow);
assert_eq!(record.description, "Workspace intake");
assert_eq!(record.body, "workspace override body");
}
#[test]
fn builtin_workflow_records_have_visible_provenance() {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
let got = load_workflows(&layout).unwrap();
let slug = Slug::parse("multi-agent-workflow").unwrap();
let record = got.get(&slug).unwrap();
assert_eq!(record.source, WorkflowSource::Builtin);
assert_eq!(record.path, PathBuf::from("builtin:multi-agent-workflow"));
}
#[test]
fn invalid_filename_is_hard_error() {
let (dir, layout) = setup();
write_workflow(dir.path(), "Bad", "description: Bad", "Body");
let err = load_workflows(&layout).unwrap_err();
assert!(matches!(err, WorkflowLoadError::InvalidSlug { .. }));
}
#[test]
fn missing_description_is_hard_error() {
let (dir, layout) = setup();
write_workflow(dir.path(), "bad", "model_invokation: false", "Body");
let err = load_workflows(&layout).unwrap_err();
assert!(matches!(err, WorkflowLoadError::Frontmatter { .. }));
}
#[test]
fn workflow_under_memory_is_ignored() {
// The legacy `.yoi/memory/workflow/` location is no longer
// a Workflow source. Files placed there must be ignored (the
// loader is rooted at `.yoi/workflow/` only).
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
let legacy = dir.path().join(".yoi/memory/workflow");
std::fs::create_dir_all(&legacy).unwrap();
std::fs::write(
legacy.join("ghost.md"),
"---\ndescription: ghost\n---\nbody\n",
)
.unwrap();
let got = load_workflows(&layout).unwrap();
assert!(got.get(&Slug::parse("ghost").unwrap()).is_none());
}
fn skill_record(slug: &str, path: &Path) -> WorkflowRecord {
WorkflowRecord {
slug: Slug::parse(slug).unwrap(),
description: format!("desc {slug}"),
model_invokation: true,
user_invocable: true,
requires: Vec::new(),
body: format!("body for {slug}"),
path: path.to_path_buf(),
source: WorkflowSource::Skill {
dir: path.parent().unwrap().parent().unwrap().to_path_buf(),
},
}
}
#[test]
fn merge_skill_inserts_when_no_collision() {
let mut reg = WorkflowRegistry::empty();
let path = std::path::PathBuf::from("/tmp/skills/x/SKILL.md");
let shadow = reg.merge_skill(skill_record("x", &path));
assert!(shadow.is_none());
assert_eq!(reg.len(), 1);
}
#[test]
fn merge_skill_shadows_existing_workflow() {
let (dir, layout) = setup();
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");
std::fs::create_dir_all(skill_path.parent().unwrap()).unwrap();
std::fs::write(&skill_path, "ignored").unwrap();
let incoming = WorkflowRecord {
slug: Slug::parse("shared").unwrap(),
description: "From skill".into(),
model_invokation: true,
user_invocable: true,
requires: Vec::new(),
body: "skill body".into(),
path: skill_path.clone(),
source: WorkflowSource::Skill {
dir: dir.path().join("user-skills"),
},
};
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 { .. }
));
// The kept record is still the workspace workflow.
let kept = reg.get(&Slug::parse("shared").unwrap()).unwrap();
assert!(matches!(kept.source, WorkflowSource::WorkspaceWorkflow));
assert_eq!(kept.body, "internal body");
}
#[test]
fn merge_skill_first_fed_wins_on_collision() {
let mut reg = WorkflowRegistry::empty();
let first_path = std::path::PathBuf::from("/a/skills/x/SKILL.md");
let second_path = std::path::PathBuf::from("/b/skills/x/SKILL.md");
let first = WorkflowRecord {
slug: Slug::parse("x").unwrap(),
description: "first".into(),
model_invokation: true,
user_invocable: true,
requires: Vec::new(),
body: "first body".into(),
path: first_path.clone(),
source: WorkflowSource::Skill {
dir: std::path::PathBuf::from("/a/skills"),
},
};
let second = WorkflowRecord {
slug: Slug::parse("x").unwrap(),
description: "second".into(),
model_invokation: true,
user_invocable: true,
requires: Vec::new(),
body: "second body".into(),
path: second_path.clone(),
source: WorkflowSource::Skill {
dir: std::path::PathBuf::from("/b/skills"),
},
};
// Caller is responsible for feeding in priority order; the
// registry just keeps whichever arrives first.
assert!(reg.merge_skill(first).is_none());
let shadow = reg
.merge_skill(second)
.expect("later-fed skill must shadow");
assert_eq!(shadow.kept_path, first_path);
assert!(matches!(shadow.kept_source, WorkflowSource::Skill { .. }));
}
#[test]
fn shadow_message_is_human_readable() {
let s = ShadowedSkill {
slug: Slug::parse("x").unwrap(),
kept_source: WorkflowSource::WorkspaceWorkflow,
kept_path: std::path::PathBuf::from("/ws/.yoi/workflow/x.md"),
shadowed_source: WorkflowSource::Skill {
dir: std::path::PathBuf::from("/skills"),
},
shadowed_path: std::path::PathBuf::from("/skills/x/SKILL.md"),
};
let msg = s.message();
assert!(msg.contains("/x"));
assert!(msg.contains("workspace workflow"));
assert!(msg.contains("skill"));
}
#[test]
fn resident_description_cap_is_enforced() {
let (dir, layout) = setup();
let desc = "x".repeat(WORKFLOW_DESCRIPTION_HARD_CAP + 1);
write_workflow(
dir.path(),
"bad",
&format!("description: {desc}\nmodel_invokation: true"),
"Body",
);
let err = load_workflows(&layout).unwrap_err();
assert!(matches!(err, WorkflowLoadError::DescriptionTooLong { .. }));
}
}
-1
View File
@@ -425,7 +425,6 @@ mod tests {
&root.join(".yoi/memory/_logs/ignored.md"),
"not frontmatter",
);
write(&root.join(".yoi/workflow/ignored.md"), "not frontmatter");
let report = lint_workspace(root).unwrap();
assert_eq!(
+2 -3
View File
@@ -1196,9 +1196,8 @@ mod tests {
assert!(config.contains("# language = \"Japanese\""));
for role in TicketRole::ALL {
assert!(config.contains(&format!(
"[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
role.default_profile(),
role.default_workflow()
"[ticket.roles.{role}]\nprofile = \"{}\"",
role.default_profile()
)));
}
assert!(!config.contains("[ticket.roles.investigator]"));