diff --git a/Cargo.lock b/Cargo.lock index abd0d015..f6f1c387 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1016,6 +1016,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1243,6 +1249,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flow" +version = "0.1.0" +dependencies = [ + "decodal", + "pretty_assertions", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", +] + [[package]] name = "fnv" version = "1.0.7" @@ -3001,6 +3019,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -5962,6 +5990,7 @@ dependencies = [ "clap", "client", "dotenv", + "flow", "fs4", "futures", "futures-util", @@ -6005,6 +6034,7 @@ dependencies = [ "axum", "base64 0.22.1", "decodal", + "flow", "futures", "llm-engine", "manifest", @@ -6070,6 +6100,12 @@ dependencies = [ "markup5ever", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoi" version = "0.1.0" @@ -6121,6 +6157,7 @@ dependencies = [ "async-trait", "axum", "chrono", + "flow", "futures", "manifest", "memory", diff --git a/Cargo.toml b/Cargo.toml index a30071dc..b7cec2ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "crates/lint-common", "crates/tools", "crates/fs-operation", + "crates/flow", "crates/workdir", "crates/tui", "crates/memory", @@ -44,6 +45,7 @@ default-members = [ "crates/lint-common", "crates/tools", "crates/fs-operation", + "crates/flow", "crates/workdir", "crates/tui", "crates/memory", diff --git a/crates/flow/Cargo.toml b/crates/flow/Cargo.toml new file mode 100644 index 00000000..728429db --- /dev/null +++ b/crates/flow/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "flow" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +decodal.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +sha2.workspace = true +thiserror.workspace = true + +[dev-dependencies] +pretty_assertions = "1" diff --git a/crates/flow/src/builtin.rs b/crates/flow/src/builtin.rs new file mode 100644 index 00000000..dae7589a --- /dev/null +++ b/crates/flow/src/builtin.rs @@ -0,0 +1,70 @@ +use crate::{CompiledFlowDefinition, FlowCompileError, compile_flow_source}; + +pub const CODER_REVIEW_FLOW_SLUG: &str = "coder-review"; + +const CODER_REVIEW_FLOW_SOURCE: &str = include_str!("../../../resources/flows/coder-review.dcdl"); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BuiltinFlowSource { + pub slug: &'static str, + /// Monotonic resource revision. Increment when built-in semantics change; + /// Runtime also pins the compiled content digest. + pub revision: u64, + pub path: &'static str, + pub content: &'static str, +} + +impl BuiltinFlowSource { + pub fn compile(self) -> Result { + compile_flow_source(self.content) + } +} + +pub fn builtin_flow_source(slug: &str) -> Option { + match slug { + CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource { + slug: CODER_REVIEW_FLOW_SLUG, + revision: 1, + path: "builtin/flows/coder-review.dcdl", + content: CODER_REVIEW_FLOW_SOURCE, + }), + _ => None, + } +} + +pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] { + const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource { + slug: CODER_REVIEW_FLOW_SLUG, + revision: 1, + path: "builtin/flows/coder-review.dcdl", + content: CODER_REVIEW_FLOW_SOURCE, + }]; + SOURCES +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_builtin_flow_compiles_and_matches_catalog_identity() { + assert!(!builtin_flow_sources().is_empty()); + for source in builtin_flow_sources() { + assert!( + source.revision > 0, + "built-in Flow revision must be positive" + ); + let definition = source.compile().unwrap_or_else(|error| { + panic!( + "builtin Flow {} failed to compile: {:?}", + source.slug, error.diagnostics + ) + }); + assert_eq!(definition.name, source.slug); + assert_eq!( + builtin_flow_source(source.slug).map(|item| item.content), + Some(source.content) + ); + } + } +} diff --git a/crates/flow/src/coordinator.rs b/crates/flow/src/coordinator.rs new file mode 100644 index 00000000..aec1ef0b --- /dev/null +++ b/crates/flow/src/coordinator.rs @@ -0,0 +1,1037 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + CANCELLED_STATE_ID, CompiledFlowDefinition, CompiledTransition, StateId, TransitionId, +}; + +const MAX_REASON_BYTES: usize = 16 * 1024; +const MAX_RATIONALE_BYTES: usize = 32 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FlowInstanceStatus { + Active, + Completed, + Cancelled, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowInstance { + pub instance_id: String, + pub definition_id: String, + pub definition_revision: u64, + pub definition_digest: String, + pub current_state: StateId, + pub state_revision: u64, + pub status: FlowInstanceStatus, + pub active_attempt_id: Option, +} + +impl FlowInstance { + pub fn start( + instance_id: impl Into, + definition_id: impl Into, + definition_revision: u64, + definition: &CompiledFlowDefinition, + ) -> Result { + let instance_id = instance_id.into(); + let definition_id = definition_id.into(); + ensure_non_empty("instance_id", &instance_id) + .and_then(|_| ensure_non_empty("definition_id", &definition_id)) + .map_err(FlowTransitionError::InvalidRequest)?; + if definition_revision == 0 { + return Err(FlowTransitionError::InvalidRequest( + "definition_revision must be positive".to_string(), + )); + } + let initial_state = definition.state(&definition.initial).ok_or_else(|| { + FlowTransitionError::Invariant("compiled initial state is missing".to_string()) + })?; + let status = if initial_state.terminal { + FlowInstanceStatus::Completed + } else { + FlowInstanceStatus::Active + }; + Ok(Self { + instance_id, + definition_id, + definition_revision, + definition_digest: definition.content_digest.clone(), + current_state: definition.initial.clone(), + state_revision: 0, + status, + active_attempt_id: None, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowTransitionRequest { + pub attempt_id: String, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowTransitionAttempt { + pub attempt_id: String, + pub instance_id: String, + pub definition_revision: u64, + pub definition_digest: String, + pub checked_state_revision: u64, + pub from_state: StateId, + pub reason: String, + pub transitions: Vec, + pub status: FlowAttemptStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransitionCheckSnapshot { + pub transition_id: TransitionId, + pub target: StateId, + pub condition: String, + pub synthetic: bool, +} + +impl From<&CompiledTransition> for TransitionCheckSnapshot { + fn from(transition: &CompiledTransition) -> Self { + Self { + transition_id: transition.id.clone(), + target: transition.target.clone(), + condition: transition.condition.clone(), + synthetic: transition.synthetic, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FlowAttemptStatus { + Verifying, + Entered, + Rejected, + Cancelled, + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConditionVerdict { + Met, + NotMet, + Indeterminate, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransitionConditionResult { + pub transition_id: TransitionId, + pub verdict: ConditionVerdict, + pub rationale: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum FlowVerifierOutcome { + Completed { + results: Vec, + }, + Cancelled, + Failed { + message: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowTransitionResolution { + pub attempt: FlowTransitionAttempt, + pub entered_state: Option, + pub state_instructions: Option, + pub rejection: Option, + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowTransitionRejection { + pub code: FlowRejectionCode, + pub message: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FlowRejectionCode { + NoConditionMet, + MultipleConditionsMet, + Indeterminate, + InvalidVerifierOutput, + VerifierCancelled, + VerifierFailed, + StaleState, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum FlowEventKind { + TransitionRequested { + attempt_id: String, + state_id: StateId, + state_revision: u64, + reason: String, + transitions: Vec, + }, + TransitionVerifying { + attempt_id: String, + }, + TransitionCheckPassed { + attempt_id: String, + transition_id: TransitionId, + results: Vec, + }, + TransitionCheckRejected { + attempt_id: String, + rejection: FlowTransitionRejection, + results: Vec, + }, + TransitionVerificationCancelled { + attempt_id: String, + }, + TransitionVerificationFailed { + attempt_id: String, + message: String, + }, + StateEntered { + attempt_id: String, + state_id: StateId, + state_revision: u64, + status: FlowInstanceStatus, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowRuntimeEvent { + pub sequence: u64, + pub event: FlowEventKind, +} + +/// Durable Flow authority owned by one Runtime Worker. +/// +/// Workspace authority resolves and revisions the source, but never mutates +/// this value. Runtime persists the complete snapshot with the Worker session +/// and replaces it only after the corresponding session-log write succeeds. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowRuntimeState { + pub definition: CompiledFlowDefinition, + pub instance: FlowInstance, + pub active_attempt: Option, + pub events: Vec, +} + +impl FlowRuntimeState { + pub fn start( + source: &crate::ResolvedFlowSource, + instance_id: impl Into, + ) -> Result<(Self, String), FlowTransitionError> { + if source.content_digest != source.definition.content_digest { + return Err(FlowTransitionError::InvalidRequest( + "resolved Flow source digest does not match compiled definition".to_string(), + )); + } + if source.selector.slug() != source.definition.name { + return Err(FlowTransitionError::InvalidRequest( + "resolved Flow selector does not match compiled definition name".to_string(), + )); + } + let instance = FlowInstance::start( + instance_id, + source.flow_id.clone(), + source.revision, + &source.definition, + )?; + let initial = source + .definition + .state(&source.definition.initial) + .ok_or_else(|| { + FlowTransitionError::Invariant("compiled initial state is missing".to_string()) + })?; + let event = FlowRuntimeEvent { + sequence: 0, + event: FlowEventKind::StateEntered { + attempt_id: String::new(), + state_id: instance.current_state.clone(), + state_revision: instance.state_revision, + status: instance.status, + }, + }; + Ok(( + Self { + definition: source.definition.clone(), + instance, + active_attempt: None, + events: vec![event], + }, + initial.instructions.clone(), + )) + } + + /// Begin a new attempt, or return the persisted active attempt after a + /// Runtime/Worker restore. A recovered attempt is never rewritten. + pub fn begin_or_recover_transition( + &mut self, + attempt_id: impl Into, + reason: impl Into, + ) -> Result { + if let Some(attempt) = &self.active_attempt { + return Ok(attempt.clone()); + } + let request = FlowTransitionRequest { + attempt_id: attempt_id.into(), + reason: reason.into(), + }; + let (attempt, events) = begin_transition(&mut self.instance, &self.definition, request)?; + self.append_events(events)?; + self.active_attempt = Some(attempt.clone()); + Ok(attempt) + } + + pub fn resolve_active_transition( + &mut self, + attempt_id: &str, + outcome: FlowVerifierOutcome, + ) -> Result { + let attempt = self.active_attempt.clone().ok_or_else(|| { + FlowTransitionError::InvalidRequest("Flow has no active transition attempt".to_string()) + })?; + if attempt.attempt_id != attempt_id { + return Err(FlowTransitionError::InvalidRequest( + "attempt is not the active/latest attempt for this Flow instance".to_string(), + )); + } + let resolution = + resolve_transition(&mut self.instance, &self.definition, attempt, outcome)?; + self.append_events(resolution.events.clone())?; + self.active_attempt = None; + Ok(resolution) + } + + fn append_events(&mut self, events: Vec) -> Result<(), FlowTransitionError> { + for event in events { + let sequence = u64::try_from(self.events.len()).map_err(|_| { + FlowTransitionError::Invariant("Flow event sequence overflowed".to_string()) + })?; + self.events.push(FlowRuntimeEvent { sequence, event }); + } + Ok(()) + } +} + +#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] +pub enum FlowTransitionError { + #[error("Flow instance is not active")] + NotActive, + #[error("another transition attempt is already active")] + AttemptInProgress, + #[error("Flow definition does not match the instance's pinned revision")] + DefinitionMismatch, + #[error("invalid transition request: {0}")] + InvalidRequest(String), + #[error("Flow invariant failed: {0}")] + Invariant(String), +} + +pub fn begin_transition( + instance: &mut FlowInstance, + definition: &CompiledFlowDefinition, + request: FlowTransitionRequest, +) -> Result<(FlowTransitionAttempt, Vec), FlowTransitionError> { + if instance.status != FlowInstanceStatus::Active { + return Err(FlowTransitionError::NotActive); + } + if instance.active_attempt_id.is_some() { + return Err(FlowTransitionError::AttemptInProgress); + } + if definition.content_digest != instance.definition_digest { + return Err(FlowTransitionError::DefinitionMismatch); + } + ensure_non_empty("attempt_id", &request.attempt_id) + .map_err(FlowTransitionError::InvalidRequest)?; + validate_reason(&request.reason)?; + let state = definition.state(&instance.current_state).ok_or_else(|| { + FlowTransitionError::Invariant(format!( + "current state {:?} is missing from the pinned definition", + instance.current_state + )) + })?; + if state.terminal || state.transitions.is_empty() { + return Err(FlowTransitionError::Invariant( + "active instance points at a state without transitions".to_string(), + )); + } + + let transitions = state + .transitions + .iter() + .map(TransitionCheckSnapshot::from) + .collect::>(); + let attempt = FlowTransitionAttempt { + attempt_id: request.attempt_id, + instance_id: instance.instance_id.clone(), + definition_revision: instance.definition_revision, + definition_digest: instance.definition_digest.clone(), + checked_state_revision: instance.state_revision, + from_state: instance.current_state.clone(), + reason: request.reason, + transitions, + status: FlowAttemptStatus::Verifying, + }; + instance.active_attempt_id = Some(attempt.attempt_id.clone()); + let events = vec![ + FlowEventKind::TransitionRequested { + attempt_id: attempt.attempt_id.clone(), + state_id: attempt.from_state.clone(), + state_revision: attempt.checked_state_revision, + reason: attempt.reason.clone(), + transitions: attempt.transitions.clone(), + }, + FlowEventKind::TransitionVerifying { + attempt_id: attempt.attempt_id.clone(), + }, + ]; + Ok((attempt, events)) +} + +pub fn resolve_transition( + instance: &mut FlowInstance, + definition: &CompiledFlowDefinition, + mut attempt: FlowTransitionAttempt, + outcome: FlowVerifierOutcome, +) -> Result { + if instance.active_attempt_id.as_deref() != Some(attempt.attempt_id.as_str()) { + return Err(FlowTransitionError::InvalidRequest( + "attempt is not the active/latest attempt for this Flow instance".to_string(), + )); + } + if definition.content_digest != instance.definition_digest + || attempt.definition_digest != instance.definition_digest + || attempt.definition_revision != instance.definition_revision + { + return Err(FlowTransitionError::DefinitionMismatch); + } + + let mut events = Vec::new(); + let resolution = match outcome { + FlowVerifierOutcome::Cancelled => { + attempt.status = FlowAttemptStatus::Cancelled; + events.push(FlowEventKind::TransitionVerificationCancelled { + attempt_id: attempt.attempt_id.clone(), + }); + rejected_resolution( + attempt, + FlowRejectionCode::VerifierCancelled, + "Flow verifier was cancelled before producing a complete verdict", + Vec::new(), + events, + ) + } + FlowVerifierOutcome::Failed { message } => { + let message = bounded_message(message); + attempt.status = FlowAttemptStatus::Failed; + events.push(FlowEventKind::TransitionVerificationFailed { + attempt_id: attempt.attempt_id.clone(), + message: message.clone(), + }); + rejected_resolution( + attempt, + FlowRejectionCode::VerifierFailed, + format!("Flow verifier failed: {message}"), + Vec::new(), + events, + ) + } + FlowVerifierOutcome::Completed { results } => { + if instance.state_revision != attempt.checked_state_revision + || instance.current_state != attempt.from_state + { + attempt.status = FlowAttemptStatus::Rejected; + let rejection = FlowTransitionRejection { + code: FlowRejectionCode::StaleState, + message: "Flow state changed after verification began".to_string(), + }; + events.push(FlowEventKind::TransitionCheckRejected { + attempt_id: attempt.attempt_id.clone(), + rejection: rejection.clone(), + results, + }); + FlowTransitionResolution { + attempt, + entered_state: None, + state_instructions: None, + rejection: Some(rejection), + events, + } + } else { + resolve_completed_results(instance, definition, attempt, results, events)? + } + } + }; + instance.active_attempt_id = None; + Ok(resolution) +} + +fn resolve_completed_results( + instance: &mut FlowInstance, + definition: &CompiledFlowDefinition, + mut attempt: FlowTransitionAttempt, + results: Vec, + mut events: Vec, +) -> Result { + let expected = attempt + .transitions + .iter() + .map(|transition| transition.transition_id.clone()) + .collect::>(); + let mut by_id = BTreeMap::new(); + let mut invalid_reason = None; + for result in &results { + if result.rationale.len() > MAX_RATIONALE_BYTES { + invalid_reason = Some(format!( + "rationale for transition {:?} exceeds {MAX_RATIONALE_BYTES} bytes", + result.transition_id + )); + break; + } + if by_id + .insert(result.transition_id.clone(), result.verdict) + .is_some() + { + invalid_reason = Some(format!( + "verifier returned transition {:?} more than once", + result.transition_id + )); + break; + } + } + let actual = by_id.keys().cloned().collect::>(); + if invalid_reason.is_none() && actual != expected { + invalid_reason = Some( + "verifier must return exactly one result for every captured outgoing transition" + .to_string(), + ); + } + if let Some(message) = invalid_reason { + attempt.status = FlowAttemptStatus::Rejected; + let rejection = FlowTransitionRejection { + code: FlowRejectionCode::InvalidVerifierOutput, + message, + }; + events.push(FlowEventKind::TransitionCheckRejected { + attempt_id: attempt.attempt_id.clone(), + rejection: rejection.clone(), + results, + }); + return Ok(FlowTransitionResolution { + attempt, + entered_state: None, + state_instructions: None, + rejection: Some(rejection), + events, + }); + } + + let met = results + .iter() + .filter(|result| result.verdict == ConditionVerdict::Met) + .collect::>(); + let rejection = if met.is_empty() { + let indeterminate = results + .iter() + .any(|result| result.verdict == ConditionVerdict::Indeterminate); + Some(FlowTransitionRejection { + code: if indeterminate { + FlowRejectionCode::Indeterminate + } else { + FlowRejectionCode::NoConditionMet + }, + message: if indeterminate { + "No transition condition was met and at least one condition could not be determined" + .to_string() + } else { + "No outgoing transition condition was met".to_string() + }, + }) + } else if met.len() > 1 { + Some(FlowTransitionRejection { + code: FlowRejectionCode::MultipleConditionsMet, + message: "More than one outgoing transition condition was met".to_string(), + }) + } else { + None + }; + if let Some(rejection) = rejection { + attempt.status = FlowAttemptStatus::Rejected; + events.push(FlowEventKind::TransitionCheckRejected { + attempt_id: attempt.attempt_id.clone(), + rejection: rejection.clone(), + results, + }); + return Ok(FlowTransitionResolution { + attempt, + entered_state: None, + state_instructions: None, + rejection: Some(rejection), + events, + }); + } + + let selected = met[0]; + let transition = attempt + .transitions + .iter() + .find(|transition| transition.transition_id == selected.transition_id) + .ok_or_else(|| { + FlowTransitionError::Invariant( + "selected transition is missing from the captured attempt".to_string(), + ) + })?; + let target_state = definition.state(&transition.target).ok_or_else(|| { + FlowTransitionError::Invariant(format!( + "target state {:?} is missing from the pinned definition", + transition.target + )) + })?; + attempt.status = FlowAttemptStatus::Entered; + events.push(FlowEventKind::TransitionCheckPassed { + attempt_id: attempt.attempt_id.clone(), + transition_id: transition.transition_id.clone(), + results, + }); + instance.current_state = transition.target.clone(); + instance.state_revision = instance.state_revision.checked_add(1).ok_or_else(|| { + FlowTransitionError::Invariant("Flow state revision overflowed".to_string()) + })?; + instance.status = if transition.target.as_str() == CANCELLED_STATE_ID { + FlowInstanceStatus::Cancelled + } else if target_state.terminal { + FlowInstanceStatus::Completed + } else { + FlowInstanceStatus::Active + }; + events.push(FlowEventKind::StateEntered { + attempt_id: attempt.attempt_id.clone(), + state_id: instance.current_state.clone(), + state_revision: instance.state_revision, + status: instance.status, + }); + Ok(FlowTransitionResolution { + attempt, + entered_state: Some(instance.current_state.clone()), + state_instructions: Some(target_state.instructions.clone()), + rejection: None, + events, + }) +} + +fn rejected_resolution( + attempt: FlowTransitionAttempt, + code: FlowRejectionCode, + message: impl Into, + results: Vec, + mut events: Vec, +) -> FlowTransitionResolution { + let rejection = FlowTransitionRejection { + code, + message: message.into(), + }; + events.push(FlowEventKind::TransitionCheckRejected { + attempt_id: attempt.attempt_id.clone(), + rejection: rejection.clone(), + results, + }); + FlowTransitionResolution { + attempt, + entered_state: None, + state_instructions: None, + rejection: Some(rejection), + events, + } +} + +fn validate_reason(reason: &str) -> Result<(), FlowTransitionError> { + if reason.trim().is_empty() { + return Err(FlowTransitionError::InvalidRequest( + "reason must not be empty".to_string(), + )); + } + if reason.len() > MAX_REASON_BYTES { + return Err(FlowTransitionError::InvalidRequest(format!( + "reason exceeds {MAX_REASON_BYTES} bytes" + ))); + } + Ok(()) +} + +fn ensure_non_empty(field: &str, value: &str) -> Result<(), String> { + if value.trim().is_empty() { + Err(format!("{field} must not be empty")) + } else { + Ok(()) + } +} + +fn bounded_message(message: String) -> String { + if message.len() <= MAX_RATIONALE_BYTES { + return message; + } + let mut end = MAX_RATIONALE_BYTES; + while !message.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &message[..end]) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::compile_flow_source; + + fn definition() -> CompiledFlowDefinition { + compile_flow_source( + r#"{ + schema_version = 1; + name = "simple"; + initial = "work"; + states = { + work = { + instructions = "Do the work."; + transitions = { + done = { + target = "done"; + condition = "The requested work and validation are complete."; + }; + }; + }; + done = { instructions = ""; terminal = true; }; + }; + }"#, + ) + .unwrap() + } + + fn begin(instance: &mut FlowInstance) -> FlowTransitionAttempt { + let (attempt, events) = begin_transition( + instance, + &definition(), + FlowTransitionRequest { + attempt_id: "attempt-1".to_string(), + reason: "Implementation and tests are complete.".to_string(), + }, + ) + .unwrap(); + assert!(matches!( + events.as_slice(), + [ + FlowEventKind::TransitionRequested { .. }, + FlowEventKind::TransitionVerifying { .. } + ] + )); + attempt + } + + #[test] + fn exactly_one_met_transition_enters_terminal_state() { + let definition = definition(); + let mut instance = FlowInstance { + instance_id: "instance-1".to_string(), + definition_id: "definition-1".to_string(), + definition_revision: 1, + definition_digest: definition.content_digest.clone(), + current_state: definition.initial.clone(), + state_revision: 0, + status: FlowInstanceStatus::Active, + active_attempt_id: None, + }; + let attempt = begin(&mut instance); + let results = attempt + .transitions + .iter() + .map(|transition| TransitionConditionResult { + transition_id: transition.transition_id.clone(), + verdict: if transition.transition_id.as_str() == "done" { + ConditionVerdict::Met + } else { + ConditionVerdict::NotMet + }, + rationale: "bounded rationale".to_string(), + }) + .collect(); + let resolution = resolve_transition( + &mut instance, + &definition, + attempt, + FlowVerifierOutcome::Completed { results }, + ) + .unwrap(); + assert_eq!(instance.current_state.as_str(), "done"); + assert_eq!(instance.state_revision, 1); + assert_eq!(instance.status, FlowInstanceStatus::Completed); + assert!(resolution.rejection.is_none()); + assert!(matches!( + resolution.events.as_slice(), + [ + FlowEventKind::TransitionCheckPassed { .. }, + FlowEventKind::StateEntered { .. } + ] + )); + } + + #[test] + fn cancellation_requires_synthetic_condition_to_be_met() { + let definition = definition(); + let mut instance = FlowInstance { + instance_id: "instance-1".to_string(), + definition_id: "definition-1".to_string(), + definition_revision: 1, + definition_digest: definition.content_digest.clone(), + current_state: definition.initial.clone(), + state_revision: 0, + status: FlowInstanceStatus::Active, + active_attempt_id: None, + }; + let attempt = begin(&mut instance); + let results = attempt + .transitions + .iter() + .map(|transition| TransitionConditionResult { + transition_id: transition.transition_id.clone(), + verdict: if transition.synthetic { + ConditionVerdict::Met + } else { + ConditionVerdict::NotMet + }, + rationale: "the required authority is unavailable".to_string(), + }) + .collect(); + let resolution = resolve_transition( + &mut instance, + &definition, + attempt, + FlowVerifierOutcome::Completed { results }, + ) + .unwrap(); + assert_eq!(instance.current_state.as_str(), CANCELLED_STATE_ID); + assert_eq!(instance.status, FlowInstanceStatus::Cancelled); + assert!(resolution.rejection.is_none()); + } + + #[test] + fn no_met_condition_keeps_state_unchanged() { + let definition = definition(); + let mut instance = FlowInstance { + instance_id: "instance-1".to_string(), + definition_id: "definition-1".to_string(), + definition_revision: 1, + definition_digest: definition.content_digest.clone(), + current_state: definition.initial.clone(), + state_revision: 0, + status: FlowInstanceStatus::Active, + active_attempt_id: None, + }; + let attempt = begin(&mut instance); + let results = attempt + .transitions + .iter() + .map(|transition| TransitionConditionResult { + transition_id: transition.transition_id.clone(), + verdict: ConditionVerdict::NotMet, + rationale: "not enough evidence".to_string(), + }) + .collect(); + let resolution = resolve_transition( + &mut instance, + &definition, + attempt, + FlowVerifierOutcome::Completed { results }, + ) + .unwrap(); + assert_eq!(instance.current_state.as_str(), "work"); + assert_eq!(instance.state_revision, 0); + assert_eq!(instance.active_attempt_id, None); + assert_eq!( + resolution.rejection.unwrap().code, + FlowRejectionCode::NoConditionMet + ); + } + + #[test] + fn duplicate_or_missing_verdict_is_invalid_output() { + let definition = definition(); + let mut instance = FlowInstance { + instance_id: "instance-1".to_string(), + definition_id: "definition-1".to_string(), + definition_revision: 1, + definition_digest: definition.content_digest.clone(), + current_state: definition.initial.clone(), + state_revision: 0, + status: FlowInstanceStatus::Active, + active_attempt_id: None, + }; + let attempt = begin(&mut instance); + let one_result = vec![TransitionConditionResult { + transition_id: attempt.transitions[0].transition_id.clone(), + verdict: ConditionVerdict::Met, + rationale: "done".to_string(), + }]; + let resolution = resolve_transition( + &mut instance, + &definition, + attempt, + FlowVerifierOutcome::Completed { + results: one_result, + }, + ) + .unwrap(); + assert_eq!( + resolution.rejection.unwrap().code, + FlowRejectionCode::InvalidVerifierOutput + ); + assert_eq!(instance.current_state.as_str(), "work"); + } + + #[test] + fn stale_result_cannot_enter_state() { + let definition = definition(); + let mut instance = FlowInstance { + instance_id: "instance-1".to_string(), + definition_id: "definition-1".to_string(), + definition_revision: 1, + definition_digest: definition.content_digest.clone(), + current_state: definition.initial.clone(), + state_revision: 0, + status: FlowInstanceStatus::Active, + active_attempt_id: None, + }; + let attempt = begin(&mut instance); + instance.state_revision = 1; + let results = attempt + .transitions + .iter() + .map(|transition| TransitionConditionResult { + transition_id: transition.transition_id.clone(), + verdict: ConditionVerdict::NotMet, + rationale: "not met".to_string(), + }) + .collect(); + let resolution = resolve_transition( + &mut instance, + &definition, + attempt, + FlowVerifierOutcome::Completed { results }, + ) + .unwrap(); + assert_eq!( + resolution.rejection.unwrap().code, + FlowRejectionCode::StaleState + ); + } + + #[test] + fn multiple_met_conditions_reject_without_state_entry() { + let definition = definition(); + let mut instance = FlowInstance { + instance_id: "instance-1".to_string(), + definition_id: "definition-1".to_string(), + definition_revision: 1, + definition_digest: definition.content_digest.clone(), + current_state: definition.initial.clone(), + state_revision: 0, + status: FlowInstanceStatus::Active, + active_attempt_id: None, + }; + let attempt = begin(&mut instance); + let results = attempt + .transitions + .iter() + .map(|transition| TransitionConditionResult { + transition_id: transition.transition_id.clone(), + verdict: ConditionVerdict::Met, + rationale: "claimed met".to_string(), + }) + .collect(); + let resolution = resolve_transition( + &mut instance, + &definition, + attempt, + FlowVerifierOutcome::Completed { results }, + ) + .unwrap(); + assert_eq!( + resolution.rejection.unwrap().code, + FlowRejectionCode::MultipleConditionsMet + ); + assert_eq!(instance.current_state.as_str(), "work"); + assert_eq!(instance.state_revision, 0); + assert!( + resolution + .events + .iter() + .all(|event| !matches!(event, FlowEventKind::StateEntered { .. })) + ); + } + + #[test] + fn verifier_cancellation_is_terminal_for_attempt_but_not_flow() { + let definition = definition(); + let mut instance = FlowInstance { + instance_id: "instance-1".to_string(), + definition_id: "definition-1".to_string(), + definition_revision: 1, + definition_digest: definition.content_digest.clone(), + current_state: definition.initial.clone(), + state_revision: 0, + status: FlowInstanceStatus::Active, + active_attempt_id: None, + }; + let attempt = begin(&mut instance); + let resolution = resolve_transition( + &mut instance, + &definition, + attempt, + FlowVerifierOutcome::Cancelled, + ) + .unwrap(); + assert_eq!(resolution.attempt.status, FlowAttemptStatus::Cancelled); + assert_eq!(instance.status, FlowInstanceStatus::Active); + assert_eq!(instance.current_state.as_str(), "work"); + assert_eq!(instance.active_attempt_id, None); + assert!( + resolution + .events + .iter() + .all(|event| !matches!(event, FlowEventKind::StateEntered { .. })) + ); + } + + #[test] + fn overlapping_attempt_is_rejected_before_verifier() { + let definition = definition(); + let mut instance = FlowInstance { + instance_id: "instance-1".to_string(), + definition_id: "definition-1".to_string(), + definition_revision: 1, + definition_digest: definition.content_digest.clone(), + current_state: definition.initial.clone(), + state_revision: 0, + status: FlowInstanceStatus::Active, + active_attempt_id: None, + }; + let _ = begin(&mut instance); + let overlap = begin_transition( + &mut instance, + &definition, + FlowTransitionRequest { + attempt_id: "attempt-2".to_string(), + reason: "retry".to_string(), + }, + ) + .unwrap_err(); + assert_eq!(overlap, FlowTransitionError::AttemptInProgress); + } +} diff --git a/crates/flow/src/definition.rs b/crates/flow/src/definition.rs new file mode 100644 index 00000000..28dd1599 --- /dev/null +++ b/crates/flow/src/definition.rs @@ -0,0 +1,709 @@ +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::fmt; +use std::fmt::Write as _; + +use decodal::{Engine, LoadedSource, SourceLoader}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub const FLOW_SCHEMA_VERSION: u32 = 1; +pub const CANCELLED_STATE_ID: &str = "$cancelled"; +pub const CANCEL_TRANSITION_ID: &str = "$cancel"; +pub const CANCEL_CONDITION: &str = "An exceptional condition makes it impossible to continue the current instructions and reach a normal terminal state with the available tools, scope, and session context."; + +const MAX_SOURCE_BYTES: usize = 256 * 1024; +const MAX_STATES: usize = 128; +const MAX_TRANSITIONS_PER_STATE: usize = 32; +const MAX_TEXT_BYTES: usize = 32 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct StateId(String); + +impl StateId { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_identifier("state", &value)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for StateId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct TransitionId(String); + +impl TransitionId { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_identifier("transition", &value)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for TransitionId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CompiledFlowDefinition { + pub schema_version: u32, + pub name: String, + pub initial: StateId, + pub states: BTreeMap, + pub content_digest: String, +} + +impl CompiledFlowDefinition { + pub fn state(&self, state_id: &StateId) -> Option<&CompiledState> { + self.states.get(state_id) + } + + pub fn outgoing(&self, state_id: &StateId) -> Option<&[CompiledTransition]> { + self.state(state_id) + .map(|state| state.transitions.as_slice()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CompiledState { + pub id: StateId, + pub instructions: String, + pub terminal: bool, + pub transitions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CompiledTransition { + pub id: TransitionId, + pub target: StateId, + pub condition: String, + pub synthetic: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowDiagnostic { + pub code: String, + pub path: String, + pub message: String, +} + +impl FlowDiagnostic { + fn new(code: impl Into, path: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + path: path.into(), + message: message.into(), + } + } +} + +#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] +#[error("Flow definition is invalid")] +pub struct FlowCompileError { + pub diagnostics: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FlowSource { + schema_version: u32, + name: String, + initial: String, + states: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct StateSource { + instructions: String, + #[serde(default)] + terminal: bool, + #[serde(default)] + transitions: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct TransitionSource { + target: String, + condition: String, +} + +pub fn compile_flow_source(content: &str) -> Result { + if content.len() > MAX_SOURCE_BYTES { + return Err(one_diagnostic( + "source_too_large", + "$", + format!("Flow source exceeds {MAX_SOURCE_BYTES} bytes"), + )); + } + + let mut engine = Engine::new(RejectImports); + let module = engine + .add_root_source("flow.dcdl", "flow.dcdl", content) + .map_err(|error| { + one_diagnostic("dcdl_parse", "$", format!("DCDL parsing failed: {error:?}")) + })?; + let value = engine.eval_module(module).map_err(|error| { + one_diagnostic( + "dcdl_evaluation", + "$", + format!("DCDL evaluation failed: {error:?}"), + ) + })?; + let data = engine.materialize(&value).map_err(|error| { + one_diagnostic( + "dcdl_materialization", + "$", + format!("DCDL materialization failed: {error:?}"), + ) + })?; + let value = decodal_data_to_json(&data); + let source = serde_json::from_value::(value).map_err(|error| { + one_diagnostic( + "schema_decode", + "$", + format!("Flow source does not match schema: {error}"), + ) + })?; + compile_typed_source(source, content) +} + +fn compile_typed_source( + source: FlowSource, + content: &str, +) -> Result { + let mut diagnostics = Vec::new(); + if source.schema_version != FLOW_SCHEMA_VERSION { + diagnostics.push(FlowDiagnostic::new( + "unsupported_schema_version", + "schema_version", + format!( + "unsupported Flow schema version {}; expected {FLOW_SCHEMA_VERSION}", + source.schema_version + ), + )); + } + if let Err(message) = validate_identifier("Flow", &source.name) { + diagnostics.push(FlowDiagnostic::new("invalid_name", "name", message)); + } + if source.states.is_empty() { + diagnostics.push(FlowDiagnostic::new( + "states_empty", + "states", + "Flow must declare at least one state", + )); + } + if source.states.len() > MAX_STATES { + diagnostics.push(FlowDiagnostic::new( + "too_many_states", + "states", + format!("Flow declares more than {MAX_STATES} states"), + )); + } + if source.initial == CANCELLED_STATE_ID { + diagnostics.push(FlowDiagnostic::new( + "reserved_state", + "initial", + format!("{CANCELLED_STATE_ID} is reserved by the Flow runtime"), + )); + } + + let initial = StateId::new(source.initial.clone()).unwrap_or_else(|message| { + diagnostics.push(FlowDiagnostic::new("invalid_initial", "initial", message)); + StateId(source.initial.clone()) + }); + + let declared_names = source.states.keys().cloned().collect::>(); + if !declared_names.contains(initial.as_str()) { + diagnostics.push(FlowDiagnostic::new( + "initial_not_found", + "initial", + format!("initial state {initial:?} is not declared"), + )); + } + if declared_names.contains(CANCELLED_STATE_ID) { + diagnostics.push(FlowDiagnostic::new( + "reserved_state", + format!("states.{CANCELLED_STATE_ID}"), + format!("{CANCELLED_STATE_ID} is reserved by the Flow runtime"), + )); + } + + let mut states = BTreeMap::new(); + for (state_name, state_source) in source.states { + let state_path = format!("states.{state_name}"); + let state_id = StateId::new(state_name.clone()).unwrap_or_else(|message| { + diagnostics.push(FlowDiagnostic::new( + "invalid_state_id", + state_path.clone(), + message, + )); + StateId(state_name.clone()) + }); + validate_text( + &mut diagnostics, + "instructions", + &format!("{state_path}.instructions"), + &state_source.instructions, + state_source.terminal, + ); + if state_source.terminal && !state_source.transitions.is_empty() { + diagnostics.push(FlowDiagnostic::new( + "terminal_has_transitions", + format!("{state_path}.transitions"), + "terminal states must not declare outgoing transitions", + )); + } + if !state_source.terminal && state_source.transitions.is_empty() { + diagnostics.push(FlowDiagnostic::new( + "non_terminal_without_transition", + format!("{state_path}.transitions"), + "non-terminal states must declare at least one transition", + )); + } + if state_source.transitions.len() > MAX_TRANSITIONS_PER_STATE { + diagnostics.push(FlowDiagnostic::new( + "too_many_transitions", + format!("{state_path}.transitions"), + format!("state declares more than {MAX_TRANSITIONS_PER_STATE} transitions"), + )); + } + + let mut transitions = Vec::new(); + for (transition_name, transition_source) in state_source.transitions { + let transition_path = format!("{state_path}.transitions.{transition_name}"); + if transition_name == CANCEL_TRANSITION_ID || transition_name.starts_with('$') { + diagnostics.push(FlowDiagnostic::new( + "reserved_transition", + transition_path.clone(), + "transition identifiers beginning with '$' are reserved by the Flow runtime", + )); + } + let transition_id = + TransitionId::new(transition_name.clone()).unwrap_or_else(|message| { + diagnostics.push(FlowDiagnostic::new( + "invalid_transition_id", + transition_path.clone(), + message, + )); + TransitionId(transition_name.clone()) + }); + if transition_source.target == CANCELLED_STATE_ID + || transition_source.target.starts_with('$') + { + diagnostics.push(FlowDiagnostic::new( + "reserved_transition_target", + format!("{transition_path}.target"), + "definition authors cannot target runtime-reserved states", + )); + } + let target = StateId::new(transition_source.target.clone()).unwrap_or_else(|message| { + diagnostics.push(FlowDiagnostic::new( + "invalid_transition_target", + format!("{transition_path}.target"), + message, + )); + StateId(transition_source.target.clone()) + }); + if !declared_names.contains(target.as_str()) { + diagnostics.push(FlowDiagnostic::new( + "transition_target_not_found", + format!("{transition_path}.target"), + format!("transition target {target:?} is not declared"), + )); + } + validate_text( + &mut diagnostics, + "condition", + &format!("{transition_path}.condition"), + &transition_source.condition, + false, + ); + transitions.push(CompiledTransition { + id: transition_id, + target, + condition: transition_source.condition, + synthetic: false, + }); + } + if !state_source.terminal { + transitions.push(CompiledTransition { + id: TransitionId(CANCEL_TRANSITION_ID.to_string()), + target: StateId(CANCELLED_STATE_ID.to_string()), + condition: CANCEL_CONDITION.to_string(), + synthetic: true, + }); + } + states.insert( + state_id.clone(), + CompiledState { + id: state_id, + instructions: state_source.instructions, + terminal: state_source.terminal, + transitions, + }, + ); + } + + if diagnostics.is_empty() { + validate_graph(&states, &initial, &mut diagnostics); + } + if !diagnostics.is_empty() { + return Err(FlowCompileError { diagnostics }); + } + + states.insert( + StateId(CANCELLED_STATE_ID.to_string()), + CompiledState { + id: StateId(CANCELLED_STATE_ID.to_string()), + instructions: String::new(), + terminal: true, + transitions: Vec::new(), + }, + ); + + Ok(CompiledFlowDefinition { + schema_version: source.schema_version, + name: source.name, + initial, + states, + content_digest: content_digest(content), + }) +} + +fn validate_identifier(kind: &str, value: &str) -> Result<(), String> { + if value.is_empty() { + return Err(format!("{kind} identifier must not be empty")); + } + if value.len() > 128 { + return Err(format!("{kind} identifier exceeds 128 bytes")); + } + if value.starts_with('$') { + return Err(format!("{kind} identifier beginning with '$' is reserved")); + } + if !value + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) + { + return Err(format!( + "{kind} identifier must contain only ASCII letters, digits, '-' or '_'" + )); + } + Ok(()) +} + +fn validate_text( + diagnostics: &mut Vec, + field: &str, + path: &str, + value: &str, + allow_empty: bool, +) { + if !allow_empty && value.trim().is_empty() { + diagnostics.push(FlowDiagnostic::new( + format!("{field}_empty"), + path, + format!("{field} must not be empty"), + )); + } + if value.len() > MAX_TEXT_BYTES { + diagnostics.push(FlowDiagnostic::new( + format!("{field}_too_large"), + path, + format!("{field} exceeds {MAX_TEXT_BYTES} bytes"), + )); + } +} + +fn validate_graph( + states: &BTreeMap, + initial: &StateId, + diagnostics: &mut Vec, +) { + if !states.contains_key(initial) { + return; + } + let mut reachable = BTreeSet::new(); + let mut pending = VecDeque::from([initial.clone()]); + while let Some(current) = pending.pop_front() { + if !reachable.insert(current.clone()) { + continue; + } + if let Some(state) = states.get(¤t) { + for transition in state + .transitions + .iter() + .filter(|transition| !transition.synthetic) + { + pending.push_back(transition.target.clone()); + } + } + } + for state_id in states.keys() { + if !reachable.contains(state_id) { + diagnostics.push(FlowDiagnostic::new( + "unreachable_state", + format!("states.{state_id}"), + format!("state {state_id:?} is unreachable from initial state {initial:?}"), + )); + } + } + + let terminal_states = states + .values() + .filter(|state| state.terminal) + .map(|state| state.id.clone()) + .collect::>(); + if terminal_states.is_empty() { + diagnostics.push(FlowDiagnostic::new( + "terminal_missing", + "states", + "Flow must declare at least one terminal state", + )); + return; + } + + let mut reverse: BTreeMap> = BTreeMap::new(); + for state in states.values() { + for transition in state + .transitions + .iter() + .filter(|transition| !transition.synthetic) + { + reverse + .entry(transition.target.clone()) + .or_default() + .push(state.id.clone()); + } + } + let mut can_reach_terminal = terminal_states.clone(); + let mut pending = terminal_states.into_iter().collect::>(); + while let Some(current) = pending.pop_front() { + for predecessor in reverse.get(¤t).into_iter().flatten() { + if can_reach_terminal.insert(predecessor.clone()) { + pending.push_back(predecessor.clone()); + } + } + } + for state_id in reachable { + if !can_reach_terminal.contains(&state_id) { + diagnostics.push(FlowDiagnostic::new( + "terminal_unreachable", + format!("states.{state_id}"), + format!( + "state {state_id:?} cannot reach a user-declared terminal state; the graph contains a closed non-terminal path" + ), + )); + } + } +} + +fn content_digest(content: &str) -> String { + let digest = Sha256::digest(content.as_bytes()); + let mut encoded = String::with_capacity(7 + digest.len() * 2); + encoded.push_str("sha256:"); + for byte in digest { + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + encoded +} + +struct RejectImports; + +impl SourceLoader for RejectImports { + fn load( + &mut self, + _current_key: Option<&str>, + specifier: &str, + ) -> decodal::Result { + Err(decodal::Diagnostic::new( + decodal::DiagnosticKind::Import, + decodal::Span::default(), + format!("Flow source imports are not enabled: {specifier}"), + )) + } +} + +fn decodal_data_to_json(data: &decodal::Data) -> serde_json::Value { + match data { + decodal::Data::Bool(value) => serde_json::Value::Bool(*value), + decodal::Data::Int(value) => serde_json::Value::Number(serde_json::Number::from(*value)), + decodal::Data::Float(value) => serde_json::Number::from_f64(*value) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + decodal::Data::String(value) => serde_json::Value::String(value.clone()), + decodal::Data::Array(values) => { + serde_json::Value::Array(values.iter().map(decodal_data_to_json).collect()) + } + decodal::Data::Object(fields) => serde_json::Value::Object( + fields + .iter() + .map(|field| (field.name.clone(), decodal_data_to_json(&field.value))) + .collect(), + ), + } +} + +fn one_diagnostic( + code: impl Into, + path: impl Into, + message: impl Into, +) -> FlowCompileError { + FlowCompileError { + diagnostics: vec![FlowDiagnostic::new(code, path, message)], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_source() -> &'static str { + r#"{ + schema_version = 1; + name = "coder-review"; + initial = "code"; + states = { + code = { + instructions = "Implement and validate the requested change."; + transitions = { + review = { + target = "review"; + condition = "Implementation and validation evidence are present."; + }; + }; + }; + review = { + instructions = "Read the independent review result."; + transitions = { + done = { + target = "done"; + condition = "The independent reviewer approved the implementation."; + }; + fix = { + target = "fix"; + condition = "The independent reviewer requested concrete changes."; + }; + }; + }; + fix = { + instructions = "Resolve every open review finding and validate the fixes."; + transitions = { + review = { + target = "review"; + condition = "The requested changes are resolved and ready for re-review."; + }; + }; + }; + done = { + instructions = ""; + terminal = true; + }; + }; + }"# + } + + #[test] + fn compiles_definition_and_injects_cancel_transition() { + let definition = compile_flow_source(valid_source()).expect("valid Flow"); + assert_eq!(definition.name, "coder-review"); + assert!(definition.content_digest.starts_with("sha256:")); + let code = definition + .state(&StateId::new("code").unwrap()) + .expect("code state"); + assert_eq!(code.transitions.len(), 2); + assert!( + code.transitions + .iter() + .any(|transition| transition.id.as_str() == CANCEL_TRANSITION_ID + && transition.synthetic) + ); + let cancelled = definition + .state(&StateId(CANCELLED_STATE_ID.to_string())) + .expect("synthetic cancelled state"); + assert!(cancelled.terminal); + assert!(cancelled.instructions.is_empty()); + } + + #[test] + fn rejects_unknown_structural_fields() { + let source = valid_source().replace( + "schema_version = 1;", + "schema_version = 1; unexpected = true;", + ); + let error = compile_flow_source(&source).unwrap_err(); + assert_eq!(error.diagnostics[0].code, "schema_decode"); + assert!(error.diagnostics[0].message.contains("unexpected")); + } + + #[test] + fn rejects_definition_authored_cancel_target() { + let source = valid_source().replace("target = \"review\";", "target = \"$cancelled\";"); + let error = compile_flow_source(&source).unwrap_err(); + assert!( + error + .diagnostics + .iter() + .any(|diagnostic| { diagnostic.code == "reserved_transition_target" }) + ); + } + + #[test] + fn rejects_unreachable_state() { + let source = valid_source().replace( + "done = {\n instructions = \"\";", + "unused = { instructions = \"unused\"; terminal = true; };\n done = {\n instructions = \"\";", + ); + let error = compile_flow_source(&source).unwrap_err(); + assert!( + error + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "unreachable_state") + ); + } + + #[test] + fn rejects_closed_non_terminal_cycle_even_with_synthetic_cancel() { + let source = r#"{ + schema_version = 1; + name = "closed-cycle"; + initial = "a"; + states = { + a = { + instructions = "a"; + transitions = { to_b = { target = "b"; condition = "go b"; }; }; + }; + b = { + instructions = "b"; + transitions = { to_a = { target = "a"; condition = "go a"; }; }; + }; + done = { instructions = ""; terminal = true; }; + }; + }"#; + let error = compile_flow_source(source).unwrap_err(); + assert!( + error + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "terminal_unreachable") + ); + } +} diff --git a/crates/flow/src/lib.rs b/crates/flow/src/lib.rs new file mode 100644 index 00000000..2093c090 --- /dev/null +++ b/crates/flow/src/lib.rs @@ -0,0 +1,15 @@ +//! Declarative Flow definitions and deterministic transition coordination. +//! +//! DCDL is a source format only. The Flow domain owns the typed source +//! schema, semantic validation, immutable transition snapshots, and state +//! transition rules. + +mod builtin; +mod coordinator; +mod definition; +mod selector; + +pub use builtin::*; +pub use coordinator::*; +pub use definition::*; +pub use selector::*; diff --git a/crates/flow/src/selector.rs b/crates/flow/src/selector.rs new file mode 100644 index 00000000..4c0f3161 --- /dev/null +++ b/crates/flow/src/selector.rs @@ -0,0 +1,199 @@ +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FlowSelector { + Builtin { slug: String }, + Workspace { slug: String }, +} + +impl FlowSelector { + pub fn builtin(slug: impl Into) -> Result { + let slug = slug.into(); + validate_slug(&slug)?; + Ok(Self::Builtin { slug }) + } + + pub fn workspace(slug: impl Into) -> Result { + let slug = slug.into(); + validate_slug(&slug)?; + Ok(Self::Workspace { slug }) + } + + pub fn slug(&self) -> &str { + match self { + Self::Builtin { slug } | Self::Workspace { slug } => slug, + } + } + + pub fn source_kind(&self) -> FlowSourceKind { + match self { + Self::Builtin { .. } => FlowSourceKind::Builtin, + Self::Workspace { .. } => FlowSourceKind::Workspace, + } + } +} + +impl fmt::Display for FlowSelector { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Builtin { slug } => write!(formatter, "builtin:{slug}"), + Self::Workspace { slug } => write!(formatter, "workspace:{slug}"), + } + } +} + +impl FromStr for FlowSelector { + type Err = FlowSelectorError; + + fn from_str(value: &str) -> Result { + let (source, slug) = value.split_once(':').ok_or_else(|| { + FlowSelectorError::InvalidFormat( + "Flow selector must be source-qualified as builtin: or workspace:" + .to_string(), + ) + })?; + if slug.contains(':') { + return Err(FlowSelectorError::InvalidFormat( + "Flow selector must contain exactly one ':' separator".to_string(), + )); + } + match source { + "builtin" => Self::builtin(slug), + "workspace" => Self::workspace(slug), + other => Err(FlowSelectorError::UnknownSource(other.to_string())), + } + } +} + +impl Serialize for FlowSelector { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for FlowSelector { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value.parse().map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FlowSourceKind { + Builtin, + Workspace, +} + +impl FlowSourceKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Builtin => "builtin", + Self::Workspace => "workspace", + } + } +} + +impl fmt::Display for FlowSourceKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FlowSourceResolveRequest { + pub selector: FlowSelector, +} + +/// Immutable source snapshot resolved by Workspace authority for one Runtime. +/// +/// This is read-only source authority. Starting or mutating a Flow instance is +/// deliberately not part of this response; Runtime persists the snapshot in +/// the target Worker's durable state before execution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolvedFlowSource { + pub selector: FlowSelector, + pub workspace_id: String, + pub flow_id: String, + pub revision: u64, + pub content_digest: String, + pub definition: crate::CompiledFlowDefinition, +} + +#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] +pub enum FlowSelectorError { + #[error("{0}")] + InvalidFormat(String), + #[error("unknown Flow selector source {0:?}")] + UnknownSource(String), + #[error("invalid Flow selector slug: {0}")] + InvalidSlug(String), +} + +fn validate_slug(slug: &str) -> Result<(), FlowSelectorError> { + if slug.is_empty() { + return Err(FlowSelectorError::InvalidSlug( + "slug must not be empty".to_string(), + )); + } + if slug.len() > 128 { + return Err(FlowSelectorError::InvalidSlug( + "slug exceeds 128 bytes".to_string(), + )); + } + if !slug + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) + { + return Err(FlowSelectorError::InvalidSlug( + "slug must contain only ASCII letters, digits, '-' or '_'".to_string(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selector_requires_explicit_known_source() { + assert_eq!( + "builtin:coder-review".parse::().unwrap(), + FlowSelector::Builtin { + slug: "coder-review".to_string() + } + ); + assert_eq!( + "workspace:coder-review" + .parse::() + .unwrap() + .to_string(), + "workspace:coder-review" + ); + assert!("coder-review".parse::().is_err()); + assert!("project:coder-review".parse::().is_err()); + assert!("builtin:bad/path".parse::().is_err()); + assert!("builtin:a:b".parse::().is_err()); + } + + #[test] + fn selector_serde_is_one_canonical_string() { + let selector = FlowSelector::builtin("coder-review").unwrap(); + let json = serde_json::to_string(&selector).unwrap(); + assert_eq!(json, r#""builtin:coder-review""#); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + selector + ); + } +} diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 3dfbc816..2debdea0 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -85,6 +85,8 @@ pub struct FeatureConfigPartial { #[serde(default)] pub sub_worker: Option, #[serde(default)] + pub flow: Option, + #[serde(default)] pub worker: Option, #[serde(default)] pub objective: Option, @@ -107,6 +109,7 @@ impl FeatureConfigPartial { other.sub_worker, FeatureFlagConfigPartial::merge, ), + flow: merge_option(self.flow, other.flow, FeatureFlagConfigPartial::merge), worker: merge_option(self.worker, other.worker, FeatureFlagConfigPartial::merge), objective: merge_option( self.objective, @@ -190,6 +193,7 @@ impl From for FeatureConfig { .sub_worker .map(FeatureFlagConfig::from) .unwrap_or_default(), + flow: value.flow.map(FeatureFlagConfig::from).unwrap_or_default(), worker: value .worker .map(FeatureFlagConfig::from) @@ -279,6 +283,7 @@ impl From for FeatureConfigPartial { memory: Some(value.memory.into()), web: Some(value.web.into()), sub_worker: Some(value.sub_worker.into()), + flow: Some(value.flow.into()), worker: Some(value.worker.into()), objective: Some(value.objective.into()), manage_workdir: Some(value.manage_workdir.into()), diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 36266fff..27fe4d2b 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -113,6 +113,8 @@ pub struct FeatureConfig { #[serde(default)] pub sub_worker: FeatureFlagConfig, #[serde(default)] + pub flow: FeatureFlagConfig, + #[serde(default)] pub worker: FeatureFlagConfig, #[serde(default)] pub objective: FeatureFlagConfig, @@ -131,6 +133,7 @@ impl Default for FeatureConfig { memory: MemoryFeatureConfig::disabled(), web: FeatureFlagConfig::disabled(), sub_worker: FeatureFlagConfig::disabled(), + flow: FeatureFlagConfig::disabled(), worker: FeatureFlagConfig::disabled(), objective: FeatureFlagConfig::disabled(), manage_workdir: FeatureFlagConfig::disabled(), diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 308cbaad..2770bda2 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -912,7 +912,7 @@ fn builtin_profile_artifact(label: &str) -> Option { true, true, true, - false, + true, ); Some(value) } @@ -999,6 +999,7 @@ fn apply_role_profile( value["feature"]["memory"] = serde_json::json!({ "enabled": memory }); value["feature"]["web"] = serde_json::json!({ "enabled": web }); value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker }); + value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" }); value["feature"]["worker"] = serde_json::json!({ "enabled": matches!(slug, "companion" | "orchestrator") }); value["feature"]["manage_workdir"] = serde_json::json!({ "enabled": slug == "orchestrator" }); @@ -1530,7 +1531,8 @@ mod tests { let coder = resolve("coder"); assert!(coder.feature.task.enabled); - assert!(!coder.feature.sub_worker.enabled); + assert!(coder.feature.sub_worker.enabled); + assert!(coder.feature.flow.enabled); assert!(!coder.feature.worker.enabled); assert!(coder.scope.allow.is_empty()); assert!(coder.delegation_scope.allow.is_empty()); @@ -1548,6 +1550,7 @@ mod tests { let reviewer = resolve("reviewer"); assert!(reviewer.feature.task.enabled); assert!(!reviewer.feature.sub_worker.enabled); + assert!(!reviewer.feature.flow.enabled); assert!(!reviewer.feature.worker.enabled); assert!(reviewer.feature.ticket.enabled); assert!(reviewer.feature.ticket.enabled); diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 5a8e29a0..8e9764ec 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -202,6 +202,11 @@ pub enum Segment { /// `[Dir: ]` listings; the flattened user text keeps the literal /// `@` placeholder either way. FileRef { path: String }, + /// Source-qualified request for the host to start a Flow before committing + /// this user input. Runtime resolves it through Workspace authority and + /// replaces it with the entered state's instructions before Worker-side + /// input resolution. + Flow { selector: String }, /// Unknown variant from a newer client. Worker treats this as an /// unresolved input — surfaces an alert and inserts a placeholder. /// Round-trip is lossy: re-serializing yields `{"kind":"unknown"}`. @@ -236,6 +241,11 @@ impl Segment { out.push('@'); out.push_str(path); } + Segment::Flow { selector } => { + out.push_str("[Flow: "); + out.push_str(selector); + out.push(']'); + } Segment::Unknown => {} } } @@ -900,6 +910,33 @@ mod tests { } } + #[test] + fn method_run_flow_segment_roundtrip() { + let method = Method::Run { + input: vec![ + Segment::Flow { + selector: "builtin:coder-review".to_string(), + }, + Segment::text("Ticket context"), + ], + }; + let json = serde_json::to_string(&method).unwrap(); + assert!(json.contains(r#""kind":"flow""#)); + assert!(json.contains(r#""selector":"builtin:coder-review""#)); + let decoded = serde_json::from_str::(&json).unwrap(); + assert!(matches!( + decoded, + Method::Run { input } + if matches!( + input.as_slice(), + [ + Segment::Flow { selector }, + Segment::Text { content } + ] if selector == "builtin:coder-review" && content == "Ticket context" + ) + )); + } + #[test] fn segment_unknown_variant_decodes_as_unknown() { // A future client sends a segment kind this Worker has never heard of. diff --git a/crates/session-store/src/lib.rs b/crates/session-store/src/lib.rs index d1fe855b..3a48ccf6 100644 --- a/crates/session-store/src/lib.rs +++ b/crates/session-store/src/lib.rs @@ -50,7 +50,7 @@ pub use segment::{ fork_at, restore, restore_by_segment, save_config_changed, save_delta, save_extension, save_run_completed, save_run_errored, save_turn_end, save_usage, save_user_input, }; -pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, collect_state}; +pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, SessionExtension, collect_state}; pub use store::{Store, StoreError}; pub use system_item::{SystemItem, SystemReminder, SystemReminderSource, render_worker_event}; pub use worker_metadata::{ diff --git a/crates/session-store/src/segment.rs b/crates/session-store/src/segment.rs index 270877f5..df05b268 100644 --- a/crates/session-store/src/segment.rs +++ b/crates/session-store/src/segment.rs @@ -183,6 +183,18 @@ pub fn save_user_input( session_id: SessionId, segment_id: SegmentId, segments: Vec, +) -> Result<(), StoreError> { + save_user_input_with_extensions(store, session_id, segment_id, segments, Vec::new()) +} + +/// Atomically persist one typed user submission and Runtime-owned session +/// extensions in the same log record. +pub fn save_user_input_with_extensions( + store: &impl Store, + session_id: SessionId, + segment_id: SegmentId, + segments: Vec, + extensions: Vec, ) -> Result<(), StoreError> { append_entry( store, @@ -191,6 +203,7 @@ pub fn save_user_input( LogEntry::UserInput { ts: segment_log::now_millis(), segments, + extensions, }, ) } diff --git a/crates/session-store/src/segment_log.rs b/crates/session-store/src/segment_log.rs index 58e62008..a15f2790 100644 --- a/crates/session-store/src/segment_log.rs +++ b/crates/session-store/src/segment_log.rs @@ -31,6 +31,21 @@ use crate::system_item::SystemItem; /// - `RunCompleted` / `RunErrored` — marks end of a `run()` or `resume()` call /// - `PausedTurnAbandoned` — explicit abandon/cancel of a paused interrupted turn /// - `ConfigChanged` — `RequestConfig` mutation +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SessionExtension { + pub domain: String, + pub payload: serde_json::Value, +} + +impl SessionExtension { + pub fn new(domain: impl Into, payload: serde_json::Value) -> Self { + Self { + domain: domain.into(), + payload, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum LogEntry { @@ -80,7 +95,15 @@ pub enum LogEntry { /// file refs) on segment restore. /// Replay flattens these into a `Item::user_message` for the worker /// history; the worker layer never sees segments directly. - UserInput { ts: u64, segments: Vec }, + UserInput { + ts: u64, + segments: Vec, + /// Typed durable state committed atomically with this input record. + /// Runtime-owned Flow invocation uses this to avoid a Backend-instance + /// commit that can get ahead of Worker history. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + extensions: Vec, + }, /// One assistant-side item appended to history — assistant message, /// reasoning, or tool call. Singular: one entry per history item so @@ -243,10 +266,19 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState { // log ends first, restore must treat the turn as interrupted. state.last_run_interrupted = true; } - LogEntry::UserInput { segments, .. } => { + LogEntry::UserInput { + segments, + extensions, + .. + } => { let text = Segment::flatten_to_text(segments); state.history.push(Item::user_message(text)); state.user_segments.push(segments.clone()); + state.extensions.extend( + extensions + .iter() + .map(|extension| (extension.domain.clone(), extension.payload.clone())), + ); } LogEntry::AssistantItem { item, .. } => { state.history.push(Item::from(item.clone())); @@ -350,6 +382,7 @@ mod tests { }, LogEntry::UserInput { ts: 2000, + extensions: vec![], segments: vec![Segment::text("Hello")], }, LogEntry::AssistantItem { @@ -389,6 +422,7 @@ mod tests { }, LogEntry::UserInput { ts: 2001, + extensions: vec![], segments: vec![Segment::text("run a tool")], }, LogEntry::AssistantItem { @@ -414,6 +448,7 @@ mod tests { }, LogEntry::UserInput { ts: 2000, + extensions: vec![], segments: vec![Segment::text("Check weather")], }, LogEntry::AssistantItem { @@ -472,6 +507,7 @@ mod tests { }, LogEntry::UserInput { ts: 2000, + extensions: vec![], segments: vec![Segment::text("hi")], }, LogEntry::LlmUsage { @@ -519,6 +555,7 @@ mod tests { }, LogEntry::UserInput { ts: 2000, + extensions: vec![], segments: vec![Segment::text("hi")], }, ]); @@ -595,6 +632,7 @@ mod tests { }, LogEntry::UserInput { ts: 101, + extensions: vec![], segments: vec![Segment::text("hi")], }, LogEntry::TurnEnd { @@ -705,6 +743,26 @@ mod tests { } } + #[test] + fn user_input_extensions_restore_with_the_same_committed_input() { + let segments = vec![Segment::text("Flow instructions"), Segment::text("Ticket")]; + let entry = LogEntry::UserInput { + ts: 9999, + segments: segments.clone(), + extensions: vec![SessionExtension::new( + "flow.runtime.v1", + serde_json::json!({ "state": "implement", "revision": 0 }), + )], + }; + let json = serde_json::to_string(&entry).unwrap(); + let decoded: LogEntry = serde_json::from_str(&json).unwrap(); + let state = collect_state(&[decoded]); + assert_eq!(state.user_segments, vec![segments]); + assert_eq!(state.extensions.len(), 1); + assert_eq!(state.extensions[0].0, "flow.runtime.v1"); + assert_eq!(state.extensions[0].1["state"], "implement"); + } + /// Mixed segments survive a JSON round-trip through `LogEntry::UserInput`, /// and `collect_state` derives `Item::user_message` from the flattened /// text while preserving the original segments separately. This covers @@ -727,6 +785,7 @@ mod tests { ]; let entry = LogEntry::UserInput { ts: 4242, + extensions: vec![], segments: segments.clone(), }; // JSON round-trip preserves the variant byte-for-byte. diff --git a/crates/session-store/tests/fs_store_test.rs b/crates/session-store/tests/fs_store_test.rs index 9ae6f1c7..95b21658 100644 --- a/crates/session-store/tests/fs_store_test.rs +++ b/crates/session-store/tests/fs_store_test.rs @@ -36,6 +36,7 @@ fn round_trip_write_and_read() { }, LogEntry::UserInput { ts: 2000, + extensions: vec![], segments: vec![protocol::Segment::text("Hello")], }, LogEntry::AssistantItem { @@ -214,6 +215,7 @@ fn read_entry_count_matches_append_tally() { }, LogEntry::UserInput { ts: 2000, + extensions: vec![], segments: vec![protocol::Segment::text("Hello")], }, ]; @@ -253,6 +255,7 @@ fn unterminated_utf8_tail_is_ignored_and_replaced_on_append() { let next = LogEntry::UserInput { ts: 2, + extensions: vec![], segments: vec![protocol::Segment::text("recovered")], }; store.append(sid, segid, &next).unwrap(); diff --git a/crates/session-store/tests/session_test.rs b/crates/session-store/tests/session_test.rs index 8b7f3ab4..7becb600 100644 --- a/crates/session-store/tests/session_test.rs +++ b/crates/session-store/tests/session_test.rs @@ -453,6 +453,7 @@ async fn session_auto_forks_on_conflict() { // Simulate another Worker writing to the same segment behind our back. let extra_entry = LogEntry::UserInput { ts: 9999, + extensions: vec![], segments: vec![protocol::Segment::text("Interloper")], }; store.append(sid, original_segid, &extra_entry).unwrap(); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 5dd3ae42..3c3096c0 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -62,6 +62,17 @@ pub fn core_builtin_tools( tools } +pub fn read_only_builtin_tools( + session: workdir::WorkdirSessionHandle, +) -> Vec { + debug_assert_eq!( + session.capabilities(), + workdir::WorkdirSessionCapabilities::READ_ONLY, + "read-only tool projection requires a read-only Workdir session" + ); + core_builtin_tools(session, Tracker::new(), std::path::PathBuf::new()) +} + pub fn web_builtin_tools( web_config: Option, ) -> Vec { diff --git a/crates/tui/src/input.rs b/crates/tui/src/input.rs index 1d059a1b..c8d0f8a2 100644 --- a/crates/tui/src/input.rs +++ b/crates/tui/src/input.rs @@ -46,11 +46,23 @@ impl FileRefAtom { } } +#[derive(Debug, Clone)] +pub struct FlowRefAtom { + pub selector: String, +} + +impl FlowRefAtom { + pub fn label(&self) -> String { + format!("[Flow: {}]", self.selector) + } +} + #[derive(Debug, Clone)] pub enum Atom { Char(char), Paste(PasteRef), FileRef(FileRefAtom), + FlowRef(FlowRefAtom), } impl Atom { @@ -61,6 +73,7 @@ impl Atom { Atom::Char(_) => None, Atom::Paste(p) => Some((Style::default().fg(Color::Magenta), p.label())), Atom::FileRef(r) => Some((Style::default().fg(Color::Cyan), r.label())), + Atom::FlowRef(r) => Some((Style::default().fg(Color::Yellow), r.label())), } } } @@ -89,7 +102,7 @@ enum WordKind { fn atom_class(atom: &Atom) -> AtomClass { match atom { Atom::Char(c) => char_class(*c), - Atom::Paste(_) | Atom::FileRef(_) => AtomClass::Chip, + Atom::Paste(_) | Atom::FileRef(_) | Atom::FlowRef(_) => AtomClass::Chip, } } @@ -181,6 +194,11 @@ impl InputBuffer { self.atoms .push(Atom::FileRef(FileRefAtom { path: path.clone() })); } + protocol::Segment::Flow { selector } => { + self.atoms.push(Atom::FlowRef(FlowRefAtom { + selector: selector.clone(), + })); + } protocol::Segment::Unknown => { self.atoms .extend("[unknown input segment]".chars().map(Atom::Char)); @@ -208,6 +226,7 @@ impl InputBuffer { Atom::Char(c) => text.push(*c), Atom::Paste(paste) => text.push_str(&paste.content), Atom::FileRef(file) => text.push_str(&file.path), + Atom::FlowRef(flow) => text.push_str(&flow.selector), } } text @@ -484,6 +503,12 @@ impl InputBuffer { path: r.path.clone(), }); } + Atom::FlowRef(r) => { + flush_text(&mut buf, &mut out); + out.push(protocol::Segment::Flow { + selector: r.selector.clone(), + }); + } } } if !buf.is_empty() { @@ -1194,7 +1219,7 @@ mod word_motion_tests { for a in &buf.atoms { match a { Atom::Char(c) => out.push(*c), - Atom::Paste(_) | Atom::FileRef(_) => out.push_str("

"), + Atom::Paste(_) | Atom::FileRef(_) | Atom::FlowRef(_) => out.push_str("

"), } } out diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index e90f14eb..873a95d5 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -1097,6 +1097,10 @@ fn chip_span_for(seg: &Segment, fallback: Style) -> (Style, String) { format!("[Clipboard #{id} | {chars} chars, {line_count} lines]"), ), Segment::FileRef { path } => (Style::default().fg(Color::Cyan), format!("@{path}")), + Segment::Flow { selector } => ( + Style::default().fg(Color::Yellow), + format!("[Flow: {selector}]"), + ), Segment::Unknown => (fallback, "[unknown segment]".to_owned()), } } @@ -1111,6 +1115,7 @@ fn segment_display_text(seg: &Segment) -> String { id, chars, lines, .. } => format!("[Clipboard #{id} | {chars} chars, {lines} lines]"), Segment::FileRef { path } => format!("@{path}"), + Segment::Flow { selector } => format!("[Flow: {selector}]"), Segment::Unknown => "[unknown segment]".to_owned(), } } diff --git a/crates/tui/src/worker_list.rs b/crates/tui/src/worker_list.rs index e94078a3..f97834ba 100644 --- a/crates/tui/src/worker_list.rs +++ b/crates/tui/src/worker_list.rs @@ -1233,6 +1233,7 @@ mod tests { &LogEntry::UserInput { ts, segments: vec![protocol::Segment::text(text)], + extensions: vec![], }, ) .unwrap(); diff --git a/crates/workdir/src/lib.rs b/crates/workdir/src/lib.rs index e5c42928..f90b25c6 100644 --- a/crates/workdir/src/lib.rs +++ b/crates/workdir/src/lib.rs @@ -11,6 +11,7 @@ mod operation; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -158,6 +159,105 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync { pub type WorkdirSessionHandle = Arc; +/// Ephemeral least-authority view over an existing Workdir session. +/// +/// The wrapper exposes only stat/read/list/glob/grep and never forwards write, +/// edit, command, or close authority to the underlying Worker session. Closing +/// the wrapper is terminal for the view but deliberately leaves the owner's +/// source session open. +#[derive(Debug)] +pub struct ReadOnlyWorkdirSession { + source: WorkdirSessionHandle, + closed: AtomicBool, +} + +impl ReadOnlyWorkdirSession { + pub fn new(source: WorkdirSessionHandle) -> Self { + Self { + source, + closed: AtomicBool::new(false), + } + } + + fn ensure_open(&self) -> Result<(), WorkdirError> { + if self.closed.load(Ordering::Acquire) { + Err(WorkdirError::Unavailable( + "read-only Workdir session is closed".to_string(), + )) + } else { + Ok(()) + } + } +} + +#[async_trait] +impl WorkdirSession for ReadOnlyWorkdirSession { + fn workdir(&self) -> &Workdir { + self.source.workdir() + } + + fn capabilities(&self) -> WorkdirSessionCapabilities { + WorkdirSessionCapabilities::READ_ONLY + } + + async fn stat(&self, request: StatRequest) -> Result { + self.ensure_open()?; + self.source.stat(request).await + } + + async fn read(&self, request: ReadRequest) -> Result { + self.ensure_open()?; + self.source.read(request).await + } + + async fn write(&self, _request: WriteRequest) -> Result { + Err(WorkdirError::Unsupported(WorkdirSessionCapability::Write)) + } + + async fn edit(&self, _request: EditRequest) -> Result { + Err(WorkdirError::Unsupported(WorkdirSessionCapability::Edit)) + } + + async fn list(&self, request: ListRequest) -> Result { + self.ensure_open()?; + self.source.list(request).await + } + + async fn glob(&self, request: GlobRequest) -> Result { + self.ensure_open()?; + self.source.glob(request).await + } + + async fn grep(&self, request: GrepRequest) -> Result { + self.ensure_open()?; + self.source.grep(request).await + } + + async fn start_command(&self, _request: CommandRequest) -> Result { + Err(WorkdirError::Unsupported(WorkdirSessionCapability::Command)) + } + + async fn command_status(&self, _handle: CommandHandle) -> Result { + Err(WorkdirError::Unsupported(WorkdirSessionCapability::Command)) + } + + async fn command_output( + &self, + _request: CommandOutputRequest, + ) -> Result { + Err(WorkdirError::Unsupported(WorkdirSessionCapability::Command)) + } + + async fn cancel_command(&self, _handle: CommandHandle) -> Result<(), WorkdirError> { + Err(WorkdirError::Unsupported(WorkdirSessionCapability::Command)) + } + + async fn close(&self) -> Result<(), WorkdirError> { + self.closed.store(true, Ordering::Release); + Ok(()) + } +} + #[derive(Debug, thiserror::Error)] pub enum WorkdirError { #[error("Workdir session does not support {0:?}")] diff --git a/crates/worker-runtime/Cargo.toml b/crates/worker-runtime/Cargo.toml index 9a7bda1d..0af34f4a 100644 --- a/crates/worker-runtime/Cargo.toml +++ b/crates/worker-runtime/Cargo.toml @@ -28,6 +28,7 @@ base64.workspace = true axum = { workspace = true, optional = true } futures = { workspace = true, optional = true } decodal.workspace = true +flow = { path = "../flow" } manifest.workspace = true protocol.workspace = true serde = { workspace = true, features = ["derive"] } diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index d087ccca..cc57999b 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -2333,7 +2333,11 @@ fn validate_create_worker_request(request: &CreateWorkerRequest) -> Result<(), R kind: format!("{:?}", input.kind), }); } - if input.content.trim().is_empty() { + let has_segments = input + .segments + .as_ref() + .is_some_and(|segments| !segments.is_empty()); + if input.content.trim().is_empty() && !has_segments { return Err(RuntimeError::InvalidRequest( "initial_input.content must not be empty".to_string(), )); @@ -2369,7 +2373,11 @@ fn validate_create_workspace_scope( } fn validate_worker_input(input: &WorkerInput) -> Result<(), RuntimeError> { - if !input.kind.is_empty_content_allowed() && input.content.trim().is_empty() { + let has_segments = input + .segments + .as_ref() + .is_some_and(|segments| !segments.is_empty()); + if !input.kind.is_empty_content_allowed() && input.content.trim().is_empty() && !has_segments { return Err(RuntimeError::InvalidRequest( "worker input content must not be empty".to_string(), )); @@ -2447,6 +2455,44 @@ mod tests { use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; + #[test] + fn typed_segments_allow_empty_flat_content() { + let input = WorkerInput { + kind: WorkerInputKind::User, + content: String::new(), + segments: Some(vec![protocol::Segment::Flow { + selector: "builtin:coder-review".to_string(), + }]), + }; + assert!(validate_worker_input(&input).is_ok()); + } + + #[test] + fn empty_user_input_without_segments_is_rejected() { + let input = WorkerInput { + kind: WorkerInputKind::User, + content: String::new(), + segments: Some(Vec::new()), + }; + assert!(matches!( + validate_worker_input(&input), + Err(RuntimeError::InvalidRequest(_)) + )); + } + + #[test] + fn typed_flow_segments_allow_empty_initial_flat_content() { + let mut request = task_request("flow"); + request.initial_input = Some(WorkerInput { + kind: WorkerInputKind::User, + content: String::new(), + segments: Some(vec![protocol::Segment::Flow { + selector: "builtin:coder-review".to_string(), + }]), + }); + assert!(validate_create_worker_request(&request).is_ok()); + } + fn task_request(_objective: &str) -> CreateWorkerRequest { let profile = ProfileSelector::Builtin("builtin:coder".to_string()); let bundle = test_bundle_for_profile(profile.clone()); @@ -2533,6 +2579,7 @@ mod tests { restore_result: Mutex>, restore_count: Mutex, contexts: Mutex>, + dispatched_inputs: Mutex>, #[cfg(feature = "ws-server")] snapshots: Mutex>, } @@ -2607,8 +2654,9 @@ mod tests { fn dispatch_input( &self, _handle: &WorkerExecutionHandle, - _input: WorkerInput, + input: WorkerInput, ) -> WorkerExecutionResult { + self.dispatched_inputs.lock().unwrap().push(input); self.dispatch_result .lock() .unwrap() @@ -3379,6 +3427,36 @@ mod tests { ); } + #[test] + fn send_input_dispatches_segment_only_flow_submission() { + let backend = Arc::new(TestExecutionBackend::default()); + let runtime = Runtime::with_execution_backend( + RuntimeOptions { + ..RuntimeOptions::default() + }, + backend.clone(), + ) + .unwrap(); + runtime.store_config_bundle(test_bundle()).unwrap(); + let detail = runtime.create_worker(task_request("flow segment")).unwrap(); + let input = WorkerInput { + kind: WorkerInputKind::User, + content: String::new(), + segments: Some(vec![protocol::Segment::Flow { + selector: "builtin:coder-review".to_string(), + }]), + }; + + runtime + .send_input(&detail.worker_ref, input.clone()) + .unwrap(); + + assert_eq!( + backend.dispatched_inputs.lock().unwrap().as_slice(), + &[input] + ); + } + #[cfg(feature = "ws-server")] #[test] fn send_input_records_protocol_observations() { diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index ed8946a0..7cf021d0 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -48,7 +48,7 @@ use worker::ipc::protocol_session::{live_log_entry_event, subscribe_worker_proto use worker::{ PromptLoader, RuntimeWorkspaceHttpClient, SegmentLogSink, Worker, WorkerController, WorkerError, WorkerFilesystemAuthority, WorkerHandle, WorkerSharedState, - WorkerWorkspaceContext, WorkspaceId, + WorkerWorkspaceContext, WorkspaceClient, WorkspaceId, }; const DEFAULT_BACKEND_ID: &str = "worker-crate"; @@ -90,6 +90,11 @@ impl Drop for OwnedRuntimeArtifactRoot { } } +pub struct RuntimeWorkerController { + pub handle: WorkerHandle, + pub workspace_client: Arc, +} + /// Factory seam used by [`WorkerRuntimeExecutionBackend`] to construct a real /// controller-backed Worker for a Runtime catalog entry. #[async_trait] @@ -97,12 +102,12 @@ pub trait RuntimeWorkerFactory: Send + Sync + 'static { async fn spawn_controller( &self, request: WorkerExecutionSpawnRequest, - ) -> Result; + ) -> Result; async fn restore_controller( &self, request: WorkerExecutionRestoreRequest, - ) -> Result; + ) -> Result; } /// Production factory that resolves a normal Worker profile and spawns it under @@ -498,7 +503,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { async fn spawn_controller( &self, request: WorkerExecutionSpawnRequest, - ) -> Result { + ) -> Result { let worker_name = Self::runtime_worker_name(&request); let profile = Self::runtime_profile(&request); let has_local_filesystem = request.working_directory.is_some(); @@ -554,6 +559,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { )? } }; + let flow_transition_enabled = manifest.feature.flow.enabled; let store_dir = self.store_dir()?; let session_store = FsStore::new(&store_dir).map_err(|err| { @@ -609,23 +615,41 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { CompositeWorkerObservationProvider::new(providers), ))); } + if flow_transition_enabled { + let report = worker + .install_runtime_flow_transition_feature() + .map_err(|error| format!("install Flow transition feature: {error}"))?; + if report.reports.iter().any(|report| !report.installed) { + return Err(format!( + "install Flow transition feature failed: {:?}", + report.reports + )); + } + } + let workspace_client = worker.workspace_client_handle(); let runtime_base = self.runtime_base_dir()?; let (handle, _shutdown_rx) = WorkerController::spawn_runtime_managed(worker, &runtime_base) .await .map_err(|err| format!("failed to spawn Worker controller: {err}"))?; + if flow_transition_enabled { + handle.shared_state.enable_flow_transition(); + } self.observation_hub.register( request.worker_ref.clone(), observation_workspace_id, &handle, ); - Ok(handle) + Ok(RuntimeWorkerController { + handle, + workspace_client, + }) } async fn restore_controller( &self, request: WorkerExecutionRestoreRequest, - ) -> Result { + ) -> Result { let worker_name = Self::runtime_worker_name_for_ref(&request.worker_ref); let filesystem_authority = request .working_directory @@ -711,6 +735,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { } Err(err) => return Err(format!("failed to restore Worker from metadata: {err}")), }; + let flow_transition_enabled = worker.manifest().feature.flow.enabled; if let Some(binding) = request.working_directory.as_ref() { worker.bind_workdir_session(Some(runtime_local_workdir_session( &binding.working_directory.id, @@ -740,23 +765,42 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { CompositeWorkerObservationProvider::new(providers), ))); } + if flow_transition_enabled { + let report = worker + .install_runtime_flow_transition_feature() + .map_err(|error| format!("install Flow transition feature: {error}"))?; + if report.reports.iter().any(|report| !report.installed) { + return Err(format!( + "install Flow transition feature failed: {:?}", + report.reports + )); + } + } + let workspace_client = worker.workspace_client_handle(); let runtime_base = self.runtime_base_dir()?; let (handle, _shutdown_rx) = WorkerController::spawn_runtime_managed(worker, &runtime_base) .await .map_err(|err| format!("failed to spawn restored Worker controller: {err}"))?; + if flow_transition_enabled { + handle.shared_state.enable_flow_transition(); + } self.observation_hub.register( request.worker_ref.clone(), observation_workspace_id, &handle, ); - Ok(handle) + Ok(RuntimeWorkerController { + handle, + workspace_client, + }) } } struct RuntimeWorkerExecution { handle: WorkerHandle, busy: Arc, + workspace_client: Option>, } /// `worker-runtime` execution backend backed by real `worker` crate Workers. @@ -847,7 +891,14 @@ where fn get_execution( &self, handle: &WorkerExecutionHandle, - ) -> Result<(WorkerHandle, Arc), WorkerExecutionResult> { + ) -> Result< + ( + WorkerHandle, + Arc, + Option>, + ), + WorkerExecutionResult, + > { if handle.backend_id() != self.backend_id() { return Err(WorkerExecutionResult::rejected( WorkerExecutionOperation::Input, @@ -866,7 +917,13 @@ where })?; workers .get(handle.worker_ref()) - .map(|execution| (execution.handle.clone(), execution.busy.clone())) + .map(|execution| { + ( + execution.handle.clone(), + execution.busy.clone(), + execution.workspace_client.clone(), + ) + }) .ok_or_else(|| { WorkerExecutionResult::rejected( WorkerExecutionOperation::Input, @@ -899,6 +956,7 @@ where bridge_context: crate::execution::WorkerExecutionContext, handle: WorkerHandle, working_directory: Option, + workspace_client: Option>, ) -> WorkerExecutionSpawnResult { let busy = Arc::new(AtomicBool::new(false)); #[cfg(feature = "ws-server")] @@ -956,7 +1014,14 @@ where )); } }; - workers.insert(worker_ref.clone(), RuntimeWorkerExecution { handle, busy }); + workers.insert( + worker_ref.clone(), + RuntimeWorkerExecution { + handle, + busy, + workspace_client, + }, + ); WorkerExecutionSpawnResult::Connected { handle: WorkerExecutionHandle::new(worker_ref, self.backend_id()), @@ -1166,8 +1231,8 @@ where let spawn_result = self.run_on_adapter_runtime(async move { factory.spawn_controller(request).await }); - let handle = match spawn_result { - Ok(handle) => handle, + let controller = match spawn_result { + Ok(controller) => controller, Err(message) => { if let (Some(materializer), Some(binding)) = ( self.working_directory_materializer.as_ref(), @@ -1186,8 +1251,9 @@ where WorkerExecutionOperation::Spawn, worker_ref, bridge_context, - handle, + controller.handle, working_directory, + Some(controller.workspace_client), ) } @@ -1267,8 +1333,8 @@ where let restore_result = self.run_on_adapter_runtime(async move { factory.restore_controller(request).await }); - let handle = match restore_result { - Ok(handle) => handle, + let controller = match restore_result { + Ok(controller) => controller, Err(message) => { return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored( WorkerExecutionOperation::Restore, @@ -1281,8 +1347,9 @@ where WorkerExecutionOperation::Restore, worker_ref, bridge_context, - handle, + controller.handle, working_directory, + Some(controller.workspace_client), ) } @@ -1291,7 +1358,7 @@ where handle: &WorkerExecutionHandle, input: WorkerInput, ) -> WorkerExecutionResult { - let (worker, busy) = match self.get_execution(handle) { + let (worker, busy, _workspace_client) = match self.get_execution(handle) { Ok(execution) => execution, Err(mut result) => { result.operation = WorkerExecutionOperation::Input; @@ -1374,7 +1441,7 @@ where handle: &WorkerExecutionHandle, method: Method, ) -> WorkerExecutionResult { - let (worker, busy) = match self.get_execution(handle) { + let (worker, busy, _workspace_client) = match self.get_execution(handle) { Ok(execution) => execution, Err(mut result) => { result.operation = WorkerExecutionOperation::ProtocolMethod; @@ -1468,7 +1535,7 @@ where } fn cancel_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult { - let (worker, _busy) = match self.get_execution(handle) { + let (worker, _busy, _workspace_client) = match self.get_execution(handle) { Ok(execution) => execution, Err(mut result) => { result.operation = WorkerExecutionOperation::Cancel; @@ -1626,7 +1693,7 @@ mod tests { async fn spawn_controller( &self, request: WorkerExecutionSpawnRequest, - ) -> Result { + ) -> Result { let manifest = WorkerManifest::from_toml( r#" [worker] @@ -1668,7 +1735,7 @@ mod tests { let workspace_backend_ref = RuntimeWorkspaceBackendRef::from_worker_request(&request.request); let workspace_context = workspace_backend_ref.worker_context(&request.worker_ref); - let workspace_client = workspace_context.client(); + let workspace_client = workspace_context.client_handle(); self.observed_workspace_clients.lock().unwrap().push(( workspace_client.kind().to_string(), workspace_client.workspace_id().map(str::to_string), @@ -1689,12 +1756,15 @@ mod tests { WorkerController::spawn_runtime_managed(worker, &self.runtime_base) .await .map_err(|err| err.to_string())?; - Ok(handle) + Ok(RuntimeWorkerController { + handle, + workspace_client, + }) } async fn restore_controller( &self, request: WorkerExecutionRestoreRequest, - ) -> Result { + ) -> Result { let request = WorkerExecutionSpawnRequest { worker_ref: request.worker_ref, request: request.request, @@ -1820,14 +1890,14 @@ mod tests { async fn spawn_controller( &self, _request: WorkerExecutionSpawnRequest, - ) -> Result { + ) -> Result { Err("spawn failed".to_string()) } async fn restore_controller( &self, _request: WorkerExecutionRestoreRequest, - ) -> Result { + ) -> Result { Err("restore failed".to_string()) } } @@ -2040,6 +2110,9 @@ mod tests { [engine] max_tokens = 100 + [feature.flow] + enabled = true + [[scope.allow]] target = "{}" permission = "write" @@ -2060,8 +2133,13 @@ mod tests { ) .unwrap(); - let request = create_request("restore"); - let handle = ProfileRuntimeWorkerFactory::new(root.path()) + let mut request = create_request("restore"); + request.workspace_api = Some(crate::catalog::WorkspaceApiRef { + workspace_id: "workspace-restore".to_string(), + base_url: "http://workspace.invalid".to_string(), + runtime_id: Some("runtime-restore".to_string()), + }); + let controller = ProfileRuntimeWorkerFactory::new(root.path()) .with_store_dir(&store_dir) .with_worker_metadata_dir(&worker_metadata_dir) .restore_controller(WorkerExecutionRestoreRequest { @@ -2074,8 +2152,9 @@ mod tests { }) .await .expect("pending restore should use the saved manifest snapshot"); + assert!(controller.handle.shared_state.flow_transition_enabled()); - handle.send(Method::Shutdown).await.unwrap(); + controller.handle.send(Method::Shutdown).await.unwrap(); } #[test] diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index 8e11eaba..35995728 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -31,6 +31,7 @@ minijinja = "2.19.0" chrono = "0.4" include_dir = "0.7.4" fs4 = { workspace = true, features = ["sync"] } +flow = { path = "../flow" } libc = { workspace = true } schemars = { workspace = true } ticket = { workspace = true } diff --git a/crates/worker/src/feature/builtin.rs b/crates/worker/src/feature/builtin.rs index 2f49f4da..28c248fe 100644 --- a/crates/worker/src/feature/builtin.rs +++ b/crates/worker/src/feature/builtin.rs @@ -4,6 +4,7 @@ //! same descriptor-approved registry path used by feature modules. They are not //! an external plugin-loading surface. +pub mod flow_transition; pub mod manage_workdir; pub mod manage_worker; pub mod memory; diff --git a/crates/worker/src/feature/builtin/flow_transition.rs b/crates/worker/src/feature/builtin/flow_transition.rs new file mode 100644 index 00000000..b1a8c3ba --- /dev/null +++ b/crates/worker/src/feature/builtin/flow_transition.rs @@ -0,0 +1,1007 @@ +use std::collections::BTreeSet; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use flow::{ + ConditionVerdict, FlowTransitionAttempt, FlowTransitionResolution, FlowVerifierOutcome, + TransitionConditionResult, TransitionId, +}; +use llm_engine::llm_client::client::LlmClient; +use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; +use manifest::{Scope, WorkerManifest}; +use schemars::JsonSchema; +use serde::Deserialize; +use session_store::{SegmentId, SessionId, Store, collect_state}; +use uuid::Uuid; + +use crate::feature::builtin::session_explore::{SessionExploreFeature, SessionExploreState}; +use crate::feature::{ + FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, + FeatureRegistryBuilder, ToolContribution, ToolDeclaration, +}; +use crate::internal_worker::{ + InternalWorkerAuthority, InternalWorkerIdentity, InternalWorkerSpec, run_internal_worker, +}; +use crate::session_capture::SessionCapture; +use crate::worker::{WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext}; + +const REQUEST_FLOW_TRANSITION_DESCRIPTION: &str = "Request evaluation of the current Flow state's outgoing conditions. The host selects the active Flow instance and captures the current state; the caller supplies only a concise reason."; +const FINISH_FLOW_VERIFICATION_DESCRIPTION: &str = "Finish one bounded Flow verification attempt with exactly one verdict for every captured outgoing transition."; + +#[async_trait] +pub trait FlowCoordinatorClient: Send + Sync { + async fn begin_transition( + &self, + attempt_id: &str, + reason: &str, + ) -> Result; + + async fn resolve_transition( + &self, + attempt_id: &str, + outcome: FlowVerifierOutcome, + ) -> Result; +} + +#[async_trait] +pub trait FlowConditionVerifier: Send + Sync { + async fn verify(&self, attempt: &FlowTransitionAttempt) -> FlowVerifierOutcome; +} + +pub trait FlowRuntimeStateCommitter: Send + Sync { + fn commit(&self, state: &flow::FlowRuntimeState) -> Result<(), String>; +} + +/// Runtime-local coordinator for Flow state durably owned by one Worker. +pub struct RuntimeFlowCoordinatorClient { + state: Arc>>, + committer: Arc, +} + +impl RuntimeFlowCoordinatorClient { + pub fn new( + state: Arc>>, + committer: Arc, + ) -> Self { + Self { state, committer } + } +} + +#[async_trait] +impl FlowCoordinatorClient for RuntimeFlowCoordinatorClient { + async fn begin_transition( + &self, + attempt_id: &str, + reason: &str, + ) -> Result { + let mut guard = self.state.lock().expect("flow runtime state poisoned"); + let current = guard + .as_ref() + .ok_or_else(|| "Worker has no active Flow instance".to_string())?; + if let Some(attempt) = ¤t.active_attempt { + return Ok(attempt.clone()); + } + let mut updated = current.clone(); + let attempt = updated + .begin_or_recover_transition(attempt_id.to_string(), reason.to_string()) + .map_err(|error| error.to_string())?; + self.committer.commit(&updated)?; + *guard = Some(updated); + Ok(attempt) + } + + async fn resolve_transition( + &self, + attempt_id: &str, + outcome: FlowVerifierOutcome, + ) -> Result { + let mut guard = self.state.lock().expect("flow runtime state poisoned"); + let current = guard + .as_ref() + .ok_or_else(|| "Worker has no active Flow instance".to_string())?; + let mut updated = current.clone(); + let resolution = updated + .resolve_active_transition(attempt_id, outcome) + .map_err(|error| error.to_string())?; + self.committer.commit(&updated)?; + *guard = Some(updated); + Ok(resolution) + } +} + +#[derive(Clone)] +pub struct FlowTransitionFeature { + state: FlowTransitionState, +} + +#[derive(Clone)] +pub struct FlowTransitionState { + coordinator: Arc, + verifier: Arc, + in_flight: Arc, +} + +impl FlowTransitionState { + pub fn new( + coordinator: Arc, + verifier: Arc, + ) -> Self { + Self { + coordinator, + verifier, + in_flight: Arc::new(AtomicBool::new(false)), + } + } +} + +impl FlowTransitionFeature { + pub fn new(state: FlowTransitionState) -> Self { + Self { state } + } +} + +impl FeatureModule for FlowTransitionFeature { + fn descriptor(&self) -> FeatureDescriptor { + FeatureDescriptor::builtin("flow-transition", "Flow Transition") + .with_description( + "Request host-authorized transitions for the Worker's active Flow instance.", + ) + .with_tool(ToolDeclaration::new( + "RequestFlowTransition", + REQUEST_FLOW_TRANSITION_DESCRIPTION, + )) + } + + fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + context.tools().register(ToolContribution::new( + "RequestFlowTransition", + request_flow_transition_definition(self.state.clone()), + ))?; + Ok(()) + } +} + +fn request_flow_transition_definition(state: FlowTransitionState) -> ToolDefinition { + Arc::new(move || { + let schema = serde_json::to_value(schemars::schema_for!(RequestFlowTransitionParams)) + .unwrap_or_else(|_| serde_json::json!({})); + let meta = ToolMeta::new("RequestFlowTransition") + .description(REQUEST_FLOW_TRANSITION_DESCRIPTION) + .input_schema(schema); + let tool: Arc = Arc::new(RequestFlowTransitionTool { + state: state.clone(), + }); + (meta, tool) + }) +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct RequestFlowTransitionParams { + reason: String, +} + +struct RequestFlowTransitionTool { + state: FlowTransitionState, +} + +struct InFlightGuard(Arc); + +impl Drop for InFlightGuard { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +#[async_trait] +impl Tool for RequestFlowTransitionTool { + async fn execute( + &self, + input_json: &str, + _context: llm_engine::tool::ToolExecutionContext, + ) -> Result { + let params: RequestFlowTransitionParams = + serde_json::from_str(input_json).map_err(|error| { + ToolError::InvalidArgument(format!("invalid RequestFlowTransition input: {error}")) + })?; + if params.reason.trim().is_empty() { + return Err(ToolError::InvalidArgument( + "RequestFlowTransition reason must not be empty".to_string(), + )); + } + if self + .state + .in_flight + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err(ToolError::ExecutionFailed( + "a Flow transition verification is already in progress".to_string(), + )); + } + let _guard = InFlightGuard(self.state.in_flight.clone()); + let attempt_id = Uuid::now_v7().to_string(); + let attempt = self + .state + .coordinator + .begin_transition(&attempt_id, params.reason.trim()) + .await + .map_err(|error| { + ToolError::ExecutionFailed(format!("begin Flow transition: {error}")) + })?; + let active_attempt_id = attempt.attempt_id.clone(); + let outcome = self.state.verifier.verify(&attempt).await; + let resolution = self + .state + .coordinator + .resolve_transition(&active_attempt_id, outcome) + .await + .map_err(|error| { + ToolError::ExecutionFailed(format!("resolve Flow transition: {error}")) + })?; + let content = serde_json::to_string_pretty(&resolution).map_err(|error| { + ToolError::ExecutionFailed(format!("serialize Flow transition result: {error}")) + })?; + let summary = match (&resolution.entered_state, &resolution.rejection) { + (Some(state), _) => format!("Flow entered state {state}."), + (_, Some(rejection)) => { + format!("Flow transition was rejected: {}", rejection.message) + } + _ => "Flow transition did not enter a state.".to_string(), + }; + Ok(ToolOutput { + summary, + content: Some(content), + }) + } +} + +#[derive(Clone)] +pub(crate) struct FinishFlowVerificationState { + expected: Arc>, + result: Arc>>>, +} + +impl FinishFlowVerificationState { + pub(crate) fn new(attempt: &FlowTransitionAttempt) -> Self { + Self { + expected: Arc::new( + attempt + .transitions + .iter() + .map(|transition| transition.transition_id.clone()) + .collect(), + ), + result: Arc::new(Mutex::new(None)), + } + } + + pub(crate) fn take(&self) -> Option> { + self.result.lock().ok()?.take() + } +} + +#[derive(Clone)] +pub(crate) struct FinishFlowVerificationFeature { + state: FinishFlowVerificationState, +} + +impl FinishFlowVerificationFeature { + pub(crate) fn new(state: FinishFlowVerificationState) -> Self { + Self { state } + } +} + +impl FeatureModule for FinishFlowVerificationFeature { + fn descriptor(&self) -> FeatureDescriptor { + FeatureDescriptor::builtin("flow-verification-finish", "Flow Verification Finish") + .with_description("Submit the complete structured Flow condition verdict set.") + .with_tool(ToolDeclaration::new( + "FinishFlowVerification", + FINISH_FLOW_VERIFICATION_DESCRIPTION, + )) + } + + fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + context.tools().register(ToolContribution::new( + "FinishFlowVerification", + finish_flow_verification_definition(self.state.clone()), + ))?; + Ok(()) + } +} + +fn finish_flow_verification_definition(state: FinishFlowVerificationState) -> ToolDefinition { + Arc::new(move || { + let schema = serde_json::to_value(schemars::schema_for!(FinishFlowVerificationParams)) + .unwrap_or_else(|_| serde_json::json!({})); + let meta = ToolMeta::new("FinishFlowVerification") + .description(FINISH_FLOW_VERIFICATION_DESCRIPTION) + .input_schema(schema); + let tool: Arc = Arc::new(FinishFlowVerificationTool { + state: state.clone(), + }); + (meta, tool) + }) +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct FinishFlowVerificationParams { + results: Vec, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct FinishTransitionResult { + transition_id: String, + verdict: String, + rationale: String, +} + +struct FinishFlowVerificationTool { + state: FinishFlowVerificationState, +} + +#[async_trait] +impl Tool for FinishFlowVerificationTool { + async fn execute( + &self, + input_json: &str, + _context: llm_engine::tool::ToolExecutionContext, + ) -> Result { + let params: FinishFlowVerificationParams = + serde_json::from_str(input_json).map_err(|error| { + ToolError::InvalidArgument(format!("invalid FinishFlowVerification input: {error}")) + })?; + let mut seen = BTreeSet::new(); + let mut results = Vec::with_capacity(params.results.len()); + for result in params.results { + let transition_id = self + .state + .expected + .iter() + .find(|expected| expected.as_str() == result.transition_id) + .cloned() + .ok_or_else(|| { + ToolError::InvalidArgument(format!( + "transition_id {:?} is not part of this attempt", + result.transition_id + )) + })?; + if !seen.insert(transition_id.clone()) { + return Err(ToolError::InvalidArgument(format!( + "transition_id {:?} was returned more than once", + result.transition_id + ))); + } + if result.rationale.trim().is_empty() { + return Err(ToolError::InvalidArgument(format!( + "rationale for transition {:?} must not be empty", + result.transition_id + ))); + } + let verdict = match result.verdict.as_str() { + "met" => ConditionVerdict::Met, + "not_met" => ConditionVerdict::NotMet, + "indeterminate" => ConditionVerdict::Indeterminate, + other => { + return Err(ToolError::InvalidArgument(format!( + "invalid verdict {other:?}; expected met, not_met, or indeterminate" + ))); + } + }; + results.push(TransitionConditionResult { + transition_id, + verdict, + rationale: result.rationale, + }); + } + if seen != *self.state.expected { + let missing = self + .state + .expected + .difference(&seen) + .map(ToString::to_string) + .collect::>() + .join(", "); + return Err(ToolError::InvalidArgument(format!( + "FinishFlowVerification is missing transition verdict(s): {missing}" + ))); + } + let mut slot = self.state.result.lock().map_err(|_| { + ToolError::ExecutionFailed("Flow verification result state is unavailable".to_string()) + })?; + if slot.is_some() { + return Err(ToolError::ExecutionFailed( + "FinishFlowVerification was already completed".to_string(), + )); + } + *slot = Some(results); + Ok(ToolOutput { + summary: "Recorded complete Flow verification verdicts.".to_string(), + content: Some("{\"accepted\":true}".to_string()), + }) + } +} + +#[derive(Clone)] +pub(crate) struct StoreFlowParentCapture +where + St: Store + Clone, +{ + store: St, + session_id: SessionId, + segment_id: SegmentId, +} + +impl StoreFlowParentCapture +where + St: Store + Clone, +{ + pub(crate) fn new(store: St, session_id: SessionId, segment_id: SegmentId) -> Self { + Self { + store, + session_id, + segment_id, + } + } + + fn capture(&self) -> Result { + let entries = self + .store + .read_all(self.session_id, self.segment_id) + .map_err(|error| format!("read committed parent session: {error}"))?; + let restored = collect_state(&entries); + Ok(SessionCapture::new( + self.segment_id.to_string(), + restored.history, + )) + } +} + +pub(crate) struct WorkerBackedFlowVerifier +where + C: LlmClient + Clone, + St: Store + Clone, +{ + client: C, + manifest: WorkerManifest, + capture: StoreFlowParentCapture, + read_only_tools: Vec, +} + +impl WorkerBackedFlowVerifier +where + C: LlmClient + Clone, + St: Store + Clone, +{ + pub(crate) fn new( + client: C, + manifest: WorkerManifest, + capture: StoreFlowParentCapture, + read_only_tools: Vec, + ) -> Self { + Self { + client, + manifest, + capture, + read_only_tools, + } + } +} + +#[async_trait] +impl FlowConditionVerifier for WorkerBackedFlowVerifier +where + C: LlmClient + Clone + Send + Sync + 'static, + St: Store + Clone + Send + Sync + 'static, +{ + async fn verify(&self, attempt: &FlowTransitionAttempt) -> FlowVerifierOutcome { + let capture = match self.capture.capture() { + Ok(capture) => capture, + Err(message) => return FlowVerifierOutcome::Failed { message }, + }; + let finish_state = FinishFlowVerificationState::new(attempt); + let mut features = FeatureRegistryBuilder::new() + .with_module(SessionExploreFeature::new(SessionExploreState::new( + capture, + ))) + .with_module(FinishFlowVerificationFeature::new(finish_state.clone())); + if !self.read_only_tools.is_empty() { + features = features.with_module(ReadOnlyFlowWorkdirFeature::new( + self.read_only_tools.clone(), + )); + } + + let catalog = match crate::PromptCatalog::builtins_only() { + Ok(catalog) => catalog, + Err(error) => { + return FlowVerifierOutcome::Failed { + message: format!("load Flow verifier prompt catalog: {error}"), + }; + } + }; + let prompt = match catalog.flow_verifier_system() { + Ok(prompt) => prompt, + Err(error) => { + return FlowVerifierOutcome::Failed { + message: format!("render Flow verifier prompt: {error}"), + }; + } + }; + let manifest = self.manifest.clone(); + let input = match serde_json::to_string(&serde_json::json!({ + "current_state": attempt.from_state, + "reason": attempt.reason, + "transitions": attempt.transitions, + })) { + Ok(input) => input, + Err(error) => { + return FlowVerifierOutcome::Failed { + message: format!("encode Flow verifier input: {error}"), + }; + } + }; + let spec = InternalWorkerSpec { + identity: InternalWorkerIdentity { + kind: "flow-verifier", + run_id: Uuid::now_v7(), + }, + manifest, + client: self.client.clone_boxed(), + system_prompt: prompt, + input, + cache_key: Some(format!( + "flow:{}:{}", + attempt.instance_id, attempt.checked_state_revision + )), + max_turns: Some(12), + features, + required_tools: &[ + "ShowOverview", + "SearchEntries", + "ReadEntry", + "FinishFlowVerification", + ], + authority: InternalWorkerAuthority { + workspace: WorkerWorkspaceContext::no_workspace(), + filesystem: WorkerFilesystemAuthority::None, + scope: Scope::empty(), + }, + }; + match run_internal_worker(spec).await { + Ok(result) if result.lifecycle == WorkerRunResult::RolledBack => { + FlowVerifierOutcome::Cancelled + } + Ok(_) => match finish_state.take() { + Some(results) => FlowVerifierOutcome::Completed { results }, + None => FlowVerifierOutcome::Failed { + message: "Flow verifier finished without FinishFlowVerification".to_string(), + }, + }, + Err(error) => FlowVerifierOutcome::Failed { + message: error.source.to_string(), + }, + } + } +} + +#[derive(Clone)] +struct ReadOnlyFlowWorkdirFeature { + tools: Vec, +} + +impl ReadOnlyFlowWorkdirFeature { + fn new(tools: Vec) -> Self { + Self { tools } + } +} + +impl FeatureModule for ReadOnlyFlowWorkdirFeature { + fn descriptor(&self) -> FeatureDescriptor { + let mut descriptor = + FeatureDescriptor::builtin("flow-read-only-workdir", "Flow read-only Workdir") + .with_description( + "Read-only Workdir evidence tools for the internal Flow verifier.", + ); + for definition in &self.tools { + let (meta, _) = definition(); + descriptor = descriptor.with_tool(ToolDeclaration::new(meta.name, meta.description)); + } + descriptor + } + + fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + for definition in &self.tools { + let (meta, _) = definition(); + context + .tools() + .register(ToolContribution::new(meta.name, definition.clone()))?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::AtomicUsize; + + use flow::{FlowAttemptStatus, FlowTransitionRejection, StateId, TransitionCheckSnapshot}; + use futures::stream; + use llm_engine::llm_client::client::{LlmClient, ResponseStream}; + use llm_engine::llm_client::error::ClientError; + use llm_engine::llm_client::event::{ + BlockDelta, BlockMetadata, BlockStart, BlockStop, BlockType, DeltaContent, + Event as LlmEvent, StopReason, + }; + use llm_engine::llm_client::types::Request; + use llm_engine::tool::ToolExecutionContext; + use manifest::WorkerManifest; + use protocol::Segment; + use session_store::{LogEntry, SegmentId, SessionId, Store}; + + use super::*; + use crate::feature::{FeatureRegistryBuilder, HookRegistryBuilder}; + + fn attempt(id: &str) -> FlowTransitionAttempt { + FlowTransitionAttempt { + attempt_id: id.to_string(), + instance_id: "instance-1".to_string(), + definition_revision: 1, + definition_digest: "sha256:test".to_string(), + checked_state_revision: 0, + from_state: StateId::new("work").unwrap(), + reason: "ready".to_string(), + transitions: vec![ + TransitionCheckSnapshot { + transition_id: TransitionId::new("done").unwrap(), + target: StateId::new("done").unwrap(), + condition: "done".to_string(), + synthetic: false, + }, + TransitionCheckSnapshot { + transition_id: TransitionId::new("cancel").unwrap(), + target: StateId::new("cancelled").unwrap(), + condition: "exceptional".to_string(), + synthetic: true, + }, + ], + status: FlowAttemptStatus::Verifying, + } + } + + #[derive(Default)] + struct RecordingFlowStateCommitter { + states: Mutex>, + } + + impl FlowRuntimeStateCommitter for RecordingFlowStateCommitter { + fn commit(&self, state: &flow::FlowRuntimeState) -> Result<(), String> { + self.states.lock().unwrap().push(state.clone()); + Ok(()) + } + } + + fn runtime_flow_state() -> flow::FlowRuntimeState { + let definition = flow::compile_flow_source( + r#"{ + schema_version = 1; + name = "test-flow"; + initial = "work"; + states = { + work = { + instructions = "Work."; + transitions = { + done = { target = "done"; condition = "Complete."; }; + }; + }; + done = { instructions = "Done."; terminal = true; }; + }; + }"#, + ) + .unwrap(); + flow::FlowRuntimeState::start( + &flow::ResolvedFlowSource { + selector: "workspace:test-flow".parse().unwrap(), + workspace_id: "workspace-1".to_string(), + flow_id: "flow-1".to_string(), + revision: 1, + content_digest: definition.content_digest.clone(), + definition, + }, + "instance-1", + ) + .unwrap() + .0 + } + + #[tokio::test] + async fn runtime_coordinator_persists_and_recovers_active_attempt() { + let state = Arc::new(Mutex::new(Some(runtime_flow_state()))); + let committer = Arc::new(RecordingFlowStateCommitter::default()); + let coordinator = RuntimeFlowCoordinatorClient::new(state.clone(), committer.clone()); + + let attempt = coordinator + .begin_transition("attempt-1", "ready") + .await + .unwrap(); + assert_eq!(attempt.attempt_id, "attempt-1"); + assert_eq!(committer.states.lock().unwrap().len(), 1); + + let restored = RuntimeFlowCoordinatorClient::new(state.clone(), committer.clone()); + let recovered = restored + .begin_transition("attempt-2", "retry after restore") + .await + .unwrap(); + assert_eq!(recovered.attempt_id, "attempt-1"); + assert_eq!(committer.states.lock().unwrap().len(), 1); + + let results = recovered + .transitions + .iter() + .map(|transition| TransitionConditionResult { + transition_id: transition.transition_id.clone(), + verdict: if transition.synthetic { + ConditionVerdict::NotMet + } else { + ConditionVerdict::Met + }, + rationale: "complete".to_string(), + }) + .collect(); + let resolution = restored + .resolve_transition("attempt-1", FlowVerifierOutcome::Completed { results }) + .await + .unwrap(); + assert!(resolution.entered_state.is_some()); + assert_eq!(committer.states.lock().unwrap().len(), 2); + assert_eq!( + state.lock().unwrap().as_ref().unwrap().instance.status, + flow::FlowInstanceStatus::Completed + ); + } + + struct FakeCoordinator { + begin_count: AtomicUsize, + resolve_count: AtomicUsize, + } + + #[async_trait] + impl FlowCoordinatorClient for FakeCoordinator { + async fn begin_transition( + &self, + attempt_id: &str, + _reason: &str, + ) -> Result { + self.begin_count.fetch_add(1, Ordering::SeqCst); + Ok(attempt(attempt_id)) + } + + async fn resolve_transition( + &self, + attempt_id: &str, + outcome: FlowVerifierOutcome, + ) -> Result { + self.resolve_count.fetch_add(1, Ordering::SeqCst); + let FlowVerifierOutcome::Completed { results } = outcome else { + return Err("unexpected verifier outcome".to_string()); + }; + Ok(FlowTransitionResolution { + attempt: attempt(attempt_id), + entered_state: None, + state_instructions: None, + rejection: Some(FlowTransitionRejection { + code: flow::FlowRejectionCode::NoConditionMet, + message: format!("{} condition(s) evaluated", results.len()), + }), + events: Vec::new(), + }) + } + } + + struct FakeVerifier; + + #[async_trait] + impl FlowConditionVerifier for FakeVerifier { + async fn verify(&self, attempt: &FlowTransitionAttempt) -> FlowVerifierOutcome { + FlowVerifierOutcome::Completed { + results: attempt + .transitions + .iter() + .map(|transition| TransitionConditionResult { + transition_id: transition.transition_id.clone(), + verdict: ConditionVerdict::NotMet, + rationale: "not met".to_string(), + }) + .collect(), + } + } + } + + #[tokio::test] + async fn request_tool_owns_attempt_identity_and_runs_one_verifier() { + let coordinator = Arc::new(FakeCoordinator { + begin_count: AtomicUsize::new(0), + resolve_count: AtomicUsize::new(0), + }); + let state = FlowTransitionState::new(coordinator.clone(), Arc::new(FakeVerifier)); + let definition = request_flow_transition_definition(state); + let (_, tool) = definition(); + let output = tool + .execute( + r#"{"reason":"implementation and validation are complete"}"#, + ToolExecutionContext::default(), + ) + .await + .unwrap(); + assert!(output.summary.contains("rejected")); + assert_eq!(coordinator.begin_count.load(Ordering::SeqCst), 1); + assert_eq!(coordinator.resolve_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn finish_tool_requires_exact_transition_set() { + let attempt = attempt("attempt-1"); + let state = FinishFlowVerificationState::new(&attempt); + let definition = finish_flow_verification_definition(state.clone()); + let (_, tool) = definition(); + let error = tool + .execute( + r#"{"results":[{"transition_id":"done","verdict":"met","rationale":"evidence"}]}"#, + ToolExecutionContext::default(), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("missing transition")); + assert!(state.take().is_none()); + } + + #[derive(Clone)] + struct FinishVerifierClient { + calls: Arc, + } + + #[async_trait] + impl LlmClient for FinishVerifierClient { + async fn stream(&self, _request: Request) -> Result { + let call = self.calls.fetch_add(1, Ordering::SeqCst); + if call == 0 { + let input = serde_json::json!({ + "results": [ + { + "transition_id": "done", + "verdict": "met", + "rationale": "the captured implementation evidence is complete" + }, + { + "transition_id": "cancel", + "verdict": "not_met", + "rationale": "no exceptional cancellation condition exists" + } + ] + }) + .to_string(); + Ok(Box::pin(stream::iter(vec![ + Ok(LlmEvent::BlockStart(BlockStart { + index: 0, + block_type: BlockType::ToolUse, + metadata: BlockMetadata::ToolUse { + id: "finish-flow".to_string(), + name: "FinishFlowVerification".to_string(), + }, + })), + Ok(LlmEvent::BlockDelta(BlockDelta { + index: 0, + delta: DeltaContent::InputJson(input), + })), + Ok(LlmEvent::BlockStop(BlockStop { + index: 0, + block_type: BlockType::ToolUse, + reasoning: None, + stop_reason: Some(StopReason::ToolUse), + })), + ]))) + } else { + Ok(Box::pin(stream::iter(vec![ + Ok(LlmEvent::BlockStart(BlockStart { + index: 0, + block_type: BlockType::Text, + metadata: BlockMetadata::Text, + })), + Ok(LlmEvent::BlockDelta(BlockDelta { + index: 0, + delta: DeltaContent::Text("verification submitted".to_string()), + })), + Ok(LlmEvent::BlockStop(BlockStop { + index: 0, + block_type: BlockType::Text, + reasoning: None, + stop_reason: Some(StopReason::EndTurn), + })), + ]))) + } + } + + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + } + + fn verifier_manifest() -> WorkerManifest { + WorkerManifest::from_toml( + r#" +[worker] +name = "flow-verifier-parent" + +[model] +scheme = "anthropic" +model_id = "test-model" + +[engine] + +[[scope.allow]] +target = "/abs/scope" +permission = "read" + "#, + ) + .unwrap() + } + + #[tokio::test] + async fn worker_backed_verifier_requires_structured_finish_result() { + let directory = tempfile::tempdir().unwrap(); + let store = session_store::FsStore::new(directory.path()).unwrap(); + let session_id: SessionId = Uuid::now_v7(); + let segment_id: SegmentId = Uuid::now_v7(); + store + .append( + session_id, + segment_id, + &LogEntry::UserInput { + ts: 1, + extensions: vec![], + segments: vec![Segment::Text { + content: "verify current Flow conditions".into(), + }], + }, + ) + .unwrap(); + let verifier = WorkerBackedFlowVerifier::new( + FinishVerifierClient { + calls: Arc::new(AtomicUsize::new(0)), + }, + verifier_manifest(), + StoreFlowParentCapture::new(store, session_id, segment_id), + Vec::new(), + ); + let outcome = verifier.verify(&attempt("attempt-worker-backed")).await; + let FlowVerifierOutcome::Completed { results } = outcome else { + panic!("expected completed verifier outcome: {outcome:?}"); + }; + assert_eq!(results.len(), 2); + assert_eq!(results[0].verdict, ConditionVerdict::Met); + assert_eq!(results[1].verdict, ConditionVerdict::NotMet); + } + + #[test] + fn feature_registers_only_request_tool() { + let coordinator = Arc::new(FakeCoordinator { + begin_count: AtomicUsize::new(0), + resolve_count: AtomicUsize::new(0), + }); + let state = FlowTransitionState::new(coordinator, Arc::new(FakeVerifier)); + let mut pending = Vec::new(); + let mut hooks = HookRegistryBuilder::new(); + let report = FeatureRegistryBuilder::new() + .with_module(FlowTransitionFeature::new(state)) + .install_into_pending(&mut pending, &mut hooks); + assert!(report.reports.iter().all(|report| report.installed)); + let names = pending + .iter() + .map(|definition| definition().0.name) + .collect::>(); + assert_eq!(names, ["RequestFlowTransition"]); + } +} diff --git a/crates/worker/src/ipc/protocol_session.rs b/crates/worker/src/ipc/protocol_session.rs index 04504df7..873d94d0 100644 --- a/crates/worker/src/ipc/protocol_session.rs +++ b/crates/worker/src/ipc/protocol_session.rs @@ -84,6 +84,7 @@ mod tests { let segments = vec![protocol::Segment::text("hello from log")]; let event = live_log_entry_event(LogEntry::UserInput { ts: session_store::segment_log::now_millis(), + extensions: vec![], segments: segments.clone(), }) .expect("UserInput must be live-relevant"); diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index 12c54458..5c6f7d5e 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -65,6 +65,8 @@ pub enum WorkerPrompt { MemoryExtractSystem, /// System prompt of the memory consolidation (integration + tidy) Engine. MemoryConsolidationSystem, + /// System prompt of the bounded Flow transition verifier. + FlowVerifierSystem, /// Wrapper around an incoming `Method::Notify` message injected into /// the next LLM request context as a transient system message. NotifyWrapper, @@ -99,6 +101,7 @@ impl WorkerPrompt { Self::CompactSystem => "compact_system", Self::MemoryExtractSystem => "memory_extract_system", Self::MemoryConsolidationSystem => "memory_consolidation_system", + Self::FlowVerifierSystem => "flow_verifier_system", Self::NotifyWrapper => "notify_wrapper", Self::InterruptToolResultSummary => "interrupt_tool_result_summary", Self::InterruptSystemNote => "interrupt_system_note", @@ -118,6 +121,7 @@ impl WorkerPrompt { WorkerPrompt::CompactSystem, WorkerPrompt::MemoryExtractSystem, WorkerPrompt::MemoryConsolidationSystem, + WorkerPrompt::FlowVerifierSystem, WorkerPrompt::NotifyWrapper, WorkerPrompt::InterruptToolResultSummary, WorkerPrompt::InterruptSystemNote, @@ -133,6 +137,7 @@ impl WorkerPrompt { "compact_system", "memory_extract_system", "memory_consolidation_system", + "flow_verifier_system", "notify_wrapper", "interrupt_tool_result_summary", "interrupt_system_note", @@ -342,6 +347,11 @@ impl PromptCatalog { ) } + /// Render `WorkerPrompt::FlowVerifierSystem` (no inputs). + pub fn flow_verifier_system(&self) -> Result { + self.render(WorkerPrompt::FlowVerifierSystem, Value::UNDEFINED) + } + /// Render `WorkerPrompt::NotifyWrapper` with `{{ message }}`. pub fn notify_wrapper(&self, message: &str) -> Result { self.render(WorkerPrompt::NotifyWrapper, single("message", message)) diff --git a/crates/worker/src/segment_log_sink.rs b/crates/worker/src/segment_log_sink.rs index 57eedd7e..0951d80c 100644 --- a/crates/worker/src/segment_log_sink.rs +++ b/crates/worker/src/segment_log_sink.rs @@ -252,6 +252,7 @@ mod tests { fn user_input(text: &str) -> LogEntry { LogEntry::UserInput { ts: now_millis(), + extensions: vec![], segments: vec![protocol::Segment::Text { content: text.to_owned(), }], diff --git a/crates/worker/src/shared_state.rs b/crates/worker/src/shared_state.rs index a10c4778..20563691 100644 --- a/crates/worker/src/shared_state.rs +++ b/crates/worker/src/shared_state.rs @@ -1,3 +1,4 @@ +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{OnceLock, RwLock}; use protocol::WorkerStatus; @@ -28,6 +29,7 @@ pub struct WorkerSharedState { /// `ListCompletions` queries without going through the controller. It is /// unset only in unit tests that construct `WorkerSharedState` directly. fs_view: OnceLock, + flow_transition_enabled: AtomicBool, } impl WorkerSharedState { @@ -44,6 +46,7 @@ impl WorkerSharedState { greeting, status: RwLock::new(WorkerStatus::Idle), fs_view: OnceLock::new(), + flow_transition_enabled: AtomicBool::new(false), } } @@ -59,6 +62,14 @@ impl WorkerSharedState { self.fs_view.get() } + pub fn enable_flow_transition(&self) { + self.flow_transition_enabled.store(true, Ordering::Release); + } + + pub fn flow_transition_enabled(&self) -> bool { + self.flow_transition_enabled.load(Ordering::Acquire) + } + pub fn set_status(&self, status: WorkerStatus) { if let Ok(mut s) = self.status.write() { *s = status; diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index d2557df6..8839dc05 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -13,7 +13,8 @@ use llm_engine::llm_client::types::Role; use llm_engine::state::Mutable; use llm_engine::{Engine, EngineError, EngineResult, ToolOutputLimits, UsageRecord}; use session_store::{ - LogEntry, SegmentId, SessionId, Store, StoreError, SystemItem, segment_log, to_logged, + LogEntry, SegmentId, SessionExtension, SessionId, Store, StoreError, SystemItem, segment_log, + to_logged, }; use session_store::{ WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore, WorkerReclaimedChild, @@ -80,7 +81,9 @@ use protocol::{ use tokio::net::UnixStream; use tokio::sync::broadcast; use tokio::task::JoinHandle; -use workdir::{LocalWorkdirSession, WorkdirSessionCapabilities, WorkdirSessionHandle}; +use workdir::{ + LocalWorkdirSession, ReadOnlyWorkdirSession, WorkdirSessionCapabilities, WorkdirSessionHandle, +}; const RESTORE_RECONCILIATION_REACHABILITY_TIMEOUT: Duration = Duration::from_millis(500); @@ -545,6 +548,7 @@ struct EmptyTurnRollbackSnapshot { usage_history_len: usize, ai_activity_count: usize, last_run_interrupted: bool, + flow_runtime_state: Option, } fn is_ai_materialized_item(item: &Item) -> bool { @@ -624,6 +628,28 @@ where } } +struct SessionFlowRuntimeStateCommitter { + writer: LogWriterHandle, +} + +impl crate::feature::builtin::flow_transition::FlowRuntimeStateCommitter + for SessionFlowRuntimeStateCommitter +where + St: Store + Clone + Send + Sync + 'static, +{ + fn commit(&self, state: &flow::FlowRuntimeState) -> Result<(), String> { + let payload = serde_json::to_value(state) + .map_err(|error| format!("serialize Flow runtime state: {error}"))?; + self.writer + .append_entry(LogEntry::Extension { + ts: segment_log::now_millis(), + domain: FLOW_RUNTIME_EXTENSION_DOMAIN.to_string(), + payload, + }) + .map_err(|error| error.to_string()) + } +} + /// An independent agent execution unit. /// /// Holds a [`Engine`] directly and persists session state via @@ -650,6 +676,10 @@ pub struct Worker { /// Path-free workspace identity/client context injected by Runtime/host. /// This never grants local filesystem authority. workspace_context: WorkerWorkspaceContext, + /// Runtime-owned durable Flow state reconstructed from Worker session + /// extensions on restore. + flow_runtime_state: Arc>>, + flow_feature_enabled: bool, /// Shared, atomically-swappable view of the Worker's resolved scope. /// Cloned into local WorkdirSession providers used by builtin tools, fs_view, /// and compaction so updates propagate at the next permission check. @@ -833,6 +863,8 @@ impl Worker filesystem_authority: self.filesystem_authority.clone(), workdir_session: self.workdir_session.clone(), workspace_context: self.workspace_context.clone(), + flow_runtime_state: self.flow_runtime_state.clone(), + flow_feature_enabled: self.flow_feature_enabled, scope: self.scope.clone(), delegation_scope: self.delegation_scope.clone(), hook_builder: HookRegistryBuilder::new(), @@ -1031,6 +1063,8 @@ impl Worker { filesystem_authority, workdir_session, workspace_context, + flow_runtime_state: Arc::new(Mutex::new(None)), + flow_feature_enabled: false, scope, delegation_scope, hook_builder: HookRegistryBuilder::new(), @@ -1317,6 +1351,81 @@ impl Worker { report } + /// Install the Flow transition feature against this Worker's Runtime-owned + /// durable Flow state. Workspace authority is used only when resolving a + /// new immutable source snapshot during Submit. + pub fn install_runtime_flow_transition_feature( + &mut self, + ) -> Result + where + C: Clone + Send + Sync + 'static, + St: Clone + Send + Sync + 'static, + { + self.flow_feature_enabled = true; + let writer = LogWriterHandle { + store: self.store.clone(), + state: self.segment_state.clone(), + sink: self.sink.clone(), + in_flight: self.in_flight.clone(), + }; + let coordinator = Arc::new( + crate::feature::builtin::flow_transition::RuntimeFlowCoordinatorClient::new( + self.flow_runtime_state.clone(), + Arc::new(SessionFlowRuntimeStateCommitter { writer }), + ), + ); + Ok(self.install_flow_transition_feature(coordinator)) + } + + /// Install the Flow transition tool for a host-authorized active Flow instance. + /// + /// The coordinator is already bound to the authenticated Workspace/Worker; + /// model input cannot select an instance or Worker identity. The verifier uses + /// an immutable capture of the current committed segment and, for local + /// Workdirs, exposes only Read/Glob/Grep through a read-only session. + pub fn install_flow_transition_feature( + &mut self, + coordinator: Arc, + ) -> FeatureRegistryInstallReport + where + C: Clone + Send + Sync + 'static, + St: Clone + Send + Sync + 'static, + { + let location = self.segment_state.location(); + let capture = crate::feature::builtin::flow_transition::StoreFlowParentCapture::new( + self.store.clone(), + location.session_id, + location.segment_id, + ); + let read_only_tools = self + .workdir_session + .clone() + .map(|source| Arc::new(ReadOnlyWorkdirSession::new(source)) as WorkdirSessionHandle) + .map(tools::read_only_builtin_tools) + .unwrap_or_default(); + let client = self + .engine + .as_ref() + .expect("worker taken during run") + .client() + .clone(); + let verifier = Arc::new( + crate::feature::builtin::flow_transition::WorkerBackedFlowVerifier::new( + client, + self.manifest.clone(), + capture, + read_only_tools, + ), + ); + let state = crate::feature::builtin::flow_transition::FlowTransitionState::new( + coordinator, + verifier, + ); + self.install_features(FeatureRegistryBuilder::new().with_module( + crate::feature::builtin::flow_transition::FlowTransitionFeature::new(state), + )) + } + /// Reference to the store. pub fn store(&self) -> &St { &self.store @@ -1910,6 +2019,11 @@ impl Worker { usage_history_len, ai_activity_count: self.ai_activity_counter.load(Ordering::SeqCst), last_run_interrupted: self.engine().last_run_interrupted(), + flow_runtime_state: self + .flow_runtime_state + .lock() + .expect("flow_runtime_state poisoned") + .clone(), } } @@ -1936,6 +2050,10 @@ impl Worker { self.engine_mut().truncate_history(snapshot.history_len); self.engine_mut() .set_last_run_interrupted(snapshot.last_run_interrupted); + *self + .flow_runtime_state + .lock() + .expect("flow_runtime_state poisoned") = snapshot.flow_runtime_state; self.user_segments.truncate(snapshot.user_segments_len); *self .pending_attachments @@ -1956,6 +2074,102 @@ impl Worker { Ok(()) } + fn prepare_flow_input( + &self, + input: Vec, + ) -> Result<(Vec, Option), WorkerError> { + let flow_segments = input + .iter() + .filter_map(|segment| match segment { + Segment::Flow { selector } => Some(selector.as_str()), + _ => None, + }) + .collect::>(); + if flow_segments.is_empty() { + return Ok((input, None)); + } + if flow_segments.len() != 1 { + return Err(WorkerError::FlowInput( + "one Submit may contain at most one Flow segment".to_string(), + )); + } + if !self.flow_feature_enabled { + return Err(WorkerError::FlowInput( + "resolved Profile does not enable feature.flow".to_string(), + )); + } + if self + .flow_runtime_state + .lock() + .expect("flow_runtime_state poisoned") + .as_ref() + .is_some_and(|state| state.instance.status == flow::FlowInstanceStatus::Active) + { + return Err(WorkerError::FlowInput( + "Worker already has an active Flow instance".to_string(), + )); + } + + let selector = flow_segments[0] + .parse::() + .map_err(|error| WorkerError::FlowInput(error.to_string()))?; + let workspace = self.workspace_context.client_handle(); + if !workspace.is_available() { + return Err(WorkerError::FlowInput( + "Workspace client is unavailable".to_string(), + )); + } + let workspace_id = workspace + .workspace_id() + .filter(|workspace_id| !workspace_id.trim().is_empty()) + .ok_or_else(|| { + WorkerError::FlowInput("Workspace client has no Workspace scope".to_string()) + })?; + let request = flow::FlowSourceResolveRequest { + selector: selector.clone(), + }; + let response = workspace + .execute(WorkspaceRequest { + method: WorkspaceRequestMethod::Post, + path: format!("/api/w/{workspace_id}/flows/resolve"), + body: Some(serde_json::to_string(&request).map_err(|error| { + WorkerError::FlowInput(format!("serialize Flow source request: {error}")) + })?), + }) + .map_err(|error| WorkerError::FlowInput(error.to_string()))?; + if !(200..300).contains(&response.status) { + let message = serde_json::from_str::(&response.body) + .ok() + .and_then(|body| { + body.get("error") + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + }) + .unwrap_or_else(|| "Workspace rejected Flow source resolution".to_string()); + return Err(WorkerError::FlowInput(message)); + } + let source: flow::ResolvedFlowSource = serde_json::from_str(&response.body) + .map_err(|error| WorkerError::FlowInput(format!("decode Flow source: {error}")))?; + if source.workspace_id != workspace_id || source.selector != selector { + return Err(WorkerError::FlowInput( + "Workspace returned a Flow source outside the requested scope".to_string(), + )); + } + let (state, initial_instructions) = + flow::FlowRuntimeState::start(&source, uuid::Uuid::now_v7().to_string()) + .map_err(|error| WorkerError::FlowInput(error.to_string()))?; + let input = input + .into_iter() + .map(|segment| match segment { + Segment::Flow { .. } => Segment::Text { + content: initial_instructions.clone(), + }, + other => other, + }) + .collect(); + Ok((input, Some(state))) + } + /// Send user input and run until the LLM turn completes. /// /// `input` is a typed segment list (see [`protocol::Segment`]). The @@ -1968,6 +2182,20 @@ impl Worker { /// the Engine is aborted, history is compacted, and execution resumes /// automatically. pub async fn run(&mut self, input: Vec) -> Result { + let (input, pending_flow_state) = self.prepare_flow_input(input)?; + let input_extensions = pending_flow_state + .as_ref() + .map(|state| { + serde_json::to_value(state) + .map(|payload| SessionExtension::new(FLOW_RUNTIME_EXTENSION_DOMAIN, payload)) + .map_err(|error| { + WorkerError::FlowInput(format!("serialize Flow runtime state: {error}")) + }) + }) + .transpose()? + .into_iter() + .collect(); + // Paused→Run transition: if the previous turn was cut short, // any `Item::ToolCall` whose tool never produced a matching // `ToolResult` is closed with a synthetic one, and a short @@ -1998,7 +2226,14 @@ impl Worker { self.commit_entry(LogEntry::UserInput { ts: segment_log::now_millis(), segments: input.clone(), + extensions: input_extensions, })?; + if let Some(state) = pending_flow_state { + *self + .flow_runtime_state + .lock() + .expect("flow_runtime_state poisoned") = Some(state); + } self.user_segments.push(input.clone()); // Resolve `@` file refs to system messages stashed for the @@ -2150,6 +2385,15 @@ impl Worker { for seg in segments { match seg { Segment::Text { .. } | Segment::Paste { .. } | Segment::FileRef { .. } => {} + Segment::Flow { selector } => { + self.alert( + AlertLevel::Error, + AlertSource::Worker, + format!( + "received unresolved Flow invocation {selector:?}; Runtime must resolve Flow segments through Workspace authority before Worker input" + ), + ); + } Segment::Unknown => { self.alert( AlertLevel::Warn, @@ -3043,7 +3287,23 @@ impl Worker { at_turn_index: source_turn_count, }), }; - let initial_entries = vec![entry.clone()]; + let mut initial_entries = vec![entry.clone()]; + if let Some(flow_state) = self + .flow_runtime_state + .lock() + .expect("flow_runtime_state poisoned") + .as_ref() + { + initial_entries.push(LogEntry::Extension { + ts: segment_log::now_millis(), + domain: FLOW_RUNTIME_EXTENSION_DOMAIN.to_string(), + payload: serde_json::to_value(flow_state).map_err(|error| { + WorkerError::InvalidState(format!( + "serialize Flow runtime state during compaction: {error}" + )) + })?, + }); + } self.store .create_segment(old_loc.session_id, new_segment_id, &initial_entries)?; self.segment_state.set_location(SegmentLocation { @@ -3052,12 +3312,10 @@ impl Worker { }); self.segment_state .set_entries_written(initial_entries.len()); - let session_start = entry; - // Broadcast the SegmentStart through the sink. This atomically - // resets the mirror to the replacement segment prefix so any subscriber - // querying after this point sees the post-compaction prefix, including - // durable extension state. - self.sink.reset_with_initial_entries(vec![session_start]); + // Broadcast the complete compacted prefix. Runtime-owned extensions + // must remain visible and restorable with the replacement segment. + self.sink + .reset_with_initial_entries(initial_entries.clone()); // Keep workers.json pointing at the live segment_id. Without this // a concurrent `restore_from_manifest(new_segment_id)` would // see no live writer and grab the session this Worker just moved @@ -3941,6 +4199,8 @@ where filesystem_authority: common.filesystem_authority, workdir_session, workspace_context: common.workspace_context, + flow_runtime_state: Arc::new(Mutex::new(None)), + flow_feature_enabled: false, scope, delegation_scope: common.delegation_scope, hook_builder: HookRegistryBuilder::new(), @@ -4016,6 +4276,8 @@ where filesystem_authority: common.filesystem_authority, workdir_session, workspace_context: common.workspace_context, + flow_runtime_state: Arc::new(Mutex::new(None)), + flow_feature_enabled: false, scope, delegation_scope: common.delegation_scope, hook_builder: HookRegistryBuilder::new(), @@ -4125,6 +4387,8 @@ where filesystem_authority: common.filesystem_authority, workdir_session, workspace_context: common.workspace_context, + flow_runtime_state: Arc::new(Mutex::new(None)), + flow_feature_enabled: false, scope, delegation_scope: common.delegation_scope, hook_builder: HookRegistryBuilder::new(), @@ -4418,6 +4682,10 @@ where filesystem_authority: common.filesystem_authority, workdir_session, workspace_context: common.workspace_context, + flow_runtime_state: Arc::new(Mutex::new(restored_flow_runtime_state( + &state.extensions, + )?)), + flow_feature_enabled: false, scope, delegation_scope: common.delegation_scope, hook_builder: HookRegistryBuilder::new(), @@ -4896,7 +5164,7 @@ fn build_rewind_targets(segment_id: uuid::Uuid, entries: &[LogEntry]) -> Vec String { preview.push('@'); preview.push_str(path); } + Segment::Flow { selector } => { + preview.push_str("[Flow: "); + preview.push_str(selector); + preview.push(']'); + } Segment::Unknown => preview.push_str("[unknown input segment]"), } } @@ -4972,8 +5245,31 @@ fn preview_segments(segments: &[Segment]) -> String { out } +const FLOW_RUNTIME_EXTENSION_DOMAIN: &str = "flow.runtime.v1"; + +fn restored_flow_runtime_state( + extensions: &[(String, serde_json::Value)], +) -> Result, WorkerError> { + extensions + .iter() + .rev() + .find(|(domain, _)| domain == FLOW_RUNTIME_EXTENSION_DOMAIN) + .map(|(_, payload)| { + serde_json::from_value(payload.clone()).map_err(|error| { + WorkerError::InvalidState(format!("invalid persisted Flow runtime state: {error}")) + }) + }) + .transpose() +} + #[derive(Debug, thiserror::Error)] pub enum WorkerError { + #[error("invalid durable Worker state: {0}")] + InvalidState(String), + + #[error("Flow input rejected: {0}")] + FlowInput(String), + #[error(transparent)] Engine(#[from] EngineError), @@ -5892,6 +6188,64 @@ mod build_summary_prompt_tests { } } + #[derive(Debug, Default)] + struct FlowSourceWorkspaceClient { + requests: Mutex>, + } + + impl WorkspaceClient for FlowSourceWorkspaceClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace-test") + } + + fn kind(&self) -> &str { + "flow-source-test" + } + + fn is_available(&self) -> bool { + true + } + + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + self.requests + .lock() + .expect("Flow source request lock") + .push(request); + let definition = flow::compile_flow_source( + r#"{ + schema_version = 1; + name = "coder-review"; + initial = "implement"; + states = { + implement = { + instructions = "Implement the Ticket and request review."; + transitions = { + done = { target = "done"; condition = "The work is approved."; }; + }; + }; + done = { instructions = "Complete."; terminal = true; }; + }; + }"#, + ) + .unwrap(); + let source = flow::ResolvedFlowSource { + selector: "builtin:coder-review".parse().unwrap(), + workspace_id: "workspace-test".to_string(), + flow_id: "flow-source-1".to_string(), + revision: 3, + content_digest: definition.content_digest.clone(), + definition, + }; + Ok(WorkspaceResponse { + status: 200, + body: serde_json::to_string(&source).unwrap(), + }) + } + } + #[derive(Clone)] struct NoopClient; @@ -5927,6 +6281,154 @@ mod build_summary_prompt_tests { } } + #[tokio::test] + async fn flow_transition_feature_installs_runtime_local_coordinator() { + let dir = tempfile::tempdir().unwrap(); + let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap(); + let workspace_client = Arc::new(RecordingAuditWorkspaceClient::default()); + let mut worker = Worker::new( + minimal_manifest(), + Engine::new(NoopClient), + store, + WorkerWorkspaceContext::with_client( + Some(WorkspaceId::new("workspace-test").unwrap()), + workspace_client, + ), + WorkerFilesystemAuthority::None, + Scope::empty(), + ) + .await + .unwrap(); + worker.ensure_segment_head().unwrap(); + let report = worker + .install_runtime_flow_transition_feature() + .expect("scoped Workspace Flow feature"); + assert_eq!(report.reports.len(), 1); + assert!(report.reports[0].installed); + assert_eq!( + report.reports[0] + .installed_tools + .iter() + .map(String::as_str) + .collect::>(), + ["RequestFlowTransition"] + ); + } + + #[tokio::test] + async fn flow_submit_persists_runtime_state_atomically_with_worker_input() { + let dir = tempfile::tempdir().unwrap(); + let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap(); + let workspace_client = Arc::new(FlowSourceWorkspaceClient::default()); + let mut worker = Worker::new( + minimal_manifest(), + Engine::new(NoopClient), + store.clone(), + WorkerWorkspaceContext::with_client( + Some(WorkspaceId::new("workspace-test").unwrap()), + workspace_client.clone(), + ), + WorkerFilesystemAuthority::None, + Scope::empty(), + ) + .await + .unwrap(); + worker.ensure_segment_head().unwrap(); + let disabled = worker.prepare_flow_input(vec![Segment::Flow { + selector: "builtin:coder-review".to_string(), + }]); + assert!( + matches!(disabled, Err(WorkerError::FlowInput(message)) if message.contains("feature.flow")) + ); + worker.install_runtime_flow_transition_feature().unwrap(); + let multiple = worker.prepare_flow_input(vec![ + Segment::Flow { + selector: "builtin:coder-review".to_string(), + }, + Segment::Flow { + selector: "workspace:coder-review".to_string(), + }, + ]); + assert!( + matches!(multiple, Err(WorkerError::FlowInput(message)) if message.contains("at most one")) + ); + let invalid = worker.prepare_flow_input(vec![Segment::Flow { + selector: "coder-review".to_string(), + }]); + assert!(matches!(invalid, Err(WorkerError::FlowInput(_)))); + + let (segments, state) = worker + .prepare_flow_input(vec![ + Segment::Flow { + selector: "builtin:coder-review".to_string(), + }, + Segment::text("Implement Ticket 00001"), + ]) + .unwrap(); + let state = state.expect("new Flow runtime state"); + let extension = SessionExtension::new( + FLOW_RUNTIME_EXTENSION_DOMAIN, + serde_json::to_value(&state).unwrap(), + ); + worker + .commit_entry(LogEntry::UserInput { + ts: segment_log::now_millis(), + segments: segments.clone(), + extensions: vec![extension], + }) + .unwrap(); + *worker + .flow_runtime_state + .lock() + .expect("flow runtime state lock") = Some(state.clone()); + + assert!(matches!(segments[0], Segment::Text { .. })); + assert_eq!(segments[1], Segment::text("Implement Ticket 00001")); + assert_eq!(state.instance.definition_revision, 3); + assert_eq!(state.instance.current_state.as_str(), "implement"); + assert_eq!(workspace_client.requests.lock().unwrap().len(), 1); + + let location = worker.segment_state.location(); + let restored = session_store::collect_state( + &store + .read_all(location.session_id, location.segment_id) + .unwrap(), + ); + let restored_flow = restored_flow_runtime_state(&restored.extensions) + .unwrap() + .expect("restored Flow state"); + assert_eq!(restored_flow, state); + assert_eq!(restored.user_segments, vec![segments]); + + let duplicate = worker.prepare_flow_input(vec![Segment::Flow { + selector: "builtin:coder-review".to_string(), + }]); + assert!( + matches!(duplicate, Err(WorkerError::FlowInput(message)) if message.contains("active Flow")) + ); + + let mut detached = Worker::new( + minimal_manifest(), + Engine::new(NoopClient), + session_store::FsStore::new(dir.path().join("detached-sessions")).unwrap(), + WorkerWorkspaceContext::unavailable( + Some(WorkspaceId::new("workspace-test").unwrap()), + "test unavailable", + ), + WorkerFilesystemAuthority::None, + Scope::empty(), + ) + .await + .unwrap(); + detached.install_runtime_flow_transition_feature().unwrap(); + let unavailable = detached.prepare_flow_input(vec![Segment::Flow { + selector: "builtin:coder-review".to_string(), + }]); + assert!( + matches!(unavailable, Err(WorkerError::FlowInput(message)) if message.contains("unavailable")) + ); + } + async fn rewind_test_worker() -> ( tempfile::TempDir, Worker, @@ -5972,6 +6474,7 @@ mod build_summary_prompt_tests { worker, LogEntry::UserInput { ts: ts + 1, + extensions: vec![], segments: vec![text_segment(text)], }, ); @@ -6603,6 +7106,7 @@ mod build_summary_prompt_tests { worker .commit_entry(LogEntry::UserInput { ts: segment_log::now_millis(), + extensions: vec![], segments: vec![text_segment( "The cancellation regression must leave this evidence available for retry.", )], diff --git a/crates/worker/tests/compact_events_test.rs b/crates/worker/tests/compact_events_test.rs index 0e4dccb9..76282bf2 100644 --- a/crates/worker/tests/compact_events_test.rs +++ b/crates/worker/tests/compact_events_test.rs @@ -310,6 +310,7 @@ permission = "write" &LogEntry::UserInput { ts: 9999, segments: vec![protocol::Segment::text("interloper")], + extensions: vec![], }, ) .unwrap(); diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index e22cbe8f..eda17ca8 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -19,6 +19,7 @@ async-trait.workspace = true axum = { workspace = true, features = ["ws"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } futures.workspace = true +flow = { path = "../flow" } manifest.workspace = true protocol = { workspace = true } project-record.workspace = true diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 20b28e66..13c720f8 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -221,6 +221,8 @@ pub struct HostSummary { pub struct WorkerWorkspaceSummary { pub visibility: String, pub identity: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -1495,6 +1497,7 @@ impl EmbeddedWorkerRuntime { workspace: WorkerWorkspaceSummary { visibility: "backend_internal".to_string(), identity: "runtime_registry_worker".to_string(), + workspace_id: summary.workspace_id.clone(), }, state: embedded_worker_status_label(summary.status).to_string(), last_seen_at: None, @@ -1533,6 +1536,7 @@ impl EmbeddedWorkerRuntime { workspace: WorkerWorkspaceSummary { visibility: "backend_internal".to_string(), identity: "runtime_registry_worker".to_string(), + workspace_id: detail.workspace_id.clone(), }, state: embedded_worker_status_label(detail.status).to_string(), last_seen_at: None, @@ -2575,6 +2579,7 @@ impl RemoteWorkerRuntime { workspace: WorkerWorkspaceSummary { visibility: "remote_runtime".to_string(), identity: "runtime_registry_worker".to_string(), + workspace_id: summary.workspace_id.clone(), }, state: embedded_worker_status_label(summary.status).to_string(), last_seen_at: None, @@ -2617,6 +2622,7 @@ impl RemoteWorkerRuntime { workspace: WorkerWorkspaceSummary { visibility: "remote_runtime".to_string(), identity: "runtime_registry_worker".to_string(), + workspace_id: detail.workspace_id.clone(), }, state: embedded_worker_status_label(detail.status).to_string(), last_seen_at: None, @@ -3885,6 +3891,7 @@ pub fn placeholder_worker(host_id: impl Into) -> WorkerSummary { workspace: WorkerWorkspaceSummary { visibility: "none".to_string(), identity: "unsupported".to_string(), + workspace_id: None, }, state: "unsupported".to_string(), last_seen_at: None, @@ -4329,6 +4336,7 @@ mod tests { workspace: WorkerWorkspaceSummary { visibility: "opaque".to_string(), identity: host_id.to_string(), + workspace_id: None, }, state: "available".to_string(), last_seen_at: None, diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 36a3af02..2e2d67ca 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -57,6 +57,8 @@ pub enum Error { Ticket(#[from] ticket::TicketError), #[error("yaml error: {0}")] Yaml(#[from] serde_yaml::Error), + #[error("invalid input: {0}")] + InvalidInput(String), #[error("invalid project record id `{0}`")] InvalidRecordId(String), #[error("workspace backend config error: {0}")] diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index f6645cf7..e70c4a51 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -12,6 +12,7 @@ use axum::response::{IntoResponse, Response}; use axum::routing::{delete, get, patch, post, put}; use axum::{Json, Router}; use chrono::{Duration, SecondsFormat, Utc}; +use flow::{FlowSourceKind, FlowSourceResolveRequest, ResolvedFlowSource}; use futures::{SinkExt, StreamExt}; use memory::backend::{ MemoryBackendHttpResponse, MemoryBackendOperation, MemoryConsolidateStagingOperation, @@ -96,9 +97,9 @@ use crate::runtime_subscription::RuntimeSubscriptionBroker; use crate::skills; use crate::store::{ AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore, - DeviceLoginFlowRecord, PasskeyCredentialRecord, RepositoryRecord, TicketWorkerAssignmentRecord, - UserRecord, WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, - WorkspaceRecord, + DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryRecord, + TicketWorkerAssignmentRecord, UserRecord, WorkdirRegistryRecord, WorkerRegistryRecord, + WorkerWorkdirLinkRecord, WorkspaceRecord, }; use crate::{Error, Result}; use worker_runtime::catalog::{ @@ -700,6 +701,18 @@ pub fn build_router(api: WorkspaceApi) -> Router { .put(scoped_update_profile_source) .delete(scoped_delete_profile_source), ) + .route( + "/api/w/{workspace_id}/flows", + get(scoped_list_flows).put(scoped_put_flow), + ) + .route( + "/api/w/{workspace_id}/flows/resolve", + post(scoped_resolve_flow_source), + ) + .route( + "/api/w/{workspace_id}/flows/{flow_id}", + get(scoped_get_flow), + ) .route("/api/tickets", get(list_tickets)) .route( "/api/w/{workspace_id}/tickets", @@ -1669,6 +1682,19 @@ struct ScopedWorkspacePath { workspace_id: String, } +#[derive(Debug, Deserialize)] +struct ScopedFlowPath { + workspace_id: String, + flow_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PutFlowRequest { + path: String, + content: String, +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct AttachCurrentWorkerWorkdirRequest { @@ -1753,6 +1779,107 @@ fn validate_workspace_scope(api: &WorkspaceApi, workspace_id: &str) -> ApiResult } } +async fn scoped_list_flows( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult>> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(api.store.list_flow_sources(&path.workspace_id)?)) +} + +async fn scoped_put_flow( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let definition = flow::compile_flow_source(&request.content).map_err(|error| { + Error::InvalidInput(format!( + "invalid Flow source: {}", + serde_json::to_string(&error.diagnostics) + .unwrap_or_else(|_| "diagnostics unavailable".to_string()) + )) + })?; + let expected_path = format!("flows/{}.dcdl", definition.name); + if request.path != expected_path { + return Err( + Error::InvalidInput(format!("Flow source path must be `{expected_path}`")).into(), + ); + } + let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + Ok(Json(api.store.put_flow_source_for_kind( + &path.workspace_id, + FlowSourceKind::Workspace, + &request.path, + &request.content, + &now, + )?)) +} + +async fn scoped_resolve_flow_source( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let resolved = match &request.selector { + flow::FlowSelector::Builtin { slug } => { + let builtin = flow::builtin_flow_source(slug) + .ok_or_else(|| Error::InvalidRecordId(request.selector.to_string()))?; + let definition = builtin.compile().map_err(|error| { + Error::Store(format!( + "compile built-in Flow {slug:?}: {:?}", + error.diagnostics + )) + })?; + ResolvedFlowSource { + selector: request.selector.clone(), + workspace_id: path.workspace_id, + flow_id: format!("builtin:{slug}"), + revision: builtin.revision, + content_digest: definition.content_digest.clone(), + definition, + } + } + flow::FlowSelector::Workspace { slug } => { + let source = api + .store + .get_flow_source_by_name(&path.workspace_id, FlowSourceKind::Workspace, slug)? + .ok_or_else(|| Error::InvalidRecordId(request.selector.to_string()))?; + let revision = api + .store + .get_flow_source_revision(&path.workspace_id, &source.flow_id, source.revision)? + .ok_or_else(|| { + Error::Store(format!( + "resolved Flow revision {}@{} is missing", + source.flow_id, source.revision + )) + })?; + ResolvedFlowSource { + selector: request.selector.clone(), + workspace_id: path.workspace_id, + flow_id: source.flow_id, + revision: source.revision, + content_digest: revision.content_digest, + definition: revision.definition, + } + } + }; + Ok(Json(resolved)) +} + +async fn scoped_get_flow( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let source = api + .store + .get_flow_source(&path.workspace_id, &path.flow_id)? + .ok_or_else(|| Error::InvalidRecordId(path.flow_id))?; + Ok(Json(source)) +} + async fn scoped_get_workspace( State(api): State, AxumPath(path): AxumPath, @@ -3157,7 +3284,7 @@ fn notify_ticket_recipients( fn authenticate_worker_mutation_source( api: &WorkspaceApi, - _workspace_id: &str, + workspace_id: &str, headers: &HeaderMap, ) -> Result { let runtime_id = headers @@ -3173,9 +3300,14 @@ fn authenticate_worker_mutation_source( Error::WorkerSourceIdentity("missing Runtime-bound Worker id".to_string()) })?; let worker = RuntimeWorkerRef::new(runtime_id, worker_id); - api.runtime.worker(&worker).map_err(|_| { + let summary = api.runtime.worker(&worker).map_err(|_| { Error::WorkerSourceIdentity("Runtime-bound Worker identity does not exist".to_string()) })?; + if summary.workspace.workspace_id.as_deref() != Some(workspace_id) { + return Err(Error::WorkerSourceIdentity(format!( + "Runtime-bound Worker is not scoped to Workspace {workspace_id}" + ))); + } Ok(worker) } @@ -6757,7 +6889,7 @@ async fn create_workspace_worker( } else { Some(EmbeddedWorkerInput { kind: EmbeddedWorkerInputKind::User, - content: initial_text, + content: initial_text.clone(), segments: None, }) }; @@ -6776,8 +6908,9 @@ async fn create_workspace_worker( if resolved_working_directory.is_none() { reject_no_workdir_for_non_embedded_runtime(&request.runtime_id)?; } + let runtime_id = request.runtime_id.clone(); let result = api.spawn_workspace_worker( - &request.runtime_id, + &runtime_id, WorkerSpawnRequest { requested_worker_name: Some(display_name.clone()), intent: WorkerSpawnIntent::WorkspaceCoding, @@ -6798,7 +6931,7 @@ async fn create_workspace_worker( )?; Ok(Json(record_browser_worker_spawn( &api, - request.runtime_id, + runtime_id, display_name, selected_working_directory_id, result, @@ -8949,6 +9082,7 @@ fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> WorkerSummary workspace: WorkerWorkspaceSummary { visibility: "backend_registry".to_string(), identity: record.workspace_id.clone(), + workspace_id: Some(record.workspace_id.clone()), }, profile: record.profile.clone(), implementation: WorkerImplementationSummary { @@ -9729,7 +9863,7 @@ impl IntoResponse for ApiError { Error::TicketAssignmentConflict(_) | Error::WorkdirAttachmentConflict(_) => { StatusCode::CONFLICT } - Error::WorkerSourceIdentity(_) => StatusCode::BAD_REQUEST, + Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST, Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => { StatusCode::BAD_REQUEST } @@ -10008,6 +10142,107 @@ mod tests { assert!(!serialized.contains("materialized_path")); } + #[tokio::test] + async fn flow_source_resolution_returns_immutable_workspace_and_builtin_snapshots() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let source = r#"{ + schema_version = 1; + name = "browser-flow"; + initial = "work"; + states = { + work = { + instructions = "Implement and validate the requested change."; + transitions = { + done = { target = "done"; condition = "The work is complete."; }; + }; + }; + done = { instructions = ""; terminal = true; }; + }; + }"#; + let stored = api + .store + .put_flow_source_for_kind( + &api.config.workspace_id, + FlowSourceKind::Workspace, + "flows/browser-flow.dcdl", + source, + "2026-08-06T00:00:00Z", + ) + .unwrap(); + + let Json(resolved) = scoped_resolve_flow_source( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: api.config.workspace_id.clone(), + }), + Json(FlowSourceResolveRequest { + selector: "workspace:browser-flow".parse().unwrap(), + }), + ) + .await + .unwrap(); + assert_eq!(resolved.flow_id, stored.flow_id); + assert_eq!(resolved.revision, stored.revision); + assert_eq!(resolved.content_digest, stored.content_digest); + assert_eq!(resolved.definition.name, "browser-flow"); + + let Json(builtin) = scoped_resolve_flow_source( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: api.config.workspace_id.clone(), + }), + Json(FlowSourceResolveRequest { + selector: "builtin:coder-review".parse().unwrap(), + }), + ) + .await + .unwrap(); + assert_eq!(builtin.definition.name, "coder-review"); + assert_eq!(builtin.selector.to_string(), "builtin:coder-review"); + assert_eq!(builtin.flow_id, "builtin:coder-review"); + assert_eq!(builtin.revision, 1); + assert_eq!( + api.store + .list_flow_sources(&api.config.workspace_id) + .unwrap(), + vec![stored], + "built-in resolution must not mutate Workspace source authority", + ); + } + + #[tokio::test] + async fn worker_source_auth_rejects_cross_workspace_mutation() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let Json(created) = create_workspace_worker( + State(api.clone()), + Json(BrowserCreateWorkerRequest { + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), + display_name: "Scoped Worker".to_string(), + profile: Some("builtin:coder".to_string()), + initial_text: String::new(), + working_directory: None, + }), + ) + .await + .unwrap(); + let mut headers = HeaderMap::new(); + headers.insert( + "x-yoi-runtime-id", + axum::http::HeaderValue::from_str(&created.worker_ref.runtime_id).unwrap(), + ); + headers.insert( + "x-yoi-worker-id", + axum::http::HeaderValue::from_str(&created.worker_ref.worker_id).unwrap(), + ); + let error = + authenticate_worker_mutation_source(&api, "other-workspace", &headers).unwrap_err(); + assert!(matches!(error, Error::WorkerSourceIdentity(_))); + } + #[tokio::test] async fn explicit_orchestrator_launch_marks_only_the_dedicated_worker() { let workspace = tempfile::tempdir().unwrap(); @@ -14375,6 +14610,81 @@ mod tests { server.abort(); } + #[tokio::test] + async fn scoped_flow_source_route_persists_compiled_dcdl() { + let temp = tempfile::tempdir().unwrap(); + let app = test_app(temp.path()).await; + let workspace_id = test_identity().workspace_id; + let source = r#"{ + schema_version = 1; + name = "route-flow"; + initial = "work"; + states = { + work = { + instructions = "Do the work."; + transitions = { + done = { + target = "done"; + condition = "The work is complete."; + }; + }; + }; + done = { instructions = ""; terminal = true; }; + }; + }"#; + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::PUT) + .uri(format!("/api/w/{workspace_id}/flows")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::json!({ + "path": "flows/route-flow.dcdl", + "content": source, + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::GET) + .uri(format!("/api/w/{workspace_id}/flows")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let response = app + .oneshot( + Request::builder() + .method(Method::PUT) + .uri(format!("/api/w/{workspace_id}/flows")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::json!({ + "path": "flows/broken.dcdl", + "content": "{ schema_version = 1; }", + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + #[tokio::test] async fn passkey_registration_rejects_unverified_credential_response() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index cedfb680..c180414f 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -3,8 +3,10 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use async_trait::async_trait; +use flow::{CompiledFlowDefinition, FlowSourceKind, compile_flow_source}; use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; use worker_runtime::identity::RuntimeWorkerRef; @@ -139,6 +141,16 @@ const MIGRATIONS: &[Migration] = &[ name: "create Worker Workdir attachment reservations", apply: create_worker_workdir_attachment_reservations, }, + Migration { + version: 25, + name: "create Flow source authority", + apply: create_flow_source_authority, + }, + Migration { + version: 26, + name: "remove Backend-owned Flow runtime authority", + apply: remove_backend_flow_runtime_authority, + }, ]; struct Migration { @@ -408,6 +420,31 @@ pub struct MemoryStagingResolutionRecord { pub resolved_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FlowSourceRecord { + pub workspace_id: String, + pub flow_id: String, + pub source_kind: FlowSourceKind, + pub name: String, + pub path: String, + pub content: String, + pub content_digest: String, + pub revision: u64, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FlowSourceRevisionRecord { + pub workspace_id: String, + pub flow_id: String, + pub revision: u64, + pub content: String, + pub content_digest: String, + pub definition: CompiledFlowDefinition, + pub created_at: String, +} + #[async_trait] pub trait ControlPlaneStore: Send + Sync { async fn schema_version(&self) -> Result; @@ -417,6 +454,33 @@ pub trait ControlPlaneStore: Send + Sync { fn upsert_repository(&self, record: &RepositoryRecord) -> Result<()>; fn list_repositories(&self, workspace_id: &str) -> Result>; + fn put_flow_source_for_kind( + &self, + workspace_id: &str, + source_kind: FlowSourceKind, + path: &str, + content: &str, + now: &str, + ) -> Result; + fn get_flow_source_by_name( + &self, + workspace_id: &str, + source_kind: FlowSourceKind, + name: &str, + ) -> Result>; + fn list_flow_sources(&self, workspace_id: &str) -> Result>; + fn get_flow_source( + &self, + workspace_id: &str, + flow_id: &str, + ) -> Result>; + fn get_flow_source_revision( + &self, + workspace_id: &str, + flow_id: &str, + revision: u64, + ) -> Result>; + fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()>; fn list_objectives(&self, workspace_id: &str, limit: usize) -> Result>; fn get_objective( @@ -866,6 +930,219 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn put_flow_source_for_kind( + &self, + workspace_id: &str, + source_kind: FlowSourceKind, + path: &str, + content: &str, + now: &str, + ) -> Result { + if source_kind != FlowSourceKind::Workspace { + return Err(Error::Store( + "built-in Flow sources are resource authority and cannot be written to Workspace DB" + .to_string(), + )); + } + let name = flow_source_name(path)?; + let definition = compile_flow_source(content).map_err(|error| { + Error::Store(format!( + "compile Flow source {path:?}: {:?}", + error.diagnostics + )) + })?; + if definition.name != name { + return Err(Error::Store(format!( + "Flow source name {:?} does not match path slug {name:?}", + definition.name + ))); + } + self.with_conn(|conn| { + let tx = conn.unchecked_transaction()?; + let existing = tx + .query_row( + r#"SELECT workspace_id, flow_id, source_kind, name, path, content, + content_digest, revision, created_at, updated_at + FROM flow_sources + WHERE workspace_id = ?1 AND source_kind = ?2 AND name = ?3"#, + params![workspace_id, source_kind.as_str(), name], + read_flow_source_record, + ) + .optional()?; + if let Some(existing) = existing { + if existing.content_digest == definition.content_digest { + tx.commit()?; + return Ok(existing); + } + let revision = existing + .revision + .checked_add(1) + .ok_or_else(|| Error::Store("Flow source revision overflowed".to_string()))?; + let definition_json = serde_json::to_string(&definition) + .map_err(|error| Error::Store(error.to_string()))?; + tx.execute( + r#"INSERT INTO flow_source_revisions ( + workspace_id, flow_id, revision, content, content_digest, + definition_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#, + params![ + workspace_id, + existing.flow_id, + revision, + content, + definition.content_digest, + definition_json, + now + ], + )?; + tx.execute( + r#"UPDATE flow_sources + SET path = ?4, content = ?5, content_digest = ?6, + revision = ?7, updated_at = ?8 + WHERE workspace_id = ?1 AND source_kind = ?2 AND name = ?3"#, + params![ + workspace_id, + source_kind.as_str(), + name, + path, + content, + definition.content_digest, + revision, + now + ], + )?; + tx.commit()?; + return Ok(FlowSourceRecord { + revision, + path: path.to_string(), + content: content.to_string(), + content_digest: definition.content_digest, + updated_at: now.to_string(), + ..existing + }); + } + + let flow_id = Uuid::now_v7().to_string(); + let definition_json = serde_json::to_string(&definition) + .map_err(|error| Error::Store(error.to_string()))?; + tx.execute( + r#"INSERT INTO flow_sources ( + workspace_id, flow_id, source_kind, name, path, content, + content_digest, revision, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1, ?8, ?8)"#, + params![ + workspace_id, + flow_id, + source_kind.as_str(), + name, + path, + content, + definition.content_digest, + now + ], + )?; + tx.execute( + r#"INSERT INTO flow_source_revisions ( + workspace_id, flow_id, revision, content, content_digest, + definition_json, created_at + ) VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6)"#, + params![ + workspace_id, + flow_id, + content, + definition.content_digest, + definition_json, + now + ], + )?; + tx.commit()?; + Ok(FlowSourceRecord { + workspace_id: workspace_id.to_string(), + flow_id, + source_kind, + name, + path: path.to_string(), + content: content.to_string(), + content_digest: definition.content_digest, + revision: 1, + created_at: now.to_string(), + updated_at: now.to_string(), + }) + }) + } + + fn get_flow_source_by_name( + &self, + workspace_id: &str, + source_kind: FlowSourceKind, + name: &str, + ) -> Result> { + self.with_conn(|conn| { + conn.query_row( + r#"SELECT workspace_id, flow_id, source_kind, name, path, content, + content_digest, revision, created_at, updated_at + FROM flow_sources + WHERE workspace_id = ?1 AND source_kind = ?2 AND name = ?3"#, + params![workspace_id, source_kind.as_str(), name], + read_flow_source_record, + ) + .optional() + .map_err(Error::from) + }) + } + + fn list_flow_sources(&self, workspace_id: &str) -> Result> { + self.with_conn(|conn| { + let mut statement = conn.prepare( + r#"SELECT workspace_id, flow_id, source_kind, name, path, content, + content_digest, revision, created_at, updated_at + FROM flow_sources WHERE workspace_id = ?1 + ORDER BY source_kind ASC, name ASC"#, + )?; + let rows = statement.query_map(params![workspace_id], read_flow_source_record)?; + rows.collect::, _>>() + .map_err(Error::from) + }) + } + + fn get_flow_source( + &self, + workspace_id: &str, + flow_id: &str, + ) -> Result> { + self.with_conn(|conn| { + conn.query_row( + r#"SELECT workspace_id, flow_id, source_kind, name, path, content, + content_digest, revision, created_at, updated_at + FROM flow_sources WHERE workspace_id = ?1 AND flow_id = ?2"#, + params![workspace_id, flow_id], + read_flow_source_record, + ) + .optional() + .map_err(Error::from) + }) + } + + fn get_flow_source_revision( + &self, + workspace_id: &str, + flow_id: &str, + revision: u64, + ) -> Result> { + self.with_conn(|conn| { + conn.query_row( + r#"SELECT workspace_id, flow_id, revision, content, content_digest, + definition_json, created_at + FROM flow_source_revisions + WHERE workspace_id = ?1 AND flow_id = ?2 AND revision = ?3"#, + params![workspace_id, flow_id, revision], + read_flow_source_revision_record, + ) + .optional() + .map_err(Error::from) + }) + } + fn upsert_objective(&self, record: &ObjectiveRecord) -> Result<()> { self.with_conn(|conn| { conn.execute( @@ -2660,6 +2937,79 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } } +fn flow_source_name(path: &str) -> Result { + let file_name = path + .rsplit('/') + .next() + .filter(|value| !value.is_empty()) + .ok_or_else(|| Error::Store("Flow source path has no file name".to_string()))?; + let name = file_name + .strip_suffix(".dcdl") + .filter(|value| !value.is_empty()) + .ok_or_else(|| Error::Store("Flow source path must end in .dcdl".to_string()))?; + flow::FlowSelector::builtin(name) + .map_err(|error| Error::Store(format!("invalid Flow source slug: {error}")))?; + Ok(name.to_string()) +} + +fn read_flow_source_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let source_kind = match row.get::<_, String>(2)?.as_str() { + "builtin" => FlowSourceKind::Builtin, + "workspace" => FlowSourceKind::Workspace, + other => { + return Err(rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Text, + format!("invalid Flow source kind {other:?}").into(), + )); + } + }; + let revision = row.get::<_, i64>(7)?; + Ok(FlowSourceRecord { + workspace_id: row.get(0)?, + flow_id: row.get(1)?, + source_kind, + name: row.get(3)?, + path: row.get(4)?, + content: row.get(5)?, + content_digest: row.get(6)?, + revision: u64::try_from(revision).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 7, + rusqlite::types::Type::Integer, + Box::new(error), + ) + })?, + created_at: row.get(8)?, + updated_at: row.get(9)?, + }) +} + +fn read_flow_source_revision_record( + row: &rusqlite::Row<'_>, +) -> rusqlite::Result { + let revision = row.get::<_, i64>(2)?; + let definition_json = row.get::<_, String>(5)?; + let definition = serde_json::from_str(&definition_json).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(error)) + })?; + Ok(FlowSourceRevisionRecord { + workspace_id: row.get(0)?, + flow_id: row.get(1)?, + revision: u64::try_from(revision).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Integer, + Box::new(error), + ) + })?, + content: row.get(3)?, + content_digest: row.get(4)?, + definition, + created_at: row.get(6)?, + }) +} + fn read_workspace_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(WorkspaceRecord { workspace_id: row.get(0)?, @@ -3623,6 +3973,52 @@ CREATE TABLE IF NOT EXISTS __yoi_schema_migrations ( Ok(()) } +fn create_flow_source_authority(conn: &Connection) -> Result<()> { + conn.execute_batch( + r#" +CREATE TABLE flow_sources ( + workspace_id TEXT NOT NULL, + flow_id TEXT NOT NULL, + source_kind TEXT NOT NULL CHECK (source_kind IN ('builtin', 'workspace')), + name TEXT NOT NULL, + path TEXT NOT NULL, + content TEXT NOT NULL, + content_digest TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, flow_id), + UNIQUE (workspace_id, source_kind, name), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE flow_source_revisions ( + workspace_id TEXT NOT NULL, + flow_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision > 0), + content TEXT NOT NULL, + content_digest TEXT NOT NULL, + definition_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, flow_id, revision), + FOREIGN KEY (workspace_id, flow_id) + REFERENCES flow_sources(workspace_id, flow_id) ON DELETE CASCADE +); +"#, + )?; + Ok(()) +} + +fn remove_backend_flow_runtime_authority(conn: &Connection) -> Result<()> { + conn.execute_batch( + r#" +DROP TABLE IF EXISTS flow_events; +DROP TABLE IF EXISTS flow_transition_attempts; +DROP TABLE IF EXISTS flow_instances; +"#, + )?; + Ok(()) +} + fn current_schema_version(conn: &Connection) -> Result { conn.query_row( "SELECT COALESCE(MAX(version), 0) FROM __yoi_schema_migrations", @@ -4182,17 +4578,54 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 24); + assert_eq!(current_schema_version(&conn).unwrap(), 26); assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap()); } + #[test] + fn schema_v26_removes_legacy_backend_flow_runtime_tables() { + let conn = Connection::open_in_memory().unwrap(); + configure_sqlite(&conn).unwrap(); + for migration in MIGRATIONS + .iter() + .filter(|migration| migration.version <= 25) + { + let tx = conn.unchecked_transaction().unwrap(); + (migration.apply)(&tx).unwrap(); + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![migration.version, migration.name], + ) + .unwrap(); + tx.commit().unwrap(); + } + conn.execute_batch( + r#" +CREATE TABLE flow_instances (instance_id TEXT PRIMARY KEY); +CREATE TABLE flow_transition_attempts (attempt_id TEXT PRIMARY KEY); +CREATE TABLE flow_events (event_id TEXT PRIMARY KEY); +"#, + ) + .unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 25); + + apply_migrations(&conn).unwrap(); + + assert_eq!(current_schema_version(&conn).unwrap(), 26); + assert!(table_exists(&conn, "flow_sources").unwrap()); + assert!(table_exists(&conn, "flow_source_revisions").unwrap()); + assert!(!table_exists(&conn, "flow_instances").unwrap()); + assert!(!table_exists(&conn, "flow_transition_attempts").unwrap()); + assert!(!table_exists(&conn, "flow_events").unwrap()); + } + #[tokio::test] async fn migrates_sqlite_and_preserves_workspace_record() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("control-plane.sqlite"); let store = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 24); + assert_eq!(store.schema_version().await.unwrap(), 26); assert!( !store .with_conn(|conn| table_exists(conn, "worker_workspace_credentials")) @@ -4209,13 +4642,88 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT); store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 24); + assert_eq!(reopened.schema_version().await.unwrap(), 26); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) ); } + #[tokio::test] + async fn workspace_flow_sources_keep_revisions_and_builtins_stay_resources() { + let dir = tempfile::tempdir().unwrap(); + let store = SqliteWorkspaceStore::open(dir.path().join("server.db")).unwrap(); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: "workspace-a".to_string(), + owner_account_id: None, + display_name: "Workspace A".to_string(), + state: "active".to_string(), + created_at: "2026-08-06T00:00:00Z".to_string(), + updated_at: "2026-08-06T00:00:00Z".to_string(), + }) + .await + .unwrap(); + let workspace_source = r#"{ + schema_version = 1; + name = "coder-review"; + initial = "work"; + states = { + work = { + instructions = "Workspace revision one."; + transitions = { done = { target = "done"; condition = "Done."; }; }; + }; + done = { instructions = ""; terminal = true; }; + }; + }"#; + let workspace = store + .put_flow_source_for_kind( + "workspace-a", + FlowSourceKind::Workspace, + "flows/coder-review.dcdl", + workspace_source, + "2026-08-06T00:00:01Z", + ) + .unwrap(); + let builtin = flow::builtin_flow_source("coder-review").unwrap(); + assert_eq!(builtin.slug, workspace.name); + assert!( + store + .put_flow_source_for_kind( + "workspace-a", + FlowSourceKind::Builtin, + builtin.path, + builtin.content, + "2026-08-06T00:00:02Z", + ) + .is_err() + ); + assert_eq!( + store.list_flow_sources("workspace-a").unwrap(), + vec![workspace.clone()] + ); + + let revision_two = + workspace_source.replace("Workspace revision one.", "Workspace revision two."); + let updated = store + .put_flow_source_for_kind( + "workspace-a", + FlowSourceKind::Workspace, + "flows/coder-review.dcdl", + &revision_two, + "2026-08-06T00:00:03Z", + ) + .unwrap(); + assert_eq!(updated.flow_id, workspace.flow_id); + assert_eq!(updated.revision, 2); + let pinned = store + .get_flow_source_revision("workspace-a", &workspace.flow_id, 1) + .unwrap() + .unwrap(); + assert_eq!(pinned.content, workspace_source); + assert_eq!(pinned.definition.name, "coder-review"); + } + #[tokio::test] async fn ticket_worker_assignment_replaces_current_and_preserves_audit_history() { let dir = tempfile::tempdir().unwrap(); @@ -4681,7 +5189,7 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT); .unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 24); + assert_eq!(store.schema_version().await.unwrap(), 26); store .with_conn(|conn| { @@ -4870,7 +5378,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn repository_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 24); + assert_eq!(store.schema_version().await.unwrap(), 26); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -4908,7 +5416,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn memory_authority_records_round_trip_and_close_staging() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 24); + assert_eq!(store.schema_version().await.unwrap(), 26); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -5156,7 +5664,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn account_and_login_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 24); + assert_eq!(store.schema_version().await.unwrap(), 26); let now = "2026-07-22T00:00:00Z".to_string(); let account = AccountRecord { account_id: "acct-user-alice".to_string(), diff --git a/docs/README.md b/docs/README.md index df1ff9ec..ba2ced5f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,17 +10,18 @@ It is not a dumping ground for external research, old plans, API inventories, or 2. [`design/context-history.md`](design/context-history.md) — the highest-risk invariant: inputs that affect the model must be committed to history before they enter context. 3. [`design/worker-session-state.md`](design/worker-session-state.md) — Worker identity, replayable session logs, current metadata, and live process hints. 4. [`design/session-observation.md`](design/session-observation.md) — common session captures, `SessionEntryRef`, Memory evidence, and host-authorized Worker observation. -5. [`design/profiles-manifests-prompts.md`](design/profiles-manifests-prompts.md) — reusable Profiles, resolved Manifests, and prompt resources. -6. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope. -7. [`design/plugin-packages.md`](design/plugin-packages.md) — plugin package distribution, discovery, and enablement boundaries. -8. [`development/plugin-development.md`](development/plugin-development.md) — how to build, package, enable, and inspect Yoi Plugins. -9. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records. -10. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions. -11. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary. -12. [`development/server-runtime-auth.md`](development/server-runtime-auth.md) — manual Workspace Server / Runtime public-key exchange and authenticated Runtime startup checks. -13. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed. -14. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them. -15. [`development/validation.md`](development/validation.md) — how to check changes. +5. [`design/flow-state-graph.md`](design/flow-state-graph.md) — Workspace Flow sources, immutable revisions, transition attempts, and bounded internal verification. +6. [`design/profiles-manifests-prompts.md`](design/profiles-manifests-prompts.md) — reusable Profiles, resolved Manifests, and prompt resources. +7. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope. +8. [`design/plugin-packages.md`](design/plugin-packages.md) — plugin package distribution, discovery, and enablement boundaries. +9. [`development/plugin-development.md`](development/plugin-development.md) — how to build, package, enable, and inspect Yoi Plugins. +10. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records. +11. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions. +12. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary. +13. [`development/server-runtime-auth.md`](development/server-runtime-auth.md) — manual Workspace Server / Runtime public-key exchange and authenticated Runtime startup checks. +14. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed. +15. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them. +16. [`development/validation.md`](development/validation.md) — how to check changes. ## What belongs here diff --git a/docs/design/flow-state-graph.md b/docs/design/flow-state-graph.md new file mode 100644 index 00000000..0f4ec776 --- /dev/null +++ b/docs/design/flow-state-graph.md @@ -0,0 +1,97 @@ +# Flow state graph and verifier + +Flow is a Workspace-scoped declarative state graph. A Worker operating under an active Flow can request evaluation of its current state's outgoing conditions, but cannot select or write a target state directly. + +## Source authority + +A Workspace-authored Flow source is one DCDL document stored under a virtual path such as `flows/coder-review.dcdl`. Built-in Flow sources are compiled resources under `resources/flows/*.dcdl` and use read-only virtual paths such as `builtin/flows/coder-review.dcdl`. The source document is the graph authority; states and transitions are not normalized into independently editable relational records. + +Every invocation uses a source-qualified typed selector: + +```text +builtin: +workspace: +``` + +Unqualified selectors and implicit override precedence are rejected. `builtin:coder-review` resolves from the embedded resource catalog. `workspace:coder-review` resolves from the current Workspace DB source. Built-in and Workspace sources with the same slug coexist as distinct logical records. + +The compiler pipeline is: + +```text +DCDL source + -> decodal evaluation + -> private Serde-compatible value + -> typed Flow source schema + -> graph validation + -> CompiledFlowDefinition +``` + +The Serde-compatible intermediate is private compiler infrastructure. Public APIs and persisted Flow runtime records use typed Flow domain values. + +`flow_sources` stores one current logical Workspace-authored source per Workspace/slug. Every changed source creates an immutable `flow_source_revisions` row containing the original content, content digest, and compiled definition. Built-ins remain read-only embedded resources with an explicit monotonic resource revision; resolving one compiles and returns that resource snapshot without writing it into Workspace DB. Runtime pins source identity, revision, digest, and compiled definition in Worker state, so editing a Workspace source or updating a built-in resource never changes an existing instance. + +The compiler rejects unknown fields, unsupported schema versions, invalid/reserved identifiers, unknown transition targets, authored `$cancelled` state/targets, terminal states with outgoing transitions, non-terminal states without transitions, unreachable states, and reachable closed paths that cannot reach a user-declared terminal state. It injects the synthetic exceptional-cancellation transition and `$cancelled` terminal state after validation. + +## Runtime-owned instance and event authority + +Flow source authority and Flow execution authority are split at the immutable source snapshot boundary. + +The Workspace Backend stores only current Flow sources and immutable source revisions. Resolving a source-qualified selector returns the Workspace id, Flow id, revision, digest, and compiled definition. Resolution is read-only with respect to Flow execution: it never creates an instance, attempt, or event. + +One Runtime Worker durably owns: + +- the pinned source snapshot and compiled definition; +- its active Flow instance, current state, revision, and lifecycle status; +- its active transition attempt; +- its ordered Flow events. + +The complete `FlowRuntimeState` is persisted as a typed `flow.runtime.v1` Worker session extension. Initial Flow state is committed in the same `UserInput` log record as the entered-state instructions and remaining Submit segments. Backend therefore cannot contain an active instance that Worker history has never observed. + +Transition mutations clone the current Runtime state, append ordered events, persist the new session extension, and only then replace the in-memory projection. A persisted verifying attempt survives Runtime restart and same-Worker restore; the next transition request recovers it instead of creating a competing attempt. + +Worker stop retains the Flow with the Worker session. Restoring the same Worker reconstructs the latest state from session extensions and the saved Profile still determines `feature.flow` eligibility. Worker deletion removes the owning Worker/session; Flow state is not implicitly handed to another Worker. + +Workspace Server schema migration v26 removes the legacy `flow_instances`, `flow_transition_attempts`, and `flow_events` tables. Backend/Web visibility, when needed, is a bounded Runtime Worker projection rather than a second instance authority. + +## Worker boundary + +Flow invocation uses the normal Submit/Run segment vector rather than a Worker-create field: + +```json +{ + "method": "run", + "input": [ + { "kind": "flow", "selector": "builtin:coder-review" }, + { "kind": "text", "content": "Ticket 00001... implementation" } + ] +} +``` + +Runtime accepts exactly one Flow segment only when the resolved Profile enables `feature.flow` and a Workspace client is available. The Worker asks Workspace authority only for an immutable source snapshot, creates the instance locally, replaces the Flow segment with the entered state's instructions, and commits that runtime state atomically with the remaining Submit segments before LLM execution. A Worker with an active Flow rejects the duplicate input without changing its local state or events. + +`RequestFlowTransition` accepts only: + +```json +{ "reason": "bounded explanation" } +``` + +It does not accept Workspace, Flow, instance, Runtime, Worker, state, transition, or target identifiers. `RuntimeFlowCoordinatorClient` reads and persists only the current Worker's local `FlowRuntimeState`; no Workspace mutation client participates in transitions. Profile enablement makes the capability eligible but does not create or select an instance; without an active instance the transition request fails closed. + +The transition tool begins or recovers a locally persisted attempt, runs one internal verifier, and resolves the typed verifier outcome into the Worker-owned state. The resulting state and next-state instructions are committed in the normal tool result history. + +## Internal verifier authority + +The internal verifier receives: + +- one immutable snapshot of committed parent-session entries through `session-explore`; +- the captured current state, request reason, and complete outgoing condition list; +- `FinishFlowVerification`; +- when the parent has a Workdir session, only `Read`, `Glob`, and `Grep` backed by a `ReadOnlyWorkdirSession` capability-reducing wrapper. + +It does not receive Workspace, Ticket, Memory, Worker-management, write/edit, Bash, or command authority. The read-only wrapper reports only read capabilities, rejects mutation/command operations, and closing it does not close the parent's source session. + +`FinishFlowVerification` accepts exactly one `met | not_met | indeterminate` result and a bounded rationale for every transition id in the attempt. Missing, unknown, or duplicate ids are rejected before a result is recorded. A prose-only internal Worker completion is a failed verifier outcome, not a successful transition. + +## Separation from the role-owned loop + +This state graph does not implement Coder/Reviewer lifecycle, state-entry side effects, deterministic condition providers, or arbitrary Flow state data. The downstream role-owned loop submits the built-in Coder Flow segment, follows entered-state instructions, and uses Flow events plus typed review/repository evidence for its higher-level completion decisions. diff --git a/resources/flows/coder-review.dcdl b/resources/flows/coder-review.dcdl new file mode 100644 index 00000000..ec4abdcd --- /dev/null +++ b/resources/flows/coder-review.dcdl @@ -0,0 +1,46 @@ +{ + schema_version = 1; + name = "coder-review"; + initial = "implement"; + + states = { + implement = { + instructions = "Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record the concrete repository/test evidence. When the implementation is ready for independent review, request a Flow transition."; + transitions = { + review = { + target = "review"; + condition = "The requested implementation is present, the relevant validation has completed, and there is enough bounded repository and session evidence for an independent Reviewer to evaluate the change."; + }; + }; + }; + + review = { + instructions = "Spawn one independent Reviewer SubWorker with bounded Ticket, repository, diff, and validation context. Read its committed review through worker observation. Do not review your own implementation or treat a prose status as approval. After the Reviewer returns a typed approval or concrete requested changes, request a Flow transition."; + transitions = { + approved = { + target = "done"; + condition = "The latest independent Reviewer attempt for the current implementation completed and approved it, with no later unresolved request_changes finding."; + }; + changes_requested = { + target = "fix"; + condition = "The latest independent Reviewer attempt for the current implementation requested one or more concrete changes that remain unresolved."; + }; + }; + }; + + fix = { + instructions = "Resolve every open Reviewer finding, rerun the validation affected by the fixes, and preserve concrete evidence. Do not claim approval from the prior request_changes review. When the corrected implementation is ready for a new independent review, request a Flow transition."; + transitions = { + review = { + target = "review"; + condition = "Every finding from the latest request_changes review has been addressed with relevant validation evidence, and the corrected implementation is ready for a fresh independent Reviewer attempt."; + }; + }; + }; + + done = { + instructions = "The Coder implementation and independent review loop is complete."; + terminal = true; + }; + }; +} diff --git a/resources/profiles/coder.dcdl b/resources/profiles/coder.dcdl index 9dc13f52..5a064bd7 100644 --- a/resources/profiles/coder.dcdl +++ b/resources/profiles/coder.dcdl @@ -8,6 +8,7 @@ import "./base.dcdl" // { memory = { enabled = true; }; web = { enabled = true; }; sub_worker = { enabled = true; }; + flow = { enabled = true; }; worker = { enabled = false; }; ticket = { enabled = true; thread = true; }; }; diff --git a/resources/prompts/internal.toml b/resources/prompts/internal.toml index b44e98e2..bbcd711e 100644 --- a/resources/prompts/internal.toml +++ b/resources/prompts/internal.toml @@ -14,6 +14,8 @@ memory_extract_system = "{% include \"$yoi/internal/memory_extract_system\" %}" memory_consolidation_system = "{% include \"$yoi/internal/memory_consolidation_system\" %}" +flow_verifier_system = "{% include \"$yoi/internal/flow_verifier_system\" %}" + notify_wrapper = """\ [Notification] {{ message }} diff --git a/resources/prompts/internal/flow_verifier_system.md b/resources/prompts/internal/flow_verifier_system.md new file mode 100644 index 00000000..5bbb1a2b --- /dev/null +++ b/resources/prompts/internal/flow_verifier_system.md @@ -0,0 +1,15 @@ +You are an internal Flow transition verifier. Your only job is to evaluate the captured outgoing transition conditions against committed parent-session evidence and, when available, the attached read-only Workdir. + +The host gives you one immutable attempt snapshot containing the current state, the Worker's reason, and every outgoing transition condition. Treat that snapshot as the complete condition set. Do not invent, omit, merge, or rewrite transitions. + +Use ShowOverview, SearchEntries, and ReadEntry to inspect bounded committed parent-session evidence. When read-only Workdir tools are available, use Read, Glob, and Grep only as needed to check current repository evidence. You have no authority to mutate files, Tickets, Memory, Workers, Workdirs, Flow state, or any other domain. + +For every supplied transition, decide exactly one verdict: + +- `met`: the available evidence establishes the condition. +- `not_met`: the available evidence establishes that the condition is not currently satisfied. +- `indeterminate`: the bounded evidence cannot establish either result. + +Apply the same evidence standard to the synthetic exceptional-cancellation condition. It is `met` only when an actual exceptional condition makes the normal Flow impossible with the available authority and tools; ordinary incomplete work, a failed check that can be fixed, or uncertainty is not exceptional cancellation. + +Finish exactly once with FinishFlowVerification. Include exactly one result for every transition id from the attempt, with a concise rationale grounded in inspected evidence. Do not report success in prose instead of calling the tool. diff --git a/web/workspace/src/lib/generated/protocol.ts b/web/workspace/src/lib/generated/protocol.ts index 2b9c0e51..023c5f20 100644 --- a/web/workspace/src/lib/generated/protocol.ts +++ b/web/workspace/src/lib/generated/protocol.ts @@ -79,7 +79,7 @@ message: string, */ timestamp_ms: number, }; -export type Segment = { "kind": "text", content: string, } | { "kind": "paste", id: number, chars: number, lines: number, content: string, } | { "kind": "file_ref", path: string, } | { "kind": "unknown" }; +export type Segment = { "kind": "text", content: string, } | { "kind": "paste", id: number, chars: number, lines: number, content: string, } | { "kind": "file_ref", path: string, } | { "kind": "flow", selector: string, } | { "kind": "unknown" }; export type WorkerEvent = { "kind": "turn_ended", worker_name: string, } | { "kind": "errored", worker_name: string, message: string, } | { "kind": "shut_down", worker_name: string, } | { "kind": "scope_sub_delegated", /**