flow: own worker flow state in runtime sessions

This commit is contained in:
2026-08-09 18:04:32 +09:00
parent c0532fda4e
commit 068ba9afa5
46 changed files with 5112 additions and 76 deletions
+70
View File
@@ -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
+709
View File
@@ -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(&current) {
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(&current).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")
);
}
}
+15
View File
@@ -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::*;
+199
View File
@@ -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
);
}
}