refactor: remove workflow machinery
This commit is contained in:
parent
83ad7506d7
commit
d801b2698b
|
|
@ -2,7 +2,7 @@
|
||||||
title: 'Remove workflow tracking and workflow resources'
|
title: 'Remove workflow tracking and workflow resources'
|
||||||
state: 'inprogress'
|
state: 'inprogress'
|
||||||
created_at: '2026-07-15T19:02:13Z'
|
created_at: '2026-07-15T19:02:13Z'
|
||||||
updated_at: '2026-07-15T19:55:42Z'
|
updated_at: '2026-07-15T20:45:12Z'
|
||||||
assignee: null
|
assignee: null
|
||||||
queued_by: 'workspace-panel'
|
queued_by: 'workspace-panel'
|
||||||
queued_at: '2026-07-15T19:54:36Z'
|
queued_at: '2026-07-15T19:54:36Z'
|
||||||
|
|
|
||||||
|
|
@ -122,4 +122,33 @@ Next action:
|
||||||
|
|
||||||
Dashboard queue authorization was inspected, no blockers were found, and an accepted plan was recorded. Moving queued Ticket to inprogress before creating worktree or spawning role Pods.
|
Dashboard queue authorization was inspected, no blockers were found, and an accepted plan was recorded. Moving queued Ticket to inprogress before creating worktree or spawning role Pods.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- event: implementation_report author: hare at: 2026-07-15T20:45:12Z -->
|
||||||
|
|
||||||
|
## Implementation report
|
||||||
|
|
||||||
|
Implemented Workflow removal.
|
||||||
|
|
||||||
|
Summary:
|
||||||
|
- Removed model-visible ActiveWorkflow tools and the Worker active-workflow store/extension/compaction/rehydration paths.
|
||||||
|
- Removed Workflow resource loading/invocation/completions and deleted the workflow crate plus tracked resources/workflows files.
|
||||||
|
- Simplified protocol/TUI/web segment handling by removing WorkflowInvoke and slash workflow completion behavior.
|
||||||
|
- Removed .yoi/workflow memory/workspace authority and workflow usage source handling.
|
||||||
|
- Removed Workflow/resident-workflow prompt/resource wording and updated Ticket role launch/config code to use typed role launch prompts without workflow slugs.
|
||||||
|
- Preserved bounded legacy session behavior by deserializing old kind:"workflow" SystemItem entries into a non-authority LegacyIgnored item that replays only an ignore diagnostic.
|
||||||
|
- Did not implement first-class Skills support.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- rg "ActiveWorkflow|active_workflow|Active workflow" -> no matches (exit 1)
|
||||||
|
- rg "resources/workflows|\\.yoi/workflow|workflow invocation|Resident workflows" -> no matches (exit 1)
|
||||||
|
- git diff --check -> exit 0
|
||||||
|
- cargo test -p session-store --lib --tests -> exit 0
|
||||||
|
- cargo test -p worker --lib --tests -> exit 0
|
||||||
|
- cargo test -p yoi --tests -> exit 0
|
||||||
|
- cargo check -p yoi -> exit 0
|
||||||
|
- yoi ticket doctor -> exit 0
|
||||||
|
- nix build .#yoi --no-link -> exit 0
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
17
Cargo.lock
generated
17
Cargo.lock
generated
|
|
@ -5742,7 +5742,6 @@ dependencies = [
|
||||||
"uuid",
|
"uuid",
|
||||||
"wasmtime",
|
"wasmtime",
|
||||||
"wat",
|
"wat",
|
||||||
"workflow",
|
|
||||||
"yoi-plugin-pdk",
|
"yoi-plugin-pdk",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -5772,22 +5771,6 @@ dependencies = [
|
||||||
"worker",
|
"worker",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "workflow"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"chrono",
|
|
||||||
"lint-common",
|
|
||||||
"manifest",
|
|
||||||
"memory",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"serde_yaml",
|
|
||||||
"tempfile",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.3"
|
version = "0.6.3"
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ members = [
|
||||||
"crates/memory",
|
"crates/memory",
|
||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
"crates/project-record",
|
"crates/project-record",
|
||||||
"crates/workflow",
|
|
||||||
"crates/workspace-server",
|
"crates/workspace-server",
|
||||||
"tests/e2e",
|
"tests/e2e",
|
||||||
]
|
]
|
||||||
|
|
@ -46,7 +45,6 @@ default-members = [
|
||||||
"crates/memory",
|
"crates/memory",
|
||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
"crates/project-record",
|
"crates/project-record",
|
||||||
"crates/workflow",
|
|
||||||
"crates/workspace-server",
|
"crates/workspace-server",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,6 @@ pub struct TicketRoleLaunchPlan {
|
||||||
pub role: TicketRole,
|
pub role: TicketRole,
|
||||||
pub worker_name: String,
|
pub worker_name: String,
|
||||||
pub profile: String,
|
pub profile: String,
|
||||||
pub workflow: String,
|
|
||||||
pub launch_prompt_ref: Option<String>,
|
pub launch_prompt_ref: Option<String>,
|
||||||
pub run_segments: Vec<Segment>,
|
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 role_config = config.role_launch_config(context.role)?;
|
||||||
let profile = role_config.profile.as_str().to_string();
|
let profile = role_config.profile.as_str().to_string();
|
||||||
let workflow = role_config.workflow.as_str().to_string();
|
|
||||||
let launch_prompt_ref = role_config
|
let launch_prompt_ref = role_config
|
||||||
.launch_prompt
|
.launch_prompt
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -336,14 +334,10 @@ pub fn plan_ticket_role_launch_with_config(
|
||||||
role: context.role,
|
role: context.role,
|
||||||
worker_name,
|
worker_name,
|
||||||
profile,
|
profile,
|
||||||
workflow: workflow.clone(),
|
|
||||||
launch_prompt_ref,
|
launch_prompt_ref,
|
||||||
run_segments: vec![
|
run_segments: vec![Segment::Text {
|
||||||
Segment::WorkflowInvoke { slug: workflow },
|
content: format!("\n\n{prompt}"),
|
||||||
Segment::Text {
|
}],
|
||||||
content: format!("\n\n{prompt}"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -759,7 +753,6 @@ mod tests {
|
||||||
role: TicketRole::Intake,
|
role: TicketRole::Intake,
|
||||||
worker_name: "ticket-intake".to_string(),
|
worker_name: "ticket-intake".to_string(),
|
||||||
profile: "project:intake".to_string(),
|
profile: "project:intake".to_string(),
|
||||||
workflow: "ticket-intake-workflow".to_string(),
|
|
||||||
launch_prompt_ref: None,
|
launch_prompt_ref: None,
|
||||||
run_segments: vec![Segment::Text {
|
run_segments: vec![Segment::Text {
|
||||||
content: "intake request".to_string(),
|
content: "intake request".to_string(),
|
||||||
|
|
@ -994,7 +987,6 @@ profile = "builtin:default"
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(intake.role, TicketRole::Intake);
|
assert_eq!(intake.role, TicketRole::Intake);
|
||||||
assert_eq!(intake.profile, TicketRole::Intake.default_profile());
|
assert_eq!(intake.profile, TicketRole::Intake.default_profile());
|
||||||
assert_eq!(intake.workflow, TicketRole::Intake.default_workflow());
|
|
||||||
|
|
||||||
let orchestrator = plan_ticket_role_launch(TicketRoleLaunchContext::new(
|
let orchestrator = plan_ticket_role_launch(TicketRoleLaunchContext::new(
|
||||||
temp.path(),
|
temp.path(),
|
||||||
|
|
@ -1006,10 +998,6 @@ profile = "builtin:default"
|
||||||
orchestrator.profile,
|
orchestrator.profile,
|
||||||
TicketRole::Orchestrator.default_profile()
|
TicketRole::Orchestrator.default_profile()
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
orchestrator.workflow,
|
|
||||||
TicketRole::Orchestrator.default_workflow()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1038,7 +1026,6 @@ profile = "builtin:default"
|
||||||
[ticket.roles.reviewer]
|
[ticket.roles.reviewer]
|
||||||
profile = "builtin:default"
|
profile = "builtin:default"
|
||||||
launch_prompt = "$workspace/ticket/reviewer/launch"
|
launch_prompt = "$workspace/ticket/reviewer/launch"
|
||||||
workflow = "ticket-review-workflow"
|
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer);
|
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.worker_name, "reviewer-fixed");
|
||||||
assert_eq!(plan.profile, "builtin:default");
|
assert_eq!(plan.profile, "builtin:default");
|
||||||
assert_eq!(plan.workflow, "ticket-review-workflow");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan.launch_prompt_ref.as_deref(),
|
plan.launch_prompt_ref.as_deref(),
|
||||||
Some("$workspace/ticket/reviewer/launch")
|
Some("$workspace/ticket/reviewer/launch")
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(&plan.run_segments[0], Segment::Text { .. }));
|
||||||
&plan.run_segments[0],
|
|
||||||
Segment::WorkflowInvoke { slug } if slug == "ticket-review-workflow"
|
|
||||||
));
|
|
||||||
assert!(!text.contains("Configured launch_prompt"));
|
assert!(!text.contains("Configured launch_prompt"));
|
||||||
assert!(!text.contains("$workspace/ticket/reviewer/launch"));
|
assert!(!text.contains("$workspace/ticket/reviewer/launch"));
|
||||||
assert!(!text.contains("Workflow: ticket-review-workflow"));
|
|
||||||
assert!(!text.contains("Profile selector: builtin:default"));
|
assert!(!text.contains("Profile selector: builtin:default"));
|
||||||
assert!(!text.contains("Role: reviewer"));
|
assert!(!text.contains("Role: reviewer"));
|
||||||
assert!(!text.contains("system_instruction"));
|
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("Route to implementation after planning sync."));
|
||||||
assert!(orchestrator_text.contains("cargo check --workspace --all-targets"));
|
assert!(orchestrator_text.contains("cargo check --workspace --all-targets"));
|
||||||
assert!(!orchestrator_text.contains("state = inprogress"));
|
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("root/original workspace reads"));
|
||||||
assert!(!orchestrator_text.contains("role_workspace_root"));
|
assert!(!orchestrator_text.contains("role_workspace_root"));
|
||||||
assert!(!orchestrator_text.contains("role_cwd"));
|
assert!(!orchestrator_text.contains("role_cwd"));
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ fn push_kind_records(out: &mut String, layout: &WorkspaceLayout, kind: RecordKin
|
||||||
let dir = match kind {
|
let dir = match kind {
|
||||||
RecordKind::Decision => layout.decisions_dir(),
|
RecordKind::Decision => layout.decisions_dir(),
|
||||||
RecordKind::Request => layout.requests_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) {
|
let entries = match std::fs::read_dir(&dir) {
|
||||||
Ok(it) => it,
|
Ok(it) => it,
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,7 @@ fn read_kind_records(layout: &WorkspaceLayout, kind: RecordKind) -> BTreeMap<Str
|
||||||
RecordKind::Decision => layout.decisions_dir(),
|
RecordKind::Decision => layout.decisions_dir(),
|
||||||
RecordKind::Request => layout.requests_dir(),
|
RecordKind::Request => layout.requests_dir(),
|
||||||
RecordKind::Knowledge => layout.knowledge_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 mut out: BTreeMap<String, String> = BTreeMap::new();
|
||||||
let entries = match std::fs::read_dir(&dir) {
|
let entries = match std::fs::read_dir(&dir) {
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ impl ExistingRecords {
|
||||||
RecordKind::Decision => self.decisions.contains_key(slug),
|
RecordKind::Decision => self.decisions.contains_key(slug),
|
||||||
RecordKind::Request => self.requests.contains(slug),
|
RecordKind::Request => self.requests.contains(slug),
|
||||||
RecordKind::Knowledge => self.knowledge.contains(slug),
|
RecordKind::Knowledge => self.knowledge.contains(slug),
|
||||||
RecordKind::Workflow => false,
|
|
||||||
RecordKind::Summary => false,
|
RecordKind::Summary => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +52,6 @@ impl ExistingRecords {
|
||||||
RecordKind::Decision => self.decisions.keys().collect(),
|
RecordKind::Decision => self.decisions.keys().collect(),
|
||||||
RecordKind::Request => self.requests.iter().collect(),
|
RecordKind::Request => self.requests.iter().collect(),
|
||||||
RecordKind::Knowledge => self.knowledge.iter().collect(),
|
RecordKind::Knowledge => self.knowledge.iter().collect(),
|
||||||
RecordKind::Workflow => Vec::new(),
|
|
||||||
RecordKind::Summary => Vec::new(),
|
RecordKind::Summary => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -140,9 +140,6 @@ impl Linter {
|
||||||
RecordKind::Summary => {
|
RecordKind::Summary => {
|
||||||
self.check_kind::<SummaryFrontmatter>(content, &classified, &mut report);
|
self.check_kind::<SummaryFrontmatter>(content, &classified, &mut report);
|
||||||
}
|
}
|
||||||
RecordKind::Workflow => {
|
|
||||||
unreachable!("workflow paths are not classified by memory linter")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
report
|
report
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
//! enumerate what records exist without knowing what's inside them.
|
//! enumerate what records exist without knowing what's inside them.
|
||||||
//!
|
//!
|
||||||
//! - `MemoryQuery` walks `.yoi/memory/{summary.md,decisions/,
|
//! - `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
|
//! `.yoi/memory/_usage/`, and `.yoi/memory/_logs/` are excluded
|
||||||
//! by construction.
|
//! by construction.
|
||||||
//! - `KnowledgeQuery` walks `.yoi/knowledge/*.md` and supports a
|
//! - `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/decisions")).unwrap();
|
||||||
std::fs::create_dir_all(dir.path().join(".yoi/memory/requests")).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/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();
|
std::fs::create_dir_all(dir.path().join(".yoi/knowledge")).unwrap();
|
||||||
(dir, layout)
|
(dir, layout)
|
||||||
}
|
}
|
||||||
|
|
@ -637,24 +636,6 @@ mod tests {
|
||||||
assert_eq!(records[0].kind, "summary");
|
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]
|
#[tokio::test]
|
||||||
async fn query_hits_do_not_log_usage() {
|
async fn query_hits_do_not_log_usage() {
|
||||||
let (dir, layout) = setup();
|
let (dir, layout) = setup();
|
||||||
|
|
|
||||||
|
|
@ -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
|
//! The log is append-only JSONL under the workspace's `.yoi/` tree. It is
|
||||||
//! intentionally evidence-only: aggregation reports explicit context reads and
|
//! intentionally evidence-only: aggregation reports explicit context reads and
|
||||||
|
|
@ -26,7 +26,6 @@ pub enum UsageEventKind {
|
||||||
pub enum UsageSource {
|
pub enum UsageSource {
|
||||||
MemoryRead,
|
MemoryRead,
|
||||||
KnowledgeRef,
|
KnowledgeRef,
|
||||||
WorkflowInvoke,
|
|
||||||
ResidentInjection,
|
ResidentInjection,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,7 +34,6 @@ impl UsageSource {
|
||||||
match self {
|
match self {
|
||||||
Self::MemoryRead => "MemoryRead",
|
Self::MemoryRead => "MemoryRead",
|
||||||
Self::KnowledgeRef => "KnowledgeRef",
|
Self::KnowledgeRef => "KnowledgeRef",
|
||||||
Self::WorkflowInvoke => "WorkflowInvoke",
|
|
||||||
Self::ResidentInjection => "ResidentInjection",
|
Self::ResidentInjection => "ResidentInjection",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -216,10 +214,6 @@ fn record_path(
|
||||||
let slug = crate::Slug::parse(slug.to_string()).map_err(invalid_slug_error)?;
|
let slug = crate::Slug::parse(slug.to_string()).map_err(invalid_slug_error)?;
|
||||||
Ok(layout.request_path(&slug))
|
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 => {
|
RecordKind::Knowledge => {
|
||||||
let slug = crate::Slug::parse(slug.to_string()).map_err(invalid_slug_error)?;
|
let slug = crate::Slug::parse(slug.to_string()).map_err(invalid_slug_error)?;
|
||||||
Ok(layout.knowledge_path(&slug))
|
Ok(layout.knowledge_path(&slug))
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,8 @@
|
||||||
//! `WorkspaceLayout` carries the root used by the memory subsystem.
|
//! `WorkspaceLayout` carries the root used by the memory subsystem.
|
||||||
//! All yoi-managed memory content lives under the conventional
|
//! All yoi-managed memory content lives under the conventional
|
||||||
//! `<root>/.yoi/` subdirectory — alongside workspace project records
|
//! `<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/knowledge/<slug>.md`
|
||||||
//! - `<root>/.yoi/memory/summary.md`
|
//! - `<root>/.yoi/memory/summary.md`
|
||||||
//! - `<root>/.yoi/memory/decisions/<slug>.md`
|
//! - `<root>/.yoi/memory/decisions/<slug>.md`
|
||||||
|
|
@ -14,9 +13,6 @@
|
||||||
//! - `<root>/.yoi/memory/_logs/current.log` (append-only audit log)
|
//! - `<root>/.yoi/memory/_logs/current.log` (append-only audit log)
|
||||||
//!
|
//!
|
||||||
//! `memory/` is reserved for session-derived / generated state;
|
//! `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
|
//! `memory.workspace_root` pins this root explicitly. Without an explicit
|
||||||
//! root, resolution searches upward from the Worker pwd for a `.yoi/memory`
|
//! root, resolution searches upward from the Worker pwd for a `.yoi/memory`
|
||||||
//! marker; `.yoi` project records alone are not a memory marker.
|
//! marker; `.yoi` project records alone are not a memory marker.
|
||||||
|
|
@ -31,7 +27,6 @@ use lint_common::RecordLintError;
|
||||||
const YOI_DIR: &str = ".yoi";
|
const YOI_DIR: &str = ".yoi";
|
||||||
const MEMORY_DIR: &str = "memory";
|
const MEMORY_DIR: &str = "memory";
|
||||||
const KNOWLEDGE_DIR: &str = "knowledge";
|
const KNOWLEDGE_DIR: &str = "knowledge";
|
||||||
const WORKFLOW_DIR: &str = "workflow";
|
|
||||||
const SUMMARY_FILE: &str = "summary.md";
|
const SUMMARY_FILE: &str = "summary.md";
|
||||||
const DECISIONS_DIR: &str = "decisions";
|
const DECISIONS_DIR: &str = "decisions";
|
||||||
const REQUESTS_DIR: &str = "requests";
|
const REQUESTS_DIR: &str = "requests";
|
||||||
|
|
@ -47,7 +42,6 @@ pub enum RecordKind {
|
||||||
Summary,
|
Summary,
|
||||||
Decision,
|
Decision,
|
||||||
Request,
|
Request,
|
||||||
Workflow,
|
|
||||||
Knowledge,
|
Knowledge,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,7 +51,6 @@ impl RecordKind {
|
||||||
Self::Summary => "summary",
|
Self::Summary => "summary",
|
||||||
Self::Decision => "decision",
|
Self::Decision => "decision",
|
||||||
Self::Request => "request",
|
Self::Request => "request",
|
||||||
Self::Workflow => "workflow",
|
|
||||||
Self::Knowledge => "knowledge",
|
Self::Knowledge => "knowledge",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -86,7 +79,7 @@ impl WorkspaceLayout {
|
||||||
/// An explicit `memory.workspace_root` is honored exactly. Without an
|
/// An explicit `memory.workspace_root` is honored exactly. Without an
|
||||||
/// explicit root, resolution searches `default_root` and its ancestors for
|
/// explicit root, resolution searches `default_root` and its ancestors for
|
||||||
/// the nearest `.yoi/memory` directory. This keeps child worktrees that
|
/// 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`.
|
/// becoming independent memory roots merely because they contain `.yoi`.
|
||||||
///
|
///
|
||||||
/// If no memory marker exists, this falls back to `default_root` because
|
/// If no memory marker exists, this falls back to `default_root` because
|
||||||
|
|
@ -133,11 +126,6 @@ impl WorkspaceLayout {
|
||||||
self.memory_dir().join(REQUESTS_DIR)
|
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 {
|
pub fn staging_dir(&self) -> PathBuf {
|
||||||
self.memory_dir().join(STAGING_DIR)
|
self.memory_dir().join(STAGING_DIR)
|
||||||
}
|
}
|
||||||
|
|
@ -170,10 +158,6 @@ impl WorkspaceLayout {
|
||||||
self.requests_dir().join(format!("{slug}.md"))
|
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 {
|
pub fn knowledge_path(&self, slug: &Slug) -> PathBuf {
|
||||||
self.knowledge_dir().join(format!("{slug}.md"))
|
self.knowledge_dir().join(format!("{slug}.md"))
|
||||||
}
|
}
|
||||||
|
|
@ -307,14 +291,6 @@ mod tests {
|
||||||
assert_eq!(cp.kind, RecordKind::Knowledge);
|
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]
|
#[test]
|
||||||
fn staging_returns_none() {
|
fn staging_returns_none() {
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -414,7 +390,6 @@ mod tests {
|
||||||
let child = workspace.join(".worktree/child");
|
let child = workspace.join(".worktree/child");
|
||||||
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
|
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/tickets")).unwrap();
|
||||||
std::fs::create_dir_all(child.join(".yoi/workflow")).unwrap();
|
|
||||||
|
|
||||||
let cfg = manifest::MemoryConfig::default();
|
let cfg = manifest::MemoryConfig::default();
|
||||||
let layout = WorkspaceLayout::resolve(&cfg, &child);
|
let layout = WorkspaceLayout::resolve(&cfg, &child);
|
||||||
|
|
@ -427,7 +402,6 @@ mod tests {
|
||||||
let workspace = tmp.path().join("workspace");
|
let workspace = tmp.path().join("workspace");
|
||||||
let child = workspace.join("child");
|
let child = workspace.join("child");
|
||||||
std::fs::create_dir_all(workspace.join(".yoi/tickets")).unwrap();
|
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();
|
std::fs::create_dir_all(&child).unwrap();
|
||||||
|
|
||||||
assert_eq!(find_memory_marker_root(&child), None);
|
assert_eq!(find_memory_marker_root(&child), None);
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,7 @@ impl WorkerEvent {
|
||||||
/// `Method::Run` and `Event::UserMessage` carry `Vec<Segment>`. Dumb
|
/// `Method::Run` and `Event::UserMessage` carry `Vec<Segment>`. Dumb
|
||||||
/// clients (CLI piping, scripts) only need to produce a single
|
/// clients (CLI piping, scripts) only need to produce a single
|
||||||
/// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms
|
/// `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
|
/// send them through directly so the Worker side never has to re-parse a
|
||||||
/// flattened string.
|
/// flattened string.
|
||||||
///
|
///
|
||||||
|
|
@ -205,8 +205,6 @@ pub enum Segment {
|
||||||
FileRef { path: String },
|
FileRef { path: String },
|
||||||
/// `#<slug>` Knowledge reference (see `docs/plan/memory.md`).
|
/// `#<slug>` Knowledge reference (see `docs/plan/memory.md`).
|
||||||
KnowledgeRef { slug: String },
|
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
|
/// Unknown variant from a newer client. Worker treats this as an
|
||||||
/// unresolved input — surfaces an alert and inserts a placeholder.
|
/// unresolved input — surfaces an alert and inserts a placeholder.
|
||||||
/// Round-trip is lossy: re-serializing yields `{"kind":"unknown"}`.
|
/// 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
|
/// to surface user-visible alerts for unresolved refs should do so
|
||||||
/// alongside this call (Worker does so at submit time).
|
/// 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>`,
|
/// 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
|
/// content (e.g. file body or shallow directory listing for `FileRef`) is
|
||||||
/// delivered as separate `Item::system_message`s adjacent to the user
|
/// delivered as separate `Item::system_message`s adjacent to the user
|
||||||
/// message; the resolution itself is the caller's job. `Unknown` falls back to
|
/// message; the resolution itself is the caller's job. `Unknown` falls back to
|
||||||
|
|
@ -246,10 +244,6 @@ impl Segment {
|
||||||
out.push('#');
|
out.push('#');
|
||||||
out.push_str(slug);
|
out.push_str(slug);
|
||||||
}
|
}
|
||||||
Segment::WorkflowInvoke { slug } => {
|
|
||||||
out.push('/');
|
|
||||||
out.push_str(slug);
|
|
||||||
}
|
|
||||||
Segment::Unknown => {
|
Segment::Unknown => {
|
||||||
out.push_str("[unknown input segment]");
|
out.push_str("[unknown input segment]");
|
||||||
}
|
}
|
||||||
|
|
@ -295,7 +289,7 @@ pub enum Event {
|
||||||
///
|
///
|
||||||
/// Carries the JSON form of `session_store::SystemItem`. Covers
|
/// Carries the JSON form of `session_store::SystemItem`. Covers
|
||||||
/// `Method::Notify` echoes, child-Worker lifecycle events from
|
/// `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.
|
/// resolution payloads, and any future agent-side injection kind.
|
||||||
/// Clients dispatch on the `kind` tag for typed rendering instead
|
/// Clients dispatch on the `kind` tag for typed rendering instead
|
||||||
/// of parsing free-text prefixes like `[Notification] …` or
|
/// of parsing free-text prefixes like `[Notification] …` or
|
||||||
|
|
@ -618,7 +612,6 @@ pub enum AlertSource {
|
||||||
pub enum CompletionKind {
|
pub enum CompletionKind {
|
||||||
File,
|
File,
|
||||||
Knowledge,
|
Knowledge,
|
||||||
Workflow,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One candidate returned in `Event::Completions::entries`.
|
/// One candidate returned in `Event::Completions::entries`.
|
||||||
|
|
@ -1190,7 +1183,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn event_completions_format_and_default_is_dir() {
|
fn event_completions_format_and_default_is_dir() {
|
||||||
let event = Event::Completions {
|
let event = Event::Completions {
|
||||||
kind: CompletionKind::Workflow,
|
kind: CompletionKind::Knowledge,
|
||||||
entries: vec![CompletionEntry {
|
entries: vec![CompletionEntry {
|
||||||
value: "clear".into(),
|
value: "clear".into(),
|
||||||
is_dir: false,
|
is_dir: false,
|
||||||
|
|
@ -1199,7 +1192,7 @@ mod tests {
|
||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(parsed["event"], "completions");
|
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");
|
assert_eq!(parsed["data"]["entries"][0]["value"], "clear");
|
||||||
|
|
||||||
// is_dir defaults to false on inbound payloads that omit it.
|
// is_dir defaults to false on inbound payloads that omit it.
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ pub enum LogEntry {
|
||||||
|
|
||||||
/// User input accepted at submit time. Carries the original typed
|
/// User input accepted at submit time. Carries the original typed
|
||||||
/// `Vec<Segment>` so clients can re-render typed atoms (paste chips,
|
/// `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
|
/// Replay flattens these into a `Item::user_message` for the worker
|
||||||
/// history; the worker layer never sees segments directly.
|
/// history; the worker layer never sees segments directly.
|
||||||
UserInput { ts: u64, segments: Vec<Segment> },
|
UserInput { ts: u64, segments: Vec<Segment> },
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
//!
|
//!
|
||||||
//! Items in worker history with `role:system` are never produced by the
|
//! Items in worker history with `role:system` are never produced by the
|
||||||
//! LLM — they are always inserted by the Worker itself (notifications,
|
//! 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
|
//! future `<system-reminder>` tags, …). [`SystemItem`] carries the
|
||||||
//! typed shape of each such injection so clients can dispatch on
|
//! typed shape of each such injection so clients can dispatch on
|
||||||
//! `kind` instead of parsing text prefixes like `[Notification] …` or
|
//! `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
|
/// Each variant carries the kind-specific raw data clients use for
|
||||||
/// typed rendering (`Notification.message`, `WorkerEvent.event`, file
|
/// 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
|
/// `body` (where applicable) that is the exact `role:system` text the
|
||||||
/// LLM actually saw at commit time. `body` is denormalised so that
|
/// LLM actually saw at commit time. `body` is denormalised so that
|
||||||
/// segment log replay reconstructs worker history byte-identical to
|
/// segment log replay reconstructs worker history byte-identical to
|
||||||
|
|
@ -144,9 +144,11 @@ pub enum SystemItem {
|
||||||
/// header + body).
|
/// header + body).
|
||||||
Knowledge { slug: String, body: String },
|
Knowledge { slug: String, body: String },
|
||||||
|
|
||||||
/// `/<slug>` Workflow invocation. `body` is the workflow's
|
/// Compatibility sink for pre-removal persisted `kind: "workflow"`
|
||||||
/// prompt body materialized into the LLM context.
|
/// system items. These entries are intentionally not replayed as
|
||||||
Workflow { slug: String, body: String },
|
/// authority-bearing context.
|
||||||
|
#[serde(rename = "workflow")]
|
||||||
|
LegacyIgnored { slug: String },
|
||||||
|
|
||||||
/// Task-management inactivity reminder inserted before an LLM request.
|
/// Task-management inactivity reminder inserted before an LLM request.
|
||||||
/// `source` is the policy that produced this durable reminder; `body` is
|
/// `source` is the policy that produced this durable reminder; `body` is
|
||||||
|
|
@ -172,7 +174,9 @@ impl SystemItem {
|
||||||
SystemItem::WorkerEvent { body, .. } => body.clone(),
|
SystemItem::WorkerEvent { body, .. } => body.clone(),
|
||||||
SystemItem::FileAttachment { body, .. } => body.clone(),
|
SystemItem::FileAttachment { body, .. } => body.clone(),
|
||||||
SystemItem::Knowledge { 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::TaskReminder { body, .. } => body.clone(),
|
||||||
SystemItem::Interrupt { body } => body.clone(),
|
SystemItem::Interrupt { body } => body.clone(),
|
||||||
}
|
}
|
||||||
|
|
@ -192,7 +196,7 @@ impl SystemItem {
|
||||||
SystemItem::WorkerEvent { .. } => "worker_event",
|
SystemItem::WorkerEvent { .. } => "worker_event",
|
||||||
SystemItem::FileAttachment { .. } => "file_attachment",
|
SystemItem::FileAttachment { .. } => "file_attachment",
|
||||||
SystemItem::Knowledge { .. } => "knowledge",
|
SystemItem::Knowledge { .. } => "knowledge",
|
||||||
SystemItem::Workflow { .. } => "workflow",
|
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
|
||||||
SystemItem::TaskReminder { .. } => "task_reminder",
|
SystemItem::TaskReminder { .. } => "task_reminder",
|
||||||
SystemItem::Interrupt { .. } => "interrupt",
|
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]
|
#[test]
|
||||||
fn round_trip_via_json() {
|
fn round_trip_via_json() {
|
||||||
let item = SystemItem::FileAttachment {
|
let item = SystemItem::FileAttachment {
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,8 @@ pub fn ticket_config_scaffold() -> String {
|
||||||
);
|
);
|
||||||
for role in TicketRole::ALL {
|
for role in TicketRole::ALL {
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
"\n[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n",
|
"\n[ticket.roles.{role}]\nprofile = \"{}\"\n",
|
||||||
role.default_profile(),
|
role.default_profile(),
|
||||||
role.default_workflow()
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
|
|
@ -349,10 +348,6 @@ impl TicketConfig {
|
||||||
pub fn launch_prompt_for(&self, role: TicketRole) -> Option<&PromptRef> {
|
pub fn launch_prompt_for(&self, role: TicketRole) -> Option<&PromptRef> {
|
||||||
self.role(role).launch_prompt.as_ref()
|
self.role(role).launch_prompt.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn workflow_for(&self, role: TicketRole) -> &WorkflowRef {
|
|
||||||
&self.role(role).workflow
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[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 {
|
pub fn default_profile(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Intake => "builtin:intake",
|
Self::Intake => "builtin:intake",
|
||||||
|
|
@ -576,15 +563,13 @@ pub enum TicketRoleLaunchConfigError {
|
||||||
pub struct TicketRoleConfig {
|
pub struct TicketRoleConfig {
|
||||||
pub profile: ProfileSelectorRef,
|
pub profile: ProfileSelectorRef,
|
||||||
pub launch_prompt: Option<PromptRef>,
|
pub launch_prompt: Option<PromptRef>,
|
||||||
pub workflow: WorkflowRef,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TicketRoleConfig {
|
impl TicketRoleConfig {
|
||||||
pub fn default_for_role(role: TicketRole) -> Self {
|
pub fn default_for_role(_role: TicketRole) -> Self {
|
||||||
Self {
|
Self {
|
||||||
profile: ProfileSelectorRef::inherit(),
|
profile: ProfileSelectorRef::inherit(),
|
||||||
launch_prompt: None,
|
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> {
|
fn normalized_non_empty(value: impl Into<String>, label: &str) -> Result<String, String> {
|
||||||
let value = value.into();
|
let value = value.into();
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
|
|
@ -930,18 +876,13 @@ struct RawTicketRoleConfig {
|
||||||
profile: Option<ProfileSelectorRef>,
|
profile: Option<ProfileSelectorRef>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
launch_prompt: Option<PromptRef>,
|
launch_prompt: Option<PromptRef>,
|
||||||
#[serde(default)]
|
|
||||||
workflow: Option<WorkflowRef>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RawTicketRoleConfig {
|
impl RawTicketRoleConfig {
|
||||||
fn resolve(self, role: TicketRole) -> TicketRoleConfig {
|
fn resolve(self, _role: TicketRole) -> TicketRoleConfig {
|
||||||
TicketRoleConfig {
|
TicketRoleConfig {
|
||||||
profile: self.profile.unwrap_or_else(ProfileSelectorRef::inherit),
|
profile: self.profile.unwrap_or_else(ProfileSelectorRef::inherit),
|
||||||
launch_prompt: self.launch_prompt,
|
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);
|
let role_config = config.role(role);
|
||||||
assert_eq!(role_config.profile.as_str(), "inherit");
|
assert_eq!(role_config.profile.as_str(), "inherit");
|
||||||
assert!(role_config.launch_prompt.is_none());
|
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]
|
[ticket.roles.intake]
|
||||||
profile = "project:intake"
|
profile = "project:intake"
|
||||||
launch_prompt = "$workspace/ticket/intake/launch"
|
launch_prompt = "$workspace/ticket/intake/launch"
|
||||||
workflow = "ticket-intake-workflow"
|
|
||||||
|
|
||||||
[ticket.roles.orchestrator]
|
[ticket.roles.orchestrator]
|
||||||
profile = "project:orchestrator"
|
profile = "project:orchestrator"
|
||||||
launch_prompt = "$workspace/ticket/orchestrator/launch"
|
launch_prompt = "$workspace/ticket/orchestrator/launch"
|
||||||
workflow = "ticket-orchestrator-routing"
|
|
||||||
|
|
||||||
[ticket.roles.coder]
|
[ticket.roles.coder]
|
||||||
profile = "inherit"
|
profile = "inherit"
|
||||||
launch_prompt = "$workspace/ticket/coder/launch"
|
launch_prompt = "$workspace/ticket/coder/launch"
|
||||||
workflow = "multi-agent-workflow"
|
|
||||||
|
|
||||||
[ticket.roles.reviewer]
|
[ticket.roles.reviewer]
|
||||||
profile = "project:reviewer"
|
profile = "project:reviewer"
|
||||||
launch_prompt = "$workspace/ticket/reviewer/launch"
|
launch_prompt = "$workspace/ticket/reviewer/launch"
|
||||||
workflow = "multi-agent-workflow"
|
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1161,10 +1097,6 @@ workflow = "multi-agent-workflow"
|
||||||
.as_str(),
|
.as_str(),
|
||||||
"$workspace/ticket/reviewer/launch"
|
"$workspace/ticket/reviewer/launch"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
config.workflow_for(TicketRole::Reviewer).as_str(),
|
|
||||||
"multi-agent-workflow"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1184,8 +1116,7 @@ workflow = "multi-agent-workflow"
|
||||||
assert!(scaffold.contains(&format!("[ticket.roles.{role}]")));
|
assert!(scaffold.contains(&format!("[ticket.roles.{role}]")));
|
||||||
assert!(scaffold.contains(&format!(
|
assert!(scaffold.contains(&format!(
|
||||||
"[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
|
"[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
|
||||||
role.default_profile(),
|
role.default_profile()
|
||||||
role.default_workflow()
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
assert!(!scaffold.contains("[ticket.roles.investigator]"));
|
assert!(!scaffold.contains("[ticket.roles.investigator]"));
|
||||||
|
|
@ -1211,7 +1142,6 @@ workflow = "multi-agent-workflow"
|
||||||
for role in TicketRole::ALL {
|
for role in TicketRole::ALL {
|
||||||
let role_config = config.role_launch_config(role).unwrap();
|
let role_config = config.role_launch_config(role).unwrap();
|
||||||
assert_eq!(role_config.profile.as_str(), role.default_profile());
|
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);
|
let coder = config.role(TicketRole::Coder);
|
||||||
assert_eq!(coder.profile.as_str(), "project:coder");
|
assert_eq!(coder.profile.as_str(), "project:coder");
|
||||||
assert!(coder.launch_prompt.is_none());
|
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");
|
assert_eq!(config.profile_for(TicketRole::Reviewer).as_str(), "inherit");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1319,7 +1248,6 @@ branch = "orchestration/panel:bad"
|
||||||
temp.path(),
|
temp.path(),
|
||||||
r#"
|
r#"
|
||||||
[roles.orchestrator]
|
[roles.orchestrator]
|
||||||
workflow = "ticket-orchestrator-routing"
|
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1386,7 +1314,6 @@ profile = "inherit"
|
||||||
r#"
|
r#"
|
||||||
[roles.investigator]
|
[roles.investigator]
|
||||||
profile = "builtin:default"
|
profile = "builtin:default"
|
||||||
workflow = "ticket-orchestrator-routing"
|
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -596,7 +596,6 @@ impl App {
|
||||||
match kind {
|
match kind {
|
||||||
CompletionKind::File => self.input.replace_with_file_ref(start, value),
|
CompletionKind::File => self.input.replace_with_file_ref(start, value),
|
||||||
CompletionKind::Knowledge => self.input.replace_with_knowledge_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;
|
self.completion = None;
|
||||||
true
|
true
|
||||||
|
|
@ -631,7 +630,6 @@ impl App {
|
||||||
match kind {
|
match kind {
|
||||||
CompletionKind::File => self.input.replace_with_file_ref(start, value),
|
CompletionKind::File => self.input.replace_with_file_ref(start, value),
|
||||||
CompletionKind::Knowledge => self.input.replace_with_knowledge_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;
|
self.completion = None;
|
||||||
true
|
true
|
||||||
|
|
@ -2170,12 +2168,12 @@ impl App {
|
||||||
}
|
}
|
||||||
session_store::SystemItem::FileAttachment { body, .. }
|
session_store::SystemItem::FileAttachment { body, .. }
|
||||||
| session_store::SystemItem::Knowledge { body, .. }
|
| session_store::SystemItem::Knowledge { body, .. }
|
||||||
| session_store::SystemItem::Workflow { body, .. }
|
|
||||||
| session_store::SystemItem::TaskReminder { body, .. }
|
| session_store::SystemItem::TaskReminder { body, .. }
|
||||||
| session_store::SystemItem::Interrupt { body } => {
|
| session_store::SystemItem::Interrupt { body } => {
|
||||||
self.task_store.apply_system_message_text(&body);
|
self.task_store.apply_system_message_text(&body);
|
||||||
self.blocks.push(Block::SystemMessage { 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();
|
let _ = app.refresh_completion();
|
||||||
// Reply for a different kind shouldn't overwrite state.
|
// Reply for a different kind shouldn't overwrite state.
|
||||||
app.handle_worker_event(Event::Completions {
|
app.handle_worker_event(Event::Completions {
|
||||||
kind: CompletionKind::Workflow,
|
kind: CompletionKind::Knowledge,
|
||||||
entries: vec![CompletionEntry {
|
entries: vec![CompletionEntry {
|
||||||
value: "stale".into(),
|
value: "stale".into(),
|
||||||
is_dir: false,
|
is_dir: false,
|
||||||
|
|
@ -3707,12 +3705,6 @@ mod completion_flow_tests {
|
||||||
Segment::KnowledgeRef {
|
Segment::KnowledgeRef {
|
||||||
slug: "design-note".into(),
|
slug: "design-note".into(),
|
||||||
},
|
},
|
||||||
Segment::Text {
|
|
||||||
content: " then ".into(),
|
|
||||||
},
|
|
||||||
Segment::WorkflowInvoke {
|
|
||||||
slug: "review".into(),
|
|
||||||
},
|
|
||||||
Segment::Paste {
|
Segment::Paste {
|
||||||
id: 1,
|
id: 1,
|
||||||
chars: 13,
|
chars: 13,
|
||||||
|
|
|
||||||
|
|
@ -4949,7 +4949,7 @@ fn orchestrator_queue_notification_message(
|
||||||
) -> String {
|
) -> String {
|
||||||
let title = ticket.title.replace(['\r', '\n'], " ");
|
let title = ticket.title.replace(['\r', '\n'], " ");
|
||||||
format!(
|
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,
|
ticket.id,
|
||||||
title.trim()
|
title.trim()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -825,7 +825,7 @@ fn ticket_queue_notification_message_carries_routing_contract() {
|
||||||
assert!(message.contains("transition state queued -> inprogress"));
|
assert!(message.contains("transition state queued -> inprogress"));
|
||||||
assert!(message.contains("before any worktree/SpawnWorker implementation side effects"));
|
assert!(message.contains("before any worktree/SpawnWorker implementation side effects"));
|
||||||
assert!(message.contains("After inprogress acceptance"));
|
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("`.worktree/<task-name>`"));
|
||||||
assert!(message.contains("tracked `.yoi` project records visible"));
|
assert!(message.contains("tracked `.yoi` project records visible"));
|
||||||
assert!(
|
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"
|
"`.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("sibling coder/reviewer Workers"));
|
||||||
assert!(message.contains("coder narrow child-worktree write scope"));
|
assert!(message.contains("coder narrow child-worktree write scope"));
|
||||||
assert!(message.contains("reviewer read-only by default"));
|
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,
|
role: TicketRole::Intake,
|
||||||
worker_name: "intake-worker".to_string(),
|
worker_name: "intake-worker".to_string(),
|
||||||
profile: "builtin:default".to_string(),
|
profile: "builtin:default".to_string(),
|
||||||
workflow: "ticket-intake-workflow".to_string(),
|
|
||||||
launch_prompt_ref: None,
|
launch_prompt_ref: None,
|
||||||
run_segments: vec![],
|
run_segments: vec![],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Atom {
|
pub enum Atom {
|
||||||
Char(char),
|
Char(char),
|
||||||
Paste(PasteRef),
|
Paste(PasteRef),
|
||||||
FileRef(FileRefAtom),
|
FileRef(FileRefAtom),
|
||||||
KnowledgeRef(KnowledgeRefAtom),
|
KnowledgeRef(KnowledgeRefAtom),
|
||||||
WorkflowInvoke(WorkflowInvokeAtom),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Atom {
|
impl Atom {
|
||||||
|
|
@ -88,7 +75,6 @@ impl Atom {
|
||||||
Atom::Paste(p) => Some((Style::default().fg(Color::Magenta), p.label())),
|
Atom::Paste(p) => Some((Style::default().fg(Color::Magenta), p.label())),
|
||||||
Atom::FileRef(r) => Some((Style::default().fg(Color::Cyan), r.label())),
|
Atom::FileRef(r) => Some((Style::default().fg(Color::Cyan), r.label())),
|
||||||
Atom::KnowledgeRef(r) => Some((Style::default().fg(Color::Green), 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 {
|
enum AtomClass {
|
||||||
Word(WordKind),
|
Word(WordKind),
|
||||||
Sep,
|
Sep,
|
||||||
/// Indivisible chip — paste / file ref / knowledge ref / workflow
|
/// Indivisible chip — paste / file ref / knowledge ref. Word motion treats one chip as one unit; deletion
|
||||||
/// invocation. Word motion treats one chip as one unit; deletion
|
|
||||||
/// removes the whole atom.
|
/// removes the whole atom.
|
||||||
Chip,
|
Chip,
|
||||||
}
|
}
|
||||||
|
|
@ -118,9 +103,7 @@ enum WordKind {
|
||||||
fn atom_class(atom: &Atom) -> AtomClass {
|
fn atom_class(atom: &Atom) -> AtomClass {
|
||||||
match atom {
|
match atom {
|
||||||
Atom::Char(c) => char_class(*c),
|
Atom::Char(c) => char_class(*c),
|
||||||
Atom::Paste(_) | Atom::FileRef(_) | Atom::KnowledgeRef(_) | Atom::WorkflowInvoke(_) => {
|
Atom::Paste(_) | Atom::FileRef(_) | Atom::KnowledgeRef(_) => AtomClass::Chip,
|
||||||
AtomClass::Chip
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -216,11 +199,6 @@ impl InputBuffer {
|
||||||
self.atoms
|
self.atoms
|
||||||
.push(Atom::KnowledgeRef(KnowledgeRefAtom { slug: slug.clone() }));
|
.push(Atom::KnowledgeRef(KnowledgeRefAtom { slug: slug.clone() }));
|
||||||
}
|
}
|
||||||
protocol::Segment::WorkflowInvoke { slug } => {
|
|
||||||
self.atoms.push(Atom::WorkflowInvoke(WorkflowInvokeAtom {
|
|
||||||
slug: slug.clone(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
protocol::Segment::Unknown => {
|
protocol::Segment::Unknown => {
|
||||||
self.atoms
|
self.atoms
|
||||||
.extend("[unknown input segment]".chars().map(Atom::Char));
|
.extend("[unknown input segment]".chars().map(Atom::Char));
|
||||||
|
|
@ -249,7 +227,6 @@ impl InputBuffer {
|
||||||
Atom::Paste(paste) => text.push_str(&paste.content),
|
Atom::Paste(paste) => text.push_str(&paste.content),
|
||||||
Atom::FileRef(file) => text.push_str(&file.path),
|
Atom::FileRef(file) => text.push_str(&file.path),
|
||||||
Atom::KnowledgeRef(knowledge) => text.push_str(&knowledge.slug),
|
Atom::KnowledgeRef(knowledge) => text.push_str(&knowledge.slug),
|
||||||
Atom::WorkflowInvoke(workflow) => text.push_str(&workflow.slug),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
text
|
text
|
||||||
|
|
@ -277,7 +254,7 @@ impl InputBuffer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace `atoms[start..self.cursor]` (the in-flight `@<typed>` /
|
/// 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
|
/// and place the cursor right after the chip. Used by the completion
|
||||||
/// confirm path.
|
/// confirm path.
|
||||||
pub fn replace_with_file_ref(&mut self, start: usize, path: String) {
|
pub fn replace_with_file_ref(&mut self, start: usize, path: String) {
|
||||||
|
|
@ -294,13 +271,6 @@ impl InputBuffer {
|
||||||
self.cursor = start + 1;
|
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`,
|
/// Replace `atoms[start..self.cursor]` with the chars of `text`,
|
||||||
/// leaving cursor at the end of the inserted run. Used by the Tab
|
/// leaving cursor at the end of the inserted run. Used by the Tab
|
||||||
/// completion path: the popup-selected entry is inserted as raw
|
/// completion path: the popup-selected entry is inserted as raw
|
||||||
|
|
@ -322,9 +292,9 @@ impl InputBuffer {
|
||||||
/// after the sigil (sigil itself excluded).
|
/// after the sigil (sigil itself excluded).
|
||||||
///
|
///
|
||||||
/// Trigger rules:
|
/// 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
|
/// 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
|
/// - Whitespace, newlines and chip atoms invalidate an in-flight
|
||||||
/// token — `@foo /` closes the `@foo` candidate as soon as the
|
/// token — `@foo /` closes the `@foo` candidate as soon as the
|
||||||
/// space lands.
|
/// space lands.
|
||||||
|
|
@ -342,7 +312,6 @@ impl InputBuffer {
|
||||||
let kind = match c {
|
let kind = match c {
|
||||||
'@' => Some(protocol::CompletionKind::File),
|
'@' => Some(protocol::CompletionKind::File),
|
||||||
'#' => Some(protocol::CompletionKind::Knowledge),
|
'#' => Some(protocol::CompletionKind::Knowledge),
|
||||||
'/' => Some(protocol::CompletionKind::Workflow),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
if let Some(k) = kind {
|
if let Some(k) = kind {
|
||||||
|
|
@ -511,7 +480,7 @@ impl InputBuffer {
|
||||||
|
|
||||||
/// Build the typed `Vec<Segment>` sent over the protocol. Adjacent
|
/// Build the typed `Vec<Segment>` sent over the protocol. Adjacent
|
||||||
/// `Atom::Char`s are concatenated into a single `Segment::Text`; each
|
/// `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
|
/// becomes a standalone `Segment` so that clients re-rendering an
|
||||||
/// `Event::UserMessage` see the same indivisible chip rather than a
|
/// `Event::UserMessage` see the same indivisible chip rather than a
|
||||||
/// flattened string.
|
/// flattened string.
|
||||||
|
|
@ -547,12 +516,6 @@ impl InputBuffer {
|
||||||
slug: r.slug.clone(),
|
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() {
|
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[0], Segment::Text { content } if content == "see "));
|
||||||
assert!(matches!(&segs[1], Segment::FileRef { path } if path == "src/main.rs"));
|
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)]
|
#[cfg(test)]
|
||||||
|
|
@ -1055,7 +990,7 @@ mod completion_prefix_tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 `/`.
|
// After `@src/m`, the only valid trigger is `@`, not the `/`.
|
||||||
let buf = buf_from("@src/m");
|
let buf = buf_from("@src/m");
|
||||||
let (kind, start, prefix) = buf.pending_completion_prefix().unwrap();
|
let (kind, start, prefix) = buf.pending_completion_prefix().unwrap();
|
||||||
|
|
@ -1101,14 +1036,6 @@ mod completion_prefix_tests {
|
||||||
assert_eq!(prefix, "abc");
|
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]
|
#[test]
|
||||||
fn newline_before_cursor_invalidates_trigger() {
|
fn newline_before_cursor_invalidates_trigger() {
|
||||||
let buf = buf_from("@a\nbc");
|
let buf = buf_from("@a\nbc");
|
||||||
|
|
@ -1307,10 +1234,7 @@ mod word_motion_tests {
|
||||||
for a in &buf.atoms {
|
for a in &buf.atoms {
|
||||||
match a {
|
match a {
|
||||||
Atom::Char(c) => out.push(*c),
|
Atom::Char(c) => out.push(*c),
|
||||||
Atom::Paste(_)
|
Atom::Paste(_) | Atom::FileRef(_) | Atom::KnowledgeRef(_) => out.push_str("<P>"),
|
||||||
| Atom::FileRef(_)
|
|
||||||
| Atom::KnowledgeRef(_)
|
|
||||||
| Atom::WorkflowInvoke(_) => out.push_str("<P>"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
|
|
|
||||||
|
|
@ -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::FileRef { path } => (Style::default().fg(Color::Cyan), format!("@{path}")),
|
||||||
Segment::KnowledgeRef { slug } => (Style::default().fg(Color::Green), format!("#{slug}")),
|
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()),
|
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]"),
|
} => format!("[Clipboard #{id} | {chars} chars, {lines} lines]"),
|
||||||
Segment::FileRef { path } => format!("@{path}"),
|
Segment::FileRef { path } => format!("@{path}"),
|
||||||
Segment::KnowledgeRef { slug } => format!("#{slug}"),
|
Segment::KnowledgeRef { slug } => format!("#{slug}"),
|
||||||
Segment::WorkflowInvoke { slug } => format!("/{slug}"),
|
|
||||||
Segment::Unknown => "[unknown segment]".to_owned(),
|
Segment::Unknown => "[unknown segment]".to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ libc = { workspace = true }
|
||||||
schemars = { workspace = true }
|
schemars = { workspace = true }
|
||||||
ticket = { workspace = true }
|
ticket = { workspace = true }
|
||||||
memory = { workspace = true }
|
memory = { workspace = true }
|
||||||
workflow-crate = { package = "workflow", path = "../workflow" }
|
|
||||||
uuid = { workspace = true, features = ["v7"] }
|
uuid = { workspace = true, features = ["v7"] }
|
||||||
session-metrics = { workspace = true }
|
session-metrics = { workspace = true }
|
||||||
arc-swap = "1.9.1"
|
arc-swap = "1.9.1"
|
||||||
|
|
|
||||||
|
|
@ -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(¶ms.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -117,15 +117,6 @@ impl WorkerHandle {
|
||||||
is_dir: false,
|
is_dir: false,
|
||||||
})
|
})
|
||||||
.collect(),
|
.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 {
|
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_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(
|
shared_state.set_knowledge(
|
||||||
worker
|
worker
|
||||||
.knowledge_completions()
|
.knowledge_completions()
|
||||||
|
|
@ -1584,7 +1568,6 @@ fn worker_error_code(e: &WorkerError) -> ErrorCode {
|
||||||
_ => ErrorCode::Internal,
|
_ => ErrorCode::Internal,
|
||||||
},
|
},
|
||||||
WorkerError::Provider(_) => ErrorCode::ProviderError,
|
WorkerError::Provider(_) => ErrorCode::ProviderError,
|
||||||
WorkerError::WorkflowResolve(_) => ErrorCode::InvalidRequest,
|
|
||||||
_ => ErrorCode::Internal,
|
_ => ErrorCode::Internal,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ use llm_engine::tool::ToolOutput;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::active_workflow::ActiveWorkflowStore;
|
|
||||||
use crate::compact::state::CompactState;
|
use crate::compact::state::CompactState;
|
||||||
use crate::compact::usage_tracker::UsageTracker;
|
use crate::compact::usage_tracker::UsageTracker;
|
||||||
use session_store::SystemItem;
|
use session_store::SystemItem;
|
||||||
|
|
@ -72,10 +71,6 @@ pub(crate) struct WorkerInterceptor {
|
||||||
/// worker. `None` in tests / `Worker::new` paths where no writer is
|
/// worker. `None` in tests / `Worker::new` paths where no writer is
|
||||||
/// attached.
|
/// attached.
|
||||||
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
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 assigned by `on_prompt_submit`.
|
||||||
next_turn_index: AtomicUsize,
|
next_turn_index: AtomicUsize,
|
||||||
/// Tool calls observed in the current turn (reset on each new prompt).
|
/// Tool calls observed in the current turn (reset on each new prompt).
|
||||||
|
|
@ -91,7 +86,6 @@ impl WorkerInterceptor {
|
||||||
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
|
||||||
prompts: Arc<PromptCatalog>,
|
prompts: Arc<PromptCatalog>,
|
||||||
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
log_writer: Option<Arc<dyn SystemItemCommitter>>,
|
||||||
active_workflows: ActiveWorkflowStore,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
registry,
|
registry,
|
||||||
|
|
@ -102,7 +96,6 @@ impl WorkerInterceptor {
|
||||||
pending_attachments,
|
pending_attachments,
|
||||||
prompts,
|
prompts,
|
||||||
log_writer,
|
log_writer,
|
||||||
active_workflows,
|
|
||||||
next_turn_index: AtomicUsize::new(0),
|
next_turn_index: AtomicUsize::new(0),
|
||||||
tool_calls_this_turn: 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 {
|
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
|
||||||
self.active_workflows.sanitize_context(context);
|
|
||||||
|
|
||||||
let initial_tokens = self.estimated_tokens(context);
|
let initial_tokens = self.estimated_tokens(context);
|
||||||
if self.request_threshold_exceeded(initial_tokens, context) {
|
if self.request_threshold_exceeded(initial_tokens, context) {
|
||||||
return PreRequestAction::Yield;
|
return PreRequestAction::Yield;
|
||||||
|
|
@ -536,7 +527,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -569,7 +559,6 @@ mod tests {
|
||||||
Some(Arc::new(RecordingSystemItemCommitter {
|
Some(Arc::new(RecordingSystemItemCommitter {
|
||||||
committed: Arc::clone(&committed),
|
committed: Arc::clone(&committed),
|
||||||
})),
|
})),
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -606,7 +595,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
)
|
)
|
||||||
.with_usage_tracker(usage_tracker);
|
.with_usage_tracker(usage_tracker);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
|
|
@ -632,7 +620,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -674,7 +661,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -702,7 +688,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let mut ctx = ctx_items;
|
let mut ctx = ctx_items;
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -724,7 +709,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let mut ctx: Vec<Item> = Vec::new();
|
let mut ctx: Vec<Item> = Vec::new();
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -753,7 +737,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
Some(committer),
|
Some(committer),
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut ctx: Vec<Item> = Vec::new();
|
let mut ctx: Vec<Item> = Vec::new();
|
||||||
|
|
@ -801,7 +784,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut ctx: Vec<Item> = Vec::new();
|
let mut ctx: Vec<Item> = Vec::new();
|
||||||
|
|
@ -859,7 +841,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let mut info = task_tool_call_info("TaskList", serde_json::json!({"scope": "all"}));
|
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())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let info = task_tool_call_info("TaskList", serde_json::json!({}));
|
let info = task_tool_call_info("TaskList", serde_json::json!({}));
|
||||||
let mut result_info = ToolResultInfo {
|
let mut result_info = ToolResultInfo {
|
||||||
|
|
@ -957,7 +937,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let history = vec![Item::user_message("hi"), Item::assistant_message("done")];
|
let history = vec![Item::user_message("hi"), Item::assistant_message("done")];
|
||||||
|
|
||||||
|
|
@ -992,7 +971,6 @@ mod tests {
|
||||||
Some(Arc::new(RecordingSystemItemCommitter {
|
Some(Arc::new(RecordingSystemItemCommitter {
|
||||||
committed: Arc::clone(&committed),
|
committed: Arc::clone(&committed),
|
||||||
})),
|
})),
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
)
|
)
|
||||||
.with_usage_tracker(Arc::clone(&usage_tracker));
|
.with_usage_tracker(Arc::clone(&usage_tracker));
|
||||||
|
|
||||||
|
|
@ -1052,7 +1030,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let items = interceptor.pending_history_appends().await;
|
let items = interceptor.pending_history_appends().await;
|
||||||
|
|
@ -1090,7 +1067,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
|
let mut ctx: Vec<Item> = vec![Item::user_message("hi")];
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
@ -1121,7 +1097,6 @@ mod tests {
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
PromptCatalog::builtins_only().unwrap(),
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
None,
|
None,
|
||||||
ActiveWorkflowStore::new(),
|
|
||||||
);
|
);
|
||||||
let mut ctx: Vec<Item> = Vec::new();
|
let mut ctx: Vec<Item> = Vec::new();
|
||||||
let action = interceptor.pre_llm_request(&mut ctx).await;
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
pub mod active_workflow;
|
|
||||||
pub mod compact;
|
pub mod compact;
|
||||||
pub mod controller;
|
pub mod controller;
|
||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
|
|
@ -15,7 +14,6 @@ pub mod segment_log_sink;
|
||||||
pub mod shared_state;
|
pub mod shared_state;
|
||||||
mod shutdown_after_idle;
|
mod shutdown_after_idle;
|
||||||
pub mod spawn;
|
pub mod spawn;
|
||||||
pub mod workflow;
|
|
||||||
|
|
||||||
mod interrupt_prep;
|
mod interrupt_prep;
|
||||||
mod permission;
|
mod permission;
|
||||||
|
|
|
||||||
|
|
@ -88,10 +88,6 @@ pub enum WorkerPrompt {
|
||||||
/// injection is enabled, and at least one `knowledge/*` record advertises
|
/// injection is enabled, and at least one `knowledge/*` record advertises
|
||||||
/// `model_invokation: true`.
|
/// `model_invokation: true`.
|
||||||
ResidentKnowledgeSection,
|
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
|
/// Trailing Worker orchestration guidance, appended when registered tools
|
||||||
/// include Worker-management capabilities.
|
/// include Worker-management capabilities.
|
||||||
WorkerOrchestrationGuidanceSection,
|
WorkerOrchestrationGuidanceSection,
|
||||||
|
|
@ -115,7 +111,6 @@ impl WorkerPrompt {
|
||||||
Self::AgentsMdSection => "agents_md_section",
|
Self::AgentsMdSection => "agents_md_section",
|
||||||
Self::ResidentMemorySummarySection => "resident_memory_summary_section",
|
Self::ResidentMemorySummarySection => "resident_memory_summary_section",
|
||||||
Self::ResidentKnowledgeSection => "resident_knowledge_section",
|
Self::ResidentKnowledgeSection => "resident_knowledge_section",
|
||||||
Self::ResidentWorkflowsSection => "resident_workflows_section",
|
|
||||||
Self::WorkerOrchestrationGuidanceSection => "worker_orchestration_guidance_section",
|
Self::WorkerOrchestrationGuidanceSection => "worker_orchestration_guidance_section",
|
||||||
Self::TicketEventCompanionNotice => "ticket_event_companion_notice",
|
Self::TicketEventCompanionNotice => "ticket_event_companion_notice",
|
||||||
Self::SpawnWorkerToolDescription => "spawn_worker_tool_description",
|
Self::SpawnWorkerToolDescription => "spawn_worker_tool_description",
|
||||||
|
|
@ -136,7 +131,6 @@ impl WorkerPrompt {
|
||||||
WorkerPrompt::AgentsMdSection,
|
WorkerPrompt::AgentsMdSection,
|
||||||
WorkerPrompt::ResidentMemorySummarySection,
|
WorkerPrompt::ResidentMemorySummarySection,
|
||||||
WorkerPrompt::ResidentKnowledgeSection,
|
WorkerPrompt::ResidentKnowledgeSection,
|
||||||
WorkerPrompt::ResidentWorkflowsSection,
|
|
||||||
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
WorkerPrompt::WorkerOrchestrationGuidanceSection,
|
||||||
WorkerPrompt::TicketEventCompanionNotice,
|
WorkerPrompt::TicketEventCompanionNotice,
|
||||||
WorkerPrompt::SpawnWorkerToolDescription,
|
WorkerPrompt::SpawnWorkerToolDescription,
|
||||||
|
|
@ -153,7 +147,6 @@ impl WorkerPrompt {
|
||||||
"agents_md_section",
|
"agents_md_section",
|
||||||
"resident_memory_summary_section",
|
"resident_memory_summary_section",
|
||||||
"resident_knowledge_section",
|
"resident_knowledge_section",
|
||||||
"resident_workflows_section",
|
|
||||||
"worker_orchestration_guidance_section",
|
"worker_orchestration_guidance_section",
|
||||||
"ticket_event_companion_notice",
|
"ticket_event_companion_notice",
|
||||||
"spawn_worker_tool_description",
|
"spawn_worker_tool_description",
|
||||||
|
|
@ -410,15 +403,6 @@ impl PromptCatalog {
|
||||||
self.render(WorkerPrompt::ResidentKnowledgeSection, Value::from(m))
|
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).
|
/// Render `WorkerPrompt::WorkerOrchestrationGuidanceSection` (no inputs).
|
||||||
pub fn worker_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
|
pub fn worker_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
|
||||||
self.render(
|
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("Do not use `sleep` or polling loops"));
|
||||||
assert!(rendered.contains("worktree state, diff, and test results"));
|
assert!(rendered.contains("worktree state, diff, and test results"));
|
||||||
assert!(rendered.contains("not scheduler or auto-maintain authorization"));
|
assert!(rendered.contains("not scheduler or auto-maintain authorization"));
|
||||||
assert!(rendered.contains("bypass user/workflow authorization"));
|
assert!(rendered.contains("bypass user/Ticket authorization"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ use memory::ResidentKnowledgeEntry;
|
||||||
use minijinja::value::Value;
|
use minijinja::value::Value;
|
||||||
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
|
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use workflow_crate::ResidentWorkflowEntry;
|
|
||||||
|
|
||||||
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
||||||
use crate::prompt::loader::{LoaderError, PromptLoader, PromptRef};
|
use crate::prompt::loader::{LoaderError, PromptLoader, PromptRef};
|
||||||
|
|
@ -127,7 +126,6 @@ impl SystemPromptTemplate {
|
||||||
ctx.agents_md.as_deref(),
|
ctx.agents_md.as_deref(),
|
||||||
ctx.resident_summary,
|
ctx.resident_summary,
|
||||||
ctx.resident_knowledge,
|
ctx.resident_knowledge,
|
||||||
ctx.resident_workflows,
|
|
||||||
ToolCapabilities::from_tool_names(&ctx.tool_names),
|
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
|
/// section entirely (memory disabled, or a consolidation worker that opts
|
||||||
/// out); `Some(&[])` also yields no section.
|
/// out); `Some(&[])` also yields no section.
|
||||||
pub resident_knowledge: Option<&'a [ResidentKnowledgeEntry]>,
|
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.
|
/// Catalog used to render the fixed trailing section headers.
|
||||||
/// Passed by reference so callers do not give up ownership across
|
/// Passed by reference so callers do not give up ownership across
|
||||||
/// the short-lived render borrow.
|
/// the short-lived render borrow.
|
||||||
|
|
@ -304,7 +298,6 @@ fn append_trailing_section(
|
||||||
agents_md: Option<&str>,
|
agents_md: Option<&str>,
|
||||||
resident_summary: Option<&str>,
|
resident_summary: Option<&str>,
|
||||||
resident_knowledge: Option<&[ResidentKnowledgeEntry]>,
|
resident_knowledge: Option<&[ResidentKnowledgeEntry]>,
|
||||||
resident_workflows: Option<&[ResidentWorkflowEntry]>,
|
|
||||||
tool_capabilities: ToolCapabilities,
|
tool_capabilities: ToolCapabilities,
|
||||||
) -> Result<String, SystemPromptError> {
|
) -> Result<String, SystemPromptError> {
|
||||||
let mut out = String::with_capacity(body.len() + 256);
|
let mut out = String::with_capacity(body.len() + 256);
|
||||||
|
|
@ -345,15 +338,6 @@ fn append_trailing_section(
|
||||||
out.push('\n');
|
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() {
|
if tool_capabilities.worker_management() {
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
let section = prompts.worker_orchestration_guidance_section()?;
|
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 {
|
fn format_resident_entries<'a>(entries: impl Iterator<Item = (&'a str, &'a str)>) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for (i, (slug, description)) in entries.enumerate() {
|
for (i, (slug, description)) in entries.enumerate() {
|
||||||
|
|
@ -451,7 +427,6 @@ mod tests {
|
||||||
agents_md,
|
agents_md,
|
||||||
resident_summary: None,
|
resident_summary: None,
|
||||||
resident_knowledge: None,
|
resident_knowledge: None,
|
||||||
resident_workflows: None,
|
|
||||||
prompts: test_prompts(),
|
prompts: test_prompts(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -470,7 +445,6 @@ mod tests {
|
||||||
agents_md: None,
|
agents_md: None,
|
||||||
resident_summary: summary,
|
resident_summary: summary,
|
||||||
resident_knowledge: None,
|
resident_knowledge: None,
|
||||||
resident_workflows: None,
|
|
||||||
prompts: test_prompts(),
|
prompts: test_prompts(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -489,26 +463,6 @@ mod tests {
|
||||||
agents_md: None,
|
agents_md: None,
|
||||||
resident_summary: None,
|
resident_summary: None,
|
||||||
resident_knowledge: Some(resident),
|
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(),
|
prompts: test_prompts(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -652,7 +606,7 @@ mod tests {
|
||||||
assert!(rendered.contains("Do not use `sleep` or polling loops"));
|
assert!(rendered.contains("Do not use `sleep` or polling loops"));
|
||||||
assert!(rendered.contains("worktree state, diff, and test results"));
|
assert!(rendered.contains("worktree state, diff, and test results"));
|
||||||
assert!(rendered.contains("not scheduler or auto-maintain authorization"));
|
assert!(rendered.contains("not scheduler or auto-maintain authorization"));
|
||||||
assert!(rendered.contains("bypass user/workflow authorization"));
|
assert!(rendered.contains("bypass user/Ticket authorization"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -955,39 +909,4 @@ mod tests {
|
||||||
assert!(rendered.contains("## Resident knowledge"));
|
assert!(rendered.contains("## Resident knowledge"));
|
||||||
assert!(rendered.contains("KnowledgeQuery / MemoryRead"));
|
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"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,6 @@ use session_store::SegmentId;
|
||||||
|
|
||||||
use crate::fs_view::WorkerFsView;
|
use crate::fs_view::WorkerFsView;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct WorkflowCandidate {
|
|
||||||
pub slug: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct KnowledgeCandidate {
|
pub struct KnowledgeCandidate {
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
|
|
@ -24,7 +19,7 @@ pub struct KnowledgeCandidate {
|
||||||
/// History and typed user-segment mirrors used to live here so the
|
/// History and typed user-segment mirrors used to live here so the
|
||||||
/// IPC layer could answer `Method::GetHistory`. Those reads now go
|
/// IPC layer could answer `Method::GetHistory`. Those reads now go
|
||||||
/// directly through the session-log sink (`Event::Snapshot` +
|
/// 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.
|
/// greeting, and completion lookup hubs.
|
||||||
pub struct WorkerSharedState {
|
pub struct WorkerSharedState {
|
||||||
pub worker_name: String,
|
pub worker_name: String,
|
||||||
|
|
@ -39,7 +34,6 @@ pub struct WorkerSharedState {
|
||||||
/// (only relevant for unit tests that build a `WorkerSharedState`
|
/// (only relevant for unit tests that build a `WorkerSharedState`
|
||||||
/// directly without spinning up a controller).
|
/// directly without spinning up a controller).
|
||||||
fs_view: OnceLock<WorkerFsView>,
|
fs_view: OnceLock<WorkerFsView>,
|
||||||
workflows: OnceLock<Vec<WorkflowCandidate>>,
|
|
||||||
knowledge: OnceLock<Vec<KnowledgeCandidate>>,
|
knowledge: OnceLock<Vec<KnowledgeCandidate>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,7 +51,6 @@ impl WorkerSharedState {
|
||||||
greeting,
|
greeting,
|
||||||
status: RwLock::new(WorkerStatus::Idle),
|
status: RwLock::new(WorkerStatus::Idle),
|
||||||
fs_view: OnceLock::new(),
|
fs_view: OnceLock::new(),
|
||||||
workflows: OnceLock::new(),
|
|
||||||
knowledge: OnceLock::new(),
|
knowledge: OnceLock::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -74,23 +67,6 @@ impl WorkerSharedState {
|
||||||
self.fs_view.get()
|
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>) {
|
pub fn set_knowledge(&self, knowledge: Vec<KnowledgeCandidate>) {
|
||||||
let _ = self.knowledge.set(knowledge);
|
let _ = self.knowledge.set(knowledge);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &layout, "bad").unwrap_err();
|
|
||||||
assert!(matches!(
|
|
||||||
err,
|
|
||||||
WorkflowResolveError::KnowledgeNotFound { .. }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 request = wait_for_captured_request(&client_for_assert).await;
|
||||||
let names = request_tool_names(&request);
|
let names = request_tool_names(&request);
|
||||||
assert_eq!(
|
assert_eq!(names, vec!["Bash", "Edit", "Glob", "Grep", "Read", "Write"]);
|
||||||
names,
|
|
||||||
vec![
|
|
||||||
"ActiveWorkflowCancel",
|
|
||||||
"ActiveWorkflowComplete",
|
|
||||||
"ActiveWorkflowList",
|
|
||||||
"Bash",
|
|
||||||
"Edit",
|
|
||||||
"Glob",
|
|
||||||
"Grep",
|
|
||||||
"Read",
|
|
||||||
"Write"
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert!(!names.iter().any(|name| name == "TaskCreate"));
|
assert!(!names.iter().any(|name| name == "TaskCreate"));
|
||||||
assert!(!names.iter().any(|name| name == "WebSearch"));
|
assert!(!names.iter().any(|name| name == "WebSearch"));
|
||||||
assert!(!names.iter().any(|name| name == "SpawnWorker"));
|
assert!(!names.iter().any(|name| name == "SpawnWorker"));
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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),
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
@ -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 { .. }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 { .. }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -425,7 +425,6 @@ mod tests {
|
||||||
&root.join(".yoi/memory/_logs/ignored.md"),
|
&root.join(".yoi/memory/_logs/ignored.md"),
|
||||||
"not frontmatter",
|
"not frontmatter",
|
||||||
);
|
);
|
||||||
write(&root.join(".yoi/workflow/ignored.md"), "not frontmatter");
|
|
||||||
|
|
||||||
let report = lint_workspace(root).unwrap();
|
let report = lint_workspace(root).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -1196,9 +1196,8 @@ mod tests {
|
||||||
assert!(config.contains("# language = \"Japanese\""));
|
assert!(config.contains("# language = \"Japanese\""));
|
||||||
for role in TicketRole::ALL {
|
for role in TicketRole::ALL {
|
||||||
assert!(config.contains(&format!(
|
assert!(config.contains(&format!(
|
||||||
"[ticket.roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
|
"[ticket.roles.{role}]\nprofile = \"{}\"",
|
||||||
role.default_profile(),
|
role.default_profile()
|
||||||
role.default_workflow()
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
assert!(!config.contains("[ticket.roles.investigator]"));
|
assert!(!config.contains("[ticket.roles.investigator]"));
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ When these roots differ, implementation worktrees must be created under `impleme
|
||||||
- Ticket role launch prompts now emit a `Workspace routing context` section for Orchestrator launches, and for any launch that explicitly provides original/target roots.
|
- Ticket role launch prompts now emit a `Workspace routing context` section for Orchestrator launches, and for any launch that explicitly provides original/target roots.
|
||||||
- `TicketRoleLaunchPlan` carries `original_workspace_root`, `implementation_worktree_root`, and `target_workspace_root` as explicit plan fields for callers/tests.
|
- `TicketRoleLaunchPlan` carries `original_workspace_root`, `implementation_worktree_root`, and `target_workspace_root` as explicit plan fields for callers/tests.
|
||||||
- Orchestrator role prompt resources now say to create implementation worktrees under the original workspace root and to run merge-completion against the recorded target workspace.
|
- Orchestrator role prompt resources now say to create implementation worktrees under the original workspace root and to run merge-completion against the recorded target workspace.
|
||||||
- `worktree-workflow` now describes `<original-workspace-root>/.worktree/<task-name>` and `git -C <original-workspace-root> ...` instead of assuming the Orchestrator cwd is the repository root.
|
|
||||||
|
|
||||||
## Follow-up boundaries
|
## Follow-up boundaries
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
# Public builtin workflows and Yoi dogfood workflows
|
|
||||||
|
|
||||||
Yoi should split workflows into public builtin procedures and project-local dogfood procedures. The builtin set should teach the general Ticket/role lifecycle; this repository's Git worktree, branch, cargo, nix, merge, cleanup, and report conventions should stay project-local unless they are explicitly selected as an optional extension.
|
|
||||||
|
|
||||||
This document records the design/audit decision for Ticket `00001KTRKZ14C`. It does not implement the builtin workflow loader.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
- Add a typed builtin workflow source, conceptually `WorkflowSource::Builtin`, with clear provenance in any list/show/launch surface.
|
|
||||||
- Store public builtin workflow resources under `resources/workflows/<slug>.md`, not under `resources/prompts`.
|
|
||||||
- Workflows are user-visible procedural artifacts selected by slug and source. They need source/provenance/listing semantics distinct from prompt fragments.
|
|
||||||
- They are still embedded runtime resources, but prompt-fragment override rules should not silently become workflow authority.
|
|
||||||
- Source priority for workflow lookup should be:
|
|
||||||
1. explicit path/file selector, if the CLI/config surface allows one;
|
|
||||||
2. workspace `.yoi/workflow/<slug>.md`;
|
|
||||||
3. user workflow directory, if/when one is added;
|
|
||||||
4. builtin `resources/workflows/<slug>.md`;
|
|
||||||
5. external skill/plugin workflow sources, only when explicitly enabled and named.
|
|
||||||
- Workspace workflows override builtin workflows by slug. Overrides must show provenance so a role launch can say whether it used workspace, user, builtin, or plugin workflow text.
|
|
||||||
- Keep role Profile policy separate from workflow prose. Profiles select role behavior/tool policy; workflows provide procedural flow; launch prompts provide concrete Ticket/action context.
|
|
||||||
- Public resident core should remain small: intake requirements sync and orchestrator routing. Multi-agent implementation/review is builtin-available but not necessarily resident in every profile. Git worktree mechanics are optional/project-local by default.
|
|
||||||
|
|
||||||
## Audit of current workspace workflows
|
|
||||||
|
|
||||||
| Current slug | Public builtin candidate | Dogfood/project-local material | Decision |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `ticket-intake-workflow` | Yes. The core requirement-sync flow, existing-Ticket refinement, readiness, duplicate avoidance, and Ticket materialization are product-generic. | Mentions current local storage and Yoi-specific Ticket command examples. | Move cleaned generic version to builtin core. Keep storage-command specifics either bounded and product-generic or in project-local notes. |
|
|
||||||
| `ticket-orchestrator-routing` | Yes. The routing classifications, queued acceptance contract, relation/orchestration-plan checks, and planning-return rules are product-generic. | Contains Yoi dogfood examples, worktree/multi-agent handoff specifics, and merge-completion guidance tied to this repo's operating policy. | Move generic routing gate to builtin core. Split dogfood merge/worktree details into workspace override or separate dogfood workflow. |
|
|
||||||
| `ticket-preflight-workflow` | Compatibility candidate only. It now describes planning/requirements sync and legacy canonical id compatibility rather than a standalone lane. | Mostly project-history wording and deprecated-preflight vocabulary. | Do not make resident core. Keep as builtin compatibility alias only if existing configs still reference it; otherwise replace references with intake/planning sync. |
|
|
||||||
| `multi-agent-workflow` | Partly. The sibling coder/reviewer loop, intent packet, branch-local review, and merge-ready dossier are useful product concepts. | Heavy Yoi dogfood specifics: Git worktrees, commits, cargo/nix validation, branch cleanup, docs/report conventions, and parent/child policy. | Split into builtin `multi-agent-workflow` for role loop + dossier, and workspace dogfood extension for Git worktree/cargo/nix mechanics. |
|
|
||||||
| `worktree-workflow` | Not resident public core. | Almost entirely this repository's Git worktree mechanics and `.yoi` path exclusions. | Keep workspace-local dogfood workflow. Optionally create a non-resident builtin `git-worktree-isolation` later, disabled unless explicitly selected. |
|
|
||||||
|
|
||||||
## Slug and workspace Ticket settings migration decision
|
|
||||||
|
|
||||||
Use explicit dogfood slugs for workflows whose semantics differ from the public builtin workflow. Same-slug workspace overrides are allowed for local wording/policy tweaks of the same public contract, but they should not be used to hide Yoi repository Git/worktree/cargo/nix/merge semantics behind a public slug. This avoids accidental shadowing when a user expects `multi-agent-workflow` to mean the generic builtin role loop.
|
|
||||||
|
|
||||||
Planned selector mapping for this repository after builtin workflow loading exists:
|
|
||||||
|
|
||||||
| Role/config surface | Workflow selector | Source intent | Rationale |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `[ticket.roles.intake].workflow` | `ticket-intake-workflow` | builtin core by default; workspace same-slug override only if this repo needs a true local refinement of the same intake contract | Intake is mostly product-generic; dogfood Git/worktree policy does not belong here. |
|
|
||||||
| `[ticket.roles.orchestrator].workflow` | `ticket-orchestrator-routing` | builtin core by default; dogfood merge/worktree instructions come from explicit launch context or a separate dogfood workflow, not a hidden same-slug override | Routing semantics should stay public/generic; repo-specific implementation mechanics should be opt-in and visible. |
|
|
||||||
| `[ticket.roles.coder].workflow` | `yoi-dogfood-multi-agent-workflow` | workspace-local dogfood workflow | Coder work in this repository is tied to the repo worktree/branch/validation contract and must not shadow generic builtin `multi-agent-workflow`. |
|
|
||||||
| `[ticket.roles.reviewer].workflow` | `yoi-dogfood-multi-agent-workflow` | workspace-local dogfood workflow | Reviewer evidence in this repository includes branch/worktree/cargo/nix/Ticket-doctor conventions; use an explicit dogfood slug. |
|
|
||||||
| compatibility references | `ticket-preflight-workflow` | builtin compatibility alias or workspace compatibility file, non-resident | Do not keep it as a role default. It should point to planning/requirements sync language only. |
|
|
||||||
| dogfood worktree helper | `yoi-dogfood-worktree-workflow` | workspace-local helper, not a role default by itself | Referenced from `yoi-dogfood-multi-agent-workflow`; keeps Git worktree mechanics out of generic builtin workflows. |
|
|
||||||
|
|
||||||
`multi-agent-workflow` should be reserved for the generic builtin sibling coder/reviewer loop. This repository should not keep a same-slug workspace override with dogfood semantics once the builtin exists. If a temporary same-slug override is needed during migration, it must be short-lived and the Ticket/config comment must say it shadows the builtin intentionally.
|
|
||||||
|
|
||||||
## Builtin workflow set
|
|
||||||
|
|
||||||
### Resident builtin core
|
|
||||||
|
|
||||||
These should be advertised by default when Ticket support is enabled:
|
|
||||||
|
|
||||||
- `ticket-intake-workflow`
|
|
||||||
- generic Ticket requirements sync/materialization;
|
|
||||||
- existing Ticket refinement with explicit user agreement;
|
|
||||||
- readiness and duplicate handling;
|
|
||||||
- no Yoi repository cargo/nix/worktree policy.
|
|
||||||
- `ticket-orchestrator-routing`
|
|
||||||
- explicit routing gate;
|
|
||||||
- queued acceptance before implementation side effects;
|
|
||||||
- relation/orchestration-plan/context checks;
|
|
||||||
- implementation-ready IntentPacket shape;
|
|
||||||
- no repository-specific merge authority or Git cleanup policy.
|
|
||||||
|
|
||||||
### Builtin available but optional
|
|
||||||
|
|
||||||
These can be embedded and selectable, but should not be injected as resident core unless the active Profile/config opts in:
|
|
||||||
|
|
||||||
- `multi-agent-workflow`
|
|
||||||
- generic sibling coder/reviewer loop;
|
|
||||||
- intent packet, reviewer evidence, blocker/fix-loop, merge-ready dossier;
|
|
||||||
- no mandatory Git worktree, cargo, nix, or `.yoi` cleanup rules.
|
|
||||||
- `ticket-preflight-workflow`
|
|
||||||
- transitional alias for old configurations that still name preflight;
|
|
||||||
- should route to planning/requirements sync language and avoid reintroducing preflight as a lifecycle state.
|
|
||||||
|
|
||||||
### Workspace-local dogfood workflows
|
|
||||||
|
|
||||||
These remain in this repository under `.yoi/workflow/` as overrides or dogfood-only slugs:
|
|
||||||
|
|
||||||
- `worktree-workflow`
|
|
||||||
- mechanical Yoi project worktree setup;
|
|
||||||
- tracked `.yoi` project records visible;
|
|
||||||
- `.yoi/memory`, local/runtime/log/lock/secret-like paths excluded;
|
|
||||||
- branch/worktree cleanup policy.
|
|
||||||
- A dogfood extension for multi-agent/merge-completion, if the generic builtin `multi-agent-workflow` is cleaned:
|
|
||||||
- concrete `cargo fmt`, `cargo test`, `target/debug/yoi ticket doctor`, validation policy;
|
|
||||||
- merge authority boundaries for this repository;
|
|
||||||
- docs/report conventions.
|
|
||||||
|
|
||||||
## Stale vocabulary cleanup
|
|
||||||
|
|
||||||
Builtin workflows and active workspace workflows should remove or replace the following old wording:
|
|
||||||
|
|
||||||
- `Action required` / `Attention required` as storage or state concepts. Use explicit Ticket state, typed relation metadata, orchestration-plan waiting notes, or Ticket thread comments instead.
|
|
||||||
- `preflight` as a lifecycle lane/state. Use planning/requirements sync or compatibility alias language.
|
|
||||||
- `open` / `pending` / `closed bucket` authority. Current authority is `state` plus optional `resolution.md` for closed Tickets.
|
|
||||||
- Long-lived umbrella/progress-container Ticket terminology. Use concrete implementation Tickets and optional Objective context.
|
|
||||||
|
|
||||||
## Follow-up implementation boundaries
|
|
||||||
|
|
||||||
1. **Builtin workflow resource loader**
|
|
||||||
- Add embedded `resources/workflows/*.md` resources.
|
|
||||||
- Add `WorkflowSource::Builtin` / equivalent typed provenance.
|
|
||||||
- Update workflow discovery/list/show/launch so workspace overrides builtin by slug and provenance is visible.
|
|
||||||
- Include tests for source priority and override behavior.
|
|
||||||
|
|
||||||
2. **Public workflow text cleanup**
|
|
||||||
- Create cleaned builtin versions of `ticket-intake-workflow` and `ticket-orchestrator-routing`.
|
|
||||||
- Split generic `multi-agent-workflow` from Yoi dogfood Git/worktree/cargo/nix mechanics.
|
|
||||||
- Keep `ticket-preflight-workflow` as compatibility-only or remove references if no longer needed.
|
|
||||||
|
|
||||||
3. **Dogfood workflow retention**
|
|
||||||
- Keep or rename workspace-local dogfood workflows for this repository's implementation mechanics.
|
|
||||||
- Ensure `.yoi/workspace.toml` `[ticket.roles.*].workflow` selectors intentionally point to builtin generic workflows or workspace dogfood overrides, with no accidental slug shadowing.
|
|
||||||
|
|
||||||
4. **Vocabulary migration**
|
|
||||||
- Sweep active workflow text, prompts, docs, and Ticket UI wording for `Action required`, `Attention required`, and old preflight lane language.
|
|
||||||
- Replace with typed state/relation/orchestration-plan terminology.
|
|
||||||
|
|
||||||
5. **Panel/role launch provenance display**
|
|
||||||
- When a role launches with a workflow, include the workflow source/provenance in diagnostic/launch context so builtin vs workspace override is debuggable.
|
|
||||||
|
|
||||||
## Non-goals for this Ticket
|
|
||||||
|
|
||||||
- Implementing the builtin workflow loader.
|
|
||||||
- Moving current workflow files into runtime resources in this branch.
|
|
||||||
- Changing Ticket lifecycle states.
|
|
||||||
- Changing role Profile policy.
|
|
||||||
- Changing scheduler/queue behavior.
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Tickets and development workflow
|
# Tickets and development workflow
|
||||||
|
|
||||||
Yoi project work is tracked through Tickets. For normal use, interact with Tickets through `yoi panel`, Ticket tools, the `yoi ticket ...` CLI, and Ticket workflows. Git history plus Ticket files remain the authoritative state-transition record behind those interfaces.
|
Yoi project work is tracked through Tickets. For normal use, interact with Tickets through `yoi panel`, Ticket tools, the `yoi ticket ...` CLI, and typed role surfaces. Git history plus Ticket files remain the authoritative state-transition record behind those interfaces.
|
||||||
|
|
||||||
The current local backend stores each Ticket in the flat `.yoi/tickets/<ticket-id>/` layout. The directory name is the canonical opaque Ticket id: a fixed-width Crockford base32 Unix epoch millisecond timestamp. Slugs and frontmatter `id`/`slug` fields are not current-state authority. That storage detail matters for maintainers and backend compatibility, but it is not the primary user-facing workflow.
|
The current local backend stores each Ticket in the flat `.yoi/tickets/<ticket-id>/` layout. The directory name is the canonical opaque Ticket id: a fixed-width Crockford base32 Unix epoch millisecond timestamp. Slugs and frontmatter `id`/`slug` fields are not current-state authority. That storage detail matters for maintainers and backend compatibility, but it is not the primary user-facing workflow.
|
||||||
|
|
||||||
|
|
@ -25,7 +25,7 @@ Use the highest-level interface that matches the work:
|
||||||
- Use `yoi panel` for the Ticket/Intake/Orchestrator workspace Dashboard and role-launch actions.
|
- Use `yoi panel` for the Ticket/Intake/Orchestrator workspace Dashboard and role-launch actions.
|
||||||
- Use `yoi objective ...` for lightweight medium-term Objective records and their non-blocking canonical Ticket links.
|
- Use `yoi objective ...` for lightweight medium-term Objective records and their non-blocking canonical Ticket links.
|
||||||
- Inside Workers, use typed Ticket tools to create, inspect, comment, review, and close Tickets.
|
- Inside Workers, use typed Ticket tools to create, inspect, comment, review, and close Tickets.
|
||||||
- For multi-step work, follow the Ticket Intake, Orchestrator Routing, planning/requirements-sync, and Multi-agent workflows.
|
- For multi-step work, follow the typed Ticket role surfaces and recorded Ticket lifecycle gates.
|
||||||
|
|
||||||
Maintainers can inspect the local `.yoi/tickets/` files directly when debugging storage, but normal user instructions should go through `yoi panel`, Ticket tools, or `yoi ticket ...`.
|
Maintainers can inspect the local `.yoi/tickets/` files directly when debugging storage, but normal user instructions should go through `yoi panel`, Ticket tools, or `yoi ticket ...`.
|
||||||
|
|
||||||
|
|
@ -55,7 +55,7 @@ Use them when a Worker needs to materialize or update project records:
|
||||||
- Reviewer records approve/request-changes review results.
|
- Reviewer records approve/request-changes review results.
|
||||||
- Maintainer closes a Ticket with a resolution when merge/validation/cleanup evidence is complete.
|
- Maintainer closes a Ticket with a resolution when merge/validation/cleanup evidence is complete.
|
||||||
|
|
||||||
Do not bypass workflow gates just because Ticket tools are available. Ticket mutation is a project-record operation and should remain auditable.
|
Do not bypass Ticket lifecycle gates just because Ticket tools are available. Ticket mutation is a project-record operation and should remain auditable.
|
||||||
|
|
||||||
## Objective records
|
## Objective records
|
||||||
|
|
||||||
|
|
@ -122,22 +122,18 @@ root = ".yoi/tickets"
|
||||||
[ticket.roles.intake]
|
[ticket.roles.intake]
|
||||||
profile = "project:intake"
|
profile = "project:intake"
|
||||||
launch_prompt = "$workspace/ticket/intake/launch"
|
launch_prompt = "$workspace/ticket/intake/launch"
|
||||||
workflow = "ticket-intake-workflow"
|
|
||||||
|
|
||||||
[ticket.roles.orchestrator]
|
[ticket.roles.orchestrator]
|
||||||
profile = "project:orchestrator"
|
profile = "project:orchestrator"
|
||||||
launch_prompt = "$workspace/ticket/orchestrator/launch"
|
launch_prompt = "$workspace/ticket/orchestrator/launch"
|
||||||
workflow = "ticket-orchestrator-routing"
|
|
||||||
|
|
||||||
[ticket.roles.coder]
|
[ticket.roles.coder]
|
||||||
profile = "project:coder"
|
profile = "project:coder"
|
||||||
launch_prompt = "$workspace/ticket/coder/launch"
|
launch_prompt = "$workspace/ticket/coder/launch"
|
||||||
workflow = "multi-agent-workflow"
|
|
||||||
|
|
||||||
[ticket.roles.reviewer]
|
[ticket.roles.reviewer]
|
||||||
profile = "project:reviewer"
|
profile = "project:reviewer"
|
||||||
launch_prompt = "$workspace/ticket/reviewer/launch"
|
launch_prompt = "$workspace/ticket/reviewer/launch"
|
||||||
workflow = "multi-agent-workflow"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Fixed roles are:
|
Fixed roles are:
|
||||||
|
|
@ -155,7 +151,7 @@ when a spike is useful, let the Orchestrator create an ordinary task-specific re
|
||||||
|
|
||||||
`launch_prompt` is a per-action first-run prompt reference for future prompt resolution. Current launcher behavior exposes the ref but does not treat it as system instruction.
|
`launch_prompt` is a per-action first-run prompt reference for future prompt resolution. Current launcher behavior exposes the ref but does not treat it as system instruction.
|
||||||
|
|
||||||
`workflow` is the workflow the launched role should follow. State and phase-specific prompt injection are future work; any dynamic prompt content must be committed as history before it affects model context.
|
Role launch prompts are plain history input. State and phase-specific prompt injection are future work; any dynamic prompt content must be committed as history before it affects model context.
|
||||||
|
|
||||||
`provider = "builtin:yoi_local"` selects Yoi's built-in local Ticket backend. `root = ".yoi/tickets"` is the canonical local storage root for this repository. Legacy `kind = "local"` is accepted only as a short transitional alias; new configs should use `provider`.
|
`provider = "builtin:yoi_local"` selects Yoi's built-in local Ticket backend. `root = ".yoi/tickets"` is the canonical local storage root for this repository. Legacy `kind = "local"` is accepted only as a short transitional alias; new configs should use `provider`.
|
||||||
|
|
||||||
|
|
@ -165,15 +161,10 @@ If `.yoi/workspace.toml` has no `[ticket]` table and no legacy fallback file exi
|
||||||
- backend root: `<workspace>/.yoi/tickets`
|
- backend root: `<workspace>/.yoi/tickets`
|
||||||
- all role profiles: `inherit`
|
- all role profiles: `inherit`
|
||||||
- no launch prompt refs
|
- no launch prompt refs
|
||||||
- workflows:
|
|
||||||
- intake: `ticket-intake-workflow`
|
|
||||||
- orchestrator: `ticket-orchestrator-routing`
|
|
||||||
- coder: `multi-agent-workflow`
|
|
||||||
- reviewer: `multi-agent-workflow`
|
|
||||||
|
|
||||||
Important: top-level Ticket role launches cannot execute `profile = "inherit"` because top-level launch has no parent Profile to inherit from. Configure concrete role profiles in `.yoi/workspace.toml` under `[ticket.roles.*]` before using `yoi panel` role-launch actions.
|
Important: top-level Ticket role launches cannot execute `profile = "inherit"` because top-level launch has no parent Profile to inherit from. Configure concrete role profiles in `.yoi/workspace.toml` under `[ticket.roles.*]` before using `yoi panel` role-launch actions.
|
||||||
|
|
||||||
## Workflow lifecycle
|
## Ticket lifecycle
|
||||||
|
|
||||||
Ticket-driven development normally moves through these gates:
|
Ticket-driven development normally moves through these gates:
|
||||||
|
|
||||||
|
|
@ -189,7 +180,7 @@ Each gate records its decision or evidence in the Ticket thread or artifacts.
|
||||||
|
|
||||||
### 1. Intake
|
### 1. Intake
|
||||||
|
|
||||||
Use `ticket-intake-workflow` when a user request is broad, ambiguous, or not yet a Ticket.
|
Use the Intake role launch prompt when a user request is broad, ambiguous, or not yet a Ticket.
|
||||||
|
|
||||||
Intake should:
|
Intake should:
|
||||||
|
|
||||||
|
|
@ -202,7 +193,7 @@ Intake should not schedule implementation, spawn coder/reviewer Workers, create
|
||||||
|
|
||||||
### 2. Orchestrator routing
|
### 2. Orchestrator routing
|
||||||
|
|
||||||
Use `ticket-orchestrator-routing` to classify the next action for an existing Ticket.
|
Use the Orchestrator role launch prompt to classify the next action for an existing Ticket.
|
||||||
|
|
||||||
Routing classifications include:
|
Routing classifications include:
|
||||||
|
|
||||||
|
|
@ -219,7 +210,7 @@ Routing decisions should be recorded with `TicketComment` using `plan` or `decis
|
||||||
|
|
||||||
### 3. Planning/requirements sync
|
### 3. Planning/requirements sync
|
||||||
|
|
||||||
Use `ticket-preflight-workflow` only as a legacy-compatible planning/requirements sync entry. Return `ready` or `queued` Tickets to `planning` only when the Orchestrator can name a concrete missing decision or information item after bounded project-context checks; risk flags and risky domains are context-lookup and reviewer-focus signals, not automatic stop gates.
|
Use planning/requirements sync only as a bounded Ticket refinement step. Return `ready` or `queued` Tickets to `planning` only when the Orchestrator can name a concrete missing decision or information item after bounded project-context checks; risk flags and risky domains are context-lookup and reviewer-focus signals, not automatic stop gates.
|
||||||
|
|
||||||
Planning sync should resolve or record:
|
Planning sync should resolve or record:
|
||||||
|
|
||||||
|
|
@ -233,7 +224,7 @@ Do not send Tickets with unresolved concrete missing decisions/information direc
|
||||||
|
|
||||||
### 4. Implementation assignment
|
### 4. Implementation assignment
|
||||||
|
|
||||||
Use `multi-agent-workflow` for implementation-ready Tickets.
|
Use the Coder and Reviewer role launch prompts for implementation-ready Tickets.
|
||||||
|
|
||||||
The Orchestrator should prepare an `IntentPacket` with:
|
The Orchestrator should prepare an `IntentPacket` with:
|
||||||
|
|
||||||
|
|
@ -292,19 +283,18 @@ The role-launch path is:
|
||||||
User triggers a Ticket action in yoi panel
|
User triggers a Ticket action in yoi panel
|
||||||
-> Dashboard builds a TicketRoleLaunchContext
|
-> Dashboard builds a TicketRoleLaunchContext
|
||||||
-> client Ticket role launcher reads .yoi/workspace.toml [ticket] settings
|
-> client Ticket role launcher reads .yoi/workspace.toml [ticket] settings
|
||||||
-> launcher selects the role Profile and workflow
|
-> launcher selects the role Profile
|
||||||
-> launcher spawns the role Worker
|
-> launcher spawns the role Worker
|
||||||
-> launcher sends Method::Run with WorkflowInvoke + Text segments
|
-> launcher sends Method::Run with Text segments
|
||||||
-> launcher waits for run-acceptance evidence
|
-> launcher waits for run-acceptance evidence
|
||||||
-> Dashboard reports success/failure
|
-> Dashboard reports success/failure
|
||||||
```
|
```
|
||||||
|
|
||||||
The launched Worker receives dynamic Ticket/action context as its first committed run input. The Dashboard does not inject hidden context, does not write Ticket files directly, and does not construct prompt/workflow segments by hand.
|
The launched Worker receives dynamic Ticket/action context as its first committed run input. The Dashboard does not inject hidden context, does not write Ticket files directly, and does not construct prompt segments by hand.
|
||||||
|
|
||||||
The first run input contains:
|
The first run input contains:
|
||||||
|
|
||||||
- the selected fixed role;
|
- the selected fixed role;
|
||||||
- the workflow canonical id from workspace `[ticket.roles.<role>]` settings;
|
|
||||||
- Ticket id when the action targets an existing Ticket;
|
- Ticket id when the action targets an existing Ticket;
|
||||||
- freeform user instruction/context from the action;
|
- freeform user instruction/context from the action;
|
||||||
- configured `launch_prompt` reference if present, as an unresolved reference for future prompt resolution.
|
- configured `launch_prompt` reference if present, as an unresolved reference for future prompt resolution.
|
||||||
|
|
@ -324,19 +314,15 @@ root = ".yoi/tickets"
|
||||||
|
|
||||||
[ticket.roles.intake]
|
[ticket.roles.intake]
|
||||||
profile = "project:intake"
|
profile = "project:intake"
|
||||||
workflow = "ticket-intake-workflow"
|
|
||||||
|
|
||||||
[ticket.roles.orchestrator]
|
[ticket.roles.orchestrator]
|
||||||
profile = "project:orchestrator"
|
profile = "project:orchestrator"
|
||||||
workflow = "ticket-orchestrator-routing"
|
|
||||||
|
|
||||||
[ticket.roles.coder]
|
[ticket.roles.coder]
|
||||||
profile = "project:coder"
|
profile = "project:coder"
|
||||||
workflow = "multi-agent-workflow"
|
|
||||||
|
|
||||||
[ticket.roles.reviewer]
|
[ticket.roles.reviewer]
|
||||||
profile = "project:reviewer"
|
profile = "project:reviewer"
|
||||||
workflow = "multi-agent-workflow"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If a role still uses `profile = "inherit"`, the Dashboard fails closed with a diagnostic explaining that a concrete profile is required.
|
If a role still uses `profile = "inherit"`, the Dashboard fails closed with a diagnostic explaining that a concrete profile is required.
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
# Workflows and orchestration
|
|
||||||
|
|
||||||
Yoi development uses workflows to make multi-step agent work repeatable without hiding authority in chat state.
|
|
||||||
|
|
||||||
Project-authored workflows live under `.yoi/workflow/`. Generated memory lives under `.yoi/memory/`; the two should not be mixed.
|
|
||||||
|
|
||||||
## Workflow role
|
|
||||||
|
|
||||||
A workflow should define how to coordinate work. It should not become a private implementation branch, an unreviewed design decision, or a replacement for Tickets.
|
|
||||||
|
|
||||||
Current workflow themes include:
|
|
||||||
|
|
||||||
- Intake clarification before materializing user requests as Tickets
|
|
||||||
- Orchestrator routing from Tickets to the next workflow/action
|
|
||||||
- planning/requirements synchronization when concrete missing decisions or information block routing
|
|
||||||
- worktree setup and cleanup
|
|
||||||
- sibling coder/reviewer Worker orchestration
|
|
||||||
- human-gated maintenance and merge readiness
|
|
||||||
|
|
||||||
## Child Workers
|
|
||||||
|
|
||||||
Spawned Workers are useful for scoped implementation, review, or exploration. They are not independent project authorities.
|
|
||||||
|
|
||||||
A parent/orchestrator must verify:
|
|
||||||
|
|
||||||
- child output via `ReadWorkerOutput`
|
|
||||||
- live/restorable state via Worker tools when relevant
|
|
||||||
- worktree state and diff
|
|
||||||
- validation command output
|
|
||||||
- Ticket requirements and acceptance criteria
|
|
||||||
|
|
||||||
Notifications are hints to inspect state. They are not proof of completion.
|
|
||||||
|
|
||||||
## Merge and close responsibility
|
|
||||||
|
|
||||||
Unless explicitly authorized otherwise, final merge, cleanup, design-boundary decisions, and ticket closure remain the orchestrator/human responsibility.
|
|
||||||
|
|
||||||
Child Workers may commit in delegated worktrees when the workflow allows it, but the merge-ready dossier should make the final decision auditable from repository records.
|
|
||||||
|
|
||||||
## Public and dogfood workflow split
|
|
||||||
|
|
||||||
Public builtin workflow candidates and this repository's dogfood-specific workflow mechanics are tracked in `docs/design/workflows-public-dogfood-split.md`.
|
|
||||||
|
|
@ -28,5 +28,4 @@
|
||||||
- [ticket](ticket.md) — # テスト妥当性レビュー: ticket - 評価: 概ね良い
|
- [ticket](ticket.md) — # テスト妥当性レビュー: ticket - 評価: 概ね良い
|
||||||
- [tools](tools.md) — # テスト妥当性レビュー: tools - 評価: 混在
|
- [tools](tools.md) — # テスト妥当性レビュー: tools - 評価: 混在
|
||||||
- [tui](tui.md) — # テスト妥当性レビュー: tui - 評価: 混在
|
- [tui](tui.md) — # テスト妥当性レビュー: tui - 評価: 混在
|
||||||
- [workflow](workflow.md) — # テスト妥当性レビュー: workflow
|
|
||||||
- [yoi](yoi.md) — # テスト妥当性レビュー: yoi - 評価: 混在
|
- [yoi](yoi.md) — # テスト妥当性レビュー: yoi - 評価: 混在
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
- `inherit` profile は top-level launches で拒否される;
|
- `inherit` profile は top-level launches で拒否される;
|
||||||
- 解決不能な profile selectors は spawn 前に失敗する;
|
- 解決不能な profile selectors は spawn 前に失敗する;
|
||||||
- concrete role config と scaffold config が launch plans を生成する;
|
- concrete role config と scaffold config が launch plans を生成する;
|
||||||
- 設定された workflow/profile/launch prompt refs が表面化される;
|
- 設定された profile/launch prompt refs が表面化される;
|
||||||
- spawn config が pod name、profile、role、workspace root、`cwd` 境界を正しく保持する;
|
- spawn config が pod name、profile、role、workspace root、`cwd` 境界を正しく保持する;
|
||||||
- prompt override precedence がテストされている;
|
- prompt override precedence がテストされている;
|
||||||
- role-specific launch prompts が intake、orchestrator、coder、reviewer 向けの重要な運用ガイダンスを含む。
|
- role-specific launch prompts が intake、orchestrator、coder、reviewer 向けの重要な運用ガイダンスを含む。
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
# テスト妥当性レビュー: protocol
|
|
||||||
|
|
||||||
- 評価: 概ね良い
|
|
||||||
|
|
||||||
## 確認範囲
|
|
||||||
|
|
||||||
- 対象は `crates/protocol` と解釈した。
|
|
||||||
- 読んだ範囲:
|
|
||||||
- `crates/protocol/README.md`
|
|
||||||
- `crates/protocol/Cargo.toml`
|
|
||||||
- `crates/protocol/src/lib.rs`
|
|
||||||
- `crates/protocol/src/stream.rs`
|
|
||||||
- 実行した検証:
|
|
||||||
- `cargo test -p protocol` — pass。unit tests 38 件、doc tests 0 件。
|
|
||||||
|
|
||||||
## 現在のテストがよくカバーしていること
|
|
||||||
|
|
||||||
`protocol` crate の主責務である「Pod client/server 間の wire protocol 型」をかなり広く検証できている。特に以下は妥当。
|
|
||||||
|
|
||||||
- `Method` / `Event` の serde tag 形式を、`serde_json::Value` または固定 JSON で確認している。
|
|
||||||
- `Method::Run` と `Segment::{Text, Paste}` の roundtrip がある。
|
|
||||||
- 未知の `Segment` kind が `Segment::Unknown` に落ちる forward compatibility 要件を直接テストしている。
|
|
||||||
- `Notify`, `PodEvent`, completion, snapshot, status, compaction, tool result, LLM retry/continuation など、最近の運用上重要そうな wire shape が個別に押さえられている。
|
|
||||||
- `Snapshot` の legacy default (`status`, `context_window`, `context_tokens`) を確認しており、互換性テストとして価値がある。
|
|
||||||
- `PodEvent::should_notify_agent()` のような単なる serde ではない分類 invariant もテストされている。
|
|
||||||
|
|
||||||
この crate はロジックが薄く、ほぼ protocol schema の authority なので、現在のテストの中心が serde shape / roundtrip になっていること自体は適切。
|
|
||||||
|
|
||||||
## 不足 / 疑問のあるテスト
|
|
||||||
|
|
||||||
- `src/stream.rs` の `JsonLineReader` / `JsonLineWriter` にテストがない。JSONL reader/writer はこの crate の README 上の「JSONL message boundary」に近い責務なので、空行 skip、EOF、invalid JSON の `InvalidData`、writer の newline/flush 相当は最低限押さえたい。
|
|
||||||
- `Method` / `Event` の variant coverage は完全ではない。
|
|
||||||
- 未確認の例: `Method::Cancel`, `ListRewindTargets`, `RewindTo`, `Shutdown`; `Event::TextDone`, `ToolCallStart`, `ToolCallArgsDelta`, `ToolCallDone`, `Usage`, `Shutdown`, `MemoryWorker`, `RewindTargets`, `RewindApplied` など。
|
|
||||||
- protocol crate は variant 追加・rename の影響が大きいため、未テスト variant が増えると wire contract の退行を見逃しやすい。
|
|
||||||
- `Segment::flatten_to_text()` と `Method::run_text()` が未テスト。特に `flatten_to_text()` は sigil 復元・貼り付け content・unknown placeholder という明確な仕様を持つので、serde よりも壊れやすい実装ロジックとしてテスト価値が高い。
|
|
||||||
- `ScopeRule` / `Permission` の wire shape と `recursive` default は `ScopeSubDelegated` 経由で一部触れているが、`recursive` 省略時の default や `Permission` の lowercase serde は直接確認されていない。
|
|
||||||
- 一部テストは roundtrip のみで、期待 JSON 名や payload 値の確認が弱い。
|
|
||||||
- 例: `pod_discovery_methods_roundtrip()` は `RestorePod { name }` / `RegisterPeer { name }` の値を assert していない。
|
|
||||||
- roundtrip だけだと、同一型内で serialize/deserialize が揃って壊れた場合に検出できないことがある。
|
|
||||||
- 固定 JSON 文字列との完全一致テストが一部ある。wire format の固定には有効だが、serde の field order に依存するため、意図しない脆さもある。重要な canonical shape だけに絞り、他は `serde_json::Value` で意味を assert する現在の混合方針は概ね妥当。
|
|
||||||
|
|
||||||
## 追加提案
|
|
||||||
|
|
||||||
- `stream.rs` に async unit tests を追加する。
|
|
||||||
- writer が JSON + `\n` を出す。
|
|
||||||
- reader が空行を skip して次の JSON を読む。
|
|
||||||
- EOF で `Ok(None)`。
|
|
||||||
- invalid JSON が `io::ErrorKind::InvalidData` になる。
|
|
||||||
- writer → reader の小さな end-to-end roundtrip。
|
|
||||||
- `Segment::flatten_to_text()` の仕様テストを追加する。
|
|
||||||
- `Text`, `Paste`, `FileRef`, `KnowledgeRef`, `WorkflowInvoke`, `Unknown` を混ぜた順序保持。
|
|
||||||
- `@`, `#`, `/` の sigil 復元。
|
|
||||||
- 未カバーの control/event variant に、少なくとも代表的な pinned wire-shape テストを足す。
|
|
||||||
- `Cancel`, `Shutdown`, `RewindTo`, `ListRewindTargets`
|
|
||||||
- `ToolCall*`, `Usage`, `TextDone`, `RewindTargets`, `RewindApplied`, `MemoryWorker`
|
|
||||||
- roundtrip だけのテストは、重要 payload を assert する。
|
|
||||||
- `RestorePod.name`
|
|
||||||
- `RegisterPeer.name`
|
|
||||||
- `PodsListed` / `PodRestored` / `PeerRegistered` は現在 payload equality があるので良い。
|
|
||||||
- `ScopeRule` の default と permission rename を単体で確認する。
|
|
||||||
- `{"target": "...", "permission": "read"}` で `recursive == true`
|
|
||||||
- `Permission::Write` が `"write"` になる。
|
|
||||||
|
|
||||||
## 実行したコマンド
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd /home/hare/Projects/yoi && cargo test -p protocol
|
|
||||||
```
|
|
||||||
|
|
||||||
結果: pass。38 unit tests passed、0 failed。doc tests は 0 件。
|
|
||||||
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
# テスト妥当性レビュー: workflow
|
|
||||||
- 判定: 概ね良い
|
|
||||||
|
|
||||||
## 確認範囲
|
|
||||||
- Crate: `crates/workflow`
|
|
||||||
- 確認した責務:
|
|
||||||
- Workflow フロントマターの分割と schema defaults
|
|
||||||
- 人間編集向け Workflow linter
|
|
||||||
- builtin resources と `.yoi/workflow` からの Workflow discovery/loading
|
|
||||||
- `SKILL.md` parsing と Skill-to-Workflow projection
|
|
||||||
- Workflow registry の collision/shadowing behavior
|
|
||||||
- Workflow directory write-deny scope helper
|
|
||||||
- 主に読んだファイル:
|
|
||||||
- `crates/workflow/README.md`
|
|
||||||
- `crates/workflow/Cargo.toml`
|
|
||||||
- `crates/workflow/src/{lib.rs,schema.rs,linter.rs,workflow.rs,skill.rs,scope.rs,error.rs}`
|
|
||||||
- 狭い integration 参照: Workflow invocation resolution 周辺の `crates/pod/src/workflow/mod.rs` tests
|
|
||||||
|
|
||||||
## 現在のテストがよくカバーしていること
|
|
||||||
- この crate には、純粋な parsing/loading 責務に対する堅実な unit-test set がある: `cargo test -p workflow` は 34 unit tests を実行し、すべて成功している。
|
|
||||||
- Workflow loading は妥当にカバーされている:
|
|
||||||
- valid workspace Workflow loading と default flags
|
|
||||||
- `model_invokation` / `user_invocable` behavior
|
|
||||||
- workspace Workflow が slug で builtin Workflow を override すること
|
|
||||||
- 少なくとも 1 つの builtin Workflow の builtin provenance
|
|
||||||
- invalid filename が hard error になること
|
|
||||||
- required `description` の欠落が hard error になること
|
|
||||||
- legacy `.yoi/memory/workflow` が無視されること
|
|
||||||
- resident description cap enforcement
|
|
||||||
- Registry/Skill collision behavior がカバーされている:
|
|
||||||
- collision がない場合の insertion
|
|
||||||
- workspace Workflow が Skill を shadow すること
|
|
||||||
- first-fed Skill が later-fed Skill に勝つこと
|
|
||||||
- human-readable shadow message の smoke check
|
|
||||||
- `SKILL.md` parsing は主要な invariants をよくカバーしている:
|
|
||||||
- minimal valid skill
|
|
||||||
- directory/name mismatch
|
|
||||||
- invalid slug names
|
|
||||||
- empty description
|
|
||||||
- description が cap ちょうど、および cap 超過の場合
|
|
||||||
- missing frontmatter
|
|
||||||
- `allowed-tools` を含む optional spec fields が受け入れられること
|
|
||||||
- scan behavior: missing root、deterministic ordering、broken sibling を skip しつつ good sibling を保持すること
|
|
||||||
- Skill-to-Workflow defaults
|
|
||||||
- Linter tests は現在の重要な linter checks をカバーしている:
|
|
||||||
- required Knowledge が存在する valid file
|
|
||||||
- missing required Knowledge
|
|
||||||
- resident description cap
|
|
||||||
- Workflow body size limit
|
|
||||||
- テストは temporary directories を使い、private implementation details よりも observable API behavior を主に assert しており、この crate には適切である。
|
|
||||||
|
|
||||||
## ギャップ / 疑問のあるテスト
|
|
||||||
- Builtin Workflow coverage はやや弱い。`missing_directory_loads_builtin_registry` は unrelated slug が存在しないことだけを確認しており、`builtin_workflow_records_have_visible_provenance` は `multi-agent-workflow` だけを確認している。別のテストがたまたま触れない限り、1 つの builtin slug が削除または misconfigure される regression を見逃す可能性がある。
|
|
||||||
- Loader と linter の invariants がテスト上で完全には揃っていない。linter は `WORKFLOW_BODY_LIMIT` を enforce するが、`load_workflows` は現在 enforce していない。body size が runtime/load invariant の意図なら loader test がない。意図的に lint-only なら、その境界を明示したままにすべきである。
|
|
||||||
- `requires` validation は linter と後段の `pod` invocation resolution でのみテストされており、`load_workflows` ではテストされていない。これは意図的かもしれないが、crate tests だけでは責務分担が明確ではない。
|
|
||||||
- linter の Knowledge existence check は filename-stem ベースである。テストは malformed Knowledge files、invalid Knowledge filenames、または「一致する `.md` file が存在する」と「valid Knowledge record が存在する」の違いをカバーしていない。意図する invariant が「valid Knowledge record」なら、現在のテストは許容しすぎている。
|
|
||||||
- 複数の hard-error paths が十分にテストされていない:
|
|
||||||
- Workflow files の malformed YAML frontmatter
|
|
||||||
- Workflow files の missing frontmatter
|
|
||||||
- `SKILL.md` の missing `name` / missing `description`
|
|
||||||
- `.yoi/workflow` 配下の non-`.md` files と subdirectories
|
|
||||||
- Workflow description cap と body cap の exact boundary
|
|
||||||
- `skill::tests::invalid_slug_name_is_error` は必要以上に緩い: fixture は uppercase の directory/name が一致しているため、期待される error は `InvalidName` であるべき。`NameDirMismatch` も許容すると、validation ordering や fixture の regression を隠してしまう。
|
|
||||||
- `skill::tests::extra_frontmatter_fields_are_kept` は名前がやや不自然である: parsed `SkillRecord` はそれらの optional fields を保持しない。このテストが実際に assert しているのは「optional/spec-compatible fields are accepted and ignored」であり、それ自体は妥当だが、より直接的に表現すべきである。
|
|
||||||
- crate-level tests は、「workspace Workflows を load し、configured directories から Skills を load し、registry に merge し、shadowed Skills を report する」という full pipeline を exercise していない。隣接する一部 behavior は `pod` でカバーされているが、`workflow` crate 自体では pieces を独立にテストしているだけである。
|
|
||||||
|
|
||||||
## 追加するとよいもの
|
|
||||||
- builtin assertions を強化する:
|
|
||||||
- 期待されるすべての builtin slugs が存在することを assert する
|
|
||||||
- それらの `source`, `path`, `model_invokation`, `user_invocable`, および選択した `requires` values を assert する
|
|
||||||
- builtin resource changes が明確に fail するよう、小さな contract test として維持する
|
|
||||||
- Workflow loader の negative/edge tests を追加する:
|
|
||||||
- missing frontmatter
|
|
||||||
- malformed YAML
|
|
||||||
- `model_invokation: true` のとき description cap ちょうどが accepted になること
|
|
||||||
- non-`.md` files が ignored されること
|
|
||||||
- subdirectories が ignored されること
|
|
||||||
- workspace Workflow が `requires` values を preserve すること
|
|
||||||
- body limit が lint-only なのか load-time でもあるのかをテストで明確にする
|
|
||||||
- linter edge tests を追加する:
|
|
||||||
- body が `WORKFLOW_BODY_LIMIT` ちょうどなら accepted
|
|
||||||
- resident description が cap ちょうどなら accepted
|
|
||||||
- 複数の `requires` がすべての missing references を report すること
|
|
||||||
- invalid `requires` slug が malformed frontmatter または slug parse error になること
|
|
||||||
- valid Knowledge parsing が意図されている場合、malformed Knowledge record behavior
|
|
||||||
- Skill tests を引き締める:
|
|
||||||
- matching invalid directory/name に対して invalid slug test が `SkillParseError::InvalidName` を assert するようにする
|
|
||||||
- missing `name` と missing `description` を追加する
|
|
||||||
- optional-field test を、それらの fields が accepted/ignored されることを表す名前または内容にする
|
|
||||||
- parsed Skills からの registry assembly に対する crate-level integration-style unit test を 1 つ追加する:
|
|
||||||
- builtin/workspace registry を load する
|
|
||||||
- 2 つの Skill directories を parse する
|
|
||||||
- priority order で merge する
|
|
||||||
- accepted Skill、shadowed Skill、および結果の user-invocable/resident entries を assert する
|
|
||||||
|
|
||||||
## 実行したコマンド
|
|
||||||
- `cargo test -p workflow`
|
|
||||||
- 結果: passed
|
|
||||||
- 34 unit tests passed, 0 failed; doc-tests: 0 tests
|
|
||||||
- `cargo test -p workflow -- --list`
|
|
||||||
- 結果: passed
|
|
||||||
- 34 unit tests, 0 benchmarks を確認; doc-tests: 0 tests
|
|
||||||
- `git status --short`
|
|
||||||
- Read-only check; この review の変更外に既存の modified paths があることを示した:
|
|
||||||
- `.yoi/tickets/00001KTVPS6K3/item.md`
|
|
||||||
- `.yoi/tickets/00001KTVPS6K3/thread.md`
|
|
||||||
- `crates/tui/src/multi_pod.rs`
|
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
|
||||||
filter = sourceFilter;
|
filter = sourceFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoHash = "sha256-OTZ0VTCz4mZiQj1Li0COMLLrcYEoSZBTdzJIPqlv70k=";
|
cargoHash = "sha256-Xlaq8/cyMGk3m4z6BNLtSgoRnWqVVnGaLyzgBlr70rk=";
|
||||||
|
|
||||||
depsExtraArgs = {
|
depsExtraArgs = {
|
||||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
---
|
|
||||||
kind: policy
|
|
||||||
description: Public workflow resources are procedural artifacts, not prompt fragments or dogfood policy
|
|
||||||
model_invokation: true
|
|
||||||
user_invocable: true
|
|
||||||
last_sources: []
|
|
||||||
---
|
|
||||||
Builtin workflow resources live under `resources/workflows` and should contain public, product-generic procedure. Project dogfood details such as repository-specific Git worktree, cargo, nix, merge, and cleanup policy belong in workspace workflows or explicit launch context. Workspace workflow records override builtin workflow resources by slug, and provenance should remain visible.
|
|
||||||
|
|
@ -9,4 +9,4 @@ Before treating delegated work as complete, read the child output and inspect co
|
||||||
|
|
||||||
Peer Workers made visible by reciprocal metadata registration are not spawned children. Use peer messaging only as explicit communication; it does not grant scope, produce a child output cursor, imply parent ownership, or create child completion notifications. Peer sends require a live peer and do not auto-restore stopped peers.
|
Peer Workers made visible by reciprocal metadata registration are not spawned children. Use peer messaging only as explicit communication; it does not grant scope, produce a child output cursor, imply parent ownership, or create child completion notifications. Peer sends require a live peer and do not auto-restore stopped peers.
|
||||||
|
|
||||||
This guidance is not scheduler or auto-maintain authorization. Do not start workflows, merge or clean up work, close tickets, or bypass user/workflow authorization solely because Worker tools or notifications exist.
|
This guidance is not scheduler or auto-maintain authorization. Do not start work, merge or clean up work, close tickets, or bypass user/Ticket authorization solely because Worker tools or notifications exist.
|
||||||
|
|
|
||||||
|
|
@ -57,15 +57,6 @@ The following knowledge records are advertised resident.{% if knowledge_query_av
|
||||||
{{ entries }}\
|
{{ entries }}\
|
||||||
"""
|
"""
|
||||||
|
|
||||||
resident_workflows_section = """\
|
|
||||||
---
|
|
||||||
## Resident workflows
|
|
||||||
|
|
||||||
The following workflows are advertised resident. When a user request matches one, follow its procedure as authoritative instead of improvising. User-invocable workflows can additionally be triggered by the user typing /<slug>; you cannot invoke any of them yourself.
|
|
||||||
|
|
||||||
{{ entries }}\
|
|
||||||
"""
|
|
||||||
|
|
||||||
worker_orchestration_guidance_section = "{% include \"$yoi/common/worker-orchestration\" %}"
|
worker_orchestration_guidance_section = "{% include \"$yoi/common/worker-orchestration\" %}"
|
||||||
|
|
||||||
ticket_event_companion_notice = "{% include \"$yoi/worker/ticket_event_companion_notice\" %}"
|
ticket_event_companion_notice = "{% include \"$yoi/worker/ticket_event_companion_notice\" %}"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ The conversation input is a bounded overview/index, not the full transcript. Tre
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. Read the provided overview/index, current TaskStore snapshot, and any Active Workflow Invocation State section.
|
1. Read the provided overview/index and current TaskStore snapshot.
|
||||||
2. If the overview does not contain enough detail, use `search_session_log` to find relevant compact-target history items, then `read_session_items` to inspect only the needed range.
|
2. If the overview does not contain enough detail, use `search_session_log` to find relevant compact-target history items, then `read_session_items` to inspect only the needed range.
|
||||||
3. Use `read_file` to inspect referenced files before deciding what the next session needs. Prefer skimming over blind inclusion.
|
3. Use `read_file` to inspect referenced files before deciding what the next session needs. Prefer skimming over blind inclusion.
|
||||||
4. For files whose current contents are load-bearing for the active work, call `mark_read_required` to inject them into the next session. These count against the auto-read token budget — spend it deliberately.
|
4. For files whose current contents are load-bearing for the active work, call `mark_read_required` to inject them into the next session. These count against the auto-read token budget — spend it deliberately.
|
||||||
|
|
@ -38,7 +38,5 @@ Produce the summary in this exact format:
|
||||||
(2–3 lines on what was happening just before compaction).
|
(2–3 lines on what was happening just before compaction).
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|
||||||
- Preserve active workflow invocation state when present: active slug, invocation scope/source/time, status, open obligations/checkpoints, and snapshotted workflow guidance. Do not replace a snapshotted invocation with merely advertised/latest workflow resources.
|
|
||||||
- Keep code snippets and raw tool output OUT of the summary — that is what auto-read and references are for.
|
- Keep code snippets and raw tool output OUT of the summary — that is what auto-read and references are for.
|
||||||
- Follow the summary target stated in the run input; if asked to shrink, call `write_summary` again with a shorter version.
|
- Follow the summary target stated in the run input; if asked to shrink, call `write_summary` again with a shorter version.
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,3 @@ You are the Ticket Coder role.
|
||||||
Keep role behavior here and treat the first committed user message as concrete Ticket/action context only. Implement only within the delegated worktree/branch and authority scope. Treat the Ticket, intent packet, binding decisions/invariants, implementation latitude, validation expectations, and report expectations as the contract.
|
Keep role behavior here and treat the first committed user message as concrete Ticket/action context only. Implement only within the delegated worktree/branch and authority scope. Treat the Ticket, intent packet, binding decisions/invariants, implementation latitude, validation expectations, and report expectations as the contract.
|
||||||
|
|
||||||
Choose local implementation tactics within that contract. Escalate to the Orchestrator instead of expanding scope when design, permission, dependency, prompt-boundary, or Ticket-boundary questions appear. Do not merge, push, close Tickets, delete worktrees, or create generated memory/local/runtime/log/lock/cache/socket/secret-like `.yoi` state.
|
Choose local implementation tactics within that contract. Escalate to the Orchestrator instead of expanding scope when design, permission, dependency, prompt-boundary, or Ticket-boundary questions appear. Do not merge, push, close Tickets, delete worktrees, or create generated memory/local/runtime/log/lock/cache/socket/secret-like `.yoi` state.
|
||||||
|
|
||||||
When a workflow is invoked, follow that workflow as the procedural authority for coder/reviewer handoff and reporting.
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
You are the Ticket Intake role.
|
You are the Ticket Intake role.
|
||||||
|
|
||||||
Keep role behavior here and treat the first committed user message as concrete Ticket/action context only. Clarify ambiguous user requests and turn agreed work into typed Ticket records, but do not rush from a user claim to `TicketCreate`. Before creating an official Ticket or making a material refinement, pass a minimum investigation gate: check existing Tickets for duplicates/related work, read any targeted Ticket before updating it, and inspect relevant workflow/prompt/docs/code files when the request is ambiguous, claims current behavior, touches authority/scope/history/prompt boundaries, or depends on existing implementation details.
|
Keep role behavior here and treat the first committed user message as concrete Ticket/action context only. Clarify ambiguous user requests and turn agreed work into typed Ticket records, but do not rush from a user claim to `TicketCreate`. Before creating an official Ticket or making a material refinement, pass a minimum investigation gate: check existing Tickets for duplicates/related work, read any targeted Ticket before updating it, and inspect relevant prompt/docs/code files when the request is ambiguous, claims current behavior, touches authority/scope/history/prompt boundaries, or depends on existing implementation details.
|
||||||
|
|
||||||
In drafts and Ticket bodies, separate user claims/request snapshot, confirmed facts with sources, unverified hypotheses, and undecided points/open questions. Do not save all user claims as requirements or acceptance criteria. If the gate cannot be satisfied with available context, stop at a draft and classify the next step as `requirements_sync_needed`, `spike_needed`, or `blocked` instead of creating an official Ticket.
|
In drafts and Ticket bodies, separate user claims/request snapshot, confirmed facts with sources, unverified hypotheses, and undecided points/open questions. Do not save all user claims as requirements or acceptance criteria. If the gate cannot be satisfied with available context, stop at a draft and classify the next step as `requirements_sync_needed`, `spike_needed`, or `blocked` instead of creating an official Ticket.
|
||||||
|
|
||||||
|
|
@ -8,4 +8,4 @@ Create or update Tickets only after user agreement or an explicit user instructi
|
||||||
|
|
||||||
Intake is not a scheduler. Do not spawn coder/reviewer/read-only investigation helper Workers, create implementation worktrees, route implementation/review, merge, close, or perform implementation side effects; leave those to the user/Orchestrator queue flow.
|
Intake is not a scheduler. Do not spawn coder/reviewer/read-only investigation helper Workers, create implementation worktrees, route implementation/review, merge, close, or perform implementation side effects; leave those to the user/Orchestrator queue flow.
|
||||||
|
|
||||||
When a workflow is invoked, follow that workflow as the procedural authority. Do not infer requirements from a Ticket id or title alone; read the relevant Ticket record before updating it.
|
Do not infer requirements from a Ticket id or title alone; read the relevant Ticket record before updating it.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,4 @@ You are the Ticket Orchestrator role.
|
||||||
|
|
||||||
Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. Record `inprogress` before implementation side effects, route concrete work to sibling Coder/Reviewer Workers when appropriate, and stop for human authority when merge/closure is not explicitly delegated.
|
Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. Record `inprogress` before implementation side effects, route concrete work to sibling Coder/Reviewer Workers when appropriate, and stop for human authority when merge/closure is not explicitly delegated.
|
||||||
|
|
||||||
Workspace roots, cwd, profile selector, workflow selector, and launch-prompt configuration are control-plane/environment facts rather than user instructions. If the launch input names explicit Git/worktree operation targets, use those paths only for that operation and do not substitute heuristic roots.
|
Workspace roots, cwd, profile selector, and launch-prompt configuration are control-plane/environment facts rather than user instructions. If the launch input names explicit Git/worktree operation targets, use those paths only for that operation and do not substitute heuristic roots.
|
||||||
|
|
||||||
When a workflow is invoked, follow that workflow as the procedural authority for routing, worktree management, validation, review, merge-ready dossier, and close/cleanup boundaries.
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
---
|
|
||||||
description: worktree と sibling の coder / reviewer Worker を使い、下位 orchestrator が concrete Ticket 群の実装・外部レビュー・修正・完了準備を管理する orchestration フロー
|
|
||||||
model_invokation: true
|
|
||||||
user_invocable: true
|
|
||||||
requires: [workflow-resource-boundary]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Multi-agent workflow
|
|
||||||
|
|
||||||
1. Orchestrator は concrete Ticket intent、binding decisions/invariants、implementation latitude、validation expectations、report expectations、worktree path、branch を Coder に渡す。Coder は delegated child worktree/branch のみを編集し、main/root workspace、merge、push、Ticket close、worktree cleanup、generated `.yoi` runtime/local/memory/log/lock/cache/socket/secret-like state を触らない。
|
|
||||||
2. Coder は local tactics を選んで実装し、変更ファイル・summary・validation・commit/status・unresolved notes・review readiness を報告する。design/permission/history/prompt-context/dependency/Ticket-boundary の未決事項は Orchestrator に escalate する。
|
|
||||||
3. Reviewer は Ticket/intent packet、binding decisions/invariants、acceptance criteria、diff/commits、validation evidence を読んでから判断する。実装を recorded requirements と project design boundary に照らして review し、blocker / non-blocking follow-up / parent-decision item を分類する。
|
|
||||||
4. Reviewer report は approve/request-changes evidence、file/line evidence where useful、validation performed、risks、unresolved concerns を含める。Reviewer は merge、close、push、cleanup、root/original workspace operation、Coder への直接指示を行わない。
|
|
||||||
5. Orchestrator は blocker fix loop、review approval、merge-ready dossier、integration/cleanup/close authority を管理する。下位 Worker の completion notification は transient hint とし、durable output/session/worktree state で確認する。
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
---
|
|
||||||
description: ユーザーの曖昧な依頼を要件同期し、合意済みの Ticket として作成・更新する Intake workflow
|
|
||||||
model_invokation: true
|
|
||||||
user_invocable: true
|
|
||||||
requires: [workflow-resource-boundary]
|
|
||||||
---
|
|
||||||
# Ticket Intake Workflow
|
|
||||||
|
|
||||||
この bundled workflow は reusable な最小 Intake 手順である。Workspace override は dogfooding 固有の Ticket/Objective/split policy 例を追加してよいが、この workflow の調査ゲート、Ticket 作成前の user agreement、Intake の非 scheduler 境界を弱めてはならない。
|
|
||||||
|
|
||||||
Intake の目的は、曖昧な依頼をいきなり実装委譲せず、Orchestrator が routing できる合意済み Ticket または「まだ Ticket 化しない」判断に変換することである。
|
|
||||||
|
|
||||||
## 境界
|
|
||||||
|
|
||||||
Intake は以下をしない。
|
|
||||||
|
|
||||||
- coder / reviewer / read-only investigation helper Worker を起動しない。
|
|
||||||
- implementation worktree を作らない。
|
|
||||||
- implementation / review routing、merge、close、branch cleanup をしない。
|
|
||||||
- unattended scheduler として自動実行しない。
|
|
||||||
- ユーザー合意なしに official Ticket を作らない。
|
|
||||||
|
|
||||||
## Ticket 化前の最小調査ゲート
|
|
||||||
|
|
||||||
`TicketCreate` または material な `TicketComment` の前に、必要最小限の調査を行う。
|
|
||||||
|
|
||||||
必ず行うこと:
|
|
||||||
|
|
||||||
- `TicketList` / `TicketShow` で duplicate / related / blocking-looking work を確認する。
|
|
||||||
- 既存 Ticket を更新する場合は、その Ticket の item/thread を読む。
|
|
||||||
|
|
||||||
次のいずれかに当たる場合は、Ticket 作成前に関連 workflow / prompt / docs / code / config を読む。
|
|
||||||
|
|
||||||
- ユーザー依頼が曖昧、または複数の concrete work item を含む。
|
|
||||||
- 「現在の挙動」「既存仕様」「壊れている」「既にある」など、事実確認を要する claim がある。
|
|
||||||
- scope / permission / history / prompt context / persistence / public API など authority boundary に触れる。
|
|
||||||
- 既存 workflow/resource/file の文言変更や source-of-truth 境界に触れる。
|
|
||||||
|
|
||||||
調査結果は draft で分けて書く。
|
|
||||||
|
|
||||||
- User claims / request snapshot: ユーザーが述べたこと。
|
|
||||||
- Confirmed facts / sources: Intake が読んで確認したことと source。
|
|
||||||
- Unverified hypotheses: ありそうだが未確認の推測。
|
|
||||||
- Undecided points / open questions: ユーザーまたは Orchestrator の判断が必要なこと。
|
|
||||||
|
|
||||||
確認できない claim を requirements / acceptance criteria として保存しない。必要な調査が大きい、current-code map がない、または仕様同期が足りない場合は、official Ticket を作らず draft で止め、readiness を `spike_needed` / `requirements_sync_needed` / `blocked` として報告する。
|
|
||||||
|
|
||||||
## 手順
|
|
||||||
|
|
||||||
1. 依頼を短く言い換え、目的、影響範囲、既決事項、未決定点を分ける。この段階では Ticket を作らない。
|
|
||||||
2. Ticket 化前の最小調査ゲートを実施する。
|
|
||||||
3. 要件を同期する。少なくとも observable な完了条件、受け入れ条件、binding decisions / invariants、implementation latitude、validation、escalation conditions を確認する。
|
|
||||||
4. readiness を分類する。
|
|
||||||
|
|
||||||
```text
|
|
||||||
implementation_ready:
|
|
||||||
- 意図、受け入れ条件、binding decisions / invariants、implementation latitude、reviewer 判断基準、validation が明確。
|
|
||||||
|
|
||||||
requirements_sync_needed:
|
|
||||||
- 目的は見えているが、仕様・用語・UX・責務境界・受け入れ条件が未同期。
|
|
||||||
|
|
||||||
spike_needed:
|
|
||||||
- 技術調査、依存関係、性能、license、diagnostics、現在コード map が先に必要。
|
|
||||||
|
|
||||||
blocked:
|
|
||||||
- 人間判断、外部イベント、別 Ticket の完了が必要。
|
|
||||||
```
|
|
||||||
|
|
||||||
5. 作成前 draft を提示する。
|
|
||||||
|
|
||||||
```text
|
|
||||||
Title:
|
|
||||||
Priority:
|
|
||||||
Readiness:
|
|
||||||
Next Ticket operation: draft_only | create_after_user_agreement | update_existing_after_user_agreement | no_ticket
|
|
||||||
Risk flags:
|
|
||||||
|
|
||||||
User claims / request snapshot:
|
|
||||||
Confirmed facts / sources:
|
|
||||||
Unverified hypotheses:
|
|
||||||
Undecided points / open questions:
|
|
||||||
|
|
||||||
Background:
|
|
||||||
Requirements:
|
|
||||||
Acceptance criteria:
|
|
||||||
Binding decisions / invariants:
|
|
||||||
Implementation latitude:
|
|
||||||
Escalation conditions:
|
|
||||||
Validation:
|
|
||||||
Related tickets/docs/files:
|
|
||||||
```
|
|
||||||
|
|
||||||
6. ユーザーの明示承認、または「作って」「切って」「記録して」など official record 作成の明示指示を待つ。
|
|
||||||
7. 合意後だけ `TicketCreate` / `TicketComment` を使う。canonical ID は storage が割り当てるため draft では提案しない。
|
|
||||||
8. 作成/更新後は id/title、readiness、open questions/risk flags、次の Orchestrator routing 候補を報告して止まる。
|
|
||||||
|
|
||||||
## 推奨 Ticket body
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## User claims / request snapshot
|
|
||||||
|
|
||||||
## Confirmed facts / sources
|
|
||||||
|
|
||||||
## Unverified hypotheses
|
|
||||||
|
|
||||||
## Undecided points / open questions
|
|
||||||
|
|
||||||
## Background
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
## Binding decisions / invariants
|
|
||||||
|
|
||||||
## Implementation latitude
|
|
||||||
|
|
||||||
## Readiness
|
|
||||||
|
|
||||||
- readiness: implementation_ready | requirements_sync_needed | spike_needed | blocked | unspecified
|
|
||||||
- risk_flags: [...]
|
|
||||||
|
|
||||||
## Escalation conditions
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
## Related work
|
|
||||||
```
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
---
|
|
||||||
description: Ticket を読み、Orchestrator が planning return / spike / implementation / review / blocked / close へ明示的に routing する workflow
|
|
||||||
model_invokation: true
|
|
||||||
user_invocable: true
|
|
||||||
requires: [workflow-resource-boundary]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Ticket orchestrator routing workflow
|
|
||||||
|
|
||||||
1. Ticket body/thread/artifacts/resolution 状態を読み、current state と blocking relation を確認する。queued Ticket は実装 side effect 前に `inprogress` を記録する。
|
|
||||||
2. 不足情報が具体的にある場合は planning return とし、何が足りないかを Ticket thread に残す。risk flag は context lookup・review focus・invariant・escalation として扱い、自動 stop gate にしない。
|
|
||||||
3. 実装に進む場合は Orchestrator workspace/orchestration HEAD を基準にし、launch input の explicit Git/worktree operation target だけを使う。role workspace/cwd は環境情報であり、original/root workspace を読み書き・検証・cleanup・git 操作対象にしない。
|
|
||||||
4. child implementation worktree は recorded implementation worktree root 配下に作成し、implementation branch は Orchestrator workspace current HEAD / orchestration branch HEAD から切る。Coder/Reviewer は sibling Workers として route し、Ticket intent・binding decisions・validation/report expectations を concrete handoff として渡す。
|
|
||||||
5. Reviewer が request-changes した場合は、scope 内で Coder に修正を戻すか、Orchestrator が blocked/escalation を記録する。approve evidence が揃うまで merge-ready とみなさない。
|
|
||||||
6. merge completion authority が無い場合は、Ticket/branch/commit identity、intent/invariant check、implementation summary、coder/reviewer evidence、blocker resolution、validation、risks、dirty state、decision needs を含む merge-ready dossier で停止する。
|
|
||||||
7. 明示 authority と clean reviewer evidence がある場合のみ、merge target workspace への統合、validation、Ticket close/completion processing、関連 Worker 停止、scope reclamation、worktree/branch cleanup、evidence reporting を進める。
|
|
||||||
|
|
@ -6,7 +6,7 @@ export type AlertLevel = "warn" | "error";
|
||||||
|
|
||||||
export type AlertSource = "worker" | "engine" | "compactor" | "agents_md";
|
export type AlertSource = "worker" | "engine" | "compactor" | "agents_md";
|
||||||
|
|
||||||
export type CompletionKind = "file" | "knowledge" | "workflow";
|
export type CompletionKind = "file" | "knowledge";
|
||||||
|
|
||||||
export type WorkerStatus = "idle" | "running" | "paused";
|
export type WorkerStatus = "idle" | "running" | "paused";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ Deno.test("segmentsToText preserves protocol segment semantics", () => {
|
||||||
{ kind: "text", content: "hello" },
|
{ kind: "text", content: "hello" },
|
||||||
{ kind: "file_ref", path: "/tmp/example.md" },
|
{ kind: "file_ref", path: "/tmp/example.md" },
|
||||||
{ kind: "knowledge_ref", slug: "design-note" },
|
{ kind: "knowledge_ref", slug: "design-note" },
|
||||||
{ kind: "workflow_invoke", slug: "ticket-review" },
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert(text.includes("hello"), "text segment should render content");
|
assert(text.includes("hello"), "text segment should render content");
|
||||||
|
|
@ -52,10 +51,6 @@ Deno.test("segmentsToText preserves protocol segment semantics", () => {
|
||||||
text.includes("@knowledge design-note"),
|
text.includes("@knowledge design-note"),
|
||||||
"knowledge ref should render as a knowledge reference",
|
"knowledge ref should render as a knowledge reference",
|
||||||
);
|
);
|
||||||
assert(
|
|
||||||
text.includes("/ticket-review"),
|
|
||||||
"workflow invocation should render as slash command",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("projectConsole projects visible protocol rows", () => {
|
Deno.test("projectConsole projects visible protocol rows", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user