first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
@@ -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, "{}", &regex.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>;
+65
View File
@@ -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);
}
+199
View File
@@ -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>;
+38
View File
@@ -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())
}
+44
View File
@@ -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::*;
+298
View File
@@ -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;
+118
View File
@@ -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) => &notebook.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,
}
+417
View File
@@ -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);
}