Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+668
View File
@@ -0,0 +1,668 @@
use chrono::{DateTime, Utc};
use comfy_table::Cell;
use futures::future;
use serde::Serialize;
use warp_cli::schedule::{
CreateScheduleArgs, DeleteScheduleArgs, GetScheduleArgs, PauseScheduleArgs, ScheduleCommand,
ScheduleSubcommand, UnpauseScheduleArgs, UpdateScheduleArgs,
};
use warp_cli::{agent::OutputFormat, GlobalOptions};
use warp_graphql::queries::get_scheduled_agent_history::ScheduledAgentHistory;
use warpui::platform::TerminationMode;
use warpui::{AppContext, SingletonEntity};
use crate::ai::ambient_agents::scheduled::{
CloudScheduledAmbientAgent, ScheduledAgentManager, ScheduledAmbientAgent, UpdateScheduleParams,
};
use crate::ai::ambient_agents::AgentConfigSnapshot;
use crate::cloud_object::CloudObject;
use crate::server::ids::{ServerId, SyncId};
use crate::util::time_format::format_approx_duration_from_now_utc;
use super::common::{EnvironmentChoice, ResolveConfigurationError};
use super::output::{self, TableFormat};
/// Run a scheduled agent command.
pub fn run(
ctx: &mut AppContext,
global_options: GlobalOptions,
command: ScheduleCommand,
) -> anyhow::Result<()> {
let output_format = global_options.output_format;
match command.into_subcommand() {
ScheduleSubcommand::Create(args) => create(ctx, args),
ScheduleSubcommand::List => list(ctx, output_format),
ScheduleSubcommand::Get(args) => get(ctx, output_format, args),
ScheduleSubcommand::Pause(args) => pause(ctx, args),
ScheduleSubcommand::Unpause(args) => unpause(ctx, args),
ScheduleSubcommand::Update(args) => update(ctx, args),
ScheduleSubcommand::Delete(args) => delete(ctx, args),
}
}
fn create(ctx: &mut AppContext, args: CreateScheduleArgs) -> anyhow::Result<()> {
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
let refresh_future = super::common::refresh_workspace_metadata(ctx);
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
let setup_future = future::try_join(refresh_future, warp_drive_sync_future);
ctx.spawn(setup_future, move |manager, setup_result, ctx| {
if let Err(err) = setup_result {
super::report_fatal_error(err, ctx);
return;
}
let loaded_file = match args.config_file.file.as_deref() {
Some(path) => match super::config_file::load_config_file(path) {
Ok(file) => Some(file),
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
},
None => None,
};
let mut environment_args = args.environment;
if environment_args.environment.is_none() && !environment_args.no_environment {
if let Some(environment_id) = loaded_file
.as_ref()
.and_then(|f| f.file.environment_id.clone())
{
environment_args.environment = Some(environment_id);
}
}
let environment_id = match EnvironmentChoice::resolve_for_create(environment_args, ctx)
{
Ok(EnvironmentChoice::None) => {
eprintln!("Scheduling agent to run without an environment.");
None
}
Ok(EnvironmentChoice::Environment { id, .. }) => Some(id),
Err(ResolveConfigurationError::Canceled) => {
ctx.terminate_app(TerminationMode::ForceTerminate, None);
return;
}
Err(err) => {
super::report_fatal_error(anyhow::anyhow!(err), ctx);
return;
}
};
let owner =
match super::common::resolve_owner(args.scope.team, args.scope.personal, ctx) {
Ok(owner) => owner,
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
};
let cli_mcp_servers =
match super::mcp_config::build_mcp_servers_from_specs(&args.mcp_specs) {
Ok(mcp_servers) => mcp_servers,
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
};
let merged_config = super::config_file::merge_with_precedence(
loaded_file.as_ref(),
crate::ai::ambient_agents::AgentConfigSnapshot {
name: None,
environment_id,
model_id: args.model.model.clone(),
base_prompt: None,
mcp_servers: cli_mcp_servers,
profile_id: None,
worker_host: args.worker_host,
skill_spec: args.skill.map(|s| s.to_string()),
// TODO(QUALITY-294): Support computer use flag in scheduled agents.
computer_use_enabled: None,
// TODO(REMOTE-1134): Support harness flag for scheduled agents.
harness: None,
harness_auth_secrets: None,
},
);
// We must wait until after workspace metadata is refreshed to check available LLMs.
let model_id = match merged_config
.model_id
.as_deref()
.map(|model_id| super::common::validate_agent_mode_base_model_id(model_id, ctx))
.transpose()
{
Ok(id) => id.map(|id| id.to_string()),
Err(err) => {
super::report_fatal_error(anyhow::anyhow!(err), ctx);
return;
}
};
let mut agent_config = merged_config;
agent_config.model_id = model_id;
let prompt = args.prompt.unwrap_or_default();
let mut config = ScheduledAmbientAgent::new(args.name, args.cron, true, prompt);
config.agent_config = agent_config;
// Print something here because scheduling an agent can take a while.
println!("Scheduling agent {}...", config.name);
let create_future = manager.create_schedule(config, owner, ctx);
ctx.spawn(create_future, |_manager, result, ctx| match result {
Ok(sync_id) => {
println!("Scheduled agent: {sync_id}");
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
});
});
});
Ok(())
}
#[derive(Serialize)]
struct ScheduleInfo {
id: String,
name: String,
cron_schedule: String,
paused: bool,
last_ran: Option<DateTime<Utc>>,
next_run: Option<DateTime<Utc>>,
scope: String,
prompt: String,
last_spawn_error: Option<String>,
agent_config: AgentConfigSnapshot,
}
impl ScheduleInfo {
fn new(
id: String,
scope: String,
config: ScheduledAmbientAgent,
history: Option<&ScheduledAgentHistory>,
) -> Self {
let last_ran = history.and_then(|h| h.last_ran.as_ref().map(|t| t.utc()));
let next_run = history.and_then(|h| h.next_run.as_ref().map(|t| t.utc()));
ScheduleInfo {
id,
name: config.name,
cron_schedule: config.cron_schedule,
paused: !config.enabled,
last_ran,
next_run,
scope,
prompt: config.prompt,
last_spawn_error: config.last_spawn_error,
agent_config: config.agent_config,
}
}
fn last_ran_display(&self) -> String {
let timestamp = self
.last_ran
.map(format_approx_duration_from_now_utc)
.unwrap_or("-".to_string());
if self.last_spawn_error.is_some() {
format!("{}", timestamp)
} else {
timestamp
}
}
fn next_run_display(&self) -> String {
// We can't use format_approx_duration_from_now_utc because the date is in the future.
// We use RTC 2822 rather than RFC 3339 because it's more human-readable.
self.next_run
.map(|dt| dt.to_rfc2822())
.unwrap_or("-".to_string())
}
}
impl TableFormat for ScheduleInfo {
fn header() -> Vec<Cell> {
vec![
Cell::new("ID"),
Cell::new("Name"),
Cell::new("Schedule"),
Cell::new("Paused"),
Cell::new("Last ran"),
Cell::new("Next run"),
Cell::new("Scope"),
]
}
fn row(&self) -> Vec<Cell> {
let paused_display = if self.paused { "Yes" } else { "No" };
vec![
Cell::new(&self.id),
Cell::new(&self.name),
Cell::new(&self.cron_schedule),
Cell::new(paused_display),
Cell::new(self.last_ran_display()),
Cell::new(self.next_run_display()),
Cell::new(&self.scope),
]
}
}
fn print_schedule_info(info: &ScheduleInfo, output_format: OutputFormat) -> anyhow::Result<()> {
let paused_display = if info.paused { "Yes" } else { "No" };
match output_format {
OutputFormat::Json => {
serde_json::to_writer(std::io::stdout(), info)?;
Ok(())
}
OutputFormat::Ndjson => output::write_json_line(info, std::io::stdout()),
OutputFormat::Text => {
println!("Name: {}", info.name);
println!("Cron schedule: {}", info.cron_schedule);
println!("Paused: {paused_display}");
let last_ran = info.last_ran_display();
let next_run = info.next_run_display();
println!("Last ran: {last_ran}");
if let Some(error) = &info.last_spawn_error {
println!("Last error: {error}");
}
println!("Next run: {next_run}");
println!("Prompt: {}", info.prompt);
if let Some(environment_id) = &info.agent_config.environment_id {
println!("Environment ID: {environment_id}");
}
if let Some(model_id) = &info.agent_config.model_id {
println!("Model ID: {model_id}");
}
if let Some(agent_name) = &info.agent_config.name {
println!("Agent name: {agent_name}");
}
if let Some(skill_spec) = &info.agent_config.skill_spec {
println!("Skill: {skill_spec}");
}
if let Some(worker_host) = &info.agent_config.worker_host {
println!("Host: {worker_host}");
}
Ok(())
}
OutputFormat::Pretty => {
let mut table = output::standard_table();
table.add_row(vec![Cell::new("Name"), Cell::new(&info.name)]);
table.add_row(vec![
Cell::new("Cron schedule"),
Cell::new(&info.cron_schedule),
]);
table.add_row(vec![Cell::new("Paused"), Cell::new(paused_display)]);
let last_ran = info.last_ran_display();
let next_run = info.next_run_display();
table.add_row(vec![Cell::new("Last ran"), Cell::new(last_ran)]);
if let Some(error) = &info.last_spawn_error {
table.add_row(vec![Cell::new("Last error"), Cell::new(error)]);
}
table.add_row(vec![Cell::new("Next run"), Cell::new(next_run)]);
table.add_row(vec![Cell::new("Prompt"), Cell::new(&info.prompt)]);
if let Some(environment_id) = &info.agent_config.environment_id {
table.add_row(vec![Cell::new("Environment ID"), Cell::new(environment_id)]);
}
if let Some(model_id) = &info.agent_config.model_id {
table.add_row(vec![Cell::new("Model ID"), Cell::new(model_id)]);
}
if let Some(agent_name) = &info.agent_config.name {
table.add_row(vec![Cell::new("Agent name"), Cell::new(agent_name)]);
}
if let Some(skill_spec) = &info.agent_config.skill_spec {
table.add_row(vec![Cell::new("Skill"), Cell::new(skill_spec)]);
}
if let Some(worker_host) = &info.agent_config.worker_host {
table.add_row(vec![Cell::new("Host"), Cell::new(worker_host)]);
}
println!("{table}");
Ok(())
}
}
}
fn pause(ctx: &mut AppContext, args: PauseScheduleArgs) -> anyhow::Result<()> {
let schedule_id = SyncId::ServerId(ServerId::try_from(args.schedule_id)?);
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| {
if let Err(err) = result {
super::report_fatal_error(err, ctx);
return;
}
println!("Pausing agent...");
let pause_future = manager.pause_schedule(schedule_id, ctx);
ctx.spawn(pause_future, |_manager, result, ctx| match result {
Ok(()) => {
println!("Schedule paused");
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
});
});
});
Ok(())
}
fn unpause(ctx: &mut AppContext, args: UnpauseScheduleArgs) -> anyhow::Result<()> {
let schedule_id = SyncId::ServerId(ServerId::try_from(args.schedule_id)?);
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| {
if let Err(err) = result {
super::report_fatal_error(err, ctx);
return;
}
println!("Resuming agent...");
let unpause_future = manager.unpause_schedule(schedule_id, ctx);
ctx.spawn(unpause_future, |_manager, result, ctx| match result {
Ok(()) => {
println!("Schedule unpaused");
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
});
});
});
Ok(())
}
fn update(ctx: &mut AppContext, args: UpdateScheduleArgs) -> anyhow::Result<()> {
let schedule_id = SyncId::ServerId(ServerId::try_from(args.schedule_id)?);
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
let refresh_future = super::common::refresh_workspace_metadata(ctx);
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
let setup_future = future::try_join(refresh_future, warp_drive_sync_future);
ctx.spawn(setup_future, move |manager, setup_result, ctx| {
if let Err(err) = setup_result {
super::report_fatal_error(err, ctx);
return;
}
let loaded_file = match args.config_file.file.as_deref() {
Some(path) => match super::config_file::load_config_file(path) {
Ok(file) => Some(file),
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
},
None => None,
};
let file_config = loaded_file.as_ref().map(|f| &f.file);
// We must wait until after workspace metadata is refreshed to check available LLMs.
let model_id = match args
.model
.model
.as_deref()
.or_else(|| file_config.and_then(|f| f.model_id.as_deref()))
.map(|model_id| super::common::validate_agent_mode_base_model_id(model_id, ctx))
.transpose()
{
Ok(id) => id.map(|id| id.to_string()),
Err(err) => {
super::report_fatal_error(anyhow::anyhow!(err), ctx);
return;
}
};
let mut environment_args = args.environment;
if environment_args.environment.is_none() && !environment_args.remove_environment {
if let Some(environment_id) = file_config.and_then(|f| f.environment_id.clone()) {
environment_args.environment = Some(environment_id);
}
}
let environment_id = match EnvironmentChoice::resolve_for_update(environment_args, ctx)
{
Ok(choice) => choice.map(|c| match c {
EnvironmentChoice::None => None,
EnvironmentChoice::Environment { id, .. } => Some(id),
}),
Err(ResolveConfigurationError::Canceled) => {
ctx.terminate_app(TerminationMode::ForceTerminate, None);
return;
}
Err(err) => {
super::report_fatal_error(anyhow::anyhow!(err), ctx);
return;
}
};
// MCP update semantics are patch-only:
// - file and CLI MCP servers are treated as upserts (CLI wins on key conflicts)
// - `--remove-mcp` removes keys
// If both are present, removals win by filtering removed names out of the upsert payload.
let mut mcp_servers_upsert = match file_config.and_then(|f| f.mcp_servers.clone()) {
Some(map) if map.is_empty() => None,
Some(map) => Some(map),
None => None,
};
let cli_mcp_servers_upsert =
match super::mcp_config::build_mcp_servers_from_specs(&args.mcp_specs) {
Ok(mcp_servers) => mcp_servers,
Err(err) => {
super::report_fatal_error(err, ctx);
return;
}
};
if let Some(cli_map) = cli_mcp_servers_upsert {
let merged = mcp_servers_upsert.get_or_insert_with(serde_json::Map::new);
for (name, config_value) in cli_map {
merged.insert(name, config_value);
}
}
if let Some(map) = mcp_servers_upsert.as_mut() {
for name in &args.remove_mcp {
map.remove(name);
}
if map.is_empty() {
mcp_servers_upsert = None;
}
}
// Handle skill update semantics: --skill sets it, --remove-skill clears it
let skill_spec = if args.remove_skill {
Some(None)
} else {
args.skill.map(|s| Some(s.to_string()))
};
println!("Updating agent...");
let update_future = manager.update_schedule(
schedule_id,
UpdateScheduleParams {
name: args.name,
cron: args.cron,
model_id,
environment_id,
base_prompt: file_config.and_then(|f| f.base_prompt.clone()),
prompt: args.prompt,
mcp_servers_upsert,
remove_mcp_server_names: args.remove_mcp,
skill_spec,
worker_host: args.worker_host,
},
ctx,
);
ctx.spawn(update_future, |_manager, result, ctx| match result {
Ok(()) => {
println!("Schedule updated");
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
});
});
});
Ok(())
}
/// List all scheduled agents available to the current user.
fn list(ctx: &mut AppContext, output_format: OutputFormat) -> anyhow::Result<()> {
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| {
if let Err(err) = result {
super::report_fatal_error(err, ctx);
return;
}
let mut schedules = manager.list_schedules(ctx);
schedules.sort_by_key(|schedule| schedule.model().string_model.name.clone());
let futures = schedules.into_iter().map(|schedule| {
let config = schedule.model().string_model.clone();
let sync_id = schedule.sync_id();
let scope = super::common::format_owner(&schedule.permissions().owner).to_string();
// TODO(ben): Consider a bulk lookup API for scheduled agent history.
let history_future = manager.fetch_schedule_history(sync_id, ctx);
async move {
// Try to fetch the scheduled agent history, but still show output if this fails.
let history = match history_future.await {
Ok(v) => v,
Err(err) => {
log::warn!("Failed to fetch scheduled agent history: {err:#}");
None
}
};
let id = match sync_id {
SyncId::ServerId(server_id) => server_id.to_string(),
SyncId::ClientId(_) => "Unsynced".to_string(),
};
ScheduleInfo::new(id, scope, config, history.as_ref())
}
});
let output_format = output_format;
ctx.spawn(
futures::future::join_all(futures),
move |_manager, infos, ctx| {
output::print_list(infos, output_format);
ctx.terminate_app(TerminationMode::ForceTerminate, None);
},
);
});
});
Ok(())
}
fn get(
ctx: &mut AppContext,
output_format: OutputFormat,
args: GetScheduleArgs,
) -> anyhow::Result<()> {
let schedule_id = SyncId::ServerId(ServerId::try_from(args.schedule_id)?);
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| {
if let Err(err) = result {
super::report_fatal_error(err, ctx);
return;
}
let Some(schedule) = CloudScheduledAmbientAgent::get_by_id(&schedule_id, ctx) else {
super::report_fatal_error(anyhow::anyhow!("Schedule not found"), ctx);
return;
};
let id = match &schedule_id {
SyncId::ServerId(server_id) => server_id.to_string(),
SyncId::ClientId(_) => "Unsynced".to_string(),
};
let scope = super::common::format_owner(&schedule.permissions().owner).to_string();
let config = schedule.model().string_model.clone();
// Don't hold references into the CloudObject store across an async spawn.
let history_future = manager.fetch_schedule_history(schedule_id, ctx);
ctx.spawn(history_future, move |_manager, history, ctx| {
let history = match history {
Ok(v) => v,
Err(err) => {
log::warn!("Failed to fetch scheduled agent history: {err:#}");
None
}
};
let info = ScheduleInfo::new(id, scope, config, history.as_ref());
if let Err(err) = print_schedule_info(&info, output_format) {
super::report_fatal_error(err, ctx);
return;
}
ctx.terminate_app(TerminationMode::ForceTerminate, None);
});
});
});
Ok(())
}
fn delete(ctx: &mut AppContext, args: DeleteScheduleArgs) -> anyhow::Result<()> {
let schedule_id = SyncId::ServerId(ServerId::try_from(args.schedule_id)?);
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| {
if let Err(err) = result {
super::report_fatal_error(err, ctx);
return;
}
println!("Deleting agent...");
let delete_future = manager.delete_schedule(schedule_id, ctx);
ctx.spawn(delete_future, |_manager, result, ctx| match result {
Ok(()) => {
println!("Schedule deleted");
ctx.terminate_app(TerminationMode::ForceTerminate, None);
}
Err(err) => {
super::report_fatal_error(err, ctx);
}
});
});
});
Ok(())
}