flow: own worker flow state in runtime sessions
This commit is contained in:
@@ -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"
|
||||
@@ -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<CompiledFlowDefinition, FlowCompileError> {
|
||||
compile_flow_source(self.content)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<String>) -> Result<Self, String> {
|
||||
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<String>) -> Result<Self, String> {
|
||||
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<StateId, CompiledState>,
|
||||
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<CompiledTransition>,
|
||||
}
|
||||
|
||||
#[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<String>, path: impl Into<String>, message: impl Into<String>) -> 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<FlowDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct FlowSource {
|
||||
schema_version: u32,
|
||||
name: String,
|
||||
initial: String,
|
||||
states: BTreeMap<String, StateSource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct StateSource {
|
||||
instructions: String,
|
||||
#[serde(default)]
|
||||
terminal: bool,
|
||||
#[serde(default)]
|
||||
transitions: BTreeMap<String, TransitionSource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct TransitionSource {
|
||||
target: String,
|
||||
condition: String,
|
||||
}
|
||||
|
||||
pub fn compile_flow_source(content: &str) -> Result<CompiledFlowDefinition, FlowCompileError> {
|
||||
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::<FlowSource>(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<CompiledFlowDefinition, FlowCompileError> {
|
||||
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::<BTreeSet<_>>();
|
||||
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<FlowDiagnostic>,
|
||||
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<StateId, CompiledState>,
|
||||
initial: &StateId,
|
||||
diagnostics: &mut Vec<FlowDiagnostic>,
|
||||
) {
|
||||
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::<BTreeSet<_>>();
|
||||
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<StateId, Vec<StateId>> = 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::<VecDeque<_>>();
|
||||
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<LoadedSource> {
|
||||
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<String>,
|
||||
path: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
) -> 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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<String>) -> Result<Self, FlowSelectorError> {
|
||||
let slug = slug.into();
|
||||
validate_slug(&slug)?;
|
||||
Ok(Self::Builtin { slug })
|
||||
}
|
||||
|
||||
pub fn workspace(slug: impl Into<String>) -> Result<Self, FlowSelectorError> {
|
||||
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<Self, Self::Err> {
|
||||
let (source, slug) = value.split_once(':').ok_or_else(|| {
|
||||
FlowSelectorError::InvalidFormat(
|
||||
"Flow selector must be source-qualified as builtin:<slug> or workspace:<slug>"
|
||||
.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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FlowSelector {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
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::<FlowSelector>().unwrap(),
|
||||
FlowSelector::Builtin {
|
||||
slug: "coder-review".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
"workspace:coder-review"
|
||||
.parse::<FlowSelector>()
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
"workspace:coder-review"
|
||||
);
|
||||
assert!("coder-review".parse::<FlowSelector>().is_err());
|
||||
assert!("project:coder-review".parse::<FlowSelector>().is_err());
|
||||
assert!("builtin:bad/path".parse::<FlowSelector>().is_err());
|
||||
assert!("builtin:a:b".parse::<FlowSelector>().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::<FlowSelector>(&json).unwrap(),
|
||||
selector
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user