Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
125be14
feat: add URL template support and scheduled resolver infrastructure
vimmotions Feb 25, 2026
bad2dc9
feat: wire condition and schedule_at codegen in proto_struct
vimmotions Feb 25, 2026
1c1e4e6
feat: add SlotScheduler module and read-only state accessor
vimmotions Feb 25, 2026
e673257
feat: integrate SlotScheduler into VmHandler with background polling
vimmotions Feb 25, 2026
c70e159
feat: add resolved_seed to Ore stack with scheduled URL resolver
vimmotions Feb 25, 2026
35684c4
chore: Update ore server cargo.lock & stack json
vimmotions Feb 25, 2026
875471f
refactor: Removed unnecessary mutable references and added warnings f…
vimmotions Feb 25, 2026
7ce848f
Merge branch 'main' into scheduled-url-resolver
vimmotions Feb 25, 2026
b4ff1f5
feat: enhance SlotScheduler with retry logic for entity state and URL…
vimmotions Feb 25, 2026
2549ee9
fix: improve callback registration logic in SlotScheduler to ensure d…
vimmotions Feb 26, 2026
ee738f1
fix: replace hardcoded MAX_RETRIES in vixen_runtime with reference to…
vimmotions Feb 26, 2026
c838f25
feat: add condition evaluation and SetOnce strategy handling in vixen…
vimmotions Feb 26, 2026
12947ac
feat: add input_value and input_path to ScheduledCallback for enhance…
vimmotions Feb 26, 2026
5c977ce
feat: add PartialEq and Eq traits to various structs and enums
vimmotions Feb 26, 2026
0e2a8d6
feat: implement Default trait for SlotScheduler and simplify URL stri…
vimmotions Feb 26, 2026
720069b
chore: Generate new ore sdk
vimmotions Feb 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
258 changes: 93 additions & 165 deletions examples/ore-server/Cargo.lock

Large diffs are not rendered by default.

28 changes: 24 additions & 4 deletions hyperstack-macros/src/ast/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,23 @@ pub enum HttpMethod {
Post,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum UrlTemplatePart {
Literal(String),
FieldRef(String),
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum UrlSource {
FieldPath(String),
Template(Vec<UrlTemplatePart>),
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct UrlResolverConfig {
/// Field path to get the URL from (e.g., "info.uri")
pub url_path: String,
/// HTTP method to use (default: GET)
pub url_source: UrlSource,
#[serde(default)]
pub method: HttpMethod,
/// JSON path to extract from response (None = full payload)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extract_path: Option<String>,
}
Expand All @@ -124,6 +133,13 @@ pub enum ResolveStrategy {
LastWrite,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolverCondition {
pub field_path: String,
pub op: ComparisonOp,
pub value: serde_json::Value,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolverSpec {
pub resolver: ResolverType,
Expand All @@ -134,6 +150,10 @@ pub struct ResolverSpec {
#[serde(default)]
pub strategy: ResolveStrategy,
pub extracts: Vec<ResolverExtractSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub condition: Option<ResolverCondition>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schedule_at: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
400 changes: 392 additions & 8 deletions hyperstack-macros/src/codegen/vixen_runtime.rs

Large diffs are not rendered by default.

58 changes: 47 additions & 11 deletions hyperstack-macros/src/parse/attributes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1005,11 +1005,14 @@ pub struct ResolveAttribute {
pub from: Option<String>,
pub address: Option<String>,
pub url: Option<String>,
pub url_is_template: bool,
pub method: Option<String>,
pub extract: Option<String>,
pub target_field_name: String,
pub resolver: Option<String>,
pub strategy: String,
pub condition: Option<String>,
pub schedule_at: Option<String>,
}

#[derive(Debug, Clone)]
Expand All @@ -1020,27 +1023,35 @@ pub struct ResolveSpec {
pub extract: Option<String>,
pub target_field_name: String,
pub strategy: String,
pub condition: Option<String>,
pub schedule_at: Option<String>,
}

struct ResolveAttributeArgs {
from: Option<String>,
address: Option<String>,
url: Option<String>,
url_is_template: bool,
method: Option<syn::Ident>,
extract: Option<String>,
resolver: Option<String>,
strategy: Option<String>,
condition: Option<String>,
schedule_at: Option<String>,
}

impl Parse for ResolveAttributeArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut from = None;
let mut address = None;
let mut url = None;
let mut url_is_template = false;
let mut method = None;
let mut extract = None;
let mut resolver = None;
let mut strategy = None;
let mut condition = None;
let mut schedule_at = None;

while !input.is_empty() {
let ident: syn::Ident = input.parse()?;
Expand All @@ -1055,19 +1066,23 @@ impl Parse for ResolveAttributeArgs {
let lit: syn::LitStr = input.parse()?;
address = Some(lit.value());
} else if ident_str == "url" {
// Parse as dotted path (e.g., info.uri) - handle both dot-separated and single identifiers
let mut parts = Vec::new();
let first: syn::Ident = input.parse()?;
parts.push(first.to_string());
if input.peek(syn::LitStr) {
let lit: syn::LitStr = input.parse()?;
url = Some(lit.value());
url_is_template = true;
} else {
let mut parts = Vec::new();
let first: syn::Ident = input.parse()?;
parts.push(first.to_string());

while input.peek(Token![.]) {
input.parse::<Token![.]>()?;
let next: syn::Ident = input.parse()?;
parts.push(next.to_string());
}

// Parse any additional .identifier segments
while input.peek(Token![.]) {
input.parse::<Token![.]>()?;
let next: syn::Ident = input.parse()?;
parts.push(next.to_string());
url = Some(parts.join("."));
}

url = Some(parts.join("."));
} else if ident_str == "method" {
let method_ident: syn::Ident = input.parse()?;
match method_ident.to_string().to_lowercase().as_str() {
Expand All @@ -1091,6 +1106,21 @@ impl Parse for ResolveAttributeArgs {
} else if ident_str == "strategy" {
let ident: syn::Ident = input.parse()?;
strategy = Some(ident.to_string());
} else if ident_str == "condition" {
let lit: syn::LitStr = input.parse()?;
condition = Some(lit.value());
} else if ident_str == "schedule_at" {
let mut parts = Vec::new();
let first: syn::Ident = input.parse()?;
parts.push(first.to_string());

while input.peek(Token![.]) {
input.parse::<Token![.]>()?;
let next: syn::Ident = input.parse()?;
parts.push(next.to_string());
}

schedule_at = Some(parts.join("."));
} else {
return Err(syn::Error::new(
ident.span(),
Expand All @@ -1107,10 +1137,13 @@ impl Parse for ResolveAttributeArgs {
from,
address,
url,
url_is_template,
method,
extract,
resolver,
strategy,
condition,
schedule_at,
})
}
}
Expand Down Expand Up @@ -1165,11 +1198,14 @@ pub fn parse_resolve_attribute(
from: args.from,
address: args.address,
url: args.url,
url_is_template: args.url_is_template,
method: args.method.map(|m| m.to_string()),
extract: args.extract,
target_field_name: target_field_name.to_string(),
resolver: args.resolver,
strategy,
condition: args.condition,
schedule_at: args.schedule_at,
}))
}

Expand Down
63 changes: 57 additions & 6 deletions hyperstack-macros/src/stream_spec/ast_writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ use crate::ast::writer::{
convert_idl_to_snapshot, parse_population_strategy, parse_transformation,
};
use crate::ast::{
ComputedFieldSpec, ConditionExpr, EntitySection, FieldPath, HookAction, IdentitySpec,
IdlSerializationSnapshot, InstructionHook, KeyResolutionStrategy, LookupIndexSpec,
MappingSource, ResolveStrategy, ResolverExtractSpec, ResolverHook, ResolverSpec,
ResolverStrategy, ResolverType, SerializableFieldMapping, SerializableHandlerSpec,
SerializableStreamSpec, SourceSpec,
ComparisonOp, ComputedFieldSpec, ConditionExpr, EntitySection, FieldPath, HookAction,
IdentitySpec, IdlSerializationSnapshot, InstructionHook, KeyResolutionStrategy,
LookupIndexSpec, MappingSource, ResolveStrategy, ResolverCondition, ResolverExtractSpec,
ResolverHook, ResolverSpec, ResolverStrategy, ResolverType, SerializableFieldMapping,
SerializableHandlerSpec, SerializableStreamSpec, SourceSpec,
};
use crate::event_type_helpers::{find_idl_for_type, program_name_for_type, IdlLookup};
use crate::parse;
Expand Down Expand Up @@ -197,6 +197,8 @@ fn build_resolver_specs(resolve_specs: &[parse::ResolveSpec]) -> Vec<ResolverSpe
spec.strategy
);

let condition = spec.condition.as_deref().map(parse_resolver_condition);

let entry = grouped.entry(key).or_insert_with(|| ResolverSpec {
resolver: spec.resolver.clone(),
input_path: spec.from.clone(),
Expand All @@ -206,6 +208,8 @@ fn build_resolver_specs(resolve_specs: &[parse::ResolveSpec]) -> Vec<ResolverSpe
.map(|value| serde_json::Value::String(value.clone())),
strategy: parse_resolve_strategy(&spec.strategy),
extracts: Vec::new(),
condition,
schedule_at: spec.schedule_at.clone(),
});

let source_path = spec.extract.clone();
Expand Down Expand Up @@ -234,10 +238,57 @@ fn parse_resolve_strategy(strategy: &str) -> ResolveStrategy {
}
}

pub fn parse_resolver_condition_from_str(s: &str) -> ResolverCondition {
parse_resolver_condition(s)
}

fn parse_resolver_condition(s: &str) -> ResolverCondition {
let operators = ["==", "!=", ">=", "<=", ">", "<"];
for op_str in &operators {
if let Some(pos) = s.find(op_str) {
let field_path = s[..pos].trim().to_string();
let raw_value = s[pos + op_str.len()..].trim();
let op = match *op_str {
"==" => ComparisonOp::Equal,
"!=" => ComparisonOp::NotEqual,
">=" => ComparisonOp::GreaterThanOrEqual,
"<=" => ComparisonOp::LessThanOrEqual,
">" => ComparisonOp::GreaterThan,
"<" => ComparisonOp::LessThan,
_ => unreachable!(),
};
let value = match raw_value {
"null" => serde_json::Value::Null,
"true" => serde_json::Value::Bool(true),
"false" => serde_json::Value::Bool(false),
s if s.parse::<f64>().is_ok() => {
serde_json::json!(s.parse::<f64>().unwrap())
}
s => serde_json::Value::String(s.trim_matches('"').to_string()),
};
return ResolverCondition {
field_path,
op,
value,
};
}
}
panic!("Invalid condition expression: '{}'. Expected format: 'field.path op value'", s);
}

fn resolver_type_key(resolver: &ResolverType) -> String {
match resolver {
ResolverType::Token => "token".to_string(),
ResolverType::Url(config) => format!("url:{}", config.url_path),
ResolverType::Url(config) => match &config.url_source {
crate::ast::UrlSource::FieldPath(path) => format!("url:{}", path),
crate::ast::UrlSource::Template(parts) => {
let key: String = parts.iter().map(|p| match p {
crate::ast::UrlTemplatePart::Literal(s) => s.clone(),
crate::ast::UrlTemplatePart::FieldRef(f) => format!("{{{}}}", f),
}).collect();
format!("url:{}", key)
}
},
}
}

Expand Down
44 changes: 39 additions & 5 deletions hyperstack-macros/src/stream_spec/entity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use std::collections::{HashMap, HashSet};
use quote::{format_ident, quote};
use syn::{Fields, GenericArgument, ItemStruct, PathArguments, Type};

use crate::ast::{EntitySection, FieldTypeInfo, HttpMethod, ResolverHook, ResolverType, UrlResolverConfig};
use crate::ast::{EntitySection, FieldTypeInfo, HttpMethod, ResolverHook, ResolverType, UrlResolverConfig, UrlSource, UrlTemplatePart};
use crate::codegen;
use crate::event_type_helpers::IdlLookup;
use crate::parse;
Expand Down Expand Up @@ -54,6 +54,27 @@ use super::sections::{
process_nested_struct,
};

pub fn parse_url_template(s: &str) -> Vec<UrlTemplatePart> {
let mut parts = Vec::new();
let mut rest = s;

while let Some(open) = rest.find('{') {
if open > 0 {
parts.push(UrlTemplatePart::Literal(rest[..open].to_string()));
}
let close = rest[open..].find('}').expect("Unclosed '{' in URL template") + open;
let field_ref = rest[open + 1..close].trim().to_string();
parts.push(UrlTemplatePart::FieldRef(field_ref));
rest = &rest[close + 1..];
}

if !rest.is_empty() {
parts.push(UrlTemplatePart::Literal(rest.to_string()));
}

parts
}

// ============================================================================
// Entity Processing
// ============================================================================
Expand Down Expand Up @@ -425,17 +446,22 @@ pub fn process_entity_struct_with_idl(
});

// Determine resolver type: URL resolver if url is present, otherwise Token resolver
let resolver = if let Some(url_path) = resolve_attr.url.clone() {
// URL resolver
let resolver = if let Some(url_val) = resolve_attr.url.clone() {
let method = resolve_attr.method.as_deref().map(|m| {
match m.to_lowercase().as_str() {
"post" => HttpMethod::Post,
_ => HttpMethod::Get,
}
}).unwrap_or(HttpMethod::Get);

let url_source = if resolve_attr.url_is_template {
UrlSource::Template(parse_url_template(&url_val))
} else {
UrlSource::FieldPath(url_val)
};

ResolverType::Url(UrlResolverConfig {
url_path,
url_source,
method,
extract_path: resolve_attr.extract.clone(),
})
Expand All @@ -449,13 +475,21 @@ pub fn process_entity_struct_with_idl(
.unwrap_or_else(|err| panic!("{}", err))
};

let from = if resolve_attr.url_is_template {
None
} else {
resolve_attr.url.clone().or(resolve_attr.from)
};

resolve_specs.push(parse::ResolveSpec {
resolver,
from: resolve_attr.url.clone().or(resolve_attr.from),
from,
address: resolve_attr.address,
extract: resolve_attr.extract,
target_field_name: resolve_attr.target_field_name,
strategy: resolve_attr.strategy,
condition: resolve_attr.condition,
schedule_at: resolve_attr.schedule_at,
});
} else if let Ok(Some(computed_attr)) =
parse::parse_computed_attribute(attr, &field_name.to_string())
Expand Down
Loading