Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
441 changes: 439 additions & 2 deletions src/agent/routine.rs

Large diffs are not rendered by default.

22 changes: 18 additions & 4 deletions src/agent/routine_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ use uuid::Uuid;

use crate::agent::Scheduler;
use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire,
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger,
apply_routine_verification_result, next_cron_fire, routine_verification_fingerprint,
};
use crate::channels::{IncomingMessage, OutgoingResponse};
use crate::config::RoutineConfig;
Expand Down Expand Up @@ -621,7 +622,7 @@ impl RoutineEngine {
);

// Load the routine to update consecutive_failures and send notification
let routine = match self.store.get_routine(run.routine_id).await {
let mut routine = match self.store.get_routine(run.routine_id).await {
Ok(Some(r)) => r,
Ok(None) => {
tracing::warn!(
Expand Down Expand Up @@ -649,6 +650,12 @@ impl RoutineEngine {
};

let now = Utc::now();
routine.state = apply_routine_verification_result(
&routine.state,
routine_verification_fingerprint(&routine),
status,
now,
);
let next_fire = if let Trigger::Cron {
ref schedule,
ref timezone,
Expand Down Expand Up @@ -1085,7 +1092,7 @@ struct EngineContext {
}

/// Execute a routine run. Handles both lightweight and full_job modes.
async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun) {
async fn execute_routine(ctx: EngineContext, mut routine: Routine, run: RoutineRun) {
// Increment running count (atomic: survives panics in the execution below)
ctx.running_count.fetch_add(1, Ordering::Relaxed);

Expand Down Expand Up @@ -1143,8 +1150,15 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
tracing::error!(routine = %routine.name, "Failed to complete run record: {}", e);
}

// Update routine runtime state
let now = Utc::now();
routine.state = apply_routine_verification_result(
&routine.state,
routine_verification_fingerprint(&routine),
status,
now,
);

// Update routine runtime state
let next_fire = if let Trigger::Cron {
ref schedule,
ref timezone,
Expand Down
2 changes: 1 addition & 1 deletion src/channels/web/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Browser-facing HTTP API and SSE/WebSocket real-time streaming. Axum-based, singl
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/routines` | List routines |
| GET | `/api/routines/summary` | Aggregated stats (total/enabled/disabled/failing/runs_today) |
| GET | `/api/routines/summary` | Aggregated stats (total/enabled/disabled/unverified/failing/runs_today) |
| GET | `/api/routines/{id}` | Routine detail with recent run history |
| POST | `/api/routines/{id}/trigger` | Manually trigger a routine |
| POST | `/api/routines/{id}/toggle` | Enable/disable a routine |
Expand Down
61 changes: 52 additions & 9 deletions src/channels/web/handlers/routines.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ use axum::{
use serde::Deserialize;
use uuid::Uuid;

use crate::agent::routine::{Trigger, next_cron_fire};
use crate::agent::routine::{
RoutineDisplayStatus, RoutineVerificationStatus, Trigger, next_cron_fire,
routine_display_status_for_verification, routine_verification_status,
};
use crate::channels::web::auth::AuthenticatedUser;
use crate::channels::web::server::GatewayState;
use crate::channels::web::types::*;
Expand All @@ -30,7 +33,18 @@ pub async fn routines_list_handler(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

let items: Vec<RoutineInfo> = routines.iter().map(RoutineInfo::from_routine).collect();
let routine_ids: Vec<Uuid> = routines.iter().map(|routine| routine.id).collect();
let last_run_statuses = store
.batch_get_last_run_status(&routine_ids)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

let items: Vec<RoutineInfo> = routines
.iter()
.map(|routine| {
RoutineInfo::from_routine(routine, last_run_statuses.get(&routine.id).copied())
})
.collect();

Ok(Json(RoutineListResponse { routines: items }))
}
Expand All @@ -49,13 +63,39 @@ pub async fn routines_summary_handler(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

let routine_ids: Vec<Uuid> = routines.iter().map(|routine| routine.id).collect();
let last_run_statuses = store
.batch_get_last_run_status(&routine_ids)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

let total = routines.len() as u64;
let enabled = routines.iter().filter(|r| r.enabled).count() as u64;
let disabled = total - enabled;
let failing = routines
.iter()
.filter(|r| r.consecutive_failures > 0)
.count() as u64;
let mut enabled = 0u64;
let mut disabled = 0u64;
let mut unverified = 0u64;
let mut failing = 0u64;

for routine in &routines {
let verification_status = routine_verification_status(routine);
if routine.enabled {
enabled += 1;
} else {
disabled += 1;
}

if verification_status == RoutineVerificationStatus::Unverified {
unverified += 1;
}

if routine_display_status_for_verification(
routine,
verification_status,
last_run_statuses.get(&routine.id).copied(),
) == RoutineDisplayStatus::Failing
{
failing += 1;
}
}

let today_start = chrono::Utc::now()
.date_naive()
Expand All @@ -74,6 +114,7 @@ pub async fn routines_summary_handler(
total,
enabled,
disabled,
unverified,
failing,
runs_today,
}))
Expand Down Expand Up @@ -120,7 +161,7 @@ pub async fn routines_detail_handler(
job_id: run.job_id,
})
.collect();
let routine_info = RoutineInfo::from_routine(&routine);
let routine_info = RoutineInfo::from_routine(&routine, runs.first().map(|run| run.status));

// Read-only lookup — do not create a conversation on a GET request.
// The conversation is created lazily when the routine first executes.
Expand Down Expand Up @@ -148,6 +189,8 @@ pub async fn routines_detail_handler(
next_fire_at: routine.next_fire_at.map(|dt| dt.to_rfc3339()),
run_count: routine.run_count,
consecutive_failures: routine.consecutive_failures,
status: routine_info.status.clone(),
verification_status: routine_info.verification_status.clone(),
created_at: routine.created_at.to_rfc3339(),
conversation_id,
recent_runs,
Expand Down
49 changes: 48 additions & 1 deletion src/channels/web/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1014,7 +1014,42 @@ async fn oauth_callback_handler(
flow.secrets
.create(&flow.user_id, params)
.await
.map_err(|e| e.to_string())?;
.map_err(|e| {
tracing::warn!(
extension = %flow.extension_name,
secret_name = %client_id_secret,
error = %e,
"Failed to store OAuth client_id secret after callback"
);
"failed to store client credentials".to_string()
})?;
}

if let (Some(client_secret_name), Some(client_secret)) = (
flow.client_secret_secret_name.as_ref(),
flow.client_secret.as_deref(),
) {
let mut params =
crate::secrets::CreateSecretParams::new(client_secret_name, client_secret)
.with_provider(flow.provider.as_ref().cloned().unwrap_or_default());
if let Some(expires_at) = flow.client_secret_expires_at
&& let Some(dt) =
chrono::DateTime::<chrono::Utc>::from_timestamp(expires_at as i64, 0)
{
params = params.with_expiry(dt);
}
flow.secrets
.create(&flow.user_id, params)
.await
.map_err(|e| {
tracing::warn!(
extension = %flow.extension_name,
secret_name = %client_secret_name,
error = %e,
"Failed to store OAuth client_secret secret after callback"
);
"failed to store client credentials".to_string()
})?;
}

Ok(())
Expand Down Expand Up @@ -3177,6 +3212,8 @@ mod tests {
gateway_token: oauth_proxy_auth_token,
token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
client_secret_secret_name: None,
client_secret_expires_at: None,
created_at: std::time::Instant::now(),
}
}
Expand Down Expand Up @@ -3542,6 +3579,8 @@ mod tests {
gateway_token: None,
token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
client_secret_secret_name: None,
client_secret_expires_at: None,
created_at,
};

Expand Down Expand Up @@ -3611,6 +3650,8 @@ mod tests {
gateway_token: None,
token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
client_secret_secret_name: None,
client_secret_expires_at: None,
created_at,
};

Expand Down Expand Up @@ -3714,6 +3755,8 @@ mod tests {
gateway_token: None,
token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
client_secret_secret_name: None,
client_secret_expires_at: None,
// Expired — handler will reject after lookup (no network I/O)
created_at,
};
Expand Down Expand Up @@ -3801,6 +3844,8 @@ mod tests {
gateway_token: None,
token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
client_secret_secret_name: None,
client_secret_expires_at: None,
created_at,
};

Expand Down Expand Up @@ -3882,6 +3927,8 @@ mod tests {
gateway_token: None,
token_exchange_extra_params: std::collections::HashMap::new(),
client_id_secret_name: None,
client_secret_secret_name: None,
client_secret_expires_at: None,
created_at,
};

Expand Down
34 changes: 27 additions & 7 deletions src/channels/web/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4182,6 +4182,7 @@ function renderRoutinesSummary(s) {
+ summaryCard(I18n.t('routines.summary.total'), s.total, '')
+ summaryCard(I18n.t('routines.summary.enabled'), s.enabled, 'active')
+ summaryCard(I18n.t('routines.summary.disabled'), s.disabled, '')
+ summaryCard(I18n.t('routines.summary.unverified'), s.unverified, 'pending')
+ summaryCard(I18n.t('routines.summary.failing'), s.failing, 'failed')
+ summaryCard(I18n.t('routines.summary.runsToday'), s.runs_today, 'completed');
}
Expand All @@ -4200,13 +4201,18 @@ function renderRoutinesList(routines) {
tbody.innerHTML = routines.map((r) => {
const statusClass = r.status === 'active' ? 'completed'
: r.status === 'failing' ? 'failed'
: r.status === 'attention' ? 'stuck'
: r.status === 'running' ? 'in_progress'
: 'pending';

const toggleLabel = r.enabled ? 'Disable' : 'Enable';
const toggleClass = r.enabled ? 'btn-cancel' : 'btn-restart';
const triggerTitle = (r.trigger_type === 'cron' && r.trigger_raw)
? ' title="' + escapeHtml(r.trigger_raw) + '"'
: '';
const runLabel = (r.verification_status === 'unverified' || r.status === 'unverified')
? 'Verify now'
: 'Run';

return '<tr class="routine-row" data-action="open-routine" data-id="' + escapeHtml(r.id) + '">'
+ '<td>' + escapeHtml(r.name) + '</td>'
Expand All @@ -4218,7 +4224,7 @@ function renderRoutinesList(routines) {
+ '<td><span class="badge ' + statusClass + '">' + escapeHtml(r.status) + '</span></td>'
+ '<td>'
+ '<button class="' + toggleClass + '" data-action="toggle-routine" data-id="' + escapeHtml(r.id) + '">' + toggleLabel + '</button> '
+ '<button class="btn-restart" data-action="trigger-routine" data-id="' + escapeHtml(r.id) + '">Run</button> '
+ '<button class="btn-restart" data-action="trigger-routine" data-id="' + escapeHtml(r.id) + '">' + runLabel + '</button> '
+ '<button class="btn-cancel" data-action="delete-routine" data-id="' + escapeHtml(r.id) + '" data-name="' + escapeHtml(r.name) + '">Delete</button>'
+ '</td>'
+ '</tr>';
Expand Down Expand Up @@ -4247,12 +4253,12 @@ function renderRoutineDetail(routine) {
const detail = document.getElementById('routine-detail');
detail.style.display = 'block';

const statusClass = !routine.enabled ? 'pending'
: routine.consecutive_failures > 0 ? 'failed'
: 'completed';
const statusLabel = !routine.enabled ? 'disabled'
: routine.consecutive_failures > 0 ? 'failing'
: 'active';
const statusClass = routine.status === 'active' ? 'completed'
: routine.status === 'failing' ? 'failed'
: routine.status === 'attention' ? 'stuck'
: routine.status === 'running' ? 'in_progress'
: 'pending';
const statusLabel = routine.status || 'active';

let html = '<div class="job-detail-header">'
+ '<button class="btn-back" data-action="close-routine-detail">&larr; Back</button>'
Expand All @@ -4277,6 +4283,20 @@ function renderRoutineDetail(routine) {
+ '<div class="job-description-body">' + escapeHtml(routine.description) + '</div></div>';
}

if (routine.verification_status === 'unverified') {
let verificationCopy = 'Created or updated, but not yet verified with a successful run.';
if (routine.recent_runs && routine.recent_runs.length > 0) {
const latestRun = routine.recent_runs[0];
if (latestRun.status === 'failed') {
verificationCopy = 'The latest verification attempt failed. Review the run details and verify again after fixing it.';
} else if (latestRun.status === 'attention') {
verificationCopy = 'The latest verification attempt needs attention. Review the run details and verify again when ready.';
}
}
html += '<div class="job-description"><h3>Verification</h3>'
+ '<div class="job-description-body">' + escapeHtml(verificationCopy) + '</div></div>';
}

// Trigger config
if (routine.trigger_type === 'cron') {
const summary = routine.trigger_summary || 'cron';
Expand Down
1 change: 1 addition & 0 deletions src/channels/web/static/i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ I18n.register('en', {
'routines.summary.total': 'Total',
'routines.summary.enabled': 'Enabled',
'routines.summary.disabled': 'Disabled',
'routines.summary.unverified': 'Unverified',
'routines.summary.failing': 'Failing',
'routines.summary.runsToday': 'Runs Today',

Expand Down
1 change: 1 addition & 0 deletions src/channels/web/static/i18n/zh-CN.js
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ I18n.register('zh-CN', {
'routines.summary.total': '总计',
'routines.summary.enabled': '已启用',
'routines.summary.disabled': '已禁用',
'routines.summary.unverified': '未验证',
'routines.summary.failing': '失败',
'routines.summary.runsToday': '今日运行',

Expand Down
Loading
Loading