first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
[package]
|
||||
name = "cloud_object_models"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
agent_mode_evals = []
|
||||
test-util = ["cloud_objects/test-util"]
|
||||
|
||||
[dependencies]
|
||||
ai.workspace = true
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
cfg-if.workspace = true
|
||||
cloud_objects.workspace = true
|
||||
handlebars.workspace = true
|
||||
lazy_static.workspace = true
|
||||
log.workspace = true
|
||||
regex.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_regex = "1.1.0"
|
||||
session-sharing-protocol.workspace = true
|
||||
settings.workspace = true
|
||||
settings_value = { workspace = true, features = ["derive"] }
|
||||
uuid.workspace = true
|
||||
warp-workflows.workspace = true
|
||||
warp_cli.workspace = true
|
||||
warp_core.workspace = true
|
||||
warp_graphql.workspace = true
|
||||
warp_util.workspace = true
|
||||
|
||||
[target.'cfg(not(target_family = "wasm"))'.dependencies]
|
||||
cloud_object_persistence.workspace = true
|
||||
diesel = { workspace = true, features = ["sqlite", "chrono"] }
|
||||
persistence.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
cloud_objects = { workspace = true, features = ["test-util"] }
|
||||
@@ -0,0 +1,519 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ai::LLMId;
|
||||
use cloud_objects::cloud_object::{
|
||||
GenericCloudObject, GenericServerObject, GenericStringModel, JsonObjectType,
|
||||
};
|
||||
use cloud_objects::ids::GenericStringObjectId;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warp_core::channel::ChannelState;
|
||||
use warp_core::features::FeatureFlag;
|
||||
|
||||
use crate::{JsonModel, JsonSerializer};
|
||||
|
||||
pub const PROFILE_NAME_MAX_LENGTH: usize = 50;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ActionPermission {
|
||||
AgentDecides,
|
||||
AlwaysAllow,
|
||||
AlwaysAsk,
|
||||
// This is intended to catch deserialization errors whenever we add new variants to this enum. Say we
|
||||
// want to add a "Never" variant. Without this catch-all, old clients wouldn't be able to deserialize
|
||||
// a "Never" into one of the existing options.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl ActionPermission {
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
ActionPermission::AgentDecides | ActionPermission::Unknown => {
|
||||
"The Agent chooses the safest path: acting on its own when confident, and asking for approval when uncertain."
|
||||
}
|
||||
ActionPermission::AlwaysAllow => {
|
||||
"Give the Agent full autonomy — no manual approval ever required."
|
||||
}
|
||||
ActionPermission::AlwaysAsk => {
|
||||
"Require explicit approval before the Agent takes any action."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_always_ask(&self) -> bool {
|
||||
matches!(self, Self::AlwaysAsk)
|
||||
}
|
||||
|
||||
pub fn is_always_allow(&self) -> bool {
|
||||
matches!(self, Self::AlwaysAllow)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum WriteToPtyPermission {
|
||||
// This is for backwards compatibility with the old "Never" value.
|
||||
#[serde(alias = "Never")]
|
||||
AlwaysAllow,
|
||||
#[default]
|
||||
AlwaysAsk,
|
||||
AskOnFirstWrite,
|
||||
// This is intended to catch deserialization errors whenever we add new variants to this enum.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl WriteToPtyPermission {
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
WriteToPtyPermission::AlwaysAllow => ActionPermission::AlwaysAllow.description(),
|
||||
WriteToPtyPermission::AskOnFirstWrite => {
|
||||
"The agent will ask for permission the first time it needs to interact with a running command. After that, it will continue automatically for the rest of that command."
|
||||
}
|
||||
WriteToPtyPermission::AlwaysAsk => {
|
||||
"The agent will always ask for permission to interact with a running command."
|
||||
}
|
||||
WriteToPtyPermission::Unknown => ActionPermission::Unknown.description(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_always_allow(&self) -> bool {
|
||||
matches!(self, Self::AlwaysAllow)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ComputerUsePermission {
|
||||
#[default]
|
||||
Never,
|
||||
AlwaysAsk,
|
||||
AlwaysAllow,
|
||||
// This is intended to catch deserialization errors whenever we add new variants to this enum.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl ComputerUsePermission {
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
ComputerUsePermission::Never => {
|
||||
"Computer use tools are disabled and will not be available to the Agent."
|
||||
}
|
||||
ComputerUsePermission::AlwaysAsk => {
|
||||
"Require explicit approval before the Agent uses computer use tools."
|
||||
}
|
||||
ComputerUsePermission::AlwaysAllow => {
|
||||
"Give the Agent full autonomy to use computer use tools without approval."
|
||||
}
|
||||
ComputerUsePermission::Unknown => "Unknown setting.",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
!matches!(self, Self::Never | Self::Unknown)
|
||||
}
|
||||
|
||||
pub fn is_always_allow(&self) -> bool {
|
||||
matches!(self, Self::AlwaysAllow)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RunAgentsPermission {
|
||||
NeverAllow,
|
||||
AlwaysAllow,
|
||||
#[default]
|
||||
AlwaysAsk,
|
||||
|
||||
// This is intended to catch deserialization errors whenever we add new variants to this enum.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl RunAgentsPermission {
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
RunAgentsPermission::NeverAllow => {
|
||||
"The Agent cannot run child agents and the run_agents tool will not be available."
|
||||
}
|
||||
RunAgentsPermission::AlwaysAllow => {
|
||||
"Give the Agent full autonomy to run child agents without approval."
|
||||
}
|
||||
RunAgentsPermission::AlwaysAsk => {
|
||||
"Require explicit approval before the Agent runs child agents."
|
||||
}
|
||||
RunAgentsPermission::Unknown => "Unknown setting.",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
matches!(self, Self::AlwaysAllow | Self::AlwaysAsk)
|
||||
}
|
||||
|
||||
pub fn is_always_allow(&self) -> bool {
|
||||
matches!(self, Self::AlwaysAllow)
|
||||
}
|
||||
|
||||
pub fn is_never_allow(&self) -> bool {
|
||||
matches!(self, Self::NeverAllow | Self::Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AskUserQuestionPermission {
|
||||
/// Never pause; skip questions and continue with best judgment.
|
||||
Never,
|
||||
/// Pause and wait for the user, unless auto-approve mode is enabled.
|
||||
AskExceptInAutoApprove,
|
||||
/// Always pause and wait for the user to answer before continuing, even in auto-approve mode.
|
||||
#[default]
|
||||
AlwaysAsk,
|
||||
|
||||
// This is intended to catch deserialization errors whenever we add new variants to this enum.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl AskUserQuestionPermission {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
AskUserQuestionPermission::Never => "Never ask",
|
||||
AskUserQuestionPermission::AskExceptInAutoApprove => "Ask unless auto-approve",
|
||||
AskUserQuestionPermission::AlwaysAsk | AskUserQuestionPermission::Unknown => {
|
||||
"Always ask"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
AskUserQuestionPermission::AskExceptInAutoApprove
|
||||
| AskUserQuestionPermission::Unknown => {
|
||||
"The Agent may ask a question and pause for your response, but will continue automatically when auto-approve is on."
|
||||
}
|
||||
AskUserQuestionPermission::Never => {
|
||||
"The Agent will not ask questions and will continue with its best judgment."
|
||||
}
|
||||
AskUserQuestionPermission::AlwaysAsk => {
|
||||
"The Agent may ask a question and will pause for your response even when auto-approve is on."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Predicate types to match commands that can be executed by Agent Mode.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
enum AgentModeCommandExecutionPredicateType {
|
||||
/// A regex with start (`^`) and end (`$`) anchors.
|
||||
///
|
||||
/// We want regex rules to apply to the entire cmd string so we anchor them
|
||||
/// (there isn't any efficient way to apply to the entire cmd string at match-time).
|
||||
#[serde(with = "serde_regex")]
|
||||
AnchoredRegex(Regex),
|
||||
}
|
||||
|
||||
impl AgentModeCommandExecutionPredicateType {
|
||||
fn new_regex(regex: &str) -> Result<Self, regex::Error> {
|
||||
// Redundant anchors aren't a problem so we can unconditionally add them.
|
||||
let anchored_regex = Regex::new(&format!("^{regex}$"))?;
|
||||
Ok(Self::AnchoredRegex(anchored_regex))
|
||||
}
|
||||
|
||||
fn matches(&self, cmd: &str) -> bool {
|
||||
match self {
|
||||
Self::AnchoredRegex(regex) => regex.is_match(cmd),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for AgentModeCommandExecutionPredicateType {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(Self::AnchoredRegex(a), Self::AnchoredRegex(b)) => {
|
||||
// Indexing should be safe since they're guaranteed to have at least
|
||||
// the anchors around them.
|
||||
let a_unanchored = &a.as_str()[1..a.as_str().len() - 1];
|
||||
let b_unanchored = &b.as_str()[1..b.as_str().len() - 1];
|
||||
a_unanchored == b_unanchored
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AgentModeCommandExecutionPredicateType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::AnchoredRegex(regex) => {
|
||||
write!(f, "{}", ®ex.as_str()[1..regex.as_str().len() - 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A wrapper around [`AgentModeCommandExecutionPredicateType`] to enforce
|
||||
/// the use of the provided constructors rather than direct construction of the variants.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
#[serde(transparent)]
|
||||
pub struct AgentModeCommandExecutionPredicate(AgentModeCommandExecutionPredicateType);
|
||||
|
||||
impl schemars::JsonSchema for AgentModeCommandExecutionPredicate {
|
||||
fn schema_name() -> std::borrow::Cow<'static, str> {
|
||||
std::borrow::Cow::Borrowed("AgentModeCommandExecutionPredicate")
|
||||
}
|
||||
|
||||
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
|
||||
generator.subschema_for::<String>()
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentModeCommandExecutionPredicate {
|
||||
pub fn new_regex(regex: &str) -> Result<Self, regex::Error> {
|
||||
Ok(Self(AgentModeCommandExecutionPredicateType::new_regex(
|
||||
regex,
|
||||
)?))
|
||||
}
|
||||
|
||||
pub fn matches(&self, cmd: &str) -> bool {
|
||||
self.0.matches(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AgentModeCommandExecutionPredicate {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl settings_value::SettingsValue for AgentModeCommandExecutionPredicate {
|
||||
fn to_file_value(&self) -> serde_json::Value {
|
||||
serde_json::Value::String(self.to_string())
|
||||
}
|
||||
|
||||
fn from_file_value(value: &serde_json::Value) -> Option<Self> {
|
||||
value.as_str().and_then(|s| Self::new_regex(s).ok())
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref OPTIONAL_ARGS_REGEX: Regex =
|
||||
Regex::new(r"(\s.*)?").expect("Can parse optional args regex");
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(test)] {
|
||||
lazy_static! {
|
||||
// Compiling the regexes for the default command execution allowlist/denylist can be slow
|
||||
// in an unoptimized build, so we use empty lists in unit tests.
|
||||
pub static ref DEFAULT_COMMAND_EXECUTION_ALLOWLIST: Vec<AgentModeCommandExecutionPredicate> = vec![];
|
||||
pub static ref DEFAULT_COMMAND_EXECUTION_DENYLIST: Vec<AgentModeCommandExecutionPredicate> = vec![];
|
||||
}
|
||||
} else {
|
||||
lazy_static! {
|
||||
pub static ref DEFAULT_COMMAND_EXECUTION_ALLOWLIST: Vec<AgentModeCommandExecutionPredicate> = vec![
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("cat{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default cat rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("echo{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default echo rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex("find .*").expect("Can parse default find rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("grep{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default grep rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("ls{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default ls rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex("which .*").expect("Can parse default which rule into regex"),
|
||||
];
|
||||
|
||||
pub static ref DEFAULT_COMMAND_EXECUTION_DENYLIST: Vec<AgentModeCommandExecutionPredicate> = vec![
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("bash{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default bash rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("fish{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default fish rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("pwsh{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default pwsh rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("sh{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default sh rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("zsh{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default zsh rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("curl{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default curl rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("eval{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default eval rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("exec{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default exec rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("source{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default source rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("wget{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default wget rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("dig{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default dig rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("nslookup{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default nslookup rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("host{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default host rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("ssh{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default ssh rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("scp{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default scp rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("rsync{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default rsync rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("telnet{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default telnet rule into regex"),
|
||||
AgentModeCommandExecutionPredicate::new_regex(&format!("rm{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default rm rule into regex"),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Core data structure representing an AI execution profile, which includes model configuration,
|
||||
/// behavior settings, and permissions.
|
||||
///
|
||||
/// NOTE: `planning_model` was removed after planning via subagent was deprecated; serialized legacy
|
||||
/// profiles may include a `planning_model` field and this field name should remain reserved
|
||||
/// indefinitely.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AIExecutionProfile {
|
||||
pub name: String,
|
||||
pub is_default_profile: bool,
|
||||
pub apply_code_diffs: ActionPermission,
|
||||
pub read_files: ActionPermission,
|
||||
|
||||
pub execute_commands: ActionPermission,
|
||||
pub write_to_pty: WriteToPtyPermission,
|
||||
pub mcp_permissions: ActionPermission,
|
||||
pub ask_user_question: AskUserQuestionPermission,
|
||||
pub run_agents: RunAgentsPermission,
|
||||
|
||||
/// Always ask for permission for these commands
|
||||
pub command_denylist: Vec<AgentModeCommandExecutionPredicate>,
|
||||
|
||||
/// When the execute_commands is set to AlwaysAsk, autoexecute these commands
|
||||
pub command_allowlist: Vec<AgentModeCommandExecutionPredicate>,
|
||||
|
||||
/// When the read_files is set to AlwaysAsk, autoread from these directories
|
||||
pub directory_allowlist: Vec<PathBuf>,
|
||||
|
||||
pub mcp_allowlist: Vec<uuid::Uuid>,
|
||||
pub mcp_denylist: Vec<uuid::Uuid>,
|
||||
|
||||
pub computer_use: ComputerUsePermission,
|
||||
|
||||
pub base_model: Option<LLMId>,
|
||||
pub coding_model: Option<LLMId>,
|
||||
pub cli_agent_model: Option<LLMId>,
|
||||
pub computer_use_model: Option<LLMId>,
|
||||
|
||||
pub context_window_limit: Option<u32>,
|
||||
|
||||
/// Whether plans created by the agent should be automatically synced to Warp Drive
|
||||
pub autosync_plans_to_warp_drive: bool,
|
||||
|
||||
/// Whether the agent may use web search when helpful for completing tasks
|
||||
pub web_search_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for AIExecutionProfile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: Default::default(),
|
||||
is_default_profile: false,
|
||||
apply_code_diffs: ActionPermission::AgentDecides,
|
||||
read_files: ActionPermission::AgentDecides,
|
||||
execute_commands: ActionPermission::AlwaysAsk,
|
||||
write_to_pty: WriteToPtyPermission::AlwaysAsk,
|
||||
mcp_permissions: ActionPermission::AgentDecides,
|
||||
ask_user_question: AskUserQuestionPermission::AlwaysAsk,
|
||||
run_agents: RunAgentsPermission::AlwaysAsk,
|
||||
command_denylist: DEFAULT_COMMAND_EXECUTION_DENYLIST.clone(),
|
||||
command_allowlist: Vec::new(),
|
||||
directory_allowlist: Vec::new(),
|
||||
mcp_allowlist: Vec::new(),
|
||||
mcp_denylist: Vec::new(),
|
||||
computer_use: ComputerUsePermission::Never,
|
||||
base_model: None,
|
||||
coding_model: None,
|
||||
cli_agent_model: None,
|
||||
computer_use_model: None,
|
||||
context_window_limit: None,
|
||||
autosync_plans_to_warp_drive: true,
|
||||
web_search_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AIExecutionProfile {
|
||||
#[cfg(feature = "agent_mode_evals")]
|
||||
pub fn create_agent_mode_eval_profile() -> Self {
|
||||
Self {
|
||||
name: "Agent Mode Eval".to_string(),
|
||||
is_default_profile: false,
|
||||
apply_code_diffs: ActionPermission::AlwaysAllow,
|
||||
read_files: ActionPermission::AlwaysAllow,
|
||||
execute_commands: ActionPermission::AlwaysAllow,
|
||||
write_to_pty: WriteToPtyPermission::AlwaysAllow,
|
||||
mcp_permissions: ActionPermission::AlwaysAllow,
|
||||
ask_user_question: AskUserQuestionPermission::Never,
|
||||
run_agents: RunAgentsPermission::AlwaysAllow,
|
||||
command_denylist: Vec::new(),
|
||||
command_allowlist: Vec::new(),
|
||||
directory_allowlist: Vec::new(),
|
||||
mcp_allowlist: Vec::new(),
|
||||
mcp_denylist: Vec::new(),
|
||||
computer_use: ComputerUsePermission::Never,
|
||||
base_model: None,
|
||||
coding_model: None,
|
||||
cli_agent_model: None,
|
||||
computer_use_model: None,
|
||||
context_window_limit: None,
|
||||
autosync_plans_to_warp_drive: false,
|
||||
web_search_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// This creates a CLI-specific profile that will never ask the user for permission,
|
||||
/// since we cannot do so in a non-interactive setting.
|
||||
pub fn create_default_cli_profile(
|
||||
is_sandboxed: bool,
|
||||
computer_use_override: Option<bool>,
|
||||
) -> Self {
|
||||
let command_denylist = if is_sandboxed {
|
||||
Vec::new()
|
||||
} else {
|
||||
DEFAULT_COMMAND_EXECUTION_DENYLIST.to_vec()
|
||||
};
|
||||
|
||||
let computer_use_permission = match computer_use_override {
|
||||
Some(true) => {
|
||||
if is_sandboxed || FeatureFlag::LocalComputerUse.is_enabled() {
|
||||
ComputerUsePermission::AlwaysAllow
|
||||
} else {
|
||||
ComputerUsePermission::Never
|
||||
}
|
||||
}
|
||||
Some(false) => ComputerUsePermission::Never,
|
||||
None => {
|
||||
if is_sandboxed && ChannelState::channel().is_dogfood() {
|
||||
ComputerUsePermission::AlwaysAllow
|
||||
} else {
|
||||
ComputerUsePermission::Never
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
name: "Default (CLI)".to_owned(),
|
||||
is_default_profile: true,
|
||||
apply_code_diffs: ActionPermission::AlwaysAllow,
|
||||
read_files: ActionPermission::AlwaysAllow,
|
||||
execute_commands: ActionPermission::AlwaysAllow,
|
||||
mcp_permissions: ActionPermission::AlwaysAllow,
|
||||
write_to_pty: WriteToPtyPermission::AlwaysAllow,
|
||||
ask_user_question: AskUserQuestionPermission::Never,
|
||||
run_agents: RunAgentsPermission::AlwaysAllow,
|
||||
command_denylist,
|
||||
command_allowlist: DEFAULT_COMMAND_EXECUTION_ALLOWLIST.to_vec(),
|
||||
directory_allowlist: Vec::new(),
|
||||
mcp_allowlist: Vec::new(),
|
||||
mcp_denylist: Vec::new(),
|
||||
computer_use: computer_use_permission,
|
||||
base_model: None,
|
||||
coding_model: None,
|
||||
cli_agent_model: None,
|
||||
computer_use_model: None,
|
||||
context_window_limit: None,
|
||||
autosync_plans_to_warp_drive: FeatureFlag::SyncAmbientPlans.is_enabled(),
|
||||
web_search_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonModel for AIExecutionProfile {
|
||||
fn json_object_type() -> JsonObjectType {
|
||||
JsonObjectType::AIExecutionProfile
|
||||
}
|
||||
}
|
||||
|
||||
pub type CloudAIExecutionProfile =
|
||||
GenericCloudObject<GenericStringObjectId, CloudAIExecutionProfileModel>;
|
||||
pub type CloudAIExecutionProfileModel = GenericStringModel<AIExecutionProfile, JsonSerializer>;
|
||||
pub type ServerAIExecutionProfile =
|
||||
GenericServerObject<GenericStringObjectId, CloudAIExecutionProfileModel>;
|
||||
@@ -0,0 +1,65 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use cloud_objects::cloud_object::{
|
||||
GenericCloudObject, GenericServerObject, GenericStringModel, JsonObjectType,
|
||||
};
|
||||
use cloud_objects::ids::GenericStringObjectId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{JsonModel, JsonSerializer};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AIFact {
|
||||
#[serde(rename = "memory")]
|
||||
Memory(AIMemory),
|
||||
}
|
||||
|
||||
/// A globally unique ID for suggested objects.
|
||||
///
|
||||
/// This is used for telemetry purposes to track and connect both:
|
||||
/// - Suggested objects generated by the AI agent.
|
||||
/// - The corresponding objects stored in the cloud, if the suggestion was accepted.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct SuggestedLoggingId(String);
|
||||
|
||||
impl Display for SuggestedLoggingId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for SuggestedLoggingId {
|
||||
fn from(value: String) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AIMemory {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
pub content: String,
|
||||
// Deprecated: This field is no longer used and will be removed in the future.
|
||||
#[serde(default)]
|
||||
pub is_autogenerated: bool,
|
||||
/// If this rule was created from a suggested rule, record the suggestion's logging_id
|
||||
/// so we can suppress re-surfacing the same suggestion in future responses.
|
||||
#[serde(default)]
|
||||
pub suggested_logging_id: Option<SuggestedLoggingId>,
|
||||
}
|
||||
|
||||
impl AIFact {
|
||||
pub fn is_memory(&self) -> bool {
|
||||
matches!(self, AIFact::Memory { .. })
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonModel for AIFact {
|
||||
fn json_object_type() -> JsonObjectType {
|
||||
JsonObjectType::AIFact
|
||||
}
|
||||
}
|
||||
|
||||
pub type CloudAIFact = GenericCloudObject<GenericStringObjectId, CloudAIFactModel>;
|
||||
pub type CloudAIFactModel = GenericStringModel<AIFact, JsonSerializer>;
|
||||
pub type ServerAIFact = GenericServerObject<GenericStringObjectId, CloudAIFactModel>;
|
||||
@@ -0,0 +1,59 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cloud_objects::cloud_object::{
|
||||
GenericCloudObject, GenericServerObject, GenericStringModel, JsonObjectType,
|
||||
};
|
||||
use cloud_objects::ids::GenericStringObjectId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{AgentConfigSnapshot, JsonModel, JsonSerializer};
|
||||
|
||||
/// A CloudAgentConfig represents a saved agent configuration that can be referenced
|
||||
/// when running agents via `--agent-id`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct AgentConfig {
|
||||
/// Configuration name
|
||||
pub name: String,
|
||||
/// Base model ID to use for the agent
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub base_model_id: Option<String>,
|
||||
/// Base prompt to prepend to user prompts
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub base_prompt: Option<String>,
|
||||
/// MCP servers configuration
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mcp_servers: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
impl AgentConfig {
|
||||
/// Convert to AgentConfigSnapshot for use in agent execution.
|
||||
///
|
||||
/// Note: `AgentConfig` matches the server's JSON format (e.g. `base_model_id`),
|
||||
/// while `AgentConfigSnapshot` is the runtime config format (e.g. `model_id`).
|
||||
pub fn to_ambient_config(&self) -> AgentConfigSnapshot {
|
||||
AgentConfigSnapshot {
|
||||
name: Some(self.name.clone()),
|
||||
environment_id: None,
|
||||
runner_id: None,
|
||||
model_id: self.base_model_id.clone(),
|
||||
base_prompt: self.base_prompt.clone(),
|
||||
mcp_servers: self.mcp_servers.clone().map(|m| m.into_iter().collect()),
|
||||
profile_id: None,
|
||||
worker_host: None,
|
||||
skill_spec: None,
|
||||
computer_use_enabled: None,
|
||||
harness: None,
|
||||
harness_auth_secrets: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonModel for AgentConfig {
|
||||
fn json_object_type() -> JsonObjectType {
|
||||
JsonObjectType::CloudAgentConfig
|
||||
}
|
||||
}
|
||||
|
||||
pub type CloudAgentConfig = GenericCloudObject<GenericStringObjectId, CloudAgentConfigModel>;
|
||||
pub type CloudAgentConfigModel = GenericStringModel<AgentConfig, JsonSerializer>;
|
||||
pub type ServerCloudAgentConfig = GenericServerObject<GenericStringObjectId, CloudAgentConfigModel>;
|
||||
@@ -0,0 +1,260 @@
|
||||
use std::fmt;
|
||||
|
||||
use cloud_objects::cloud_object::{
|
||||
GenericCloudObject, GenericServerObject, GenericStringModel, JsonObjectType,
|
||||
};
|
||||
use cloud_objects::ids::GenericStringObjectId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{JsonModel, JsonSerializer};
|
||||
|
||||
/// Source-control provider hosting an environment's repositories.
|
||||
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum CodeForge {
|
||||
#[default]
|
||||
#[serde(rename = "GITHUB")]
|
||||
GitHub,
|
||||
#[serde(rename = "GITLAB")]
|
||||
GitLab,
|
||||
}
|
||||
|
||||
impl CodeForge {
|
||||
pub const fn host(self) -> &'static str {
|
||||
match self {
|
||||
CodeForge::GitHub => "github.com",
|
||||
CodeForge::GitLab => "gitlab.com",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CodeForge {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
CodeForge::GitHub => write!(f, "GitHub"),
|
||||
CodeForge::GitLab => write!(f, "GitLab"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GithubRepo {
|
||||
/// Repository owner (e.g. "warpdotdev")
|
||||
pub owner: String,
|
||||
/// Repository name (e.g. "warp-internal")
|
||||
pub repo: String,
|
||||
}
|
||||
|
||||
impl GithubRepo {
|
||||
pub fn new(owner: String, repo: String) -> Self {
|
||||
Self { owner, repo }
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for GithubRepo {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}/{}", self.owner, self.repo)
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies a repository and the source-control provider that hosts it.
|
||||
///
|
||||
/// For GitLab, `owner` contains the full, potentially nested namespace.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SourceRepo {
|
||||
/// The repository's explicit source-control provider.
|
||||
///
|
||||
/// When absent, this inherits the associated environment's effective forge.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub code_forge: Option<CodeForge>,
|
||||
pub owner: String,
|
||||
pub repo: String,
|
||||
}
|
||||
|
||||
impl SourceRepo {
|
||||
pub fn new(code_forge: CodeForge, owner: String, repo: String) -> Self {
|
||||
Self {
|
||||
code_forge: Some(code_forge),
|
||||
owner,
|
||||
repo,
|
||||
}
|
||||
}
|
||||
pub fn with_default_code_forge(&self, code_forge: CodeForge) -> Self {
|
||||
Self::new(
|
||||
self.code_forge.unwrap_or(code_forge),
|
||||
self.owner.clone(),
|
||||
self.repo.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn https_clone_url(&self) -> String {
|
||||
format!(
|
||||
"https://{}/{}/{}.git",
|
||||
self.code_forge.unwrap_or_default().host(),
|
||||
self.owner,
|
||||
self.repo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a legacy GitHub repository into the provider-neutral representation.
|
||||
impl From<&GithubRepo> for SourceRepo {
|
||||
fn from(repo: &GithubRepo) -> Self {
|
||||
Self::new(CodeForge::GitHub, repo.owner.clone(), repo.repo.clone())
|
||||
}
|
||||
}
|
||||
/// Formats the forge-relative repository path.
|
||||
impl fmt::Display for SourceRepo {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}/{}", self.owner, self.repo)
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BaseImage {
|
||||
DockerImage(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for BaseImage {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
BaseImage::DockerImage(s) => s.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct GcpProviderConfig {
|
||||
pub project_number: String,
|
||||
pub workload_identity_federation_pool_id: String,
|
||||
pub workload_identity_federation_provider_id: String,
|
||||
/// Service account email for impersonation. When set, the federated token
|
||||
/// is exchanged for a service account access token.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub service_account_email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AwsProviderConfig {
|
||||
pub role_arn: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct ProvidersConfig {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gcp: Option<GcpProviderConfig>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub aws: Option<AwsProviderConfig>,
|
||||
}
|
||||
|
||||
impl ProvidersConfig {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.gcp.is_none() && self.aws.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies a managed secret configured on an environment.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct EnvironmentSecretRef {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// An AmbientAgentEnvironment represents an environment that we would run a Warp agent in.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AmbientAgentEnvironment {
|
||||
/// Environment name
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// Optional description of the environment (max 240 characters)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// Source-control provider hosting this environment's repositories.
|
||||
///
|
||||
/// Absent means GitHub for legacy environments.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub code_forge: Option<CodeForge>,
|
||||
/// List of GitHub repositories
|
||||
#[serde(default)]
|
||||
pub github_repos: Vec<GithubRepo>,
|
||||
/// Provider-neutral repository list.
|
||||
///
|
||||
/// When present, including when empty, this is authoritative over
|
||||
/// `github_repos`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_repos: Option<Vec<SourceRepo>>,
|
||||
/// Base image specification
|
||||
#[serde(flatten)]
|
||||
pub base_image: BaseImage,
|
||||
/// List of setup commands to run after cloning
|
||||
#[serde(default)]
|
||||
pub setup_commands: Vec<String>,
|
||||
/// Optional cloud provider configurations for automatic auth.
|
||||
#[serde(default, skip_serializing_if = "ProvidersConfig::is_empty")]
|
||||
pub providers: ProvidersConfig,
|
||||
/// Default set of managed secrets for runs using this environment.
|
||||
/// - `None`: no environment-level secret scoping (all secrets / defer to run config)
|
||||
/// - `Some([])`: no secrets by default
|
||||
/// - `Some([...])`: these specific secrets are the default
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub secrets: Option<Vec<EnvironmentSecretRef>>,
|
||||
}
|
||||
|
||||
impl AmbientAgentEnvironment {
|
||||
pub fn new(
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
github_repos: Vec<GithubRepo>,
|
||||
docker_image: String,
|
||||
setup_commands: Vec<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
name,
|
||||
description,
|
||||
code_forge: None,
|
||||
github_repos,
|
||||
source_repos: None,
|
||||
base_image: BaseImage::DockerImage(docker_image),
|
||||
setup_commands,
|
||||
providers: ProvidersConfig::default(),
|
||||
secrets: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the environment's source-control provider, defaulting to GitHub
|
||||
/// for legacy environments.
|
||||
pub fn effective_code_forge(&self) -> CodeForge {
|
||||
self.code_forge.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Returns the authoritative provider-neutral repository list.
|
||||
pub fn effective_repos(&self) -> Vec<SourceRepo> {
|
||||
let code_forge = self.effective_code_forge();
|
||||
match &self.source_repos {
|
||||
Some(source_repos) => source_repos
|
||||
.iter()
|
||||
.map(|repo| repo.with_default_code_forge(code_forge))
|
||||
.collect(),
|
||||
None => self
|
||||
.github_repos
|
||||
.iter()
|
||||
.map(|repo| SourceRepo::new(code_forge, repo.owner.clone(), repo.repo.clone()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonModel for AmbientAgentEnvironment {
|
||||
fn json_object_type() -> JsonObjectType {
|
||||
JsonObjectType::CloudEnvironment
|
||||
}
|
||||
}
|
||||
|
||||
pub type CloudAmbientAgentEnvironment =
|
||||
GenericCloudObject<GenericStringObjectId, CloudAmbientAgentEnvironmentModel>;
|
||||
pub type CloudAmbientAgentEnvironmentModel =
|
||||
GenericStringModel<AmbientAgentEnvironment, JsonSerializer>;
|
||||
pub type ServerAmbientAgentEnvironment =
|
||||
GenericServerObject<GenericStringObjectId, CloudAmbientAgentEnvironmentModel>;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "cloud_environment_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,348 @@
|
||||
use super::{
|
||||
AmbientAgentEnvironment, AwsProviderConfig, BaseImage, CodeForge, EnvironmentSecretRef,
|
||||
GcpProviderConfig, GithubRepo, ProvidersConfig, SourceRepo,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn deserialize_legacy_environment_without_providers() {
|
||||
let json = serde_json::json!({
|
||||
"name": "my-env",
|
||||
"github_repos": [{"owner": "warpdotdev", "repo": "warp"}],
|
||||
"docker_image": "ubuntu:latest",
|
||||
"setup_commands": ["echo hello"]
|
||||
});
|
||||
|
||||
let env: AmbientAgentEnvironment = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(env.name, "my-env");
|
||||
assert_eq!(env.providers, ProvidersConfig::default());
|
||||
assert_eq!(env.github_repos.len(), 1);
|
||||
assert_eq!(env.code_forge, None);
|
||||
assert_eq!(env.source_repos, None);
|
||||
assert_eq!(
|
||||
env.effective_repos(),
|
||||
vec![SourceRepo::new(
|
||||
CodeForge::GitHub,
|
||||
"warpdotdev".into(),
|
||||
"warp".into()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
env.base_image,
|
||||
BaseImage::DockerImage("ubuntu:latest".into())
|
||||
);
|
||||
assert_eq!(env.setup_commands, vec!["echo hello"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_gitlab_environment_uses_authoritative_source_repos() {
|
||||
let json = serde_json::json!({
|
||||
"name": "gitlab-env",
|
||||
"code_forge": "GITLAB",
|
||||
"github_repos": [{"owner": "legacy-mirror", "repo": "ignored"}],
|
||||
"source_repos": [{
|
||||
"owner": "platform/backend",
|
||||
"repo": "api"
|
||||
}],
|
||||
"docker_image": "ubuntu:latest"
|
||||
});
|
||||
|
||||
let env: AmbientAgentEnvironment = serde_json::from_value(json).unwrap();
|
||||
|
||||
assert_eq!(env.effective_code_forge(), CodeForge::GitLab);
|
||||
assert_eq!(env.source_repos.as_ref().unwrap()[0].code_forge, None);
|
||||
assert_eq!(
|
||||
env.effective_repos(),
|
||||
vec![SourceRepo::new(
|
||||
CodeForge::GitLab,
|
||||
"platform/backend".into(),
|
||||
"api".into()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
env.effective_repos()[0].https_clone_url(),
|
||||
"https://gitlab.com/platform/backend/api.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn present_empty_source_repos_override_legacy_mirror() {
|
||||
let json = serde_json::json!({
|
||||
"name": "empty-env",
|
||||
"code_forge": "GITLAB",
|
||||
"github_repos": [{"owner": "legacy-mirror", "repo": "ignored"}],
|
||||
"source_repos": [],
|
||||
"docker_image": "ubuntu:latest"
|
||||
});
|
||||
|
||||
let env: AmbientAgentEnvironment = serde_json::from_value(json).unwrap();
|
||||
|
||||
assert!(env.effective_repos().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_environment_serialization_omits_provider_neutral_fields() {
|
||||
let env = AmbientAgentEnvironment::new(
|
||||
"legacy-env".into(),
|
||||
None,
|
||||
vec![GithubRepo::new("warpdotdev".into(), "warp".into())],
|
||||
"ubuntu:latest".into(),
|
||||
vec![],
|
||||
);
|
||||
|
||||
let json = serde_json::to_value(&env).unwrap();
|
||||
|
||||
assert!(!json.as_object().unwrap().contains_key("code_forge"));
|
||||
assert!(!json.as_object().unwrap().contains_key("source_repos"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_with_aws_provider() {
|
||||
let json = serde_json::json!({
|
||||
"name": "aws-env",
|
||||
"github_repos": [],
|
||||
"docker_image": "node:18",
|
||||
"providers": {
|
||||
"aws": {
|
||||
"role_arn": "arn:aws:iam::123456789012:role/my-role"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let env: AmbientAgentEnvironment = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(env.name, "aws-env");
|
||||
let providers = env.providers;
|
||||
assert_eq!(providers.gcp, None);
|
||||
let aws = providers.aws.unwrap();
|
||||
assert_eq!(aws.role_arn, "arn:aws:iam::123456789012:role/my-role");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_with_gcp_provider() {
|
||||
let json = serde_json::json!({
|
||||
"name": "gcp-env",
|
||||
"github_repos": [],
|
||||
"docker_image": "node:18",
|
||||
"providers": {
|
||||
"gcp": {
|
||||
"project_number": "123456",
|
||||
"workload_identity_federation_pool_id": "pool-1",
|
||||
"workload_identity_federation_provider_id": "provider-1"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let env: AmbientAgentEnvironment = serde_json::from_value(json).unwrap();
|
||||
let gcp = env.providers.gcp.unwrap();
|
||||
assert_eq!(gcp.project_number, "123456");
|
||||
assert_eq!(gcp.workload_identity_federation_pool_id, "pool-1");
|
||||
assert_eq!(gcp.workload_identity_federation_provider_id, "provider-1");
|
||||
assert_eq!(gcp.service_account_email, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_with_gcp_provider_service_account() {
|
||||
let json = serde_json::json!({
|
||||
"name": "gcp-sa-env",
|
||||
"github_repos": [],
|
||||
"docker_image": "node:18",
|
||||
"providers": {
|
||||
"gcp": {
|
||||
"project_number": "123456",
|
||||
"workload_identity_federation_pool_id": "pool-1",
|
||||
"workload_identity_federation_provider_id": "provider-1",
|
||||
"service_account_email": "sa@project.iam.gserviceaccount.com"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let env: AmbientAgentEnvironment = serde_json::from_value(json).unwrap();
|
||||
let gcp = env.providers.gcp.unwrap();
|
||||
assert_eq!(gcp.project_number, "123456");
|
||||
assert_eq!(
|
||||
gcp.service_account_email.as_deref(),
|
||||
Some("sa@project.iam.gserviceaccount.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_with_both_providers() {
|
||||
let json = serde_json::json!({
|
||||
"name": "both-env",
|
||||
"github_repos": [],
|
||||
"docker_image": "node:18",
|
||||
"providers": {
|
||||
"gcp": {
|
||||
"project_number": "123456",
|
||||
"workload_identity_federation_pool_id": "pool-1",
|
||||
"workload_identity_federation_provider_id": "provider-1"
|
||||
},
|
||||
"aws": {
|
||||
"role_arn": "arn:aws:iam::123456789012:role/my-role"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let env: AmbientAgentEnvironment = serde_json::from_value(json).unwrap();
|
||||
let providers = env.providers;
|
||||
assert!(providers.gcp.is_some());
|
||||
assert!(providers.aws.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_with_providers_none_omits_field() {
|
||||
let env = AmbientAgentEnvironment::new(
|
||||
"test-env".into(),
|
||||
None,
|
||||
vec![],
|
||||
"ubuntu:latest".into(),
|
||||
vec![],
|
||||
);
|
||||
|
||||
let json = serde_json::to_value(&env).unwrap();
|
||||
assert!(!json.as_object().unwrap().contains_key("providers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_with_providers_includes_field() {
|
||||
let mut env = AmbientAgentEnvironment::new(
|
||||
"test-env".into(),
|
||||
None,
|
||||
vec![],
|
||||
"ubuntu:latest".into(),
|
||||
vec![],
|
||||
);
|
||||
env.providers = ProvidersConfig {
|
||||
gcp: None,
|
||||
aws: Some(AwsProviderConfig {
|
||||
role_arn: "arn:aws:iam::123456789012:role/test".into(),
|
||||
}),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&env).unwrap();
|
||||
let providers = json.get("providers").unwrap();
|
||||
assert!(providers.get("aws").is_some());
|
||||
assert!(providers.get("gcp").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_serde_with_providers() {
|
||||
let mut env = AmbientAgentEnvironment::new(
|
||||
"rt-env".into(),
|
||||
Some("desc".into()),
|
||||
vec![GithubRepo::new("owner".into(), "repo".into())],
|
||||
"alpine:latest".into(),
|
||||
vec!["make build".into()],
|
||||
);
|
||||
env.providers = ProvidersConfig {
|
||||
gcp: Some(GcpProviderConfig {
|
||||
project_number: "999".into(),
|
||||
workload_identity_federation_pool_id: "p".into(),
|
||||
workload_identity_federation_provider_id: "pr".into(),
|
||||
service_account_email: Some("sa@proj.iam.gserviceaccount.com".into()),
|
||||
}),
|
||||
aws: Some(AwsProviderConfig {
|
||||
role_arn: "arn:aws:iam::1:role/r".into(),
|
||||
}),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&env).unwrap();
|
||||
let deserialized: AmbientAgentEnvironment = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(env, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_legacy_environment_without_secrets() {
|
||||
let json = serde_json::json!({
|
||||
"name": "no-secrets-env",
|
||||
"github_repos": [],
|
||||
"docker_image": "ubuntu:latest"
|
||||
});
|
||||
|
||||
let env: AmbientAgentEnvironment = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(env.secrets, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_with_empty_secrets() {
|
||||
let json = serde_json::json!({
|
||||
"name": "empty-secrets-env",
|
||||
"github_repos": [],
|
||||
"docker_image": "ubuntu:latest",
|
||||
"secrets": []
|
||||
});
|
||||
|
||||
let env: AmbientAgentEnvironment = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(env.secrets, Some(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_with_specific_secrets() {
|
||||
let json = serde_json::json!({
|
||||
"name": "secrets-env",
|
||||
"github_repos": [],
|
||||
"docker_image": "ubuntu:latest",
|
||||
"secrets": [
|
||||
{"name": "GH_TOKEN"},
|
||||
{"name": "NPM_TOKEN"}
|
||||
]
|
||||
});
|
||||
|
||||
let env: AmbientAgentEnvironment = serde_json::from_value(json).unwrap();
|
||||
let secrets = env.secrets.unwrap();
|
||||
assert_eq!(secrets.len(), 2);
|
||||
assert_eq!(secrets[0].name, "GH_TOKEN");
|
||||
assert_eq!(secrets[1].name, "NPM_TOKEN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_with_secrets_none_omits_field() {
|
||||
let env = AmbientAgentEnvironment::new(
|
||||
"test-env".into(),
|
||||
None,
|
||||
vec![],
|
||||
"ubuntu:latest".into(),
|
||||
vec![],
|
||||
);
|
||||
|
||||
let json = serde_json::to_value(&env).unwrap();
|
||||
assert!(!json.as_object().unwrap().contains_key("secrets"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_with_empty_secrets_includes_field() {
|
||||
let mut env = AmbientAgentEnvironment::new(
|
||||
"test-env".into(),
|
||||
None,
|
||||
vec![],
|
||||
"ubuntu:latest".into(),
|
||||
vec![],
|
||||
);
|
||||
env.secrets = Some(vec![]);
|
||||
|
||||
let json = serde_json::to_value(&env).unwrap();
|
||||
let secrets = json.get("secrets").unwrap();
|
||||
assert!(secrets.as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_serde_with_secrets() {
|
||||
let mut env = AmbientAgentEnvironment::new(
|
||||
"secrets-rt".into(),
|
||||
None,
|
||||
vec![],
|
||||
"ubuntu:latest".into(),
|
||||
vec![],
|
||||
);
|
||||
env.secrets = Some(vec![
|
||||
EnvironmentSecretRef {
|
||||
name: "MY_SECRET".into(),
|
||||
},
|
||||
EnvironmentSecretRef {
|
||||
name: "OTHER_SECRET".into(),
|
||||
},
|
||||
]);
|
||||
|
||||
let serialized = serde_json::to_string(&env).unwrap();
|
||||
let deserialized: AmbientAgentEnvironment = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(env, deserialized);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
use cloud_objects::cloud_object::{
|
||||
GenericCloudObject, GenericServerObject, GenericStringModel, JsonObjectType,
|
||||
};
|
||||
use cloud_objects::ids::GenericStringObjectId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warp_util::path::ShellFamily;
|
||||
|
||||
use crate::{JsonModel, JsonSerializer};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct EnvVarSecretCommand {
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
/// Represents a completed external secret reference.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ExternalSecret {
|
||||
OnePassword(OnePasswordSecret),
|
||||
LastPass(LastPassSecret),
|
||||
}
|
||||
|
||||
impl ExternalSecret {
|
||||
pub fn get_secret_extraction_command(&self, shell_family: ShellFamily) -> String {
|
||||
let prefix = match shell_family {
|
||||
ShellFamily::Posix => "\\",
|
||||
ShellFamily::PowerShell => "",
|
||||
};
|
||||
match self {
|
||||
ExternalSecret::OnePassword(secret) => {
|
||||
format!(
|
||||
"{}op item get --fields credential --reveal {}",
|
||||
prefix, secret.reference
|
||||
)
|
||||
}
|
||||
ExternalSecret::LastPass(secret) => {
|
||||
format!("{}lpass show --password {}", prefix, secret.reference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_display_name(&self) -> String {
|
||||
match self {
|
||||
ExternalSecret::OnePassword(secret) => secret.name.clone(),
|
||||
ExternalSecret::LastPass(secret) => secret.name.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct OnePasswordSecret {
|
||||
name: String,
|
||||
reference: String,
|
||||
}
|
||||
|
||||
impl OnePasswordSecret {
|
||||
pub fn new(name: String, reference: String) -> Self {
|
||||
Self { name, reference }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct LastPassSecret {
|
||||
name: String,
|
||||
reference: String,
|
||||
}
|
||||
|
||||
impl LastPassSecret {
|
||||
pub fn new(name: String, reference: String) -> Self {
|
||||
Self { name, reference }
|
||||
}
|
||||
}
|
||||
|
||||
/// Defines the data model for a single environment variable
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct EnvVar {
|
||||
// Variable name
|
||||
pub name: String,
|
||||
// Variable value
|
||||
pub value: EnvVarValue,
|
||||
// Description of variable
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// Defines the various forms a value can take
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub enum EnvVarValue {
|
||||
// Represents a string variable, i.e. PORT=4000
|
||||
Constant(String),
|
||||
// Represents a computed secret, i.e. gcloud print auth token
|
||||
Command(EnvVarSecretCommand),
|
||||
// Represents a secret from an external secret manager
|
||||
Secret(ExternalSecret),
|
||||
}
|
||||
|
||||
impl Default for EnvVarValue {
|
||||
fn default() -> Self {
|
||||
EnvVarValue::Constant(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl EnvVar {
|
||||
pub fn new(name: String, value: String, description: Option<String>) -> Self {
|
||||
Self {
|
||||
name,
|
||||
value: EnvVarValue::Constant(value),
|
||||
description,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Defines the data model for a cloud synced collection of environment variables.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct EnvVarCollection {
|
||||
// Collection title
|
||||
pub title: Option<String>,
|
||||
// Description of collection
|
||||
pub description: Option<String>,
|
||||
// Environment variables associated with this collection
|
||||
pub vars: Vec<EnvVar>,
|
||||
}
|
||||
|
||||
impl EnvVarCollection {
|
||||
pub fn new(title: Option<String>, description: Option<String>, vars: Vec<EnvVar>) -> Self {
|
||||
Self {
|
||||
title,
|
||||
description,
|
||||
vars,
|
||||
}
|
||||
}
|
||||
|
||||
fn key_value_iter(&self) -> impl Iterator<Item = (&str, &EnvVarValue)> {
|
||||
self.vars.iter().map(|var| (var.name.as_str(), &var.value))
|
||||
}
|
||||
|
||||
pub fn export_variables(&self, delimiter: &str, shell_family: ShellFamily) -> String {
|
||||
serialize_variables_internal(self.key_value_iter(), "", "=", "", delimiter, shell_family)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serialize_variables_internal<'s, I: IntoIterator<Item = (&'s str, &'s EnvVarValue)>>(
|
||||
pairs: I,
|
||||
prefix: &str,
|
||||
separator: &str,
|
||||
postfix: &str,
|
||||
delimiter: &str,
|
||||
shell_family: ShellFamily,
|
||||
) -> String {
|
||||
// Prefix — what's prepended to each variable
|
||||
// Separator — what separates the variable name from the value
|
||||
// Postfix — what's appended to the end of each variable
|
||||
// Delimiter — what separates one variable from the next one
|
||||
// set -x var_name var_value; set -x name2 value2;
|
||||
// ------ - - -
|
||||
// ^ ^ ^ ^
|
||||
// prefix separator postfix delimiter (in this case 4 spaces, usually one space or newline)
|
||||
pairs
|
||||
.into_iter()
|
||||
.map(|(name, value)| {
|
||||
format!(
|
||||
"{}{}{}{}{}",
|
||||
prefix,
|
||||
shell_family.escape(name),
|
||||
separator,
|
||||
get_init_command_for_env_var_value(value, shell_family),
|
||||
postfix
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(delimiter)
|
||||
}
|
||||
|
||||
pub fn get_init_command_for_env_var_value(
|
||||
value: &EnvVarValue,
|
||||
shell_family: ShellFamily,
|
||||
) -> String {
|
||||
match value {
|
||||
EnvVarValue::Constant(val) => match shell_family {
|
||||
ShellFamily::Posix => shell_family.escape(val).into_owned(),
|
||||
ShellFamily::PowerShell => format!("'{}'", val.replace("'", "''")),
|
||||
},
|
||||
EnvVarValue::Command(cmd) => format!("$({})", cmd.command),
|
||||
EnvVarValue::Secret(secret) => {
|
||||
format!("$({})", secret.get_secret_extraction_command(shell_family))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonModel for EnvVarCollection {
|
||||
fn json_object_type() -> JsonObjectType {
|
||||
JsonObjectType::EnvVarCollection
|
||||
}
|
||||
}
|
||||
|
||||
pub type CloudEnvVarCollection =
|
||||
GenericCloudObject<GenericStringObjectId, CloudEnvVarCollectionModel>;
|
||||
pub type CloudEnvVarCollectionModel = GenericStringModel<EnvVarCollection, JsonSerializer>;
|
||||
pub type ServerEnvVarCollection =
|
||||
GenericServerObject<GenericStringObjectId, CloudEnvVarCollectionModel>;
|
||||
@@ -0,0 +1,38 @@
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub mod persistence;
|
||||
|
||||
use cloud_objects::cloud_object::{
|
||||
GenericCloudObject, GenericServerObject, ObjectType, ServerObjectModel,
|
||||
};
|
||||
use cloud_objects::ids::FolderId;
|
||||
|
||||
/// The model for a `CloudFolder`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CloudFolderModel {
|
||||
pub name: String,
|
||||
// TODO: since this is local only state, we should consider only surfacing it as part of the
|
||||
// CloudViewModel. Right now, every server folder uses CloudFolderModel, which means it
|
||||
// hardcodes a value of `false` for this property since it can't know what the local state is.
|
||||
pub is_open: bool,
|
||||
pub is_warp_pack: bool,
|
||||
}
|
||||
|
||||
impl CloudFolderModel {
|
||||
pub fn new(name: &str, is_warp_pack: bool) -> Self {
|
||||
Self {
|
||||
name: name.to_owned(),
|
||||
is_open: false,
|
||||
is_warp_pack,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerObjectModel for CloudFolderModel {
|
||||
fn object_type(&self) -> ObjectType {
|
||||
ObjectType::Folder
|
||||
}
|
||||
}
|
||||
|
||||
/// `CloudFolder` is a folder retrieved from the server.
|
||||
pub type CloudFolder = GenericCloudObject<FolderId, CloudFolderModel>;
|
||||
pub type ServerFolder = GenericServerObject<FolderId, CloudFolderModel>;
|
||||
@@ -0,0 +1,89 @@
|
||||
use cloud_object_persistence::{
|
||||
CloudObjectReadContext, id_from_metadata, to_cloud_object_metadata, upsert_cloud_object,
|
||||
};
|
||||
use cloud_objects::cloud_object::ObjectType;
|
||||
use cloud_objects::ids::FolderId;
|
||||
use diesel::result::Error;
|
||||
use diesel::{Connection, ExpressionMethods, QueryDsl, RunQueryDsl, SqliteConnection};
|
||||
use persistence::model::{Folder, NewFolder};
|
||||
use persistence::schema;
|
||||
|
||||
use super::{CloudFolder, CloudFolderModel};
|
||||
|
||||
pub fn upsert_folders(
|
||||
conn: &mut SqliteConnection,
|
||||
cloud_folders: Vec<CloudFolder>,
|
||||
) -> Result<(), Error> {
|
||||
use schema::folders::dsl::*;
|
||||
conn.transaction::<(), Error, _>(|conn| {
|
||||
for cloud_folder in cloud_folders {
|
||||
let folder_clone = cloud_folder.clone();
|
||||
let folder_name = cloud_folder.model().name.clone();
|
||||
let folder_is_open = cloud_folder.model().is_open;
|
||||
let folder_is_warp_pack = cloud_folder.model().is_warp_pack;
|
||||
upsert_cloud_object(
|
||||
conn,
|
||||
ObjectType::Folder,
|
||||
cloud_folder.id,
|
||||
cloud_folder.metadata,
|
||||
cloud_folder.permissions,
|
||||
Box::new(move |conn| {
|
||||
let new_folder = NewFolder {
|
||||
name: folder_name,
|
||||
is_open: folder_is_open,
|
||||
is_warp_pack: folder_is_warp_pack,
|
||||
};
|
||||
diesel::insert_into(schema::folders::dsl::folders)
|
||||
.values(new_folder)
|
||||
.execute(conn)?;
|
||||
let folder_id: i32 = schema::folders::dsl::folders
|
||||
.select(schema::folders::columns::id)
|
||||
.order(schema::folders::columns::id.desc())
|
||||
.first(conn)?;
|
||||
Ok(folder_id)
|
||||
}),
|
||||
Box::new(move |conn, folder_id| {
|
||||
diesel::update(folders.filter(schema::folders::dsl::id.eq(folder_id)))
|
||||
.set((
|
||||
name.eq(folder_clone.model().name.clone()),
|
||||
is_open.eq(folder_clone.model().is_open),
|
||||
is_warp_pack.eq(folder_clone.model().is_warp_pack),
|
||||
))
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
}),
|
||||
)?
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_folders(
|
||||
conn: &mut SqliteConnection,
|
||||
read_context: &CloudObjectReadContext,
|
||||
) -> Result<Vec<CloudFolder>, Error> {
|
||||
Ok(schema::folders::dsl::folders
|
||||
.load::<Folder>(conn)?
|
||||
.into_iter()
|
||||
.filter_map(|folder| {
|
||||
let metadata = read_context.metadata_for_object(folder.id, ObjectType::Folder)?;
|
||||
let folder_id = id_from_metadata::<FolderId>(metadata)?;
|
||||
let cloud_object_permissions = read_context.permissions_for_metadata(metadata)?;
|
||||
Some(CloudFolder::new(
|
||||
folder_id,
|
||||
CloudFolderModel {
|
||||
name: folder.name,
|
||||
is_open: folder.is_open,
|
||||
is_warp_pack: folder.is_warp_pack,
|
||||
},
|
||||
to_cloud_object_metadata(metadata),
|
||||
cloud_object_permissions,
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn delete_folder(conn: &mut SqliteConnection, folder_id: i32) -> Result<(), Error> {
|
||||
diesel::delete(folders.filter(id.eq(folder_id))).execute(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub mod persistence;
|
||||
|
||||
use std::fmt::Debug;
|
||||
|
||||
use anyhow::Result;
|
||||
use cloud_objects::cloud_object::{
|
||||
GenericStringObjectFormat, JsonObjectType, SerializedModel, Serializer,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
/// A JSON-backed cloud object payload.
|
||||
pub trait JsonModel: Clone + Debug + Send + Sync + Serialize + DeserializeOwned + 'static {
|
||||
/// Returns the JSON object type used by the generic string object API.
|
||||
fn json_object_type() -> JsonObjectType;
|
||||
|
||||
/// Returns the generic string format for this JSON model.
|
||||
fn model_format() -> GenericStringObjectFormat {
|
||||
GenericStringObjectFormat::Json(Self::json_object_type())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct JsonSerializer;
|
||||
|
||||
impl<M: JsonModel> Serializer<M> for JsonSerializer {
|
||||
fn model_format() -> GenericStringObjectFormat {
|
||||
M::model_format()
|
||||
}
|
||||
|
||||
fn serialize(model: &M) -> SerializedModel {
|
||||
SerializedModel::new(serde_json::to_string(model).expect("model should serialize"))
|
||||
}
|
||||
|
||||
fn deserialize_owned(serialized: &str) -> Result<M>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
Ok(serde_json::from_str(serialized)?)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
use cloud_object_persistence::{
|
||||
CloudObjectReadContext, id_from_metadata, read_generic_string_object_rows,
|
||||
to_cloud_object_metadata,
|
||||
};
|
||||
use cloud_objects::cloud_object::{
|
||||
GENERIC_STRING_OBJECT_PREFIX, GenericStringObjectFormat, JSON_OBJECT_PREFIX, JsonObjectType,
|
||||
ObjectType,
|
||||
};
|
||||
use cloud_objects::ids::GenericStringObjectId;
|
||||
use diesel::SqliteConnection;
|
||||
use diesel::result::Error;
|
||||
|
||||
use crate::{
|
||||
CloudAIExecutionProfile, CloudAIExecutionProfileModel, CloudAIFact, CloudAIFactModel,
|
||||
CloudAmbientAgentEnvironment, CloudAmbientAgentEnvironmentModel, CloudEnvVarCollection,
|
||||
CloudEnvVarCollectionModel, CloudMCPServer, CloudMCPServerModel, CloudPreference,
|
||||
CloudPreferenceModel, CloudScheduledAmbientAgent, CloudScheduledAmbientAgentModel,
|
||||
CloudTemplatableMCPServer, CloudTemplatableMCPServerModel, CloudWorkflowEnum,
|
||||
CloudWorkflowEnumModel,
|
||||
};
|
||||
|
||||
pub enum PersistedGenericStringObject {
|
||||
Preference(CloudPreference),
|
||||
EnvVarCollection(CloudEnvVarCollection),
|
||||
WorkflowEnum(CloudWorkflowEnum),
|
||||
AIFact(CloudAIFact),
|
||||
MCPServer(CloudMCPServer),
|
||||
TemplatableMCPServer(CloudTemplatableMCPServer),
|
||||
AIExecutionProfile(CloudAIExecutionProfile),
|
||||
CloudEnvironment(CloudAmbientAgentEnvironment),
|
||||
ScheduledAmbientAgent(CloudScheduledAmbientAgent),
|
||||
}
|
||||
|
||||
pub fn read_generic_string_objects(
|
||||
conn: &mut SqliteConnection,
|
||||
read_context: &CloudObjectReadContext,
|
||||
) -> Result<Vec<PersistedGenericStringObject>, Error> {
|
||||
Ok(read_generic_string_object_rows(conn)?
|
||||
.into_iter()
|
||||
.filter_map(|object| {
|
||||
let metadata = read_context.metadata_for_object(
|
||||
object.id,
|
||||
ObjectType::GenericStringObject(GenericStringObjectFormat::Json(
|
||||
JsonObjectType::Preference,
|
||||
)),
|
||||
)?;
|
||||
let object_id = id_from_metadata::<GenericStringObjectId>(metadata)?;
|
||||
let cloud_object_permissions = read_context.permissions_for_metadata(metadata)?;
|
||||
let json_object_type: JsonObjectType = metadata
|
||||
.object_type
|
||||
.strip_prefix(&format!(
|
||||
"{GENERIC_STRING_OBJECT_PREFIX}{JSON_OBJECT_PREFIX}"
|
||||
))?
|
||||
.try_into()
|
||||
.ok()?;
|
||||
match json_object_type {
|
||||
JsonObjectType::Preference => {
|
||||
let model = CloudPreferenceModel::deserialize_owned(&object.data);
|
||||
model.ok().map(|model| {
|
||||
PersistedGenericStringObject::Preference(CloudPreference::new(
|
||||
object_id,
|
||||
model,
|
||||
to_cloud_object_metadata(metadata),
|
||||
cloud_object_permissions,
|
||||
))
|
||||
})
|
||||
}
|
||||
JsonObjectType::EnvVarCollection => {
|
||||
let model = CloudEnvVarCollectionModel::deserialize_owned(&object.data);
|
||||
model.ok().map(|model| {
|
||||
PersistedGenericStringObject::EnvVarCollection(CloudEnvVarCollection::new(
|
||||
object_id,
|
||||
model,
|
||||
to_cloud_object_metadata(metadata),
|
||||
cloud_object_permissions,
|
||||
))
|
||||
})
|
||||
}
|
||||
JsonObjectType::WorkflowEnum => {
|
||||
let model = CloudWorkflowEnumModel::deserialize_owned(&object.data);
|
||||
model.ok().map(|model| {
|
||||
PersistedGenericStringObject::WorkflowEnum(CloudWorkflowEnum::new(
|
||||
object_id,
|
||||
model,
|
||||
to_cloud_object_metadata(metadata),
|
||||
cloud_object_permissions,
|
||||
))
|
||||
})
|
||||
}
|
||||
JsonObjectType::AIFact => {
|
||||
let model = CloudAIFactModel::deserialize_owned(&object.data);
|
||||
model.ok().map(|model| {
|
||||
PersistedGenericStringObject::AIFact(CloudAIFact::new(
|
||||
object_id,
|
||||
model,
|
||||
to_cloud_object_metadata(metadata),
|
||||
cloud_object_permissions,
|
||||
))
|
||||
})
|
||||
}
|
||||
JsonObjectType::MCPServer => {
|
||||
let model = CloudMCPServerModel::deserialize_owned(&object.data);
|
||||
model.ok().map(|model| {
|
||||
PersistedGenericStringObject::MCPServer(CloudMCPServer::new(
|
||||
object_id,
|
||||
model,
|
||||
to_cloud_object_metadata(metadata),
|
||||
cloud_object_permissions,
|
||||
))
|
||||
})
|
||||
}
|
||||
JsonObjectType::TemplatableMCPServer => {
|
||||
let model = CloudTemplatableMCPServerModel::deserialize_owned(&object.data);
|
||||
model.ok().map(|model| {
|
||||
PersistedGenericStringObject::TemplatableMCPServer(
|
||||
CloudTemplatableMCPServer::new(
|
||||
object_id,
|
||||
model,
|
||||
to_cloud_object_metadata(metadata),
|
||||
cloud_object_permissions,
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
JsonObjectType::AIExecutionProfile => {
|
||||
let model = CloudAIExecutionProfileModel::deserialize_owned(&object.data);
|
||||
model.ok().map(|model| {
|
||||
PersistedGenericStringObject::AIExecutionProfile(
|
||||
CloudAIExecutionProfile::new(
|
||||
object_id,
|
||||
model,
|
||||
to_cloud_object_metadata(metadata),
|
||||
cloud_object_permissions,
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
JsonObjectType::CloudEnvironment => {
|
||||
let model = CloudAmbientAgentEnvironmentModel::deserialize_owned(&object.data);
|
||||
model.ok().map(|model| {
|
||||
PersistedGenericStringObject::CloudEnvironment(
|
||||
CloudAmbientAgentEnvironment::new(
|
||||
object_id,
|
||||
model,
|
||||
to_cloud_object_metadata(metadata),
|
||||
cloud_object_permissions,
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
JsonObjectType::ScheduledAmbientAgent => {
|
||||
let model = CloudScheduledAmbientAgentModel::deserialize_owned(&object.data);
|
||||
model.ok().map(|model| {
|
||||
PersistedGenericStringObject::ScheduledAmbientAgent(
|
||||
CloudScheduledAmbientAgent::new(
|
||||
object_id,
|
||||
model,
|
||||
to_cloud_object_metadata(metadata),
|
||||
cloud_object_permissions,
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
// TODO: Implement CloudAgentConfig model when full sync support is added
|
||||
JsonObjectType::CloudAgentConfig => None,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! This crate defines the concrete Warp cloud object models and typed cloud object aliases built
|
||||
//! on top of `cloud_objects`.
|
||||
//!
|
||||
//! Each model module should own the model payload for one cloud object family, plus any model-specific
|
||||
//! adapters that should move with that model during future verticalization.
|
||||
//!
|
||||
//! Native SQLite adapters may live under model-local `persistence` modules, while shared persistence
|
||||
//! infrastructure should stay in `cloud_object_persistence`.
|
||||
|
||||
// Multiple modules contain `persistence` submodules; it is expected that
|
||||
// code from the persistence modules is imported with fully-qualified paths.
|
||||
#![allow(ambiguous_glob_reexports)]
|
||||
|
||||
pub mod ai_execution_profile;
|
||||
pub mod ai_fact;
|
||||
pub mod cloud_agent_config;
|
||||
pub mod cloud_environment;
|
||||
pub mod env_vars;
|
||||
pub mod folder;
|
||||
pub mod json_model;
|
||||
pub mod mcp;
|
||||
pub mod notebook;
|
||||
pub mod preference;
|
||||
pub mod scheduled_ambient_agent;
|
||||
pub mod server_cloud_object;
|
||||
pub mod user_profile;
|
||||
pub mod workflow;
|
||||
pub mod workflow_enum;
|
||||
|
||||
pub use ai_execution_profile::*;
|
||||
pub use ai_fact::*;
|
||||
pub use cloud_agent_config::*;
|
||||
pub use cloud_environment::*;
|
||||
pub use env_vars::*;
|
||||
pub use folder::*;
|
||||
pub use json_model::*;
|
||||
pub use mcp::*;
|
||||
pub use notebook::*;
|
||||
pub use preference::*;
|
||||
pub use scheduled_ambient_agent::*;
|
||||
pub use server_cloud_object::*;
|
||||
pub use user_profile::*;
|
||||
pub use workflow::*;
|
||||
pub use workflow_enum::*;
|
||||
@@ -0,0 +1,298 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use cloud_objects::cloud_object::{
|
||||
GenericCloudObject, GenericServerObject, GenericStringModel, JsonObjectType,
|
||||
};
|
||||
use cloud_objects::ids::GenericStringObjectId;
|
||||
use handlebars::get_arguments;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{JsonModel, JsonSerializer};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JSONMCPServer {
|
||||
#[serde(flatten)]
|
||||
pub transport_type: JSONTransportType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum JSONTransportType {
|
||||
CLIServer {
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
working_directory: Option<String>,
|
||||
},
|
||||
SSEServer {
|
||||
#[serde(alias = "serverUrl")]
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
headers: HashMap<String, String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MCPServer {
|
||||
pub transport_type: TransportType,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub uuid: uuid::Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum MCPServerState {
|
||||
NotRunning,
|
||||
Starting,
|
||||
Authenticating,
|
||||
Running,
|
||||
ShuttingDown,
|
||||
FailedToStart,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TransportType {
|
||||
CLIServer(CLIServer),
|
||||
ServerSentEvents(ServerSentEvents),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CLIServer {
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
pub cwd_parameter: Option<String>,
|
||||
/// Static env vars added via editor inputs.
|
||||
pub static_env_vars: Vec<StaticEnvVar>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StaticEnvVar {
|
||||
pub name: String,
|
||||
/// To avoid leaking environment variables, we ensure that values are not
|
||||
/// serialized before being sent to our servers
|
||||
#[serde(skip_serializing, default)]
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StaticHeader {
|
||||
pub name: String,
|
||||
/// To avoid leaking header values (which may contain secrets), we ensure that values are not
|
||||
/// serialized before being sent to our servers
|
||||
#[serde(skip_serializing, default)]
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ServerSentEvents {
|
||||
pub url: String,
|
||||
/// Static headers added via editor inputs.
|
||||
#[serde(default)]
|
||||
pub headers: Vec<StaticHeader>,
|
||||
}
|
||||
|
||||
impl JsonModel for MCPServer {
|
||||
fn json_object_type() -> JsonObjectType {
|
||||
JsonObjectType::MCPServer
|
||||
}
|
||||
}
|
||||
|
||||
pub type CloudMCPServer = GenericCloudObject<GenericStringObjectId, CloudMCPServerModel>;
|
||||
pub type CloudMCPServerModel = GenericStringModel<MCPServer, JsonSerializer>;
|
||||
pub type ServerMCPServer = GenericServerObject<GenericStringObjectId, CloudMCPServerModel>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, Hash)]
|
||||
pub struct JsonTemplate {
|
||||
pub json: String,
|
||||
pub variables: Vec<TemplateVariable>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
pub struct TemplateVariable {
|
||||
pub key: String,
|
||||
/// When present, the variable should be filled via a dropdown of these values
|
||||
/// instead of a freetext input.
|
||||
#[serde(default)]
|
||||
pub allowed_values: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GalleryData {
|
||||
pub gallery_item_id: Uuid,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct TemplatableMCPServer {
|
||||
pub uuid: uuid::Uuid,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub template: JsonTemplate,
|
||||
#[serde(default)]
|
||||
pub version: i64, // This will default to 0 if stored objects have no version
|
||||
pub gallery_data: Option<GalleryData>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum FromStoredJsonError {
|
||||
NoServersFound,
|
||||
TooManyServersFound,
|
||||
ParseError(serde_json::Error),
|
||||
}
|
||||
|
||||
impl TemplatableMCPServer {
|
||||
/// Looks for MCP servers under known wrapper keys (`mcpServers`, `servers`,
|
||||
/// `mcp.servers`, `mcp_servers`). Returns `None` if no known key is found.
|
||||
fn find_servers_under_known_keys(
|
||||
config: &serde_json::Value,
|
||||
) -> Option<HashMap<String, serde_json::Value>> {
|
||||
const POINTERS: [&str; 4] = ["/mcp/servers", "/servers", "/mcpServers", "/mcp_servers"];
|
||||
for pointer in POINTERS {
|
||||
if let Some(value) = config.pointer(pointer)
|
||||
&& let Ok(servers) =
|
||||
serde_json::from_value::<HashMap<String, serde_json::Value>>(value.clone())
|
||||
{
|
||||
return Some(servers);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Permissively parses MCP servers from JSON.
|
||||
///
|
||||
/// Accepts servers under known wrapper keys (VSCode, Claude Desktop, etc.)
|
||||
/// and also falls back to treating the entire object as a bare server map.
|
||||
/// This is appropriate for user-pasted input.
|
||||
pub fn find_template_map(
|
||||
config: serde_json::Value,
|
||||
) -> serde_json::Result<HashMap<String, serde_json::Value>> {
|
||||
if let Some(servers) = Self::find_servers_under_known_keys(&config) {
|
||||
return Ok(servers);
|
||||
}
|
||||
// Fallback: treat the entire object as a bare map of servers.
|
||||
serde_json::from_value::<HashMap<String, serde_json::Value>>(config)
|
||||
}
|
||||
/// Like [`find_template_map`], but without the bare-object fallback.
|
||||
///
|
||||
/// Returns servers only when found under a known wrapper key. This prevents
|
||||
/// misinterpreting unrelated JSON files (e.g. Claude Code's `~/.claude.json`
|
||||
/// settings) as MCP config.
|
||||
pub fn find_template_map_strict(
|
||||
config: &serde_json::Value,
|
||||
) -> HashMap<String, serde_json::Value> {
|
||||
Self::find_servers_under_known_keys(config).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn to_user_json(&self) -> String {
|
||||
let value: serde_json::Value = serde_json::from_str(&self.template.json)
|
||||
// All templates should be valid JSON - this should never fail
|
||||
// Ones that are not should not have been saved in the first place
|
||||
.unwrap_or_else(|err| {
|
||||
log::error!("Could not parse MCP server template to json: {err:?}");
|
||||
Default::default()
|
||||
});
|
||||
serde_json::to_string_pretty(&value)
|
||||
// serde_json::to_string_pretty should never fail on this value since we just parsed it as valid json
|
||||
.unwrap_or_else(|err| {
|
||||
log::error!("Could not serialize MCP server to user json: {err:?}");
|
||||
Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
// Uses from_user_json to parse the json and then returns the first TemplatableMCPServer
|
||||
// This is meant to be used for stored json from the database, which should only contain
|
||||
// a single server and already checked for json validity
|
||||
pub fn from_stored_json(
|
||||
json: &str,
|
||||
uuid: uuid::Uuid,
|
||||
) -> Result<TemplatableMCPServer, FromStoredJsonError> {
|
||||
let templates = Self::from_user_json(json);
|
||||
match templates {
|
||||
Ok(templates) => {
|
||||
if templates.is_empty() {
|
||||
// This should never happen for stored json from the database
|
||||
log::error!("No templatable MCP servers found in stored json: {uuid}");
|
||||
Err(FromStoredJsonError::NoServersFound)
|
||||
} else if templates.len() > 1 {
|
||||
Err(FromStoredJsonError::TooManyServersFound)
|
||||
} else {
|
||||
// templates should always contain exactly one server for stored json from the database
|
||||
let mut templatable_mcp_server = templates[0].clone();
|
||||
templatable_mcp_server.uuid = uuid;
|
||||
Ok(templatable_mcp_server)
|
||||
}
|
||||
}
|
||||
Err(err) => Err(FromStoredJsonError::ParseError(err)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_user_json(json: &str) -> serde_json::Result<Vec<TemplatableMCPServer>> {
|
||||
// Some docs don't show curly braces around the json object, so add them if necessary.
|
||||
let json = json.trim();
|
||||
let json = if json.starts_with("{") {
|
||||
json.to_owned()
|
||||
} else {
|
||||
format!("{{{json}}}")
|
||||
};
|
||||
|
||||
let config: serde_json::Value = serde_json::from_str(&json)?;
|
||||
let template_jsons = Self::find_template_map(config)?;
|
||||
Ok(template_jsons
|
||||
.iter()
|
||||
.map(|(name, json)| {
|
||||
// Each template_json is the nested config for a single MCP server
|
||||
// We need to re-wrap it in a top level object so that we can
|
||||
// reuse from_user_json to read it later
|
||||
let normalized_map =
|
||||
serde_json::Map::from_iter(vec![(name.to_owned(), json.clone())]);
|
||||
let normalized_json = serde_json::Value::Object(normalized_map).to_string();
|
||||
|
||||
let description: Option<String> = json
|
||||
.get("description")
|
||||
.and_then(|value| value.as_str().map(|s| s.to_owned()));
|
||||
let arguments = get_arguments(&normalized_json);
|
||||
let variables = arguments
|
||||
.iter()
|
||||
.map(|argument| TemplateVariable {
|
||||
key: argument.to_owned(),
|
||||
allowed_values: None,
|
||||
})
|
||||
.collect::<Vec<TemplateVariable>>();
|
||||
|
||||
TemplatableMCPServer {
|
||||
uuid: uuid::Uuid::new_v4(),
|
||||
name: name.to_owned(),
|
||||
description,
|
||||
template: JsonTemplate {
|
||||
json: normalized_json,
|
||||
variables,
|
||||
},
|
||||
version: Utc::now().timestamp(),
|
||||
gallery_data: None,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonModel for TemplatableMCPServer {
|
||||
fn json_object_type() -> JsonObjectType {
|
||||
JsonObjectType::TemplatableMCPServer
|
||||
}
|
||||
}
|
||||
|
||||
pub type CloudTemplatableMCPServer =
|
||||
GenericCloudObject<GenericStringObjectId, CloudTemplatableMCPServerModel>;
|
||||
pub type CloudTemplatableMCPServerModel = GenericStringModel<TemplatableMCPServer, JsonSerializer>;
|
||||
pub type ServerTemplatableMCPServer =
|
||||
GenericServerObject<GenericStringObjectId, CloudTemplatableMCPServerModel>;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mcp_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,118 @@
|
||||
use super::{CLIServer, MCPServer, ServerSentEvents, StaticEnvVar, TransportType};
|
||||
|
||||
#[test]
|
||||
fn test_mcp_server_config_serialization_excludes_secret_env_values() {
|
||||
// Create a CLI server with environment variables containing secrets
|
||||
let cli_server = CLIServer {
|
||||
command: "npx".to_string(),
|
||||
args: vec!["@modelcontextprotocol/server-postgres".to_string()],
|
||||
cwd_parameter: Some("/tmp".to_string()),
|
||||
static_env_vars: vec![
|
||||
StaticEnvVar {
|
||||
name: "API_KEY".to_string(),
|
||||
value: "SOME_LEAKED_SECRET".to_string(),
|
||||
},
|
||||
StaticEnvVar {
|
||||
name: "DATABASE_URL".to_string(),
|
||||
value: "postgresql://user:password@localhost/db".to_string(),
|
||||
},
|
||||
StaticEnvVar {
|
||||
name: "PUBLIC_CONFIG".to_string(),
|
||||
value: "not-secret-value".to_string(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let mcp_server = MCPServer {
|
||||
transport_type: TransportType::CLIServer(cli_server),
|
||||
name: "test-server".to_string(),
|
||||
uuid: uuid::Uuid::new_v4(),
|
||||
};
|
||||
// Test direct serde serialization
|
||||
let serialized = serde_json::to_string(&mcp_server).expect("Failed to serialize MCP server");
|
||||
// The serialized config should NOT contain the secret values
|
||||
assert!(
|
||||
!serialized.contains("SOME_LEAKED_SECRET"),
|
||||
"Serialized config contains leaked secret value: {serialized}",
|
||||
);
|
||||
assert!(
|
||||
!serialized.contains("password"),
|
||||
"Serialized config contains password: {serialized}",
|
||||
);
|
||||
assert!(
|
||||
!serialized.contains("not-secret-value"),
|
||||
"Serialized config contains env var value: {serialized}",
|
||||
);
|
||||
// But should contain the environment variable names/keys
|
||||
assert!(
|
||||
serialized.contains("API_KEY"),
|
||||
"Serialized config should contain env var key 'API_KEY': {serialized}",
|
||||
);
|
||||
assert!(
|
||||
serialized.contains("DATABASE_URL"),
|
||||
"Serialized config should contain env var key 'DATABASE_URL': {serialized}",
|
||||
);
|
||||
assert!(
|
||||
serialized.contains("PUBLIC_CONFIG"),
|
||||
"Serialized config should contain env var key 'PUBLIC_CONFIG': {serialized}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_static_env_var_direct_serialization() {
|
||||
// Test direct serialization of StaticEnvVar to ensure skip_serializing works
|
||||
let env_var = StaticEnvVar {
|
||||
name: "TEST_SECRET".to_string(),
|
||||
value: "SOME_LEAKED_SECRET".to_string(),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&env_var).expect("Failed to serialize env var");
|
||||
|
||||
// Should contain the name but not the value due to skip_serializing
|
||||
assert!(
|
||||
serialized.contains("TEST_SECRET"),
|
||||
"Serialized env var should contain name: {serialized}",
|
||||
);
|
||||
assert!(
|
||||
!serialized.contains("SOME_LEAKED_SECRET"),
|
||||
"Serialized env var should not contain value due to skip_serializing: {serialized}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_static_env_var_deserialization_with_default() {
|
||||
// Test that StaticEnvVar can be deserialized properly with default value
|
||||
let json = r#"{"name": "API_KEY"}"#;
|
||||
|
||||
let env_var: StaticEnvVar = serde_json::from_str(json).expect("Failed to deserialize env var");
|
||||
|
||||
assert_eq!(env_var.name, "API_KEY");
|
||||
assert_eq!(env_var.value, ""); // Should default to empty string
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sse_server_serialization() {
|
||||
// Test that ServerSentEvents transport type serializes correctly
|
||||
let sse_server = ServerSentEvents {
|
||||
url: "https://example.com/sse".to_string(),
|
||||
headers: Default::default(),
|
||||
};
|
||||
|
||||
let mcp_server = MCPServer {
|
||||
transport_type: TransportType::ServerSentEvents(sse_server),
|
||||
name: "sse-server".to_string(),
|
||||
uuid: uuid::Uuid::new_v4(),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&mcp_server).expect("Failed to serialize MCP server");
|
||||
|
||||
// Should contain the URL since it's not a secret field
|
||||
assert!(
|
||||
serialized.contains("https://example.com/sse"),
|
||||
"Serialized SSE server should contain URL: {serialized}",
|
||||
);
|
||||
assert!(
|
||||
serialized.contains("sse-server"),
|
||||
"Serialized SSE server should contain name: {serialized}",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub mod persistence;
|
||||
|
||||
use ai::document::AIDocumentId;
|
||||
use cloud_objects::cloud_object::{
|
||||
GenericCloudObject, GenericServerObject, ObjectType, ServerObjectModel,
|
||||
};
|
||||
use cloud_objects::ids::{ServerId, SyncId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Serialized representation of a notebook for sync queue
|
||||
/// The AIDocumentID and ConversationID are stored here to avoid polluting the
|
||||
/// generic CreateObjectRequest type.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct SerializedNotebook {
|
||||
pub data: String,
|
||||
pub ai_document_id: Option<String>,
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct CloudNotebookModel {
|
||||
pub title: String,
|
||||
pub data: String,
|
||||
pub ai_document_id: Option<AIDocumentId>,
|
||||
/// This is the server-generated conversation token, not the client-side AIConversationId.
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ServerObjectModel for CloudNotebookModel {
|
||||
fn object_type(&self) -> ObjectType {
|
||||
ObjectType::Notebook
|
||||
}
|
||||
}
|
||||
|
||||
/// This is the notebook_id in the database associated with this notebook.
|
||||
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
pub struct NotebookId(ServerId);
|
||||
cloud_objects::server_id_traits! { NotebookId, "Notebook" }
|
||||
|
||||
impl From<NotebookId> for SyncId {
|
||||
fn from(id: NotebookId) -> Self {
|
||||
Self::ServerId(id.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// `CloudNotebook` is a notebook retrieved from the server.
|
||||
pub type CloudNotebook = GenericCloudObject<NotebookId, CloudNotebookModel>;
|
||||
pub type ServerNotebook = GenericServerObject<NotebookId, CloudNotebookModel>;
|
||||
@@ -0,0 +1,103 @@
|
||||
use ai::document::AIDocumentId;
|
||||
use cloud_object_persistence::{
|
||||
CloudObjectReadContext, id_from_metadata, to_cloud_object_metadata, upsert_cloud_object,
|
||||
};
|
||||
use cloud_objects::cloud_object::ObjectType;
|
||||
use diesel::result::Error;
|
||||
use diesel::{Connection, ExpressionMethods, QueryDsl, RunQueryDsl, SqliteConnection};
|
||||
use persistence::model::{NewNotebook, Notebook};
|
||||
use persistence::schema;
|
||||
|
||||
use super::{CloudNotebook, CloudNotebookModel, NotebookId};
|
||||
|
||||
pub fn upsert_notebooks(
|
||||
conn: &mut SqliteConnection,
|
||||
cloud_notebooks: Vec<CloudNotebook>,
|
||||
) -> Result<(), Error> {
|
||||
use schema::notebooks::dsl::*;
|
||||
conn.transaction::<(), Error, _>(|conn| {
|
||||
for cloud_notebook in cloud_notebooks {
|
||||
// todo: wrap in an arc to avoid unnecessary cloning.
|
||||
let notebook_clone = cloud_notebook.clone();
|
||||
let title_clone = cloud_notebook.model().title.clone();
|
||||
let data_clone = cloud_notebook.model().data.clone();
|
||||
let ai_document_id_clone = cloud_notebook
|
||||
.model()
|
||||
.ai_document_id
|
||||
.as_ref()
|
||||
.map(|doc_id| doc_id.to_string());
|
||||
upsert_cloud_object(
|
||||
conn,
|
||||
ObjectType::Notebook,
|
||||
cloud_notebook.id,
|
||||
cloud_notebook.metadata,
|
||||
cloud_notebook.permissions,
|
||||
Box::new(move |conn| {
|
||||
let new_notebook = NewNotebook {
|
||||
title: Some(title_clone),
|
||||
data: Some(data_clone),
|
||||
ai_document_id: ai_document_id_clone,
|
||||
};
|
||||
diesel::insert_into(schema::notebooks::dsl::notebooks)
|
||||
.values(new_notebook)
|
||||
.execute(conn)?;
|
||||
let notebook_id: i32 = schema::notebooks::dsl::notebooks
|
||||
.select(schema::notebooks::columns::id)
|
||||
.order(schema::notebooks::columns::id.desc())
|
||||
.first(conn)?;
|
||||
Ok(notebook_id)
|
||||
}),
|
||||
Box::new(move |conn, notebook_id| {
|
||||
diesel::update(notebooks.filter(schema::notebooks::dsl::id.eq(notebook_id)))
|
||||
.set((
|
||||
title.eq(notebook_clone.model().title.clone()),
|
||||
data.eq(notebook_clone.model().data.clone()),
|
||||
ai_document_id.eq(notebook_clone
|
||||
.model()
|
||||
.ai_document_id
|
||||
.as_ref()
|
||||
.map(|doc_id| doc_id.to_string())),
|
||||
))
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
}),
|
||||
)?
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_notebooks(
|
||||
conn: &mut SqliteConnection,
|
||||
read_context: &CloudObjectReadContext,
|
||||
) -> Result<Vec<CloudNotebook>, Error> {
|
||||
Ok(schema::notebooks::dsl::notebooks
|
||||
.load::<Notebook>(conn)?
|
||||
.into_iter()
|
||||
.filter_map(|notebook| {
|
||||
let metadata = read_context.metadata_for_object(notebook.id, ObjectType::Notebook)?;
|
||||
let notebook_id = id_from_metadata::<NotebookId>(metadata)?;
|
||||
let cloud_object_permissions = read_context.permissions_for_metadata(metadata)?;
|
||||
let ai_document_id = notebook
|
||||
.ai_document_id
|
||||
.as_ref()
|
||||
.and_then(|doc_id_str| AIDocumentId::try_from(doc_id_str.as_str()).ok());
|
||||
Some(CloudNotebook::new(
|
||||
notebook_id,
|
||||
CloudNotebookModel {
|
||||
title: notebook.title.unwrap_or_default(),
|
||||
data: notebook.data.unwrap_or_default(),
|
||||
ai_document_id,
|
||||
conversation_id: None,
|
||||
},
|
||||
to_cloud_object_metadata(metadata),
|
||||
cloud_object_permissions,
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn delete_notebook(conn: &mut SqliteConnection, notebook_id: i32) -> Result<(), Error> {
|
||||
diesel::delete(notebooks.filter(id.eq(notebook_id))).execute(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use cloud_objects::cloud_object::{
|
||||
GenericCloudObject, GenericServerObject, GenericStringModel, JsonObjectType,
|
||||
};
|
||||
use cloud_objects::ids::GenericStringObjectId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use settings::SyncToCloud;
|
||||
|
||||
use crate::{JsonModel, JsonSerializer};
|
||||
|
||||
/// Defines the platform that a preference was set on.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub enum Platform {
|
||||
Mac,
|
||||
Linux,
|
||||
Windows,
|
||||
Web,
|
||||
/// This implies the preference applies on all supported platforms
|
||||
Global,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Platform {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Mac => write!(f, "Mac"),
|
||||
Self::Linux => write!(f, "Linux"),
|
||||
Self::Windows => write!(f, "Windows"),
|
||||
Self::Web => write!(f, "Web"),
|
||||
Self::Global => write!(f, "Global"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Platform {
|
||||
pub fn applies_to_current_platform(&self) -> bool {
|
||||
*self == Platform::current_platform() || *self == Platform::Global
|
||||
}
|
||||
|
||||
pub fn current_platform() -> Self {
|
||||
if cfg!(all(not(target_family = "wasm"), target_os = "macos")) {
|
||||
return Self::Mac;
|
||||
}
|
||||
if cfg!(all(
|
||||
not(target_family = "wasm"),
|
||||
any(target_os = "linux", target_os = "freebsd")
|
||||
)) {
|
||||
return Self::Linux;
|
||||
}
|
||||
if cfg!(all(not(target_family = "wasm"), target_os = "windows")) {
|
||||
return Self::Windows;
|
||||
}
|
||||
if cfg!(target_family = "wasm") {
|
||||
return Self::Web;
|
||||
}
|
||||
panic!("Unsupported platform");
|
||||
}
|
||||
}
|
||||
|
||||
/// Defines the data model for a cloud synced user preference.
|
||||
///
|
||||
/// The expected usage is that each storage key is modeled as its own cloud preference object.
|
||||
/// This allows users to edit individual cloud preferences with less fear of an offline
|
||||
/// collision (e.g. if I change one preference on one machine and then update another while
|
||||
/// offline on another machine, modeling them individually allows for both changes to be applied).
|
||||
///
|
||||
/// Note that I considered adding a concept of "preference group" as a higher level namespace
|
||||
/// for preferences (in case users want to create groups of them), but decided to hold off on
|
||||
/// this until we actually support that feature.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Preference {
|
||||
/// The storage key (unique identifier for this preference).
|
||||
pub storage_key: String,
|
||||
/// The value of the preference, which can be any JSON value.
|
||||
pub value: Value,
|
||||
/// The platform that this preference was set on.
|
||||
/// If the preference is global, this will be set to Platform::Global.
|
||||
pub platform: Platform,
|
||||
}
|
||||
|
||||
impl Preference {
|
||||
/// Creates a new preference object with the given storage key and value and the appropriate
|
||||
/// platform key for the given syncing mode.
|
||||
/// Used when creating a new preference the first time. For preferences synced from the
|
||||
/// cloud they will desererialize directly from JSON.
|
||||
pub fn new(storage_key: String, value: &str, syncing_mode: SyncToCloud) -> Result<Self> {
|
||||
let platform = match syncing_mode {
|
||||
SyncToCloud::PerPlatform(_) => Platform::current_platform(),
|
||||
SyncToCloud::Globally(_) => Platform::Global,
|
||||
SyncToCloud::Never => Err(anyhow!(
|
||||
"Cannot create a preference with SyncToCloud::Never"
|
||||
))?,
|
||||
};
|
||||
match serde_json::from_str(value) {
|
||||
Ok(value) => Ok(Self {
|
||||
storage_key,
|
||||
value,
|
||||
platform,
|
||||
}),
|
||||
Err(err) => Err(anyhow!("Failed to parse preference value {err}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonModel for Preference {
|
||||
fn json_object_type() -> JsonObjectType {
|
||||
JsonObjectType::Preference
|
||||
}
|
||||
}
|
||||
|
||||
pub type CloudPreference = GenericCloudObject<GenericStringObjectId, CloudPreferenceModel>;
|
||||
pub type CloudPreferenceModel = GenericStringModel<Preference, JsonSerializer>;
|
||||
pub type ServerPreference = GenericServerObject<GenericStringObjectId, CloudPreferenceModel>;
|
||||
@@ -0,0 +1,213 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cloud_objects::cloud_object::{
|
||||
GenericCloudObject, GenericServerObject, GenericStringModel, JsonObjectType,
|
||||
};
|
||||
use cloud_objects::ids::GenericStringObjectId;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use warp_cli::agent::Harness;
|
||||
|
||||
use crate::{JsonModel, JsonSerializer};
|
||||
|
||||
/// Runtime configuration snapshot for agent execution.
|
||||
///
|
||||
/// This is the merged/resolved config used when spawning or running an agent.
|
||||
/// It combines settings from config files and CLI args.
|
||||
/// Unlike `AgentConfig` (the cloud model), field names here use the runtime format
|
||||
/// (e.g. `model_id` instead of `base_model_id`).
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AgentConfigSnapshot {
|
||||
/// Config name for searchability/traceability.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub environment_id: Option<String>,
|
||||
/// Runner ID (JsonRunner GSO) used to override the environment's compute
|
||||
/// config (docker image, instance shape, setup commands).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub runner_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_prompt: Option<String>,
|
||||
/// MCP server configuration map (unwrapped; no `mcpServers` wrapper).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mcp_servers: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
/// Profile ID for local agent runs. This configures the terminal session
|
||||
/// with the specified execution profile. Only used for local runs, not cloud runs.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile_id: Option<String>,
|
||||
/// Self-hosted worker ID that should execute this task.
|
||||
/// If None or Some("warp"), the task will be dispatched to Warp-hosted (Namespace) workers.
|
||||
/// Otherwise, the task will only be assigned to a connected self-hosted worker with matching ID.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub worker_host: Option<String>,
|
||||
/// Skill spec to use as the base prompt for the agent.
|
||||
/// Format: "skill_name", "repo:skill_name", or "org/repo:skill_name".
|
||||
/// The skill is resolved at runtime in the agent environment.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub skill_spec: Option<String>,
|
||||
/// Whether computer use is enabled for this agent run.
|
||||
/// If None, the default behavior is used.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub computer_use_enabled: Option<bool>,
|
||||
/// Execution harness for the agent run.
|
||||
/// If None, we use Warp's default ("oz").
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub harness: Option<HarnessConfig>,
|
||||
/// Authentication secrets for third-party harnesses.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub harness_auth_secrets: Option<HarnessAuthSecretsConfig>,
|
||||
}
|
||||
|
||||
/// Configuration for a third-party execution harness.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct HarnessConfig {
|
||||
/// The harness type, e.g. [`Harness::Claude`].
|
||||
#[serde(
|
||||
rename = "type",
|
||||
serialize_with = "serialize_harness",
|
||||
deserialize_with = "deserialize_harness"
|
||||
)]
|
||||
pub harness_type: Harness,
|
||||
/// The model to use with this harness. None means use the harness default.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model_id: Option<String>,
|
||||
/// Optional reasoning level for harnesses that support it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_level: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct HarnessModelConfig {
|
||||
pub model_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_level: Option<String>,
|
||||
}
|
||||
|
||||
impl HarnessConfig {
|
||||
/// Builds a harness config from just the harness type.
|
||||
pub fn from_harness_type(harness_type: Harness) -> Self {
|
||||
Self {
|
||||
harness_type,
|
||||
model_id: None,
|
||||
reasoning_level: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn model_config(&self) -> Option<HarnessModelConfig> {
|
||||
self.model_id
|
||||
.as_ref()
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(|model_id| HarnessModelConfig {
|
||||
model_id: model_id.clone(),
|
||||
reasoning_level: self.reasoning_level.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_harness<S: Serializer>(harness: &Harness, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(harness.config_name())
|
||||
}
|
||||
|
||||
fn deserialize_harness<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Harness, D::Error> {
|
||||
let name = String::deserialize(deserializer)?;
|
||||
Ok(Harness::from_config_name(&name).unwrap_or_else(|| {
|
||||
log::warn!("Unknown harness config name: {name:?}; treating as Unknown");
|
||||
Harness::Unknown
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct HarnessAuthSecretsConfig {
|
||||
/// Name of a managed secret for Claude Code harness authentication.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub claude_auth_secret_name: Option<String>,
|
||||
/// Name of a managed secret for Codex harness authentication.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub codex_auth_secret_name: Option<String>,
|
||||
}
|
||||
|
||||
impl AgentConfigSnapshot {
|
||||
/// Returns true if this config is empty (no options are set).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
let Self {
|
||||
name,
|
||||
environment_id,
|
||||
runner_id,
|
||||
model_id,
|
||||
base_prompt,
|
||||
mcp_servers,
|
||||
profile_id,
|
||||
worker_host,
|
||||
skill_spec,
|
||||
computer_use_enabled,
|
||||
harness,
|
||||
harness_auth_secrets,
|
||||
} = self;
|
||||
|
||||
name.is_none()
|
||||
&& environment_id.is_none()
|
||||
&& runner_id.is_none()
|
||||
&& model_id.is_none()
|
||||
&& base_prompt.is_none()
|
||||
&& mcp_servers.is_none()
|
||||
&& profile_id.is_none()
|
||||
&& worker_host.is_none()
|
||||
&& skill_spec.is_none()
|
||||
&& computer_use_enabled.is_none()
|
||||
&& harness.is_none()
|
||||
&& harness_auth_secrets.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// A ScheduledAmbientAgent represents configuration for ambient agents that run on a cron schedule.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ScheduledAmbientAgent {
|
||||
/// Agent name
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// Cron schedule expression
|
||||
#[serde(default)]
|
||||
pub cron_schedule: String,
|
||||
/// Whether the scheduled agent is enabled
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// The prompt to use for the scheduled agent
|
||||
#[serde(default)]
|
||||
pub prompt: String,
|
||||
/// The latest failure to execute this scheduled agent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_spawn_error: Option<String>,
|
||||
/// Configuration for how the ambient agent should run.
|
||||
#[serde(default, skip_serializing_if = "AgentConfigSnapshot::is_empty")]
|
||||
pub agent_config: AgentConfigSnapshot,
|
||||
}
|
||||
|
||||
impl ScheduledAmbientAgent {
|
||||
pub fn new(name: String, cron_schedule: String, enabled: bool, prompt: String) -> Self {
|
||||
Self {
|
||||
name,
|
||||
cron_schedule,
|
||||
enabled,
|
||||
prompt,
|
||||
last_spawn_error: None,
|
||||
agent_config: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonModel for ScheduledAmbientAgent {
|
||||
fn json_object_type() -> JsonObjectType {
|
||||
JsonObjectType::ScheduledAmbientAgent
|
||||
}
|
||||
}
|
||||
|
||||
pub type CloudScheduledAmbientAgent =
|
||||
GenericCloudObject<GenericStringObjectId, CloudScheduledAmbientAgentModel>;
|
||||
pub type CloudScheduledAmbientAgentModel =
|
||||
GenericStringModel<ScheduledAmbientAgent, JsonSerializer>;
|
||||
pub type ServerScheduledAmbientAgent =
|
||||
GenericServerObject<GenericStringObjectId, CloudScheduledAmbientAgentModel>;
|
||||
|
||||
pub type AgentConfigMap = HashMap<String, serde_json::Value>;
|
||||
@@ -0,0 +1,327 @@
|
||||
use std::any::Any;
|
||||
|
||||
use anyhow::Result;
|
||||
use cloud_objects::cloud_object::{
|
||||
GenericServerObject, GenericStringModel, Serializer, ServerMetadata,
|
||||
};
|
||||
use cloud_objects::ids::{GenericStringObjectId, ObjectUid, ServerId, SyncId};
|
||||
use warp_graphql::object::CloudObjectWithDescendants;
|
||||
|
||||
use crate::{
|
||||
AIExecutionProfile, AIFact, AmbientAgentEnvironment, CloudFolderModel, CloudNotebookModel,
|
||||
CloudWorkflowModel, EnvVarCollection, JsonSerializer, MCPServer, Preference,
|
||||
ScheduledAmbientAgent, ServerAIExecutionProfile, ServerAIFact, ServerAmbientAgentEnvironment,
|
||||
ServerCloudAgentConfig, ServerEnvVarCollection, ServerFolder, ServerMCPServer, ServerNotebook,
|
||||
ServerPreference, ServerScheduledAmbientAgent, ServerTemplatableMCPServer, ServerWorkflow,
|
||||
ServerWorkflowEnum, TemplatableMCPServer, WorkflowEnum,
|
||||
};
|
||||
|
||||
/// A cloud object from the server.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ServerCloudObject {
|
||||
Notebook(ServerNotebook),
|
||||
Workflow(Box<ServerWorkflow>),
|
||||
Folder(ServerFolder),
|
||||
Preference(ServerPreference),
|
||||
EnvVarCollection(ServerEnvVarCollection),
|
||||
WorkflowEnum(ServerWorkflowEnum),
|
||||
AIFact(ServerAIFact),
|
||||
MCPServer(ServerMCPServer),
|
||||
AIExecutionProfile(ServerAIExecutionProfile),
|
||||
TemplatableMCPServer(ServerTemplatableMCPServer),
|
||||
AmbientAgentEnvironment(ServerAmbientAgentEnvironment),
|
||||
ScheduledAmbientAgent(ServerScheduledAmbientAgent),
|
||||
CloudAgentConfig(ServerCloudAgentConfig),
|
||||
}
|
||||
|
||||
impl ServerCloudObject {
|
||||
pub fn metadata(&self) -> &ServerMetadata {
|
||||
match self {
|
||||
ServerCloudObject::Notebook(notebook) => ¬ebook.metadata,
|
||||
ServerCloudObject::Workflow(workflow) => &workflow.metadata,
|
||||
ServerCloudObject::Folder(folder) => &folder.metadata,
|
||||
ServerCloudObject::Preference(preferences) => &preferences.metadata,
|
||||
ServerCloudObject::EnvVarCollection(env_var_collection) => &env_var_collection.metadata,
|
||||
ServerCloudObject::WorkflowEnum(workflow_enum) => &workflow_enum.metadata,
|
||||
ServerCloudObject::AIFact(aifact) => &aifact.metadata,
|
||||
ServerCloudObject::MCPServer(mcp_server) => &mcp_server.metadata,
|
||||
ServerCloudObject::TemplatableMCPServer(templatable_mcp_server) => {
|
||||
&templatable_mcp_server.metadata
|
||||
}
|
||||
ServerCloudObject::AIExecutionProfile(ai_execution_profile) => {
|
||||
&ai_execution_profile.metadata
|
||||
}
|
||||
ServerCloudObject::AmbientAgentEnvironment(ambient_agent_environment) => {
|
||||
&ambient_agent_environment.metadata
|
||||
}
|
||||
ServerCloudObject::ScheduledAmbientAgent(scheduled_ambient_agent) => {
|
||||
&scheduled_ambient_agent.metadata
|
||||
}
|
||||
ServerCloudObject::CloudAgentConfig(cloud_agent_config) => &cloud_agent_config.metadata,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uid(&self) -> ObjectUid {
|
||||
match self {
|
||||
ServerCloudObject::Notebook(notebook) => notebook.id.uid(),
|
||||
ServerCloudObject::Workflow(workflow) => workflow.id.uid(),
|
||||
ServerCloudObject::Folder(folder) => folder.id.uid(),
|
||||
ServerCloudObject::Preference(preferences) => preferences.id.uid(),
|
||||
ServerCloudObject::EnvVarCollection(env_var_collection) => env_var_collection.id.uid(),
|
||||
ServerCloudObject::WorkflowEnum(workflow_enum) => workflow_enum.id.uid(),
|
||||
ServerCloudObject::AIFact(aifact) => aifact.id.uid(),
|
||||
ServerCloudObject::MCPServer(mcp_server) => mcp_server.id.uid(),
|
||||
ServerCloudObject::AIExecutionProfile(ai_execution_profile) => {
|
||||
ai_execution_profile.id.uid()
|
||||
}
|
||||
ServerCloudObject::TemplatableMCPServer(templatable_mcp_server) => {
|
||||
templatable_mcp_server.id.uid()
|
||||
}
|
||||
ServerCloudObject::AmbientAgentEnvironment(ambient_agent_environment) => {
|
||||
ambient_agent_environment.id.uid()
|
||||
}
|
||||
ServerCloudObject::ScheduledAmbientAgent(scheduled_ambient_agent) => {
|
||||
scheduled_ambient_agent.id.uid()
|
||||
}
|
||||
ServerCloudObject::CloudAgentConfig(cloud_agent_config) => cloud_agent_config.id.uid(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K, M> From<&GenericServerObject<K, M>> for ServerCloudObject
|
||||
where
|
||||
K: 'static,
|
||||
M: 'static,
|
||||
{
|
||||
fn from(value: &GenericServerObject<K, M>) -> Self {
|
||||
let value = value as &dyn Any;
|
||||
if let Some(server_notebook) = value.downcast_ref::<ServerNotebook>() {
|
||||
ServerCloudObject::Notebook(server_notebook.clone())
|
||||
} else if let Some(server_workflow) = value.downcast_ref::<ServerWorkflow>() {
|
||||
ServerCloudObject::Workflow(Box::new(server_workflow.clone()))
|
||||
} else if let Some(server_folder) = value.downcast_ref::<ServerFolder>() {
|
||||
ServerCloudObject::Folder(server_folder.clone())
|
||||
} else if let Some(server_preferences) = value.downcast_ref::<ServerPreference>() {
|
||||
ServerCloudObject::Preference(server_preferences.clone())
|
||||
} else if let Some(server_env_var_collection) =
|
||||
value.downcast_ref::<ServerEnvVarCollection>()
|
||||
{
|
||||
ServerCloudObject::EnvVarCollection(server_env_var_collection.clone())
|
||||
} else if let Some(server_workflow_enum) = value.downcast_ref::<ServerWorkflowEnum>() {
|
||||
ServerCloudObject::WorkflowEnum(server_workflow_enum.clone())
|
||||
} else if let Some(server_aifact) = value.downcast_ref::<ServerAIFact>() {
|
||||
ServerCloudObject::AIFact(server_aifact.clone())
|
||||
} else if let Some(server_mcp_server) = value.downcast_ref::<ServerMCPServer>() {
|
||||
ServerCloudObject::MCPServer(server_mcp_server.clone())
|
||||
} else if let Some(server_ai_execution_profile) =
|
||||
value.downcast_ref::<ServerAIExecutionProfile>()
|
||||
{
|
||||
ServerCloudObject::AIExecutionProfile(server_ai_execution_profile.clone())
|
||||
} else if let Some(server_templatable_mcp_server) =
|
||||
value.downcast_ref::<ServerTemplatableMCPServer>()
|
||||
{
|
||||
ServerCloudObject::TemplatableMCPServer(server_templatable_mcp_server.clone())
|
||||
} else if let Some(server_ambient_agent_environment) =
|
||||
value.downcast_ref::<ServerAmbientAgentEnvironment>()
|
||||
{
|
||||
ServerCloudObject::AmbientAgentEnvironment(server_ambient_agent_environment.clone())
|
||||
} else if let Some(server_scheduled_ambient_agent) =
|
||||
value.downcast_ref::<ServerScheduledAmbientAgent>()
|
||||
{
|
||||
ServerCloudObject::ScheduledAmbientAgent(server_scheduled_ambient_agent.clone())
|
||||
} else if let Some(server_cloud_agent_config) =
|
||||
value.downcast_ref::<ServerCloudAgentConfig>()
|
||||
{
|
||||
ServerCloudObject::CloudAgentConfig(server_cloud_agent_config.clone())
|
||||
} else {
|
||||
panic!("Unknown server object type");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries to convert a GraphQL object payload into a local server object.
|
||||
pub trait TryFromGql: Sized {
|
||||
type GqlType;
|
||||
|
||||
fn try_from_gql(value: Self::GqlType) -> Result<Self>;
|
||||
}
|
||||
|
||||
impl<T, S> TryFromGql for GenericServerObject<GenericStringObjectId, GenericStringModel<T, S>>
|
||||
where
|
||||
T: std::fmt::Debug + Clone + Send + Sync + 'static,
|
||||
S: Serializer<T>,
|
||||
{
|
||||
type GqlType = warp_graphql::generic_string_object::GenericStringObject;
|
||||
|
||||
fn try_from_gql(value: Self::GqlType) -> Result<Self> {
|
||||
let uid = ServerId::from_string_lossy(value.metadata.uid.inner());
|
||||
let model = GenericStringModel::<T, S>::deserialize_owned(&value.serialized_model)?;
|
||||
Ok(Self::new(
|
||||
SyncId::ServerId(uid),
|
||||
model,
|
||||
value.metadata.try_into()?,
|
||||
value.permissions.try_into()?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFromGql for ServerFolder {
|
||||
type GqlType = warp_graphql::folder::Folder;
|
||||
|
||||
fn try_from_gql(value: Self::GqlType) -> Result<Self> {
|
||||
let uid = ServerId::from_string_lossy(value.metadata.uid.inner());
|
||||
Ok(Self::new(
|
||||
SyncId::ServerId(uid),
|
||||
CloudFolderModel::new(&value.name, value.is_warp_pack),
|
||||
value.metadata.try_into()?,
|
||||
value.permissions.try_into()?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFromGql for ServerNotebook {
|
||||
type GqlType = warp_graphql::notebook::Notebook;
|
||||
|
||||
fn try_from_gql(value: Self::GqlType) -> Result<Self> {
|
||||
let uid = ServerId::from_string_lossy(value.metadata.uid.inner());
|
||||
let ai_document_id = value
|
||||
.ai_document_id
|
||||
.map(|id| ai::document::AIDocumentId::try_from(&id[..]))
|
||||
.transpose()?;
|
||||
Ok(Self::new(
|
||||
SyncId::ServerId(uid),
|
||||
CloudNotebookModel {
|
||||
title: value.title,
|
||||
data: value.data,
|
||||
ai_document_id,
|
||||
conversation_id: None,
|
||||
},
|
||||
value.metadata.try_into()?,
|
||||
value.permissions.try_into()?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFromGql for ServerWorkflow {
|
||||
type GqlType = warp_graphql::workflow::Workflow;
|
||||
|
||||
fn try_from_gql(value: Self::GqlType) -> Result<Self> {
|
||||
let uid = ServerId::from_string_lossy(value.metadata.uid.inner());
|
||||
let workflow = serde_json::from_str(value.data.as_str())?;
|
||||
Ok(Self::new(
|
||||
SyncId::ServerId(uid),
|
||||
CloudWorkflowModel { data: workflow },
|
||||
value.metadata.try_into()?,
|
||||
value.permissions.try_into()?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<warp_graphql::object::CloudObject> for ServerCloudObject {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: warp_graphql::object::CloudObject) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
warp_graphql::object::CloudObject::AIConversation(_) => Err(anyhow::anyhow!(
|
||||
"AIConversation is not a supported object type for this operation"
|
||||
)),
|
||||
warp_graphql::object::CloudObject::Folder(folder) => Ok(ServerCloudObject::Folder(
|
||||
ServerFolder::try_from_gql(folder)?,
|
||||
)),
|
||||
warp_graphql::object::CloudObject::GenericStringObject(gso) => {
|
||||
server_gso_to_cloud_object(gso)
|
||||
}
|
||||
warp_graphql::object::CloudObject::Notebook(notebook) => Ok(
|
||||
ServerCloudObject::Notebook(ServerNotebook::try_from_gql(notebook)?),
|
||||
),
|
||||
warp_graphql::object::CloudObject::Workflow(workflow) => Ok(
|
||||
ServerCloudObject::Workflow(Box::new(ServerWorkflow::try_from_gql(workflow)?)),
|
||||
),
|
||||
warp_graphql::object::CloudObject::Unknown => {
|
||||
Err(anyhow::anyhow!("Unable to convert cloud object type"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<CloudObjectWithDescendants> for ServerCloudObject {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: CloudObjectWithDescendants) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
CloudObjectWithDescendants::AIConversation(_) => Err(anyhow::anyhow!(
|
||||
"AIConversation is not a supported object type for this operation"
|
||||
)),
|
||||
CloudObjectWithDescendants::FolderWithDescendants(fwd) => Ok(
|
||||
ServerCloudObject::Folder(ServerFolder::try_from_gql(fwd.folder)?),
|
||||
),
|
||||
CloudObjectWithDescendants::GenericStringObject(gso) => server_gso_to_cloud_object(gso),
|
||||
CloudObjectWithDescendants::Notebook(notebook) => Ok(ServerCloudObject::Notebook(
|
||||
ServerNotebook::try_from_gql(notebook)?,
|
||||
)),
|
||||
CloudObjectWithDescendants::Workflow(workflow) => Ok(ServerCloudObject::Workflow(
|
||||
Box::new(ServerWorkflow::try_from_gql(workflow)?),
|
||||
)),
|
||||
CloudObjectWithDescendants::Unknown => Err(anyhow::anyhow!(
|
||||
"Unable to convert cloud object with descendants type"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn server_gso_to_cloud_object(
|
||||
gso: warp_graphql::generic_string_object::GenericStringObject,
|
||||
) -> Result<ServerCloudObject> {
|
||||
match gso.format {
|
||||
warp_graphql::generic_string_object::GenericStringObjectFormat::JsonEnvVarCollection => {
|
||||
Ok(ServerCloudObject::EnvVarCollection(
|
||||
GenericServerObject::<GenericStringObjectId, GenericStringModel<EnvVarCollection, JsonSerializer>>::try_from_gql(gso)?,
|
||||
))
|
||||
}
|
||||
warp_graphql::generic_string_object::GenericStringObjectFormat::JsonPreference => Ok(
|
||||
ServerCloudObject::Preference(
|
||||
GenericServerObject::<GenericStringObjectId, GenericStringModel<Preference, JsonSerializer>>::try_from_gql(gso)?,
|
||||
),
|
||||
),
|
||||
warp_graphql::generic_string_object::GenericStringObjectFormat::JsonWorkflowEnum => Ok(
|
||||
ServerCloudObject::WorkflowEnum(
|
||||
GenericServerObject::<GenericStringObjectId, GenericStringModel<WorkflowEnum, JsonSerializer>>::try_from_gql(gso)?,
|
||||
),
|
||||
),
|
||||
warp_graphql::generic_string_object::GenericStringObjectFormat::JsonAIFact => Ok(
|
||||
ServerCloudObject::AIFact(
|
||||
GenericServerObject::<GenericStringObjectId, GenericStringModel<AIFact, JsonSerializer>>::try_from_gql(gso)?,
|
||||
),
|
||||
),
|
||||
warp_graphql::generic_string_object::GenericStringObjectFormat::JsonMCPServer => Ok(
|
||||
ServerCloudObject::MCPServer(
|
||||
GenericServerObject::<GenericStringObjectId, GenericStringModel<MCPServer, JsonSerializer>>::try_from_gql(gso)?,
|
||||
),
|
||||
),
|
||||
warp_graphql::generic_string_object::GenericStringObjectFormat::JsonAIExecutionProfile => {
|
||||
Ok(ServerCloudObject::AIExecutionProfile(
|
||||
GenericServerObject::<GenericStringObjectId, GenericStringModel<AIExecutionProfile, JsonSerializer>>::try_from_gql(gso)?,
|
||||
))
|
||||
}
|
||||
warp_graphql::generic_string_object::GenericStringObjectFormat::JsonTemplatableMCPServer => {
|
||||
Ok(ServerCloudObject::TemplatableMCPServer(
|
||||
GenericServerObject::<GenericStringObjectId, GenericStringModel<TemplatableMCPServer, JsonSerializer>>::try_from_gql(gso)?,
|
||||
))
|
||||
}
|
||||
warp_graphql::generic_string_object::GenericStringObjectFormat::JsonCloudEnvironment => {
|
||||
Ok(ServerCloudObject::AmbientAgentEnvironment(
|
||||
GenericServerObject::<GenericStringObjectId, GenericStringModel<AmbientAgentEnvironment, JsonSerializer>>::try_from_gql(gso)?,
|
||||
))
|
||||
}
|
||||
warp_graphql::generic_string_object::GenericStringObjectFormat::JsonScheduledAmbientAgent => {
|
||||
Ok(ServerCloudObject::ScheduledAmbientAgent(
|
||||
GenericServerObject::<GenericStringObjectId, GenericStringModel<ScheduledAmbientAgent, JsonSerializer>>::try_from_gql(gso)?,
|
||||
))
|
||||
}
|
||||
// Formats unknown to this client build (e.g. the server-only `JsonRunner`).
|
||||
// Returning an error lets callers skip the object rather than failing.
|
||||
warp_graphql::generic_string_object::GenericStringObjectFormat::Unknown => Err(anyhow::anyhow!(
|
||||
"unsupported generic string object format (unknown to this client build)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use cloud_objects::UserUid;
|
||||
use cloud_objects::ids::ServerId;
|
||||
use session_sharing_protocol::common::ProfileData;
|
||||
|
||||
/// Public struct for storing all the UserProfile data that's fed in from either sqlite or the server.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct UserProfileWithUID {
|
||||
pub firebase_uid: UserUid,
|
||||
pub display_name: Option<String>,
|
||||
pub email: String,
|
||||
pub photo_url: String,
|
||||
}
|
||||
|
||||
impl From<ProfileData> for UserProfileWithUID {
|
||||
fn from(data: ProfileData) -> Self {
|
||||
Self {
|
||||
firebase_uid: UserUid::new(&data.firebase_uid),
|
||||
display_name: Some(data.display_name),
|
||||
email: data.email.unwrap_or_default(),
|
||||
photo_url: data.photo_url.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<warp_graphql::user::PublicUserProfile> for UserProfileWithUID {
|
||||
fn from(value: warp_graphql::user::PublicUserProfile) -> Self {
|
||||
UserProfileWithUID {
|
||||
firebase_uid: UserUid::new(&value.uid),
|
||||
display_name: value.display_name,
|
||||
email: value.email.unwrap_or_default(),
|
||||
photo_url: value.photo_url.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct UserProfileIdAndName {
|
||||
pub user_uid: UserUid,
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TeamProfileIdAndName {
|
||||
pub team_uid: ServerId,
|
||||
pub display_name: String,
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub mod persistence;
|
||||
|
||||
use cloud_objects::cloud_object::{
|
||||
GenericCloudObject, GenericServerObject, ObjectType, ServerObjectModel,
|
||||
};
|
||||
use cloud_objects::ids::{GenericStringObjectId, ServerId, SyncId};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Workflow model used by Warp and warp-internal.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Hash)]
|
||||
#[serde(tag = "type")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum Workflow {
|
||||
AgentMode {
|
||||
name: String,
|
||||
/// The query to be inserted in the terminal input.
|
||||
query: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
arguments: Vec<Argument>,
|
||||
},
|
||||
#[serde(untagged)]
|
||||
Command {
|
||||
name: String,
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
tags: Vec<String>,
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
arguments: Vec<Argument>,
|
||||
source_url: Option<String>,
|
||||
author: Option<String>,
|
||||
author_url: Option<String>,
|
||||
#[serde(default)]
|
||||
shells: Vec<warp_workflows::Shell>,
|
||||
#[serde(default)]
|
||||
environment_variables: Option<SyncId>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Workflow {
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
Self::AgentMode { name, .. } => name.as_str(),
|
||||
Self::Command { name, .. } => name.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The core "content" of the workflow.
|
||||
///
|
||||
/// For Command workflows, this is the shell command. For Agent Mode workflows, this is the
|
||||
/// query.
|
||||
pub fn content(&self) -> &str {
|
||||
match self {
|
||||
Self::AgentMode { query, .. } => query,
|
||||
Self::Command { command, .. } => command,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prompt(&self) -> Option<&str> {
|
||||
if let Self::AgentMode { query, .. } = self {
|
||||
Some(query.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command(&self) -> Option<&str> {
|
||||
if let Self::Command { command, .. } = self {
|
||||
Some(command.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description(&self) -> Option<&String> {
|
||||
match self {
|
||||
Self::AgentMode { description, .. } => description.as_ref(),
|
||||
Self::Command { description, .. } => description.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn arguments(&self) -> &Vec<Argument> {
|
||||
match self {
|
||||
Self::AgentMode { arguments, .. } => arguments,
|
||||
Self::Command { arguments, .. } => arguments,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tags(&self) -> Option<&Vec<String>> {
|
||||
match self {
|
||||
Self::Command { tags, .. } => Some(tags),
|
||||
Self::AgentMode { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn source_url(&self) -> Option<&String> {
|
||||
match self {
|
||||
Self::Command { source_url, .. } => source_url.as_ref(),
|
||||
Self::AgentMode { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn author_name(&self) -> Option<&String> {
|
||||
match self {
|
||||
Self::Command { author, .. } => author.as_ref(),
|
||||
Self::AgentMode { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shells(&self) -> Option<&Vec<warp_workflows::Shell>> {
|
||||
match self {
|
||||
Self::Command { shells, .. } => Some(shells),
|
||||
Self::AgentMode { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_command_workflow(&self) -> bool {
|
||||
matches!(self, Self::Command { .. })
|
||||
}
|
||||
|
||||
pub fn is_agent_mode_workflow(&self) -> bool {
|
||||
matches!(self, Self::AgentMode { .. })
|
||||
}
|
||||
|
||||
/// Returns `true` if the workflow name starts with the given character (case-insensitive).
|
||||
///
|
||||
/// Used by prompt search datasources to prefix-match on single-character queries, where
|
||||
/// fuzzy matching would be unreliable.
|
||||
pub fn name_starts_with_char_ignore_case(&self, c: char) -> bool {
|
||||
self.name()
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|first| first.eq_ignore_ascii_case(&c))
|
||||
}
|
||||
|
||||
/// Return a list of every enum ID referenced by this workflow.
|
||||
pub fn get_enum_ids(&self) -> Vec<SyncId> {
|
||||
self.arguments()
|
||||
.iter()
|
||||
.filter_map(|arg| match arg.arg_type {
|
||||
ArgumentType::Enum { enum_id } => Some(enum_id),
|
||||
ArgumentType::Text => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return a list of every enum ID that has been synced to the server, used for telemetry.
|
||||
pub fn get_server_enum_ids(&self) -> Vec<GenericStringObjectId> {
|
||||
self.arguments()
|
||||
.iter()
|
||||
.filter_map(|arg| match arg.arg_type {
|
||||
ArgumentType::Enum { enum_id } => enum_id.into_server(),
|
||||
ArgumentType::Text => None,
|
||||
})
|
||||
.map(Into::into)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn default_env_vars(&self) -> Option<SyncId> {
|
||||
match self {
|
||||
Workflow::Command {
|
||||
environment_variables,
|
||||
..
|
||||
} => *environment_variables,
|
||||
Workflow::AgentMode { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Given two IDs, replace any instance of the old ID referenced by this workflow with the new ID.
|
||||
/// Returns `true` if any instances of the old_id were present.
|
||||
pub fn replace_object_id(&mut self, old_id: SyncId, new_id: SyncId) -> bool {
|
||||
let mut changed = false;
|
||||
let arguments = match self {
|
||||
Self::Command { arguments, .. } => arguments,
|
||||
Self::AgentMode { arguments, .. } => arguments,
|
||||
};
|
||||
for arg in arguments.iter_mut() {
|
||||
match &mut arg.arg_type {
|
||||
ArgumentType::Enum { enum_id } if *enum_id == old_id => {
|
||||
*enum_id = new_id;
|
||||
changed = true;
|
||||
}
|
||||
ArgumentType::Enum { .. } | ArgumentType::Text => {}
|
||||
}
|
||||
}
|
||||
if let Self::Command {
|
||||
environment_variables,
|
||||
..
|
||||
} = self
|
||||
&& *environment_variables == Some(old_id)
|
||||
{
|
||||
*environment_variables = Some(new_id);
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
pub fn new(name: impl Into<String>, command: impl Into<String>) -> Self {
|
||||
Workflow::Command {
|
||||
name: name.into(),
|
||||
command: command.into(),
|
||||
tags: Vec::new(),
|
||||
arguments: Vec::new(),
|
||||
description: None,
|
||||
source_url: None,
|
||||
author: None,
|
||||
author_url: None,
|
||||
shells: Vec::new(),
|
||||
environment_variables: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_arguments(mut self, new_arguments: Vec<Argument>) -> Self {
|
||||
match self {
|
||||
Workflow::AgentMode {
|
||||
ref mut arguments, ..
|
||||
}
|
||||
| Workflow::Command {
|
||||
ref mut arguments, ..
|
||||
} => {
|
||||
*arguments = new_arguments;
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_description(mut self, new_description: String) -> Self {
|
||||
match self {
|
||||
Workflow::AgentMode {
|
||||
ref mut description,
|
||||
..
|
||||
}
|
||||
| Workflow::Command {
|
||||
ref mut description,
|
||||
..
|
||||
} => {
|
||||
*description = Some(new_description);
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_name(&mut self, new_name: &str) {
|
||||
match self {
|
||||
Workflow::AgentMode { name, .. } | Workflow::Command { name, .. } => {
|
||||
new_name.clone_into(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a warp-internal Workflow model from a public-facing workflow
|
||||
/// https://github.com/warpdotdev/workflows/blob/main/workflow-types/src/lib.rs
|
||||
impl From<warp_workflows::Workflow> for Workflow {
|
||||
fn from(workflow: warp_workflows::Workflow) -> Self {
|
||||
Workflow::Command {
|
||||
name: workflow.name,
|
||||
command: workflow.command,
|
||||
description: workflow.description,
|
||||
arguments: workflow.arguments.into_iter().map(Argument::from).collect(),
|
||||
tags: workflow.tags,
|
||||
source_url: workflow.source_url,
|
||||
author: workflow.author,
|
||||
author_url: workflow.author_url,
|
||||
shells: workflow.shells,
|
||||
environment_variables: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Argument model to be used in `warp-internal`
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Hash, Default)]
|
||||
pub struct Argument {
|
||||
pub name: String,
|
||||
/// The type of the argument to the workflow
|
||||
#[serde(flatten, deserialize_with = "deserialize_arg_type")]
|
||||
pub arg_type: ArgumentType,
|
||||
pub description: Option<String>,
|
||||
pub default_value: Option<String>,
|
||||
}
|
||||
|
||||
impl From<warp_workflows::Argument> for Argument {
|
||||
fn from(arg: warp_workflows::Argument) -> Self {
|
||||
Argument {
|
||||
name: arg.name,
|
||||
arg_type: ArgumentType::Text,
|
||||
description: arg.description,
|
||||
default_value: arg.default_value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Argument {
|
||||
pub fn new(name: impl Into<String>, arg_type: ArgumentType) -> Self {
|
||||
Argument {
|
||||
arg_type,
|
||||
name: name.into(),
|
||||
description: None,
|
||||
default_value: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_description(mut self, description: impl Into<String>) -> Self {
|
||||
self.description = Some(description.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_default(mut self, default: impl Into<String>) -> Self {
|
||||
self.default_value = Some(default.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn description(&self) -> &Option<String> {
|
||||
&self.description
|
||||
}
|
||||
|
||||
pub fn arg_type(&self) -> &ArgumentType {
|
||||
&self.arg_type
|
||||
}
|
||||
|
||||
pub fn default_value(&self) -> &Option<String> {
|
||||
&self.default_value
|
||||
}
|
||||
}
|
||||
|
||||
/// The type of the workflow argument
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Hash)]
|
||||
#[serde(tag = "arg_type")]
|
||||
#[derive(Default)]
|
||||
pub enum ArgumentType {
|
||||
#[default]
|
||||
Text,
|
||||
Enum {
|
||||
/// The ID of the associated WorkflowEnum Generic String Object
|
||||
enum_id: SyncId,
|
||||
},
|
||||
}
|
||||
|
||||
/// Custom deserialization for argument types, used to both `flatten` the argument type
|
||||
/// and allow for the specification of `default` behavior.
|
||||
///
|
||||
/// Necessary because serde currently does not support the use of `flatten` with a `default`,
|
||||
/// related GitHub issue here: https://github.com/serde-rs/serde/issues/1626
|
||||
fn deserialize_arg_type<'de, D>(deserializer: D) -> Result<ArgumentType, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value: Value = Deserialize::deserialize(deserializer)?;
|
||||
|
||||
let arg_type = match value.get("arg_type").and_then(|value| value.as_str()) {
|
||||
Some("Text") => ArgumentType::Text,
|
||||
Some("Enum") => {
|
||||
let enum_id = value
|
||||
.get("enum_id")
|
||||
.ok_or(serde::de::Error::missing_field("enum_id"))?;
|
||||
let deserialized_id = SyncId::deserialize(enum_id)
|
||||
.map_err(|_| serde::de::Error::custom("Unable to parse enum_id"))?;
|
||||
ArgumentType::Enum {
|
||||
enum_id: deserialized_id,
|
||||
}
|
||||
}
|
||||
_ => ArgumentType::default(),
|
||||
};
|
||||
|
||||
Ok(arg_type)
|
||||
}
|
||||
|
||||
/// The model for a `CloudWorkflow`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CloudWorkflowModel {
|
||||
pub data: Workflow,
|
||||
}
|
||||
|
||||
impl CloudWorkflowModel {
|
||||
pub fn new(workflow: Workflow) -> Self {
|
||||
Self { data: workflow }
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerObjectModel for CloudWorkflowModel {
|
||||
fn object_type(&self) -> ObjectType {
|
||||
ObjectType::Workflow
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
pub struct WorkflowId(ServerId);
|
||||
cloud_objects::server_id_traits! { WorkflowId, "Workflow" }
|
||||
|
||||
/// `CloudWorkflow` is a workflow retrieved from the server.
|
||||
pub type CloudWorkflow = GenericCloudObject<WorkflowId, CloudWorkflowModel>;
|
||||
pub type ServerWorkflow = GenericServerObject<WorkflowId, CloudWorkflowModel>;
|
||||
|
||||
impl From<CloudWorkflow> for Workflow {
|
||||
fn from(cloud_workflow: CloudWorkflow) -> Self {
|
||||
cloud_workflow.model().data.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&CloudWorkflow> for Workflow {
|
||||
fn from(cloud_workflow: &CloudWorkflow) -> Self {
|
||||
cloud_workflow.model().data.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "workflow_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,82 @@
|
||||
use cloud_object_persistence::{
|
||||
CloudObjectReadContext, id_from_metadata, to_cloud_object_metadata, upsert_cloud_object,
|
||||
};
|
||||
use cloud_objects::cloud_object::ObjectType;
|
||||
use diesel::result::Error;
|
||||
use diesel::{Connection, ExpressionMethods, QueryDsl, RunQueryDsl, SqliteConnection};
|
||||
use persistence::model::{NewWorkflow, Workflow as PersistedWorkflow};
|
||||
use persistence::schema;
|
||||
|
||||
use super::{CloudWorkflow, CloudWorkflowModel, WorkflowId};
|
||||
|
||||
pub fn upsert_workflows(
|
||||
conn: &mut SqliteConnection,
|
||||
cloud_workflows: Vec<CloudWorkflow>,
|
||||
) -> Result<(), Error> {
|
||||
use schema::workflows::dsl::*;
|
||||
conn.transaction::<(), Error, _>(|conn| {
|
||||
for cloud_workflow in cloud_workflows {
|
||||
let workflow_id = cloud_workflow.id;
|
||||
if let Ok(serialized_workflow) = serde_json::to_string(&cloud_workflow.model().data) {
|
||||
// todo: wrap in an arc to avoid unnecessary cloning.
|
||||
let serialized_workflow_clone = serialized_workflow.clone();
|
||||
upsert_cloud_object(
|
||||
conn,
|
||||
ObjectType::Workflow,
|
||||
workflow_id,
|
||||
cloud_workflow.metadata,
|
||||
cloud_workflow.permissions,
|
||||
Box::new(move |conn| {
|
||||
let workflow = NewWorkflow {
|
||||
data: serialized_workflow.clone(),
|
||||
};
|
||||
diesel::insert_into(schema::workflows::dsl::workflows)
|
||||
.values(workflow)
|
||||
.execute(conn)?;
|
||||
let workflow_id: i32 = schema::workflows::dsl::workflows
|
||||
.select(schema::workflows::columns::id)
|
||||
.order(schema::workflows::columns::id.desc())
|
||||
.first(conn)?;
|
||||
Ok(workflow_id)
|
||||
}),
|
||||
Box::new(move |conn, workflow_id| {
|
||||
diesel::update(
|
||||
workflows.filter(schema::workflows::dsl::id.eq(workflow_id)),
|
||||
)
|
||||
.set((data.eq(serialized_workflow_clone),))
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
}),
|
||||
)?
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_workflows(
|
||||
conn: &mut SqliteConnection,
|
||||
read_context: &CloudObjectReadContext,
|
||||
) -> Result<Vec<CloudWorkflow>, Error> {
|
||||
Ok(schema::workflows::dsl::workflows
|
||||
.load::<PersistedWorkflow>(conn)?
|
||||
.into_iter()
|
||||
.filter_map(|workflow| {
|
||||
let metadata = read_context.metadata_for_object(workflow.id, ObjectType::Workflow)?;
|
||||
let workflow_content = serde_json::from_str(workflow.data.as_str()).ok()?;
|
||||
let workflow_id = id_from_metadata::<WorkflowId>(metadata)?;
|
||||
let cloud_object_permissions = read_context.permissions_for_metadata(metadata)?;
|
||||
Some(CloudWorkflow::new(
|
||||
workflow_id,
|
||||
CloudWorkflowModel::new(workflow_content),
|
||||
to_cloud_object_metadata(metadata),
|
||||
cloud_object_permissions,
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn delete_workflow(conn: &mut SqliteConnection, workflow_id: i32) -> Result<(), Error> {
|
||||
diesel::delete(workflows.filter(id.eq(workflow_id))).execute(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use cloud_objects::cloud_object::{
|
||||
GenericCloudObject, GenericServerObject, GenericStringModel, JsonObjectType,
|
||||
};
|
||||
use cloud_objects::ids::GenericStringObjectId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{JsonModel, JsonSerializer};
|
||||
|
||||
/// Data model for a workflow enum, one type of argument that can be inserted into a workflow
|
||||
/// A workflow enum can either be static or dynamic, as determined by the type of `EnumVariants` it uses
|
||||
///
|
||||
/// A `Static` enum contains a finite set of user-specified string values
|
||||
/// A `Dynamic` enum contains a single shell command, which is executed to determine suggested variants for that argument
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Hash, PartialOrd)]
|
||||
pub struct WorkflowEnum {
|
||||
/// Enum name
|
||||
pub name: String,
|
||||
/// Whether or not the variable should be visible to other workflows
|
||||
pub is_shared: bool,
|
||||
/// The variants for this enum
|
||||
pub variants: EnumVariants,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Hash, PartialOrd)]
|
||||
pub enum EnumVariants {
|
||||
Static(Vec<String>), // contains the explicit variants for a static enum
|
||||
Dynamic(String), // contains the value of the shell command associated with the dynamic enum
|
||||
}
|
||||
|
||||
impl JsonModel for WorkflowEnum {
|
||||
fn json_object_type() -> JsonObjectType {
|
||||
JsonObjectType::WorkflowEnum
|
||||
}
|
||||
}
|
||||
|
||||
pub type CloudWorkflowEnum = GenericCloudObject<GenericStringObjectId, CloudWorkflowEnumModel>;
|
||||
pub type CloudWorkflowEnumModel = GenericStringModel<WorkflowEnum, JsonSerializer>;
|
||||
pub type ServerWorkflowEnum = GenericServerObject<GenericStringObjectId, CloudWorkflowEnumModel>;
|
||||
@@ -0,0 +1,131 @@
|
||||
use cloud_objects::ids::{ClientId, GenericStringObjectId, HashableId, ServerId, SyncId};
|
||||
|
||||
use super::{Argument, ArgumentType, Workflow};
|
||||
|
||||
fn server_id(id: &str) -> ServerId {
|
||||
ServerId::try_from(id).expect("test server ID should be valid")
|
||||
}
|
||||
|
||||
fn assert_workflow_roundtrips(workflow: &Workflow) {
|
||||
let serialized = serde_json::to_string(workflow).expect("Serialized workflow.");
|
||||
let deserialized =
|
||||
serde_json::from_str::<Workflow>(&serialized).expect("Deserialized workflow.");
|
||||
assert_eq!(&deserialized, workflow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workflow_serialization_with_enum_params() {
|
||||
let workflow = Workflow::Command {
|
||||
name: "name".to_string(),
|
||||
command: "command".to_string(),
|
||||
arguments: vec![
|
||||
Argument {
|
||||
name: "text".to_string(),
|
||||
arg_type: ArgumentType::Text,
|
||||
description: None,
|
||||
default_value: Some("default".to_string()),
|
||||
},
|
||||
Argument {
|
||||
name: "server id enum".to_string(),
|
||||
arg_type: ArgumentType::Enum {
|
||||
enum_id: SyncId::from(GenericStringObjectId::from(server_id(
|
||||
"test_uid00000000000123",
|
||||
))),
|
||||
},
|
||||
description: Some("description".to_string()),
|
||||
default_value: None,
|
||||
},
|
||||
Argument {
|
||||
name: "client id enum".to_string(),
|
||||
arg_type: ArgumentType::Enum {
|
||||
enum_id: SyncId::ClientId(
|
||||
ClientId::from_hash("Client-06d26381-ac61-4a4a-8a23-a3431f1d340c")
|
||||
.expect("should be able to construct ClientId from hash"),
|
||||
),
|
||||
},
|
||||
description: Some("description".to_string()),
|
||||
default_value: None,
|
||||
},
|
||||
],
|
||||
description: None,
|
||||
source_url: None,
|
||||
author: None,
|
||||
author_url: None,
|
||||
shells: vec![],
|
||||
tags: vec![],
|
||||
environment_variables: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&workflow).expect("failed to serialize");
|
||||
let correct_serialized = r#"{"name":"name","command":"command","tags":[],"description":null,"arguments":[{"name":"text","arg_type":"Text","description":null,"default_value":"default"},{"name":"server id enum","arg_type":"Enum","enum_id":"test_uid00000000000123","description":"description","default_value":null},{"name":"client id enum","arg_type":"Enum","enum_id":"Client-06d26381-ac61-4a4a-8a23-a3431f1d340c","description":"description","default_value":null}],"source_url":null,"author":null,"author_url":null,"shells":[],"environment_variables":null}"#;
|
||||
|
||||
assert_eq!(
|
||||
serialized, correct_serialized,
|
||||
"Workflow should serialize correctly"
|
||||
);
|
||||
|
||||
let deserialized: Workflow =
|
||||
serde_json::from_str(serialized.as_str()).expect("failed to deserialized");
|
||||
|
||||
assert_eq!(deserialized, workflow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_mode_workflow_serialization() {
|
||||
let workflow = Workflow::AgentMode {
|
||||
name: "name".to_string(),
|
||||
query: "query {{text}}".to_string(),
|
||||
arguments: vec![Argument {
|
||||
name: "text".to_string(),
|
||||
arg_type: ArgumentType::Text,
|
||||
description: None,
|
||||
default_value: Some("default".to_string()),
|
||||
}],
|
||||
description: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&workflow).expect("failed to serialize");
|
||||
let correct_serialized = r#"{"type":"agent_mode","name":"name","query":"query {{text}}","arguments":[{"name":"text","arg_type":"Text","description":null,"default_value":"default"}]}"#;
|
||||
|
||||
assert_eq!(
|
||||
serialized, correct_serialized,
|
||||
"Workflow should serialize correctly"
|
||||
);
|
||||
|
||||
let deserialized: Workflow =
|
||||
serde_json::from_str(serialized.as_str()).expect("failed to deserialized");
|
||||
|
||||
assert_eq!(deserialized, workflow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_cloud_workflow() {
|
||||
let sample_workflow = Workflow::new("Test name", "Command name");
|
||||
assert_workflow_roundtrips(&sample_workflow);
|
||||
|
||||
let arguments = vec![Argument {
|
||||
name: "Argument".to_string(),
|
||||
description: Some("no".to_string()),
|
||||
default_value: None,
|
||||
arg_type: Default::default(),
|
||||
}];
|
||||
let arguments_workflow = sample_workflow.clone().with_arguments(arguments);
|
||||
assert_workflow_roundtrips(&arguments_workflow);
|
||||
|
||||
let description_workflow = sample_workflow.with_description("cool description".to_string());
|
||||
assert_workflow_roundtrips(&description_workflow);
|
||||
|
||||
let workflow_with_additional_fields = Workflow::Command {
|
||||
name: "Test".to_string(),
|
||||
command: "Command".to_string(),
|
||||
tags: vec![],
|
||||
description: None,
|
||||
arguments: vec![],
|
||||
source_url: Some("url".to_string()),
|
||||
author: Some("author_name".to_string()),
|
||||
author_url: None,
|
||||
shells: vec![],
|
||||
environment_variables: Some(SyncId::ServerId(server_id("test_uid00000000000123"))),
|
||||
};
|
||||
assert_workflow_roundtrips(&workflow_with_additional_fields);
|
||||
}
|
||||
Reference in New Issue
Block a user