feat(backend,frontend): tâches planifiées projet — tool MCP idea_scheduled_task_* et menu utilisateur #305, fix transcript lié indisponible après reboot #314 (QA verte)

#305: nouvel agrégat durable ScheduledProjectTask dans domain (scheduled_task.rs, ids, ports ScheduledProjectTaskStore), source de vérité <app_data_dir>/scheduled-project-tasks/<projectId>.json, schedules bornés V1 interval/daily/weekly avec fuseau, cycle À approuver/Active/Suspendue avec approbation obligatoire des tâches demandées par agent; use cases application/scheduled_task (list/create/update/delete/run_now/reconcile/fire) réutilisant BackgroundTask pour les runs avec distinction planifié/manuel; store infrastructure dédié + scheduler reconcile au boot sans rattrapage en rafale, cwd confiné au projet; 7 tools MCP idea_scheduled_task_* (list/read lecture seule, create/update/delete/run_now/set_enabled write-actions soumises aux permissions natives); commandes Tauri + routes HTTP aux contrats DTO identiques (camelCase nextRunAtMs/lastRunAtMs/lastTaskId/createdBy/updatedBy); frontend: gateways Tauri/HTTP/mock + types ports/domain, surface projet 'Tâches planifiées' (liste utilisateur+agent unifiée, création/édition avec récapitulatif avant activation, approbation/refus, exécuter maintenant, suspendre/reprendre, suppression), catalogage surface web (webSurfaceCatalog/viewPlacement/ViewPanelBody/WebWorkspace); docs/scheduled-project-tasks.md et sdk docs/services.md (047c38b: pas un service plugin V1, pas d'émulation via cron système).

#314: correction de 'Conversation liée — transcript indisponible' après reboot: restauration du transcript lié côté application (orchestrator service, lifecycle, session_limit, background sink, terminal registry) et côté frontend (adapters agent/http frames/stream/requestResponse, workStateNormalization, CustomAgentChatView avec état et action utile), tests dto_chat/agent_lifecycle/structured_registry_d1/background_completion_sink étendus.

QA verte rapportée par QA sur feature/305-scheduled-project-tasks-mcp, sans bloquant, pour #305 et #314.

Hors périmètre worktree: tickets non liés, attachments/, scripts/; aucun push.
This commit is contained in:
2026-09-11 10:32:01 +02:00
parent 72792bb02b
commit 7007b2cc7e
63 changed files with 3887 additions and 100 deletions

View File

@ -3719,11 +3719,12 @@ pub fn reattach_agent_chat(
// this live channel — generation supersede, no double emission).
let _gen = state.chat_bridge.register(sid, on_reply);
Ok(ReattachChatDto {
session_id: sid.to_string(),
Ok(ReattachChatDto::from_scrollback(
sid.to_string(),
conversation_id,
scrollback,
})
state.structured_sessions.transcript_restore_expected(&sid),
))
}
/// `detach_agent_chat` — detach only the custom chat view transport from a live
@ -5201,6 +5202,7 @@ pub async fn spawn_background_command(
rendezvous: None,
parent_task_id: None,
deadline_ms: request.deadline_ms,
origin: domain::BackgroundTaskOrigin::Manual,
})
.await
.map(|out| BackgroundTaskDto::from(out.task))
@ -5346,3 +5348,124 @@ pub async fn list_background_tasks(
tasks.sort_by_key(|task| (task.created_at_ms, task.id));
Ok(tasks.into_iter().map(BackgroundTaskDto::from).collect())
}
// ---------------------------------------------------------------------------
// Scheduled project tasks (#305)
// ---------------------------------------------------------------------------
/// Lists durable scheduled definitions for one project.
#[tauri::command]
pub async fn list_scheduled_project_tasks(
project_id: String,
state: State<'_, AppState>,
) -> Result<Vec<domain::ScheduledProjectTask>, ErrorDto> {
state
.scheduled_project_tasks
.list(parse_project_id(&project_id)?)
.await
.map_err(ErrorDto::from)
}
/// Creates a human-approved scheduled project task definition.
#[tauri::command]
pub async fn create_scheduled_project_task(
request: crate::dto::CreateScheduledProjectTaskRequestDto,
state: State<'_, AppState>,
) -> Result<domain::ScheduledProjectTask, ErrorDto> {
state
.scheduled_project_tasks
.create(application::CreateScheduledProjectTaskInput {
project_id: parse_project_id(&request.project_id)?,
name: request.name,
description: request.description,
command: request.command,
schedule: request.schedule,
timezone: request.timezone,
enabled: request.enabled,
timeout_ms: request.timeout_ms,
actor: domain::ScheduledTaskActor::Human,
})
.await
.map_err(ErrorDto::from)
}
/// Replaces a scheduled project task definition under revision control.
#[tauri::command]
pub async fn update_scheduled_project_task(
request: crate::dto::UpdateScheduledProjectTaskRequestDto,
state: State<'_, AppState>,
) -> Result<domain::ScheduledProjectTask, ErrorDto> {
state
.scheduled_project_tasks
.update(application::UpdateScheduledProjectTaskInput {
project_id: parse_project_id(&request.project_id)?,
scheduled_task_id: crate::dto::parse_scheduled_task_id(&request.scheduled_task_id)?,
name: request.name,
description: request.description,
command: request.command,
schedule: request.schedule,
timezone: request.timezone,
enabled: request.enabled,
timeout_ms: request.timeout_ms,
audit_revision: request.audit_revision,
actor: domain::ScheduledTaskActor::Human,
})
.await
.map_err(ErrorDto::from)
}
/// Deletes a definition without cancelling a run already in progress.
#[tauri::command]
pub async fn delete_scheduled_project_task(
project_id: String,
scheduled_task_id: String,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
state
.scheduled_project_tasks
.delete(
parse_project_id(&project_id)?,
crate::dto::parse_scheduled_task_id(&scheduled_task_id)?,
domain::ScheduledTaskActor::Human,
)
.await
.map_err(ErrorDto::from)
}
/// Starts an immediate background run for an approved definition.
#[tauri::command]
pub async fn run_scheduled_project_task_now(
project_id: String,
scheduled_task_id: String,
state: State<'_, AppState>,
) -> Result<domain::ScheduledProjectTask, ErrorDto> {
state
.scheduled_project_tasks
.run_now(
parse_project_id(&project_id)?,
crate::dto::parse_scheduled_task_id(&scheduled_task_id)?,
domain::ScheduledTaskActor::Human,
)
.await
.map_err(ErrorDto::from)
}
/// Enables/approves or disables a scheduled definition.
#[tauri::command]
pub async fn set_scheduled_project_task_enabled(
project_id: String,
scheduled_task_id: String,
enabled: bool,
state: State<'_, AppState>,
) -> Result<domain::ScheduledProjectTask, ErrorDto> {
state
.scheduled_project_tasks
.set_enabled(
parse_project_id(&project_id)?,
crate::dto::parse_scheduled_task_id(&scheduled_task_id)?,
enabled,
domain::ScheduledTaskActor::Human,
)
.await
.map_err(ErrorDto::from)
}

View File

@ -418,6 +418,12 @@ pub fn run() {
commands::retry_background_task,
commands::attach_background_task,
commands::list_background_tasks,
commands::list_scheduled_project_tasks,
commands::create_scheduled_project_task,
commands::update_scheduled_project_task,
commands::delete_scheduled_project_task,
commands::run_scheduled_project_task_now,
commands::set_scheduled_project_task_enabled,
plugins::plugin_list_plugins,
plugins::plugin_list_project_settings,
plugins::plugin_set_project_enabled,

View File

@ -8,9 +8,10 @@
use app_tauri_lib::dto::{
CellKind, ChatAttachmentDto, ChatAttachmentInputDto, ChatDraftScopeRequestDto,
ChatPromptAttachmentDto, ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto,
ReadChatDraftResponseDto, ReattachChatDto, ReplyChunk, ReplyProgressDto, ReplyProgressKindDto,
ReplyProgressSourceDto, ReplyProgressStageDto, SaveChatDraftRequestDto, TerminalSessionDto,
ChatPromptAttachmentDto, ConversationTranscriptUnavailableReasonDto,
ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto, ReadChatDraftResponseDto,
ReattachChatDto, ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, ReplyProgressSourceDto,
ReplyProgressStageDto, SaveChatDraftRequestDto, TerminalSessionDto,
};
use application::{LaunchAgentOutput, StructuredSessionDescriptor};
use domain::project::ProjectPath;
@ -407,10 +408,10 @@ fn import_chat_attachments_request_response_use_camel_case() {
#[test]
fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
let dto = ReattachChatDto {
session_id: "sess-1".into(),
conversation_id: Some("conv-1".into()),
scrollback: vec![
let dto = ReattachChatDto::from_scrollback(
"sess-1".into(),
Some("conv-1".into()),
vec![
ReplyChunk::UserPrompt {
at_ms: None,
text: "Hi".into(),
@ -426,7 +427,8 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
content: "Hi".into(),
},
],
};
true,
);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(
v,
@ -445,14 +447,22 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
#[test]
fn reattach_chat_dto_empty_scrollback_is_empty_array() {
let dto = ReattachChatDto {
session_id: "s".into(),
conversation_id: None,
scrollback: vec![],
};
let dto = ReattachChatDto::from_scrollback("s".into(), None, vec![], true);
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["scrollback"], json!([]));
assert!(v.get("conversationId").is_none());
assert_eq!(v["transcriptUnavailableReason"], "missingTranscript");
assert_eq!(
dto.transcript_unavailable_reason,
Some(ConversationTranscriptUnavailableReasonDto::MissingTranscript)
);
}
#[test]
fn reattach_chat_dto_distinguishes_fresh_empty_conversation() {
let dto = ReattachChatDto::from_scrollback("s".into(), None, vec![], false);
let v = serde_json::to_value(dto).unwrap();
assert_eq!(v["transcriptUnavailableReason"], "emptyConversation");
}
// ---------------------------------------------------------------------------

View File

@ -21,6 +21,8 @@ subtle = { workspace = true }
dirs = { workspace = true }
# `v5` derives stable reference-profile ids from a fixed namespace (catalogue).
uuid = { workspace = true }
chrono = { workspace = true }
chrono-tz = { workspace = true }
# `time` feature only : borne le rendez-vous synchrone `send_blocking` (§17.4).
# Déjà le runtime async du workspace ; pas une nouvelle dépendance externe.
tokio = { workspace = true }

View File

@ -2752,6 +2752,7 @@ impl LaunchAgent {
&input.project.root,
input.node_id,
size,
launch_conversation_id.is_some(),
&spec.env,
None,
structured_policy.as_ref(),
@ -2855,6 +2856,7 @@ impl LaunchAgent {
root: &ProjectPath,
node_id: Option<NodeId>,
size: PtySize,
transcript_restore_expected: bool,
env: &[(String, String)],
sandbox: Option<&SandboxPlan>,
structured_policy: Option<&StructuredProviderLaunchPolicy>,
@ -2898,6 +2900,7 @@ impl LaunchAgent {
model_server_guard,
launch_managed_temp.map(|temp| temp.handle),
);
structured.set_transcript_restore_expected(session_id, transcript_restore_expected);
// ── SÉPARATION DES DEUX CLÉS (ARCHITECTURE §19.7, lot P8a) ──
// - **id de paire** (`pair_conversation_id`) : clé **logique** persistée sur

View File

@ -243,7 +243,12 @@ impl SessionLimitService {
agent_id,
node_id,
conversation_id,
} = task;
} = task
else {
return Err(AppError::Invalid(
"session-limit service received a non-resume scheduled task".to_owned(),
));
};
// Le réveil a tiré : l'entrée armée n'a plus lieu d'être (qu'on réussisse ou non).
self.disarm(RuntimeAgentKey::new(project_id, agent_id));

View File

@ -21,9 +21,9 @@ use domain::ports::{
EventBus, IdGenerator, SpawnSpec,
};
use domain::{
AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskRendezvousLink,
BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, DomainEvent, ProjectId,
TaskId,
AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskOrigin,
BackgroundTaskRendezvousLink, BackgroundTaskResult, BackgroundTaskState,
BackgroundTaskWakePolicy, DomainEvent, ProjectId, TaskId,
};
use crate::error::AppError;
@ -60,6 +60,8 @@ pub struct SpawnBackgroundCommandInput {
pub parent_task_id: Option<TaskId>,
/// Optional absolute deadline, epoch milliseconds.
pub deadline_ms: Option<u64>,
/// Manual or scheduled occurrence provenance.
pub origin: BackgroundTaskOrigin,
}
/// Output of [`SpawnBackgroundCommand`] / [`RetryBackgroundTask`].
@ -121,6 +123,7 @@ impl SpawnBackgroundCommand {
input.rendezvous,
input.parent_task_id,
input.deadline_ms,
input.origin,
)
.await?;
Ok(SpawnBackgroundCommandOutput { task })
@ -137,6 +140,7 @@ impl SpawnBackgroundCommand {
rendezvous: Option<BackgroundTaskRendezvousLink>,
parent_task_id: Option<TaskId>,
deadline_ms: Option<u64>,
origin: BackgroundTaskOrigin,
) -> Result<BackgroundTask, AppError> {
let task_id = TaskId::from_uuid(self.ids.new_uuid());
let now = u64::try_from(self.clock.now_millis().max(0)).unwrap_or(0);
@ -151,6 +155,7 @@ impl SpawnBackgroundCommand {
deadline_ms,
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
task = task.with_origin(origin);
if let Some(link) = rendezvous {
task = task.with_rendezvous(link);
}
@ -485,6 +490,7 @@ impl RetryBackgroundTask {
None,
None,
None,
BackgroundTaskOrigin::Manual,
)
.await?;
Ok(SpawnBackgroundCommandOutput { task })

View File

@ -35,6 +35,7 @@ pub mod plugin;
pub mod project;
pub mod project_plugin;
pub mod remote;
pub mod scheduled_task;
pub mod settings;
pub mod skill;
pub mod slash_command;
@ -220,6 +221,10 @@ pub use project_plugin::{
ProjectPluginSettingsView, SetProjectPluginEnabled,
};
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
pub use scheduled_task::{
next_occurrence, CreateScheduledProjectTaskInput, ScheduledProjectTaskService,
UpdateScheduledProjectTaskInput,
};
pub use settings::{
ReadProjectSettings, ReadUserSettings, UpdateProjectSettings, UpdateUserSettings,
};

View File

@ -54,6 +54,9 @@ use crate::orchestrator::{
ReadMemoryInput, UpdateProjectContext, UpdateProjectContextInput, WriteMemory,
WriteMemoryInput,
};
use crate::scheduled_task::{
CreateScheduledProjectTaskInput, ScheduledProjectTaskService, UpdateScheduledProjectTaskInput,
};
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
use crate::terminal::{
stop_live_agent_runtime_in_project, CloseTerminal, LiveSessions, StructuredSessions,
@ -1205,6 +1208,8 @@ pub struct OrchestratorService {
/// Use case B8 qui produit une tâche de fond command-backed depuis la surface
/// agent MCP `idea_run_in_background`. `None` ⇒ outil non configuré.
spawn_background_command: Option<Arc<SpawnBackgroundCommand>>,
/// Durable project schedule use cases backing `idea_scheduled_task_*`.
scheduled_project_tasks: Option<Arc<ScheduledProjectTaskService>>,
/// Detached self-restart coordinator, wired at the composition root.
self_restart: Option<Arc<dyn AgentSelfRestartScheduler>>,
/// Stable control-plane handoff between an active ask and a detached restart.
@ -1308,6 +1313,7 @@ impl OrchestratorService {
background_runner: None,
session_limits: None,
spawn_background_command: None,
scheduled_project_tasks: None,
self_restart: None,
restart_rendezvous: Arc::new(RestartRendezvousCoordinator::new()),
}
@ -1334,6 +1340,16 @@ impl OrchestratorService {
self
}
/// Wires requester-scoped scheduled-task tools.
#[must_use]
pub fn with_scheduled_project_tasks(
mut self,
service: Arc<ScheduledProjectTaskService>,
) -> Self {
self.scheduled_project_tasks = Some(service);
self
}
/// Wires the requester-scoped detached self-restart coordinator.
#[must_use]
pub fn with_self_restart(mut self, restart: Arc<dyn AgentSelfRestartScheduler>) -> Self {
@ -2112,9 +2128,158 @@ impl OrchestratorService {
self.run_in_background(project, owner, label, command, args, cwd, deadline_ms)
.await
}
OrchestratorCommand::ListScheduledTasks => self.scheduled_list(project).await,
OrchestratorCommand::ReadScheduledTask { scheduled_task_id } => {
self.scheduled_read(project, scheduled_task_id).await
}
OrchestratorCommand::CreateScheduledTask {
requester,
name,
description,
command,
schedule,
timezone,
timeout_ms,
} => {
let task = self
.require_scheduled_tasks()?
.create(CreateScheduledProjectTaskInput {
project_id: project.id,
name,
description,
command,
schedule,
timezone,
enabled: false,
timeout_ms,
actor: domain::ScheduledTaskActor::Agent {
agent_id: requester,
},
})
.await?;
Self::scheduled_outcome(task, "scheduled task request saved pending human approval")
}
OrchestratorCommand::UpdateScheduledTask {
requester,
scheduled_task_id,
name,
description,
command,
schedule,
timezone,
timeout_ms,
audit_revision,
} => {
let task = self
.require_scheduled_tasks()?
.update(UpdateScheduledProjectTaskInput {
project_id: project.id,
scheduled_task_id,
name,
description,
command,
schedule,
timezone,
enabled: false,
timeout_ms,
audit_revision,
actor: domain::ScheduledTaskActor::Agent {
agent_id: requester,
},
})
.await?;
Self::scheduled_outcome(task, "scheduled task update saved pending human approval")
}
OrchestratorCommand::DeleteScheduledTask {
requester,
scheduled_task_id,
} => {
self.require_scheduled_tasks()?
.delete(
project.id,
scheduled_task_id,
domain::ScheduledTaskActor::Agent {
agent_id: requester,
},
)
.await?;
Ok(OrchestratorOutcome {
detail: "scheduled task deleted".to_owned(),
reply: Some("{\"status\":\"deleted\"}".to_owned()),
})
}
OrchestratorCommand::RunScheduledTaskNow {
requester,
scheduled_task_id,
} => {
let task = self
.require_scheduled_tasks()?
.run_now(
project.id,
scheduled_task_id,
domain::ScheduledTaskActor::Agent {
agent_id: requester,
},
)
.await?;
Self::scheduled_outcome(task, "scheduled task run started")
}
OrchestratorCommand::SetScheduledTaskEnabled {
requester,
scheduled_task_id,
enabled,
} => {
let task = self
.require_scheduled_tasks()?
.set_enabled(
project.id,
scheduled_task_id,
enabled,
domain::ScheduledTaskActor::Agent {
agent_id: requester,
},
)
.await?;
Self::scheduled_outcome(task, "scheduled task enabled state updated")
}
}
}
fn require_scheduled_tasks(&self) -> Result<&Arc<ScheduledProjectTaskService>, AppError> {
self.scheduled_project_tasks.as_ref().ok_or_else(|| {
AppError::Invalid("scheduled project tasks are not configured".to_owned())
})
}
async fn scheduled_list(&self, project: &Project) -> Result<OrchestratorOutcome, AppError> {
let tasks = self.require_scheduled_tasks()?.list(project.id).await?;
let reply = serde_json::to_string(&tasks).map_err(|e| AppError::Invalid(e.to_string()))?;
Ok(OrchestratorOutcome {
detail: format!("listed {} scheduled task(s)", tasks.len()),
reply: Some(reply),
})
}
async fn scheduled_read(
&self,
project: &Project,
id: domain::ScheduledTaskId,
) -> Result<OrchestratorOutcome, AppError> {
let task = self.require_scheduled_tasks()?.get(project.id, id).await?;
Self::scheduled_outcome(task, "scheduled task read")
}
fn scheduled_outcome(
task: domain::ScheduledProjectTask,
detail: &str,
) -> Result<OrchestratorOutcome, AppError> {
let reply = serde_json::to_string(&task).map_err(|e| AppError::Invalid(e.to_string()))?;
Ok(OrchestratorOutcome {
detail: detail.to_owned(),
reply: Some(reply),
})
}
fn require_project_store(&self) -> Result<&Arc<dyn ProjectStore>, AppError> {
self.projects.as_ref().ok_or_else(|| {
AppError::Invalid("cross-project MCP tools are not configured".to_owned())
@ -2341,6 +2506,7 @@ impl OrchestratorService {
rendezvous: rendezvous.clone(),
parent_task_id,
deadline_ms,
origin: domain::BackgroundTaskOrigin::Manual,
})
.await?;
if let Some(link) = rendezvous {

View File

@ -2048,6 +2048,7 @@ impl PluginCommandTasks {
rendezvous: None,
parent_task_id: None,
deadline_ms: input.deadline_ms,
origin: domain::BackgroundTaskOrigin::Manual,
})
.await
.map(|out| out.task)

View File

@ -0,0 +1,762 @@
//! Project scheduled task use cases and one-shot reconciliation.
use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};
use chrono::{Datelike, TimeZone, Timelike, Utc};
use chrono_tz::Tz;
use domain::ports::{Clock, FileSystem, IdGenerator, ProjectStore, Scheduler, SpawnSpec};
use domain::{
AgentId, BackgroundTaskWakePolicy, PathAccess, PathGrant, Posture, ProjectId, ProjectPath,
RemotePath, SandboxPlan, ScheduleId, ScheduledProjectTask, ScheduledProjectTaskStore,
ScheduledTask, ScheduledTaskActor, ScheduledTaskApproval, ScheduledTaskAuditEntry,
ScheduledTaskCommand, ScheduledTaskId, ScheduledTaskSandbox, ScheduledTaskSchedule,
ScheduledTaskStoreError, Weekday,
};
use tokio::sync::Mutex as AsyncMutex;
use crate::{AppError, SpawnBackgroundCommand, SpawnBackgroundCommandInput};
fn map_store(error: ScheduledTaskStoreError) -> AppError {
match error {
ScheduledTaskStoreError::NotFound => {
AppError::NotFound("scheduled project task".to_owned())
}
ScheduledTaskStoreError::Invalid(message) => AppError::Invalid(message),
ScheduledTaskStoreError::Store(message) => AppError::Store(message),
}
}
/// Full create payload used by both human commands and requester-scoped MCP.
#[derive(Debug, Clone)]
pub struct CreateScheduledProjectTaskInput {
/// Owning project.
pub project_id: ProjectId,
/// Display name.
pub name: String,
/// Optional review context.
pub description: Option<String>,
/// Command to execute without free-form environment.
pub command: ScheduledTaskCommand,
/// Bounded recurring schedule.
pub schedule: ScheduledTaskSchedule,
/// IANA timezone.
pub timezone: String,
/// Requested initial enabled state; agent requests are forced off.
pub enabled: bool,
/// Optional per-run deadline.
pub timeout_ms: Option<u64>,
/// Request origin used for approval enforcement and audit.
pub actor: ScheduledTaskActor,
}
/// Full replacement payload. `audit_revision` is optimistic concurrency control.
#[derive(Debug, Clone)]
pub struct UpdateScheduledProjectTaskInput {
/// Owning project.
pub project_id: ProjectId,
/// Definition to replace.
pub scheduled_task_id: ScheduledTaskId,
/// Display name.
pub name: String,
/// Optional review context.
pub description: Option<String>,
/// Command to execute without free-form environment.
pub command: ScheduledTaskCommand,
/// Bounded recurring schedule.
pub schedule: ScheduledTaskSchedule,
/// IANA timezone.
pub timezone: String,
/// Requested enabled state; agent changes are forced off.
pub enabled: bool,
/// Optional per-run deadline.
pub timeout_ms: Option<u64>,
/// Expected current revision for optimistic concurrency.
pub audit_revision: u64,
/// Request origin used for approval enforcement and audit.
pub actor: ScheduledTaskActor,
}
/// Coordinates durable definitions with an in-memory one-shot scheduler.
pub struct ScheduledProjectTaskService {
store: Arc<dyn ScheduledProjectTaskStore>,
projects: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
scheduler: Arc<dyn Scheduler>,
spawn: Arc<SpawnBackgroundCommand>,
clock: Arc<dyn Clock>,
ids: Arc<dyn IdGenerator>,
arms: Mutex<HashMap<ScheduledTaskId, (ProjectId, ScheduleId)>>,
mutation: AsyncMutex<()>,
}
impl ScheduledProjectTaskService {
/// Builds the scheduled-task application service from inward-facing ports.
#[must_use]
pub fn new(
store: Arc<dyn ScheduledProjectTaskStore>,
projects: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
scheduler: Arc<dyn Scheduler>,
spawn: Arc<SpawnBackgroundCommand>,
clock: Arc<dyn Clock>,
ids: Arc<dyn IdGenerator>,
) -> Self {
Self {
store,
projects,
fs,
scheduler,
spawn,
clock,
ids,
arms: Mutex::new(HashMap::new()),
mutation: AsyncMutex::new(()),
}
}
fn now(&self) -> u64 {
self.clock.now_millis().max(0) as u64
}
/// Lists definitions for a registered project.
pub async fn list(&self, project_id: ProjectId) -> Result<Vec<ScheduledProjectTask>, AppError> {
self.projects.load_project(project_id).await?;
self.store.list(project_id).await.map_err(map_store)
}
/// Reads one definition from a registered project.
pub async fn get(
&self,
project_id: ProjectId,
id: ScheduledTaskId,
) -> Result<ScheduledProjectTask, AppError> {
self.projects.load_project(project_id).await?;
self.store
.get(project_id, id)
.await
.map_err(map_store)?
.ok_or_else(|| AppError::NotFound(format!("scheduled project task {id}")))
}
/// Creates a definition, forcing agent requests into pending approval.
pub async fn create(
&self,
input: CreateScheduledProjectTaskInput,
) -> Result<ScheduledProjectTask, AppError> {
let _guard = self.mutation.lock().await;
self.projects.load_project(input.project_id).await?;
validate_relative_cwd(input.command.cwd.as_deref())?;
let now = self.now();
let (approval, enabled) = match input.actor {
ScheduledTaskActor::Agent { .. } => (ScheduledTaskApproval::Pending, false),
_ => (ScheduledTaskApproval::Approved, input.enabled),
};
let mut task = ScheduledProjectTask {
id: ScheduledTaskId::from_uuid(self.ids.new_uuid()),
project_id: input.project_id,
name: input.name,
description: input.description,
enabled,
approval,
sandbox: ScheduledTaskSandbox::ProjectReadWrite,
command: input.command,
schedule: input.schedule,
timezone: input.timezone,
next_run_at_ms: None,
last_run_at_ms: None,
last_task_id: None,
created_by: input.actor.clone(),
updated_by: input.actor.clone(),
created_at_ms: now,
updated_at_ms: now,
audit_revision: 1,
timeout_ms: input.timeout_ms,
};
validate_timezone(&task.timezone)?;
task.validate()
.map_err(|e| AppError::Invalid(e.to_string()))?;
task.next_run_at_ms = enabled
.then(|| next_occurrence(&task.schedule, &task.timezone, now))
.transpose()?;
let audit = audit(&task, input.actor, "create", "scheduled task created", now);
self.store.save(&task, audit).await.map_err(map_store)?;
self.arm_definition(&task);
Ok(task)
}
/// Replaces a definition under optimistic concurrency control.
pub async fn update(
&self,
input: UpdateScheduledProjectTaskInput,
) -> Result<ScheduledProjectTask, AppError> {
let _guard = self.mutation.lock().await;
validate_relative_cwd(input.command.cwd.as_deref())?;
validate_timezone(&input.timezone)?;
let mut task = self
.store
.get(input.project_id, input.scheduled_task_id)
.await
.map_err(map_store)?
.ok_or_else(|| {
AppError::NotFound(format!(
"scheduled project task {}",
input.scheduled_task_id
))
})?;
if task.audit_revision != input.audit_revision {
return Err(AppError::Invalid(
"scheduled task revision conflict".to_owned(),
));
}
let actor_is_agent = matches!(input.actor, ScheduledTaskActor::Agent { .. });
task.name = input.name;
task.description = input.description;
task.command = input.command;
task.schedule = input.schedule;
task.timezone = input.timezone;
task.timeout_ms = input.timeout_ms;
task.updated_by = input.actor.clone();
task.updated_at_ms = self.now();
task.audit_revision += 1;
if actor_is_agent {
task.approval = ScheduledTaskApproval::Pending;
task.enabled = false;
task.next_run_at_ms = None;
} else {
task.approval = ScheduledTaskApproval::Approved;
task.enabled = input.enabled;
task.next_run_at_ms = input
.enabled
.then(|| next_occurrence(&task.schedule, &task.timezone, task.updated_at_ms))
.transpose()?;
}
task.validate()
.map_err(|e| AppError::Invalid(e.to_string()))?;
let audit = audit(
&task,
input.actor,
"update",
"definition replaced",
task.updated_at_ms,
);
self.store.save(&task, audit).await.map_err(map_store)?;
self.cancel_arm(task.id);
self.arm_definition(&task);
Ok(task)
}
/// Enables, approves, rejects, or suspends a definition according to actor policy.
pub async fn set_enabled(
&self,
project_id: ProjectId,
id: ScheduledTaskId,
enabled: bool,
actor: ScheduledTaskActor,
) -> Result<ScheduledProjectTask, AppError> {
let _guard = self.mutation.lock().await;
let mut task = self
.store
.get(project_id, id)
.await
.map_err(map_store)?
.ok_or_else(|| AppError::NotFound(format!("scheduled project task {id}")))?;
if enabled
&& matches!(actor, ScheduledTaskActor::Agent { .. })
&& task.approval != ScheduledTaskApproval::Approved
{
return Err(AppError::Invalid(
"only a human can approve an agent-requested scheduled task".to_owned(),
));
}
let now = self.now();
task.enabled = enabled;
task.updated_by = actor.clone();
task.updated_at_ms = now;
task.audit_revision += 1;
if enabled {
task.approval = ScheduledTaskApproval::Approved;
task.next_run_at_ms = Some(next_occurrence(&task.schedule, &task.timezone, now)?);
} else {
if task.approval == ScheduledTaskApproval::Pending
&& matches!(actor, ScheduledTaskActor::Human)
{
task.approval = ScheduledTaskApproval::Rejected;
}
task.next_run_at_ms = None;
}
let action = if enabled { "enable" } else { "disable" };
self.store
.save(&task, audit(&task, actor, action, action, now))
.await
.map_err(map_store)?;
self.cancel_arm(id);
self.arm_definition(&task);
Ok(task)
}
/// Deletes a definition without interrupting any concrete run already started.
pub async fn delete(
&self,
project_id: ProjectId,
id: ScheduledTaskId,
actor: ScheduledTaskActor,
) -> Result<(), AppError> {
let _guard = self.mutation.lock().await;
let task = self
.store
.get(project_id, id)
.await
.map_err(map_store)?
.ok_or_else(|| AppError::NotFound(format!("scheduled project task {id}")))?;
let now = self.now();
self.store
.delete(
project_id,
id,
ScheduledTaskAuditEntry {
revision: task.audit_revision + 1,
project_id,
scheduled_task_id: id,
actor,
action: "delete".to_owned(),
summary:
"definition deleted; an already-running BackgroundTask is not interrupted"
.to_owned(),
at_ms: now,
},
)
.await
.map_err(map_store)?;
self.cancel_arm(id);
Ok(())
}
/// Creates an immediate concrete background run for an approved definition.
pub async fn run_now(
&self,
project_id: ProjectId,
id: ScheduledTaskId,
actor: ScheduledTaskActor,
) -> Result<ScheduledProjectTask, AppError> {
let _guard = self.mutation.lock().await;
let task = self
.store
.get(project_id, id)
.await
.map_err(map_store)?
.ok_or_else(|| AppError::NotFound(format!("scheduled project task {id}")))?;
if task.approval != ScheduledTaskApproval::Approved {
return Err(AppError::Invalid(
"scheduled task requires human approval".to_owned(),
));
}
self.spawn_run(task, self.now(), actor, "runNow").await
}
/// Recomputes missed deadlines and arms exactly one future occurrence.
pub async fn reconcile_project(&self, project_id: ProjectId) -> Result<(), AppError> {
let _guard = self.mutation.lock().await;
let now = self.now();
for mut task in self.store.list(project_id).await.map_err(map_store)? {
self.cancel_arm(task.id);
if !task.enabled || task.approval != ScheduledTaskApproval::Approved {
continue;
}
if task.next_run_at_ms.is_none_or(|at| at <= now) {
task.next_run_at_ms = Some(next_occurrence(&task.schedule, &task.timezone, now)?);
task.updated_at_ms = now;
task.audit_revision += 1;
let entry = audit(
&task,
ScheduledTaskActor::System,
"reconcile",
"missed occurrence skipped; next future occurrence armed",
now,
);
self.store.save(&task, entry).await.map_err(map_store)?;
}
self.arm_definition(&task);
}
Ok(())
}
/// Cancels the in-memory one-shot arms owned by a closing project.
pub fn cancel_project(&self, project_id: ProjectId) {
if let Ok(mut arms) = self.arms.lock() {
let ids = arms
.iter()
.filter_map(|(id, (owner, _))| (*owner == project_id).then_some(*id))
.collect::<Vec<_>>();
let handles = ids
.into_iter()
.filter_map(|id| arms.remove(&id).map(|(_, handle)| handle))
.collect::<Vec<_>>();
drop(arms);
for handle in handles {
self.scheduler.cancel(handle);
}
}
}
/// Handles a one-shot wake. Persisting the next deadline before spawning
/// makes duplicate/stale delivery harmless.
pub async fn fire(
&self,
project_id: ProjectId,
id: ScheduledTaskId,
planned_at_ms: u64,
) -> Result<(), AppError> {
let _guard = self.mutation.lock().await;
self.arms.lock().expect("scheduled arms mutex").remove(&id);
let Some(mut task) = self.store.get(project_id, id).await.map_err(map_store)? else {
return Ok(());
};
if !task.enabled
|| task.approval != ScheduledTaskApproval::Approved
|| task.next_run_at_ms != Some(planned_at_ms)
{
return Ok(());
}
let now = self.now();
task.next_run_at_ms = Some(next_occurrence(
&task.schedule,
&task.timezone,
now.max(planned_at_ms),
)?);
task.updated_at_ms = now;
task.audit_revision += 1;
self.store
.save(
&task,
audit(
&task,
ScheduledTaskActor::System,
"fire",
"next occurrence persisted before spawn",
now,
),
)
.await
.map_err(map_store)?;
self.arm_definition(&task);
self.spawn_run(task, planned_at_ms, ScheduledTaskActor::System, "scheduled")
.await
.map(|_| ())
}
async fn spawn_run(
&self,
mut task: ScheduledProjectTask,
planned_at_ms: u64,
actor: ScheduledTaskActor,
action: &str,
) -> Result<ScheduledProjectTask, AppError> {
let project = self.projects.load_project(task.project_id).await?;
let cwd = match confined_cwd(self.fs.as_ref(), &project.root, task.command.cwd.as_deref())
.await
{
Ok(cwd) => cwd,
Err(error) => {
let now = self.now();
task.updated_at_ms = now;
task.audit_revision += 1;
let entry = audit(
&task,
ScheduledTaskActor::System,
"skip",
"run skipped because the project or working directory is inaccessible",
now,
);
self.store.save(&task, entry).await.map_err(map_store)?;
return Err(error);
}
};
let now = self.now();
let owner = match task.created_by {
ScheduledTaskActor::Agent { agent_id } => agent_id,
_ => AgentId::from_uuid(uuid::Uuid::nil()),
};
let deadline_ms = task.timeout_ms.map(|timeout| now.saturating_add(timeout));
let output = self
.spawn
.execute(SpawnBackgroundCommandInput {
project_id: task.project_id,
owner_agent_id: owner,
label: task.name.clone(),
command: SpawnSpec {
command: task.command.command.clone(),
args: task.command.args.clone(),
cwd,
env: Vec::new(),
context_plan: None,
sandbox: Some(confined_command_sandbox(&project.root)),
},
wake_policy: BackgroundTaskWakePolicy::RecordOnly,
rendezvous: None,
parent_task_id: None,
deadline_ms,
origin: domain::BackgroundTaskOrigin::Scheduled {
scheduled_task_id: task.id,
planned_at_ms,
},
})
.await?;
task.last_run_at_ms = Some(now);
task.last_task_id = Some(output.task.id);
task.updated_by = actor.clone();
task.updated_at_ms = now;
task.audit_revision += 1;
let summary = format!(
"{action} created BackgroundTask {} for occurrence {planned_at_ms}",
output.task.id
);
self.store
.save(&task, audit(&task, actor, action, &summary, now))
.await
.map_err(map_store)?;
Ok(task)
}
fn arm_definition(&self, task: &ScheduledProjectTask) {
let Some(at) = task
.next_run_at_ms
.filter(|_| task.enabled && task.approval == ScheduledTaskApproval::Approved)
else {
return;
};
let handle = self.scheduler.arm(
at.min(i64::MAX as u64) as i64,
ScheduledTask::FireProjectTask {
project_id: task.project_id,
scheduled_task_id: task.id,
planned_at_ms: at,
},
);
self.arms
.lock()
.expect("scheduled arms mutex")
.insert(task.id, (task.project_id, handle));
}
fn cancel_arm(&self, id: ScheduledTaskId) {
if let Some((_, handle)) = self.arms.lock().expect("scheduled arms mutex").remove(&id) {
self.scheduler.cancel(handle);
}
}
}
fn audit(
task: &ScheduledProjectTask,
actor: ScheduledTaskActor,
action: &str,
summary: &str,
at_ms: u64,
) -> ScheduledTaskAuditEntry {
ScheduledTaskAuditEntry {
revision: task.audit_revision,
project_id: task.project_id,
scheduled_task_id: task.id,
actor,
action: action.to_owned(),
summary: summary.to_owned(),
at_ms,
}
}
fn validate_timezone(value: &str) -> Result<Tz, AppError> {
value
.parse::<Tz>()
.map_err(|_| AppError::Invalid(format!("unknown IANA timezone `{value}`")))
}
/// Returns the first future minute matching the local wall-clock schedule.
pub fn next_occurrence(
schedule: &ScheduledTaskSchedule,
timezone: &str,
after_ms: u64,
) -> Result<u64, AppError> {
if let ScheduledTaskSchedule::Interval { every_minutes } = schedule {
schedule
.validate()
.map_err(|e| AppError::Invalid(e.to_string()))?;
return Ok(after_ms.saturating_add(u64::from(*every_minutes) * 60_000));
}
let tz = validate_timezone(timezone)?;
schedule
.validate()
.map_err(|e| AppError::Invalid(e.to_string()))?;
let start = (after_ms / 60_000 + 1) * 60_000;
for offset in 0..=(8 * 24 * 60) {
let millis = start.saturating_add(offset * 60_000);
if millis > i64::MAX as u64 {
return Err(AppError::Invalid(
"schedule timestamp out of range".to_owned(),
));
}
let utc = Utc
.timestamp_millis_opt(millis as i64)
.single()
.ok_or_else(|| AppError::Invalid("schedule timestamp out of range".to_owned()))?;
let local = utc.with_timezone(&tz);
let wanted_time = match schedule {
ScheduledTaskSchedule::Daily { time } | ScheduledTaskSchedule::Weekly { time, .. } => {
time
}
_ => unreachable!(),
};
let hour: u32 = wanted_time[0..2].parse().unwrap_or(99);
let minute: u32 = wanted_time[3..5].parse().unwrap_or(99);
if local.hour() != hour || local.minute() != minute {
continue;
}
let day_matches = match schedule {
ScheduledTaskSchedule::Daily { .. } => true,
ScheduledTaskSchedule::Weekly { days, .. } => days
.iter()
.any(|day| weekday_number(*day) == local.weekday().number_from_monday()),
_ => false,
};
if day_matches {
return Ok(millis);
}
}
Err(AppError::Invalid(
"could not resolve next scheduled occurrence".to_owned(),
))
}
fn weekday_number(day: Weekday) -> u32 {
match day {
Weekday::Monday => 1,
Weekday::Tuesday => 2,
Weekday::Wednesday => 3,
Weekday::Thursday => 4,
Weekday::Friday => 5,
Weekday::Saturday => 6,
Weekday::Sunday => 7,
}
}
fn validate_relative_cwd(cwd: Option<&str>) -> Result<(), AppError> {
let value = cwd.unwrap_or(".").trim();
let path = Path::new(value);
if value.is_empty()
|| path.is_absolute()
|| path.components().any(|c| {
matches!(
c,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
{
return Err(AppError::Invalid(
"scheduled task cwd must be project-relative and cannot contain `..`".to_owned(),
));
}
Ok(())
}
async fn confined_cwd(
fs: &dyn FileSystem,
root: &ProjectPath,
cwd: Option<&str>,
) -> Result<ProjectPath, AppError> {
validate_relative_cwd(cwd)?;
let root_canonical = fs
.canonicalize(&RemotePath::new(root.as_str().to_owned()))
.await
.map_err(|_| AppError::NotFound("scheduled task project is inaccessible".to_owned()))?;
let candidate = PathBuf::from(root.as_str()).join(cwd.unwrap_or("."));
let candidate_canonical = fs
.canonicalize(&RemotePath::new(candidate.to_string_lossy().into_owned()))
.await
.map_err(|_| {
AppError::NotFound("scheduled task working directory is inaccessible".to_owned())
})?;
if !Path::new(candidate_canonical.as_str()).starts_with(Path::new(root_canonical.as_str())) {
return Err(AppError::Invalid(
"scheduled task cwd escapes the project root through a symlink".to_owned(),
));
}
ProjectPath::new(candidate_canonical.as_str().to_owned())
.map_err(|e| AppError::Invalid(e.to_string()))
}
fn confined_command_sandbox(root: &ProjectPath) -> SandboxPlan {
let mut allowed = vec![PathGrant {
abs_root: root.as_str().to_owned(),
access: PathAccess::RO | PathAccess::RW | PathAccess::EXEC,
}];
// Runtime/toolchain roots are read/execute-only; only the project can be mutated.
for system_root in ["/bin", "/usr", "/lib", "/lib64", "/etc"] {
allowed.push(PathGrant {
abs_root: system_root.to_owned(),
access: PathAccess::RO | PathAccess::EXEC,
});
}
SandboxPlan {
allowed,
default_posture: Posture::Deny,
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{NaiveDate, TimeZone};
#[test]
fn interval_is_strictly_future_and_never_catches_up() {
let next = next_occurrence(
&ScheduledTaskSchedule::Interval { every_minutes: 5 },
"UTC",
1_000,
)
.unwrap();
assert_eq!(next, 301_000);
}
#[test]
fn daily_schedule_uses_iana_timezone() {
let paris: Tz = "Europe/Paris".parse().unwrap();
let after = paris
.from_local_datetime(
&NaiveDate::from_ymd_opt(2026, 7, 1)
.unwrap()
.and_hms_opt(8, 59, 30)
.unwrap(),
)
.single()
.unwrap()
.timestamp_millis() as u64;
let expected = paris
.from_local_datetime(
&NaiveDate::from_ymd_opt(2026, 7, 1)
.unwrap()
.and_hms_opt(9, 0, 0)
.unwrap(),
)
.single()
.unwrap()
.timestamp_millis() as u64;
assert_eq!(
next_occurrence(
&ScheduledTaskSchedule::Daily {
time: "09:00".to_owned()
},
"Europe/Paris",
after
)
.unwrap(),
expected
);
}
#[test]
fn cwd_rejects_absolute_and_parent_traversal() {
assert!(validate_relative_cwd(Some("/tmp")).is_err());
assert!(validate_relative_cwd(Some("build/../outside")).is_err());
assert!(validate_relative_cwd(Some("build/cache")).is_ok());
}
}

View File

@ -530,6 +530,9 @@ struct StructuredEntry {
agent_id: AgentId,
/// La cellule (feuille de layout) qui héberge actuellement la vue.
node_id: NodeId,
/// The launch resumed a logical conversation already linked to the cell.
/// A fresh in-memory chat bridge therefore cannot provide its transcript.
transcript_restore_expected: bool,
/// Launch-scoped managed-temp handle retained for release.
managed_temp_handle: Option<String>,
/// Optional local-model-server usage guard retained for the live session.
@ -654,6 +657,7 @@ impl StructuredSessions {
session,
agent_id,
node_id,
transcript_restore_expected: false,
managed_temp_handle,
_model_server_guard: model_server_guard,
},
@ -688,6 +692,30 @@ impl StructuredSessions {
})
}
/// Records whether this runtime was opened from an existing logical conversation.
pub fn set_transcript_restore_expected(&self, id: SessionId, expected: bool) {
if let Ok(mut entries) = self.entries.lock() {
if let Some(entry) = entries.get_mut(&id) {
entry.transcript_restore_expected = expected;
}
}
}
/// Returns whether an empty volatile scrollback means a linked transcript
/// should have been restored, rather than a genuinely fresh conversation.
#[must_use]
pub fn transcript_restore_expected(&self, id: &SessionId) -> bool {
self.entries
.lock()
.ok()
.and_then(|entries| {
entries
.get(id)
.map(|entry| entry.transcript_restore_expected)
})
.unwrap_or(false)
}
/// Enregistre une session assistant ticket éphémère, keyée par `issue_ref`.
///
/// Retourne l'éventuelle session remplacée pour que l'appelant puisse la fermer

View File

@ -5038,9 +5038,13 @@ async fn custom_chat_codex_resume_recomputes_project_and_managed_tmp_writable_ro
let mut input = launch_input_with_project_root(agent.id, &root);
input.require_structured = true;
input.conversation_id = Some("pair-conversation-306".to_owned());
launch.execute(input).await.expect("structured launch");
let out = launch.execute(input).await.expect("structured launch");
assert!(pty.spawns().is_empty());
assert!(
structured.transcript_restore_expected(&out.session.id),
"a structured resume from a linked cell must expose that transcript restoration was expected"
);
let env = factory.envs().remove(0);
let env: HashMap<_, _> = env.into_iter().collect();
assert_eq!(env.get("TMPDIR"), Some(&tmp));

View File

@ -122,6 +122,18 @@ fn structured_insert_resolve_and_remove() {
assert!(reg.session_for_agent(&a).is_none());
}
#[test]
fn structured_transcript_restore_expectation_defaults_false_and_can_be_marked() {
let reg = StructuredSessions::new();
let session_id = sid(1);
reg.insert(fake(session_id), aid(10), nid(100));
assert!(!reg.transcript_restore_expected(&session_id));
reg.set_transcript_restore_expected(session_id, true);
assert!(reg.transcript_restore_expected(&session_id));
assert!(!reg.transcript_restore_expected(&sid(999)));
}
#[test]
fn structured_meta_for_session_resolves_agent_node_and_conversation() {
// LS7 (§21.10) : le tap niveau 1 n'a que le `SessionId` du tour et doit retrouver

View File

@ -3917,11 +3917,32 @@ pub enum ReplyChunk {
},
}
/// Reason why a successful structured attach has no replayable turns.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ConversationTranscriptUnavailableReasonDto {
/// A linked conversation was resumed, but the volatile live transcript was absent.
MissingTranscript,
/// The session is fresh and has not produced a conversation turn yet.
EmptyConversation,
}
impl ConversationTranscriptUnavailableReasonDto {
/// Returns an additive reason only when no replayable chunk exists.
#[must_use]
pub fn for_scrollback(is_empty: bool, transcript_restore_expected: bool) -> Option<Self> {
is_empty.then_some(if transcript_restore_expected {
Self::MissingTranscript
} else {
Self::EmptyConversation
})
}
}
/// Response DTO for `reattach_agent_chat`: the retained **conversation
/// scrollback** of a still-live structured session, replayed into the
/// re-mounting `AgentChatView` before the new reply stream is wired (§17.6). The
/// typed twin of [`ReattachResultDto`] (PTY scrollback bytes) — here the
/// scrollback is the ordered list of [`ReplyChunk`]s already streamed.
/// scrollback** of a still-live structured session, replayed before the new
/// reply stream is wired. Unlike a transport error, an empty successful result
/// carries a typed reason so the UI can offer an appropriate recovery action.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReattachChatDto {
@ -3936,6 +3957,32 @@ pub struct ReattachChatDto {
/// replays them to rebuild the visible turns, then receives subsequent chunks
/// over the freshly-registered channel.
pub scrollback: Vec<ReplyChunk>,
/// Structured reason for an empty scrollback. Absent when replayable chunks exist.
#[serde(skip_serializing_if = "Option::is_none")]
pub transcript_unavailable_reason: Option<ConversationTranscriptUnavailableReasonDto>,
}
impl ReattachChatDto {
/// Builds the additive read/open contract from runtime transcript metadata.
#[must_use]
pub fn from_scrollback(
session_id: String,
conversation_id: Option<String>,
scrollback: Vec<ReplyChunk>,
transcript_restore_expected: bool,
) -> Self {
let transcript_unavailable_reason =
ConversationTranscriptUnavailableReasonDto::for_scrollback(
scrollback.is_empty(),
transcript_restore_expected,
);
Self {
session_id,
conversation_id,
scrollback,
transcript_unavailable_reason,
}
}
}
/// Request DTO for `inspect_conversation` (T7).
@ -5751,7 +5798,7 @@ mod ls6_pagination_tests {
turns: vec![
TurnView {
id: tid(1),
at_ms: 1_700_000_000_000,
at_ms: Some(1_700_000_000_000),
role: TurnRole::Prompt,
source: TurnSource::Human,
text: big.clone(),
@ -5759,7 +5806,7 @@ mod ls6_pagination_tests {
},
TurnView {
id: tid(2),
at_ms: 1_700_000_000_001,
at_ms: Some(1_700_000_000_001),
role: TurnRole::Response,
source: TurnSource::Agent {
agent_id: domain::AgentId::from_uuid(uuid::Uuid::from_u128(7)),
@ -5822,7 +5869,8 @@ mod ls6_pagination_tests {
// ---------------------------------------------------------------------------
use domain::{
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, TaskId,
BackgroundTask, BackgroundTaskKind, BackgroundTaskOrigin, BackgroundTaskResult,
BackgroundTaskState, TaskId,
};
/// One first-class background task as seen by the frontend (camelCase wire shape).
@ -5846,6 +5894,8 @@ pub struct BackgroundTaskDto {
/// Kind discriminant (`"command"`, `"headlessRendezvous"`, `"sessionResume"`,
/// `"maintenance"`).
pub kind: String,
/// Manual launch or scheduled occurrence provenance.
pub origin: BackgroundTaskOrigin,
/// Lifecycle state (`"queued"`, `"running"`, `"waiting"`, `"completed"`,
/// `"failed"`, `"cancelled"`, `"expired"`).
pub state: String,
@ -5955,6 +6005,7 @@ impl From<BackgroundTask> for BackgroundTaskDto {
project_id: task.project_id.to_string(),
parent_task_id: task.parent_task_id.map(|id| id.to_string()),
kind: background_kind_label(&task.kind).to_owned(),
origin: task.origin.clone(),
state: background_state_label(task.state).to_owned(),
exit_code,
summary,
@ -6000,6 +6051,55 @@ pub fn parse_task_id(raw: &str) -> Result<TaskId, ErrorDto> {
})
}
/// Parses a durable scheduled-task id.
pub fn parse_scheduled_task_id(raw: &str) -> Result<domain::ScheduledTaskId, ErrorDto> {
uuid::Uuid::parse_str(raw)
.map(domain::ScheduledTaskId::from_uuid)
.map_err(|_| ErrorDto {
code: "INVALID".to_owned(),
message: format!("invalid scheduled task id: {raw}"),
})
}
/// Stable scheduled task response; domain serialization already uses camelCase.
pub type ScheduledProjectTaskDto = domain::ScheduledProjectTask;
/// Create request shared by Tauri and HTTP.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateScheduledProjectTaskRequestDto {
pub project_id: String,
pub name: String,
#[serde(default)]
pub description: Option<String>,
pub command: domain::ScheduledTaskCommand,
pub schedule: domain::ScheduledTaskSchedule,
pub timezone: String,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub timeout_ms: Option<u64>,
}
/// Full replacement request with optimistic audit revision.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateScheduledProjectTaskRequestDto {
pub project_id: String,
pub scheduled_task_id: String,
pub name: String,
#[serde(default)]
pub description: Option<String>,
pub command: domain::ScheduledTaskCommand,
pub schedule: domain::ScheduledTaskSchedule,
pub timezone: String,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub timeout_ms: Option<u64>,
pub audit_revision: u64,
}
/// Request DTO for `spawn_background_command`.
///
/// Builds a command-backed [`BackgroundTask`]: the command line runs under the

View File

@ -55,17 +55,18 @@ use application::{
RestoreOpenProjectsOutput, RestoreOpenWindows, RetryBackgroundTask, RetryOpenProjectRestore,
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft,
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveOpenProjectRestoreOrder,
SaveProfile, SessionLimitService, SetActiveLayout, SetAgentMcpToolAllowed, SetPluginEnabled,
SetProjectPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents,
SnapshotRunningAgentsInput, SpawnBackgroundCommand, StopAgentRuntime, StructuredRoutingMode,
StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions,
UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet,
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions,
UpdateProjectOrchestrator, UpdateProjectPermissions, UpdateProjectSettings,
UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, UpdateUserSettings,
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
SaveProfile, ScheduledProjectTaskService, SessionLimitService, SetActiveLayout,
SetAgentMcpToolAllowed, SetPluginEnabled, SetProjectPluginEnabled, SlashCommandRegistry,
SnapshotOpenWindows, SnapshotRunningAgents, SnapshotRunningAgentsInput, SpawnBackgroundCommand,
StopAgentRuntime, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContactable,
UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectOrchestrator,
UpdateProjectPermissions, UpdateProjectSettings, UpdateProjectSystemPermissions, UpdateSkill,
UpdateTemplate, UpdateUserSettings, WakeSessionProvider, WriteMemory, WriteToTerminal,
AGENT_MEMORY_RECALL_BUDGET,
};
use async_trait::async_trait;
use domain::events::AgentRestartPhase;
@ -107,9 +108,9 @@ use infrastructure::{
FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOpenProjectRestoreStore,
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectSettingsStore,
FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore,
FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, FsWindowStateStore,
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
FsProjectStore, FsProviderSessionStore, FsScheduledProjectTaskStore, FsSecretStore,
FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore,
FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner,
@ -2165,6 +2166,8 @@ pub struct BackendCore {
pub background_command_runner: Arc<CommandBackgroundRunner>,
/// Store handle used by `list_background_tasks` to read the task read-model.
pub background_task_store: Arc<dyn BackgroundTaskStore>,
/// Durable definitions plus their runtime one-shot reconciliation.
pub scheduled_project_tasks: Arc<ScheduledProjectTaskService>,
// --- Plugins (#43) ---
/// Review a local plugin package before install.
pub review_plugin_package: Arc<ReviewPluginPackage>,
@ -2470,6 +2473,9 @@ impl BackendCore {
Arc::clone(&fs) as Arc<dyn FileSystem>,
app_data_dir.to_string_lossy().into_owned(),
));
let scheduled_task_store = Arc::new(FsScheduledProjectTaskStore::new(&app_data_dir));
let scheduled_task_store_port =
Arc::clone(&scheduled_task_store) as Arc<dyn domain::ScheduledProjectTaskStore>;
let pair_attempt_limiter = Arc::new(InMemoryPairAttemptLimiter::new(
Arc::clone(&clock) as Arc<dyn Clock>
));
@ -3945,10 +3951,19 @@ impl BackendCore {
}) as Arc<dyn AgentResumer>;
let session_limit_service = Arc::new(SessionLimitService::new(
Arc::clone(&clock) as Arc<dyn Clock>,
scheduler,
Arc::clone(&scheduler),
Arc::clone(&events_port),
resumer,
));
let scheduled_project_tasks = Arc::new(ScheduledProjectTaskService::new(
Arc::clone(&scheduled_task_store_port),
Arc::clone(&store_port),
Arc::clone(&fs_port),
Arc::clone(&scheduler),
Arc::clone(&spawn_background_command),
Arc::clone(&clock) as Arc<dyn Clock>,
Arc::clone(&ids) as Arc<dyn IdGenerator>,
));
let restart_rendezvous = Arc::new(application::RestartRendezvousCoordinator::new());
let self_restart = Arc::new(AppAgentSelfRestarter {
launch_agent: Arc::clone(&launch_agent),
@ -3967,9 +3982,24 @@ impl BackendCore {
// peut tourner sans runtime ambiant). Detache ⇒ vit autant que l'app.
{
let service = Arc::clone(&session_limit_service);
let scheduled = Arc::clone(&scheduled_project_tasks);
spawn_detached(async move {
while let Some(task) = resume_rx.recv().await {
if let Err(e) = service.execute_resume(task).await {
let result = match task {
task @ ScheduledTask::ResumeAgent { .. } => {
service.execute_resume(task).await
}
ScheduledTask::FireProjectTask {
project_id,
scheduled_task_id,
planned_at_ms,
} => {
scheduled
.fire(project_id, scheduled_task_id, planned_at_ms)
.await
}
};
if let Err(e) = result {
// Best-effort : une relance qui échoue ne fige pas le drain.
eprintln!("[session-limit] reprise auto échouée : {e}");
}
@ -4129,7 +4159,8 @@ impl BackendCore {
.with_session_limits(Arc::clone(&session_limit_service))
// Producteur B8 côté agent MCP : `idea_run_in_background` passe par le
// même use case que la commande desktop, sans aller-retour frontend.
.with_spawn_background_command(Arc::clone(&spawn_background_command)),
.with_spawn_background_command(Arc::clone(&spawn_background_command))
.with_scheduled_project_tasks(Arc::clone(&scheduled_project_tasks)),
);
openai_tool_invoker.bind(Arc::new(AppOpenAiToolInvoker::new(
Arc::clone(&orchestrator_service),
@ -4375,6 +4406,7 @@ impl BackendCore {
retry_background_task,
background_command_runner: Arc::clone(&background_runner),
background_task_store: Arc::clone(&background_tasks_port),
scheduled_project_tasks,
review_plugin_package,
install_plugin_from_archive,
install_plugin_from_directory,
@ -4435,6 +4467,10 @@ impl BackendCore {
.execute(application::ReconcileLiveStateInput { project_id })
.await;
let _ = self.reconcile_background_tasks.execute(project_id).await;
let _ = self
.scheduled_project_tasks
.reconcile_project(project_id)
.await;
self.ensure_orchestrator_watch(project);
self.reconcile_claude_run_dirs(project).await;
}
@ -4708,6 +4744,7 @@ impl BackendCore {
/// Symmetrically stops the project's MCP server (its twin) so both entry doors
/// are torn down together.
pub fn stop_orchestrator_watch(&self, project_id: &ProjectId) {
self.scheduled_project_tasks.cancel_project(*project_id);
if let Some(handle) = self
.orchestrator_watchers
.lock()
@ -8797,3 +8834,107 @@ mod open_project_restore_tests {
let _ = std::fs::remove_dir_all(&root);
}
}
#[cfg(test)]
mod ticket305_scheduled_task_tests {
use super::BackendCore;
use application::CreateProjectInput;
use domain::{
AgentId, OrchestratorCommand, ScheduledTaskApproval, ScheduledTaskCommand,
ScheduledTaskSchedule,
};
use uuid::Uuid;
#[tokio::test]
async fn agent_request_is_machine_local_pending_and_cannot_be_enabled_by_agent() {
let app_data = std::env::temp_dir().join(format!("idea-ticket305-app-{}", Uuid::new_v4()));
let root = std::env::temp_dir().join(format!("idea-ticket305-project-{}", Uuid::new_v4()));
std::fs::create_dir_all(&root).unwrap();
let core = BackendCore::build(app_data.clone());
let project = core
.create_project
.execute(CreateProjectInput {
name: "scheduled".to_owned(),
root: root.to_string_lossy().into_owned(),
remote: None,
default_profile_id: None,
})
.await
.unwrap()
.project;
let requester = AgentId::new_random();
let outcome = core
.orchestrator_service
.dispatch(
&project,
OrchestratorCommand::CreateScheduledTask {
requester,
name: "cleanup".to_owned(),
description: Some("remove generated files".to_owned()),
command: ScheduledTaskCommand {
command: "find".to_owned(),
args: vec!["build".to_owned()],
cwd: Some(".".to_owned()),
},
schedule: ScheduledTaskSchedule::Interval { every_minutes: 15 },
timezone: "UTC".to_owned(),
timeout_ms: Some(60_000),
},
)
.await
.unwrap();
let task: domain::ScheduledProjectTask =
serde_json::from_str(outcome.reply.as_deref().unwrap()).unwrap();
assert_eq!(task.approval, ScheduledTaskApproval::Pending);
assert!(!task.enabled);
assert!(task.next_run_at_ms.is_none());
assert!(app_data
.join("scheduled-project-tasks")
.join(format!("{}.json", project.id))
.is_file());
let error = core
.scheduled_project_tasks
.set_enabled(
project.id,
task.id,
true,
domain::ScheduledTaskActor::Agent {
agent_id: requester,
},
)
.await
.unwrap_err();
assert!(error.to_string().contains("only a human can approve"));
let approved = core
.scheduled_project_tasks
.set_enabled(project.id, task.id, true, domain::ScheduledTaskActor::Human)
.await
.unwrap();
let planned_at_ms = approved.next_run_at_ms.unwrap();
core.scheduled_project_tasks
.fire(project.id, task.id, planned_at_ms)
.await
.unwrap();
let fired = core
.scheduled_project_tasks
.get(project.id, task.id)
.await
.unwrap();
let concrete_task = core
.background_task_store
.get(fired.last_task_id.unwrap())
.await
.unwrap()
.unwrap();
assert!(matches!(
concrete_task.origin,
domain::BackgroundTaskOrigin::Scheduled {
scheduled_task_id,
planned_at_ms: occurrence,
} if scheduled_task_id == task.id && occurrence == planned_at_ms
));
let _ = tokio::fs::remove_dir_all(app_data).await;
let _ = tokio::fs::remove_dir_all(root).await;
}
}

View File

@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::conversation::ConversationId;
use crate::ids::{AgentId, ProjectId, RendezvousId, ScheduleId, TaskId};
use crate::ids::{AgentId, ProjectId, RendezvousId, ScheduleId, ScheduledTaskId, TaskId};
use crate::mailbox::TicketId;
/// Maximum length for human-facing task labels.
@ -63,6 +63,26 @@ pub enum BackgroundTaskWakePolicy {
RecordOnly,
}
/// Provenance of one concrete run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(
rename_all = "camelCase",
rename_all_fields = "camelCase",
tag = "kind"
)]
pub enum BackgroundTaskOrigin {
/// Direct/manual launch or legacy task.
#[default]
Manual,
/// Occurrence of a durable scheduled definition.
Scheduled {
/// Durable definition that produced this run.
scheduled_task_id: ScheduledTaskId,
/// Persisted occurrence timestamp represented by this run.
planned_at_ms: u64,
},
}
/// Structural link from a background task to a composite inter-agent rendezvous.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -137,6 +157,9 @@ pub struct BackgroundTask {
pub state: BackgroundTaskState,
/// Completion wake policy.
pub wake_policy: BackgroundTaskWakePolicy,
/// Manual launch or scheduled occurrence provenance.
#[serde(default)]
pub origin: BackgroundTaskOrigin,
/// Creation timestamp, epoch milliseconds.
pub created_at_ms: u64,
/// Last update timestamp, epoch milliseconds.
@ -173,6 +196,7 @@ impl BackgroundTask {
parent_task_id: None,
state: BackgroundTaskState::Queued,
wake_policy,
origin: BackgroundTaskOrigin::Manual,
created_at_ms: now_ms,
updated_at_ms: now_ms,
deadline_ms,
@ -183,6 +207,13 @@ impl BackgroundTask {
Ok(task)
}
/// Attaches immutable run provenance before persistence.
#[must_use]
pub fn with_origin(mut self, origin: BackgroundTaskOrigin) -> Self {
self.origin = origin;
self
}
/// Returns a copy linked to a composite inter-agent rendezvous.
#[must_use]
pub fn with_rendezvous(mut self, link: BackgroundTaskRendezvousLink) -> Self {

View File

@ -111,6 +111,10 @@ typed_id!(
/// [`crate::ports::Scheduler::cancel`].
ScheduleId
);
typed_id!(
/// Identifies a durable project-scoped scheduled task definition.
ScheduledTaskId
);
typed_id!(
/// Identifies a first-class background task.
TaskId

View File

@ -66,6 +66,7 @@ pub mod project_restore;
pub mod readiness;
pub mod remote;
pub mod sandbox;
pub mod scheduled_task;
pub mod session_limit;
pub mod settings;
pub mod skill;
@ -86,7 +87,8 @@ pub use events::RendezvousContext;
pub use ids::{
AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, RendezvousId,
RuntimeAgentKey, ScheduleId, SessionId, SkillId, SprintId, TabId, TaskId, TemplateId, WindowId,
RuntimeAgentKey, ScheduleId, ScheduledTaskId, SessionId, SkillId, SprintId, TabId, TaskId,
TemplateId, WindowId,
};
pub use project::{Project, ProjectPath};
@ -105,10 +107,10 @@ pub use mcp_tool_permissions::{
};
pub use background_task::{
BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskRendezvousLink,
BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy,
BACKGROUND_TASK_LABEL_MAX_CHARS, BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES,
BACKGROUND_TASK_TEXT_MAX_BYTES,
BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskOrigin,
BackgroundTaskRendezvousLink, BackgroundTaskResult, BackgroundTaskState,
BackgroundTaskWakePolicy, BACKGROUND_TASK_LABEL_MAX_CHARS,
BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES, BACKGROUND_TASK_TEXT_MAX_BYTES,
};
pub use chat_attachment::{
@ -259,6 +261,12 @@ pub use sandbox::{
SandboxKind, SandboxPlan, SandboxStatus,
};
pub use scheduled_task::{
ScheduledProjectTask, ScheduledProjectTaskError, ScheduledTaskActor, ScheduledTaskApproval,
ScheduledTaskAuditEntry, ScheduledTaskCommand, ScheduledTaskSandbox, ScheduledTaskSchedule,
Weekday, MIN_SCHEDULE_INTERVAL_MINUTES,
};
pub use settings::{DefaultAgentTerminalInterface, IdeaUserSettings, ProjectSettings, ThemeColor};
pub use orchestrator::{
@ -282,8 +290,8 @@ pub use ports::{
PluginStorageStore, PluginStoreError, PreparedContext, ProcessError, ProcessSpawner,
ProfileStore, ProjectPluginSettingsStore, ProjectSettingsStore, ProjectStore,
ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath,
RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler, SpawnSpec, SprintStore,
SprintStoreError, StoreError, StructuredSessionEnvironment,
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, UserSettingsStore,
WindowStateStore,
RuntimeError, RuntimePermissionProbe, ScheduledProjectTaskStore, ScheduledTask,
ScheduledTaskStoreError, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore,
TemplateStore, UserSettingsStore, WindowStateStore,
};

View File

@ -14,10 +14,11 @@
use serde::{Deserialize, Serialize};
use crate::conversation::ConversationParty;
use crate::ids::{AgentId, NodeId, SkillId};
use crate::ids::{AgentId, NodeId, ScheduledTaskId, SkillId};
use crate::live_state::WorkStatus;
use crate::mailbox::TicketId;
use crate::plugin::{PluginId, PluginSkillId};
use crate::scheduled_task::{ScheduledTaskCommand, ScheduledTaskSchedule};
use crate::skill::{SkillKind, SkillScope};
/// Plugin origin for a resolved assigned skill. `None` on
@ -252,6 +253,27 @@ pub struct OrchestratorRequest {
/// Optional absolute deadline for `background.run`, epoch milliseconds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deadline_ms: Option<u64>,
/// Durable scheduled-task id for scheduled-task read/update/action tools.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scheduled_task_id: Option<String>,
/// Optional description for scheduled-task create/update.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Bounded recurring schedule for scheduled-task create/update.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schedule: Option<ScheduledTaskSchedule>,
/// IANA timezone for scheduled-task create/update.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
/// Desired enabled state.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
/// Per-run timeout in milliseconds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
/// Optimistic audit revision for scheduled-task updates.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audit_revision: Option<u64>,
}
/// A validated orchestrator command — the only thing the application layer acts on.
@ -454,6 +476,74 @@ pub enum OrchestratorCommand {
/// Optional absolute deadline, epoch milliseconds.
deadline_ms: Option<u64>,
},
/// List the current project's durable schedule definitions.
ListScheduledTasks,
/// Read one current-project schedule definition.
ReadScheduledTask {
/// Definition to read.
scheduled_task_id: ScheduledTaskId,
},
/// Request a schedule definition. Agent requests are approval-pending.
CreateScheduledTask {
/// Requesting agent from the authenticated handshake.
requester: AgentId,
/// Display name.
name: String,
/// Optional human review context.
description: Option<String>,
/// Exact invocation without a free-form environment.
command: ScheduledTaskCommand,
/// Bounded recurring schedule.
schedule: ScheduledTaskSchedule,
/// IANA timezone.
timezone: String,
/// Optional per-run deadline duration.
timeout_ms: Option<u64>,
},
/// Replace a definition; agent updates revoke approval.
UpdateScheduledTask {
/// Requesting agent from the authenticated handshake.
requester: AgentId,
/// Definition to replace.
scheduled_task_id: ScheduledTaskId,
/// Display name.
name: String,
/// Optional human review context.
description: Option<String>,
/// Exact invocation without a free-form environment.
command: ScheduledTaskCommand,
/// Bounded recurring schedule.
schedule: ScheduledTaskSchedule,
/// IANA timezone.
timezone: String,
/// Optional per-run deadline duration.
timeout_ms: Option<u64>,
/// Expected revision for optimistic concurrency.
audit_revision: u64,
},
/// Delete a definition without cancelling an already-running run.
DeleteScheduledTask {
/// Requesting agent from the authenticated handshake.
requester: AgentId,
/// Definition to delete.
scheduled_task_id: ScheduledTaskId,
},
/// Run an approved definition now.
RunScheduledTaskNow {
/// Requesting agent from the authenticated handshake.
requester: AgentId,
/// Definition to run.
scheduled_task_id: ScheduledTaskId,
},
/// Enable/disable an approved definition. MCP cannot grant initial approval.
SetScheduledTaskEnabled {
/// Requesting agent from the authenticated handshake.
requester: AgentId,
/// Definition to mutate.
scheduled_task_id: ScheduledTaskId,
/// Requested enabled state.
enabled: bool,
},
}
/// Where IdeA should place a launched agent session.
@ -593,6 +683,73 @@ impl OrchestratorRequest {
.map(str::to_owned),
deadline_ms: self.deadline_ms,
}),
"scheduled_task.list" => Ok(OrchestratorCommand::ListScheduledTasks),
"scheduled_task.read" => Ok(OrchestratorCommand::ReadScheduledTask {
scheduled_task_id: self.require_scheduled_task_id(action)?,
}),
"scheduled_task.create" => Ok(OrchestratorCommand::CreateScheduledTask {
requester: self.require_from(action)?,
name: self.require_name(action)?,
description: self.description.clone(),
command: ScheduledTaskCommand {
command: self.require("command", action, self.command.as_deref())?,
args: self.args.clone(),
cwd: self.cwd.clone(),
},
schedule: self
.schedule
.clone()
.ok_or_else(|| OrchestratorError::MissingField {
action: action.to_owned(),
field: "schedule".to_owned(),
})?,
timezone: self.require("timezone", action, self.timezone.as_deref())?,
timeout_ms: self.timeout_ms,
}),
"scheduled_task.update" => Ok(OrchestratorCommand::UpdateScheduledTask {
requester: self.require_from(action)?,
scheduled_task_id: self.require_scheduled_task_id(action)?,
name: self.require_name(action)?,
description: self.description.clone(),
command: ScheduledTaskCommand {
command: self.require("command", action, self.command.as_deref())?,
args: self.args.clone(),
cwd: self.cwd.clone(),
},
schedule: self
.schedule
.clone()
.ok_or_else(|| OrchestratorError::MissingField {
action: action.to_owned(),
field: "schedule".to_owned(),
})?,
timezone: self.require("timezone", action, self.timezone.as_deref())?,
timeout_ms: self.timeout_ms,
audit_revision: self.audit_revision.ok_or_else(|| {
OrchestratorError::MissingField {
action: action.to_owned(),
field: "auditRevision".to_owned(),
}
})?,
}),
"scheduled_task.delete" => Ok(OrchestratorCommand::DeleteScheduledTask {
requester: self.require_from(action)?,
scheduled_task_id: self.require_scheduled_task_id(action)?,
}),
"scheduled_task.run_now" => Ok(OrchestratorCommand::RunScheduledTaskNow {
requester: self.require_from(action)?,
scheduled_task_id: self.require_scheduled_task_id(action)?,
}),
"scheduled_task.set_enabled" => Ok(OrchestratorCommand::SetScheduledTaskEnabled {
requester: self.require_from(action)?,
scheduled_task_id: self.require_scheduled_task_id(action)?,
enabled: self
.enabled
.ok_or_else(|| OrchestratorError::MissingField {
action: action.to_owned(),
field: "enabled".to_owned(),
})?,
}),
"list_agents" | "agent.list" => Ok(OrchestratorCommand::ListAgents),
"create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill {
name: self.require_name(action)?,
@ -603,6 +760,19 @@ impl OrchestratorRequest {
}
}
fn require_scheduled_task_id(
&self,
action: &str,
) -> Result<ScheduledTaskId, OrchestratorError> {
let value = self.require("scheduledTaskId", action, self.scheduled_task_id.as_deref())?;
uuid::Uuid::parse_str(&value)
.map(ScheduledTaskId::from_uuid)
.map_err(|_| OrchestratorError::MissingField {
action: action.to_owned(),
field: "valid scheduledTaskId".to_owned(),
})
}
/// Parses the optional `scope` field into a [`SkillScope`], defaulting to
/// [`SkillScope::Project`] when absent or empty.
///

View File

@ -1037,6 +1037,16 @@ pub enum ScheduledTask {
/// id, comme le reste du chemin de reprise §15).
conversation_id: Option<String>,
},
/// Fire a durable project task. The application re-reads the definition
/// before execution so stale one-shot payloads cannot bypass updates.
FireProjectTask {
/// Owning project.
project_id: ProjectId,
/// Durable definition id.
scheduled_task_id: crate::ScheduledTaskId,
/// Occurrence this wake-up represents.
planned_at_ms: u64,
},
}
// ---------------------------------------------------------------------------
@ -1272,6 +1282,20 @@ pub enum BackgroundTaskPortError {
Store(String),
}
/// Errors from the machine-local scheduled task store.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ScheduledTaskStoreError {
/// Requested definition is absent.
#[error("scheduled project task not found")]
NotFound,
/// Persisted or supplied data is invalid.
#[error("scheduled project task invalid: {0}")]
Invalid(String),
/// Persistence failed.
#[error("scheduled project task store failed: {0}")]
Store(String),
}
/// Errors from the owner wake port.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum WakeError {
@ -2790,6 +2814,40 @@ pub trait BackgroundTaskStore: Send + Sync {
) -> Result<(), BackgroundTaskPortError>;
}
/// Persistence port for durable project-scoped schedule definitions and audit.
#[async_trait]
pub trait ScheduledProjectTaskStore: Send + Sync {
/// Lists one project's definitions.
async fn list(
&self,
project_id: ProjectId,
) -> Result<Vec<crate::ScheduledProjectTask>, ScheduledTaskStoreError>;
/// Reads one definition.
async fn get(
&self,
project_id: ProjectId,
scheduled_task_id: crate::ScheduledTaskId,
) -> Result<Option<crate::ScheduledProjectTask>, ScheduledTaskStoreError>;
/// Upserts a definition and appends its audit entry atomically.
async fn save(
&self,
task: &crate::ScheduledProjectTask,
audit: crate::ScheduledTaskAuditEntry,
) -> Result<(), ScheduledTaskStoreError>;
/// Deletes a definition and appends its audit entry atomically.
async fn delete(
&self,
project_id: ProjectId,
scheduled_task_id: crate::ScheduledTaskId,
audit: crate::ScheduledTaskAuditEntry,
) -> Result<(), ScheduledTaskStoreError>;
/// Lists enabled, approved definitions whose persisted deadline is due.
async fn list_due(
&self,
now_ms: u64,
) -> Result<Vec<crate::ScheduledProjectTask>, ScheduledTaskStoreError>;
}
/// Execution port for first-class background tasks.
#[async_trait]
pub trait BackgroundTaskRunner: Send + Sync {

View File

@ -0,0 +1,288 @@
//! Durable, project-scoped scheduled task definitions.
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{AgentId, ProjectId, ScheduledTaskId, TaskId};
/// Lowest interval accepted by the V1 scheduler.
pub const MIN_SCHEDULE_INTERVAL_MINUTES: u32 = 5;
/// Maximum command/name/description lengths keep the machine-local document bounded.
pub const SCHEDULED_TASK_NAME_MAX_CHARS: usize = 160;
/// Maximum size accepted for each persisted free-text or command value.
pub const SCHEDULED_TASK_TEXT_MAX_BYTES: usize = 64 * 1024;
/// Day names used by weekly schedules, independent from locale.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Weekday {
/// Monday.
Monday,
/// Tuesday.
Tuesday,
/// Wednesday.
Wednesday,
/// Thursday.
Thursday,
/// Friday.
Friday,
/// Saturday.
Saturday,
/// Sunday.
Sunday,
}
/// V1 bounded schedule. Free-form cron expressions are deliberately absent.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
rename_all = "camelCase",
rename_all_fields = "camelCase",
tag = "kind"
)]
pub enum ScheduledTaskSchedule {
/// Recur after a fixed number of minutes.
Interval {
/// Interval length, subject to [`MIN_SCHEDULE_INTERVAL_MINUTES`].
every_minutes: u32,
},
/// Recur once each local calendar day.
Daily {
/// Local wall-clock time formatted as `HH:mm`.
time: String,
},
/// Recur on selected local weekdays.
Weekly {
/// Non-empty set of distinct weekdays.
days: Vec<Weekday>,
/// Local wall-clock time formatted as `HH:mm`.
time: String,
},
}
impl ScheduledTaskSchedule {
/// Validates the bounded schedule syntax.
pub fn validate(&self) -> Result<(), ScheduledProjectTaskError> {
match self {
Self::Interval { every_minutes } if *every_minutes < MIN_SCHEDULE_INTERVAL_MINUTES => {
Err(ScheduledProjectTaskError::IntervalTooShort)
}
Self::Interval { .. } => Ok(()),
Self::Daily { time } => validate_time(time),
Self::Weekly { days, time } => {
validate_time(time)?;
if days.is_empty() {
return Err(ScheduledProjectTaskError::EmptyWeekdays);
}
let mut unique = std::collections::HashSet::new();
if !days.iter().all(|day| unique.insert(*day)) {
return Err(ScheduledProjectTaskError::DuplicateWeekday);
}
Ok(())
}
}
}
}
fn validate_time(value: &str) -> Result<(), ScheduledProjectTaskError> {
let Some((hour, minute)) = value.split_once(':') else {
return Err(ScheduledProjectTaskError::InvalidTime(value.to_owned()));
};
if hour.len() != 2
|| minute.len() != 2
|| hour.parse::<u8>().map_or(true, |h| h > 23)
|| minute.parse::<u8>().map_or(true, |m| m > 59)
{
return Err(ScheduledProjectTaskError::InvalidTime(value.to_owned()));
}
Ok(())
}
/// Exact invocation persisted for a scheduled task. Environment injection is
/// intentionally absent in V1.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScheduledTaskCommand {
/// Executable passed directly to the background runner.
pub command: String,
/// Literal argument vector; no shell interpolation is performed here.
#[serde(default)]
pub args: Vec<String>,
/// Project-relative working directory (`.` by default).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
}
/// Actor that created or last updated a schedule.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
rename_all = "camelCase",
rename_all_fields = "camelCase",
tag = "kind"
)]
pub enum ScheduledTaskActor {
/// A human used a trusted application surface.
Human,
/// An agent used its requester-scoped MCP surface.
Agent {
/// Requesting agent identity.
agent_id: AgentId,
},
/// An internal scheduler or reconciliation action.
System,
}
/// Explicit approval state prevents agent-originated definitions from running silently.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ScheduledTaskApproval {
/// A human approved this exact definition.
Approved,
/// An agent-created or modified definition awaits human review.
Pending,
/// A human explicitly rejected the proposal.
Rejected,
}
/// Fixed V1 permission profile: project files may be modified, while filesystem
/// access outside the project is denied except read/execute system toolchains.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum ScheduledTaskSandbox {
/// Confined automatic project maintenance.
#[default]
ProjectReadWrite,
}
/// Durable recurring intent, distinct from every concrete [`crate::BackgroundTask`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScheduledProjectTask {
/// Stable definition identity.
pub id: ScheduledTaskId,
/// Owning project.
pub project_id: ProjectId,
/// Human-readable task name.
pub name: String,
/// Optional explanation shown during review.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Whether the next occurrence may be armed.
pub enabled: bool,
/// Human approval state for this exact revision.
pub approval: ScheduledTaskApproval,
/// Explicit, immutable V1 sandbox profile used by every run.
#[serde(default)]
pub sandbox: ScheduledTaskSandbox,
/// Exact command definition, with no free-form environment.
pub command: ScheduledTaskCommand,
/// Bounded recurring schedule.
pub schedule: ScheduledTaskSchedule,
/// IANA timezone used for daily and weekly schedules.
pub timezone: String,
/// Persisted next future occurrence, in Unix milliseconds.
pub next_run_at_ms: Option<u64>,
/// Time the most recent concrete run was created.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_run_at_ms: Option<u64>,
/// Most recently created background task, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_task_id: Option<TaskId>,
/// Creator of the durable definition.
pub created_by: ScheduledTaskActor,
/// Actor responsible for its current revision.
pub updated_by: ScheduledTaskActor,
/// Creation time in Unix milliseconds.
pub created_at_ms: u64,
/// Last mutation time in Unix milliseconds.
pub updated_at_ms: u64,
/// Optimistic concurrency and audit sequence.
pub audit_revision: u64,
/// Hard execution limit for each concrete run.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
}
impl ScheduledProjectTask {
/// Validates domain invariants independent of persistence and runtime I/O.
pub fn validate(&self) -> Result<(), ScheduledProjectTaskError> {
let name = self.name.trim();
if name.is_empty() || name.chars().count() > SCHEDULED_TASK_NAME_MAX_CHARS {
return Err(ScheduledProjectTaskError::InvalidName);
}
if self.command.command.trim().is_empty() {
return Err(ScheduledProjectTaskError::EmptyCommand);
}
if self.command.command.len() > SCHEDULED_TASK_TEXT_MAX_BYTES
|| self
.command
.args
.iter()
.any(|arg| arg.len() > SCHEDULED_TASK_TEXT_MAX_BYTES)
|| self
.description
.as_ref()
.is_some_and(|v| v.len() > SCHEDULED_TASK_TEXT_MAX_BYTES)
{
return Err(ScheduledProjectTaskError::TextTooLong);
}
if self.timezone.trim().is_empty() {
return Err(ScheduledProjectTaskError::InvalidTimezone);
}
if self.enabled && self.approval != ScheduledTaskApproval::Approved {
return Err(ScheduledProjectTaskError::ApprovalRequired);
}
self.schedule.validate()
}
}
/// One append-only audit record stored beside the project definitions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScheduledTaskAuditEntry {
/// Monotonic definition revision associated with the action.
pub revision: u64,
/// Owning project.
pub project_id: ProjectId,
/// Definition affected by the action.
pub scheduled_task_id: ScheduledTaskId,
/// Actor responsible for the action.
pub actor: ScheduledTaskActor,
/// Stable action key.
pub action: String,
/// Human-readable outcome summary.
pub summary: String,
/// Action time in Unix milliseconds.
pub at_ms: u64,
}
/// Invalid scheduled task definition.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ScheduledProjectTaskError {
/// Name is empty or exceeds the length limit.
#[error("scheduled task name must be non-empty and at most 160 characters")]
InvalidName,
/// Executable is empty.
#[error("scheduled task command must not be empty")]
EmptyCommand,
/// One persisted text value exceeds its size limit.
#[error("scheduled task text is too long")]
TextTooLong,
/// Interval is below the V1 minimum.
#[error("scheduled task interval must be at least 5 minutes")]
IntervalTooShort,
/// Weekly schedule does not select any day.
#[error("weekly schedule must contain at least one day")]
EmptyWeekdays,
/// Weekly schedule repeats a day.
#[error("weekly schedule contains a duplicate day")]
DuplicateWeekday,
/// Daily or weekly wall-clock time is not valid `HH:mm`.
#[error("invalid HH:mm schedule time `{0}`")]
InvalidTime(String),
/// Timezone is absent or invalid.
#[error("timezone must be a valid non-empty IANA timezone")]
InvalidTimezone,
/// Enabled definition has not been approved by a human.
#[error("scheduled task requires human approval before it can be enabled")]
ApprovalRequired,
}

View File

@ -3,8 +3,9 @@
//! The sink is the durable boundary between a runner completion and later
//! mailbox/wake work: it writes the terminal task state to the
//! [`BackgroundTaskStore`](domain::ports::BackgroundTaskStore) first, then emits
//! a lightweight "ready to deliver" signal. It never wakes an agent and never
//! enqueues mailbox items; B4/B5 consume the ready signal.
//! a lightweight "ready to deliver" signal only for `WakeOwner` tasks.
//! `RecordOnly` completions are durably marked delivered without a signal. The
//! sink never wakes an agent or enqueues mailbox items; B4/B5 consume signals.
use std::collections::HashSet;
use std::sync::Arc;
@ -36,6 +37,11 @@ pub struct BackgroundTaskReadyToDeliver {
pub enum BackgroundCompletionSinkOutcome {
/// Completion was persisted and signalled as ready to deliver.
PersistedAndSignalled(BackgroundTaskReadyToDeliver),
/// Completion was persisted silently according to the `RecordOnly` policy.
PersistedRecordOnly {
/// Persisted task id.
task_id: TaskId,
},
/// Task was already terminal, so the duplicate completion was ignored.
IgnoredAlreadyTerminal {
/// Ignored task id.
@ -64,7 +70,7 @@ pub enum BackgroundCompletionSinkError {
ReadySignalClosed,
}
/// Consumes runner completions, persists terminal state, then signals delivery.
/// Consumes runner completions, persists terminal state, then applies wake policy.
pub struct BackgroundCompletionSink {
store: Arc<dyn BackgroundTaskStore>,
ready: UnboundedSender<BackgroundTaskReadyToDeliver>,
@ -154,9 +160,20 @@ impl BackgroundCompletionSink {
});
}
let terminal = task.complete(completion.result).map_err(|e| {
let mut terminal = task.complete(completion.result).map_err(|e| {
BackgroundCompletionSinkError::Store(BackgroundTaskPortError::Invalid(e.to_string()))
})?;
if terminal.wake_policy == domain::BackgroundTaskWakePolicy::RecordOnly {
terminal = terminal.mark_completion_delivered().map_err(|e| {
BackgroundCompletionSinkError::Store(BackgroundTaskPortError::Invalid(
e.to_string(),
))
})?;
self.store.save(&terminal).await?;
return Ok(BackgroundCompletionSinkOutcome::PersistedRecordOnly {
task_id: terminal.id,
});
}
self.store.save(&terminal).await?;
let ready = BackgroundTaskReadyToDeliver {

View File

@ -120,9 +120,9 @@ pub use store::{
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore,
FsMcpToolPermissionStore, FsMemoryStore, FsOpenProjectRestoreStore, FsPermissionStore,
FsProfileStore, FsProjectSettingsStore, FsProjectStore, FsSecretStore, FsSkillStore,
FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, FsWindowStateStore,
HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder,
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
FsProfileStore, FsProjectSettingsStore, FsProjectStore, FsScheduledProjectTaskStore,
FsSecretStore, FsSkillStore, FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore,
FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo,
StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
};

View File

@ -46,7 +46,7 @@ const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
/// Compact MCP instructions advertised at initialization. The first 512 chars are
/// intentionally self-contained because some clients truncate this field.
const MCP_INSTRUCTIONS: &str = "Use IdeA tools by intent: ticket work -> idea_ticket_*; ask/delegate to one agent -> idea_ask_agent, several -> idea_ask_agents; durable project facts -> idea_memory_*; project/agent context -> idea_context_*. Then use idea_skill_* for assigned workflows, idea_template_* for agent templates, idea_workstate_* for live status, idea_run_in_background/background tools for long commands, agent lifecycle tools to launch/stop/resume/swap agents, and sprint tools for planning.";
const MCP_INSTRUCTIONS: &str = "Use IdeA tools by intent: ticket work -> idea_ticket_*; ask/delegate to one agent -> idea_ask_agent, several -> idea_ask_agents; durable project facts -> idea_memory_*; project/agent context -> idea_context_*; recurring project maintenance -> idea_scheduled_task_* (never system cron). Then use idea_skill_* for assigned workflows, idea_template_* for agent templates, idea_workstate_* for live status, idea_run_in_background/background tools for long commands, agent lifecycle tools to launch/stop/resume/swap agents, and sprint tools for planning.";
/// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`].
///

View File

@ -61,6 +61,8 @@ pub const READ_ONLY_TOOLS: &[&str] = &[
"idea_template_list",
"idea_template_read",
"idea_managed_temp_resolve",
"idea_scheduled_task_list",
"idea_scheduled_task_read",
];
/// Canonical write/action MCP tools denied by default.
@ -99,6 +101,11 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
"idea_managed_temp_gc",
"idea_plugin_install_from_directory",
"idea_plugin_reload",
"idea_scheduled_task_create",
"idea_scheduled_task_update",
"idea_scheduled_task_delete",
"idea_scheduled_task_run_now",
"idea_scheduled_task_set_enabled",
];
/// All MCP tool names that have an explicit access classification.
@ -158,6 +165,13 @@ pub fn tool_returns_reply(tool: &str) -> bool {
| "idea_run_in_background"
| "idea_restart_self"
| "idea_workstate_read"
| "idea_scheduled_task_list"
| "idea_scheduled_task_read"
| "idea_scheduled_task_create"
| "idea_scheduled_task_update"
| "idea_scheduled_task_delete"
| "idea_scheduled_task_run_now"
| "idea_scheduled_task_set_enabled"
) || is_ticket_tool(tool)
|| is_template_tool(tool)
|| is_managed_temp_tool(tool)
@ -342,6 +356,41 @@ pub fn catalogue() -> Vec<ToolDef> {
"additionalProperties": false
}),
},
ToolDef {
name: "idea_scheduled_task_list",
description: "List durable scheduled tasks for the current project, including approval and next/last run state.",
input_schema: json!({"type":"object","properties":{},"additionalProperties":false}),
},
ToolDef {
name: "idea_scheduled_task_read",
description: "Read one durable scheduled task from the current project.",
input_schema: json!({"type":"object","properties":{"scheduledTaskId":{"type":"string","format":"uuid"}},"required":["scheduledTaskId"],"additionalProperties":false}),
},
ToolDef {
name: "idea_scheduled_task_create",
description: "Request a recurring project command. Agent-created tasks are saved disabled and pending human approval; this tool never activates them silently.",
input_schema: scheduled_mutation_schema(false),
},
ToolDef {
name: "idea_scheduled_task_update",
description: "Replace a scheduled task proposal. Agent updates disable it and require fresh human approval.",
input_schema: scheduled_mutation_schema(true),
},
ToolDef {
name: "idea_scheduled_task_delete",
description: "Delete a scheduled definition. An already-running BackgroundTask is not interrupted.",
input_schema: json!({"type":"object","properties":{"scheduledTaskId":{"type":"string","format":"uuid"}},"required":["scheduledTaskId"],"additionalProperties":false}),
},
ToolDef {
name: "idea_scheduled_task_run_now",
description: "Run an approved scheduled task now using its exact confined command and sandbox.",
input_schema: json!({"type":"object","properties":{"scheduledTaskId":{"type":"string","format":"uuid"}},"required":["scheduledTaskId"],"additionalProperties":false}),
},
ToolDef {
name: "idea_scheduled_task_set_enabled",
description: "Suspend or resume an already-approved scheduled task. This cannot approve an agent proposal.",
input_schema: json!({"type":"object","properties":{"scheduledTaskId":{"type":"string","format":"uuid"},"enabled":{"type":"boolean"}},"required":["scheduledTaskId","enabled"],"additionalProperties":false}),
},
ToolDef {
name: "idea_launch_agent",
description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \
@ -595,6 +644,61 @@ pub fn map_tool_call(
deadline_ms: optional_u64(args.get("deadline_ms"), name)?,
..base()
},
"idea_scheduled_task_list" => OrchestratorRequest {
request_type: Some("scheduled_task.list".to_owned()),
..base()
},
"idea_scheduled_task_read" => OrchestratorRequest {
request_type: Some("scheduled_task.read".to_owned()),
scheduled_task_id: s("scheduledTaskId"),
..base()
},
"idea_scheduled_task_create" | "idea_scheduled_task_update" => {
let is_update = name.ends_with("update");
OrchestratorRequest {
request_type: Some(
if is_update {
"scheduled_task.update"
} else {
"scheduled_task.create"
}
.to_owned(),
),
requested_by: Some(requester.to_owned()),
scheduled_task_id: s("scheduledTaskId"),
name: s("name"),
description: s("description"),
command: s("command"),
args: string_array(args.get("args"), name)?,
cwd: s("cwd"),
timezone: s("timezone"),
schedule: args
.get("schedule")
.cloned()
.map(serde_json::from_value)
.transpose()
.map_err(|_| ToolMapError::BadArguments(name.to_owned()))?,
timeout_ms: optional_u64(args.get("timeoutMs"), name)?,
audit_revision: optional_u64(args.get("auditRevision"), name)?,
..base()
}
}
"idea_scheduled_task_delete"
| "idea_scheduled_task_run_now"
| "idea_scheduled_task_set_enabled" => OrchestratorRequest {
request_type: Some(
match name {
"idea_scheduled_task_delete" => "scheduled_task.delete",
"idea_scheduled_task_run_now" => "scheduled_task.run_now",
_ => "scheduled_task.set_enabled",
}
.to_owned(),
),
requested_by: Some(requester.to_owned()),
scheduled_task_id: s("scheduledTaskId"),
enabled: args.get("enabled").and_then(Value::as_bool),
..base()
},
"idea_stop_agent" => OrchestratorRequest {
request_type: Some("agent.stop".to_owned()),
requested_by: Some(requester.to_owned()),
@ -713,9 +817,44 @@ fn base() -> OrchestratorRequest {
args: Vec::new(),
cwd: None,
deadline_ms: None,
scheduled_task_id: None,
description: None,
schedule: None,
timezone: None,
enabled: None,
timeout_ms: None,
audit_revision: None,
}
}
fn scheduled_mutation_schema(update: bool) -> Value {
let mut required = vec![
json!("name"),
json!("command"),
json!("schedule"),
json!("timezone"),
];
if update {
required.insert(0, json!("scheduledTaskId"));
required.push(json!("auditRevision"));
}
json!({
"type":"object",
"properties":{
"scheduledTaskId":{"type":"string","format":"uuid"}, "name":{"type":"string"}, "description":{"type":"string"},
"command":{"type":"string"}, "args":{"type":"array","items":{"type":"string"}},
"cwd":{"type":"string","description":"Project-relative working directory; absolute paths and .. are rejected."},
"schedule":{"oneOf":[
{"type":"object","properties":{"kind":{"const":"interval"},"everyMinutes":{"type":"integer","minimum":5}},"required":["kind","everyMinutes"],"additionalProperties":false},
{"type":"object","properties":{"kind":{"const":"daily"},"time":{"type":"string","pattern":"^[0-2][0-9]:[0-5][0-9]$"}},"required":["kind","time"],"additionalProperties":false},
{"type":"object","properties":{"kind":{"const":"weekly"},"days":{"type":"array","minItems":1,"items":{"type":"string","enum":["monday","tuesday","wednesday","thursday","friday","saturday","sunday"]}},"time":{"type":"string","pattern":"^[0-2][0-9]:[0-5][0-9]$"}},"required":["kind","days","time"],"additionalProperties":false}
]},
"timezone":{"type":"string","description":"IANA timezone, for example Europe/Paris."},
"timeoutMs":{"type":"integer","minimum":1}, "auditRevision":{"type":"integer","minimum":1}
}, "required":required, "additionalProperties":false
})
}
fn reject_unknown_keys(
tool: &str,
args: &serde_json::Map<String, Value>,
@ -781,6 +920,38 @@ mod tests {
map_tool_call(name, args, "")
}
#[test]
fn scheduled_create_is_write_action_and_carries_requester_identity() {
assert_eq!(
tool_access("idea_scheduled_task_create"),
Some(McpToolAccess::WriteAction)
);
let command = map_tool_call(
"idea_scheduled_task_create",
&json!({
"name":"cleanup", "command":"find", "args":["build"], "cwd":".",
"schedule":{"kind":"interval","everyMinutes":15}, "timezone":"Europe/Paris"
}),
REQ,
)
.unwrap();
assert!(matches!(command, OrchestratorCommand::CreateScheduledTask {
requester, schedule: domain::ScheduledTaskSchedule::Interval { every_minutes: 15 }, ..
} if requester.to_string() == REQ));
}
#[test]
fn scheduled_read_tools_are_read_only() {
assert_eq!(
tool_access("idea_scheduled_task_list"),
Some(McpToolAccess::Read)
);
assert_eq!(
tool_access("idea_scheduled_task_read"),
Some(McpToolAccess::Read)
);
}
#[test]
fn catalogue_tools_have_explicit_read_or_write_access() {
let catalogue_names = catalogue()

View File

@ -258,7 +258,10 @@ mod tests {
// `ScheduledTask` est mono-variante : déstructuration directe (pas de `if let`).
let ScheduledTask::ResumeAgent {
conversation_id, ..
} = task;
} = task
else {
panic!("expected resume task")
};
got.push(conversation_id.expect("conv présent"));
}
got.sort();
@ -288,7 +291,10 @@ mod tests {
.expect("canal ouvert");
let ScheduledTask::ResumeAgent {
conversation_id, ..
} = task;
} = task
else {
panic!("expected resume task")
};
got.push(conversation_id.expect("conv présent"));
}
got.sort();

View File

@ -16,6 +16,7 @@ mod permission;
mod profile;
mod project;
mod project_plugin;
mod scheduled_task;
mod secrets;
mod settings;
mod skill;
@ -44,6 +45,7 @@ pub use permission::FsPermissionStore;
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
pub use project::FsProjectStore;
pub use project_plugin::FsProjectPluginSettingsStore;
pub use scheduled_task::FsScheduledProjectTaskStore;
pub use secrets::FsSecretStore;
pub use settings::{FsProjectSettingsStore, FsUserSettingsStore};
pub use skill::FsSkillStore;

View File

@ -0,0 +1,282 @@
//! Machine-local JSON persistence for project scheduled task definitions.
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use domain::{
ProjectId, ScheduledProjectTask, ScheduledProjectTaskStore, ScheduledTaskApproval,
ScheduledTaskAuditEntry, ScheduledTaskId, ScheduledTaskStoreError,
};
const DOCUMENT_VERSION: u32 = 1;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Document {
version: u32,
project_id: ProjectId,
#[serde(default)]
tasks: Vec<ScheduledProjectTask>,
#[serde(default)]
audit: Vec<ScheduledTaskAuditEntry>,
}
/// JSON store rooted at `<app_data_dir>/scheduled-project-tasks`.
pub struct FsScheduledProjectTaskStore {
dir: PathBuf,
lock: Mutex<()>,
}
impl FsScheduledProjectTaskStore {
/// Creates a store rooted at `<app_data_dir>/scheduled-project-tasks`.
#[must_use]
pub fn new(app_data_dir: impl AsRef<Path>) -> Self {
Self {
dir: app_data_dir.as_ref().join("scheduled-project-tasks"),
lock: Mutex::new(()),
}
}
/// Returns the machine-local storage directory.
#[must_use]
pub fn dir(&self) -> &Path {
&self.dir
}
fn path(&self, project_id: ProjectId) -> PathBuf {
self.dir.join(format!("{project_id}.json"))
}
async fn read_unlocked(
&self,
project_id: ProjectId,
) -> Result<Document, ScheduledTaskStoreError> {
let path = self.path(project_id);
let bytes = match tokio::fs::read(&path).await {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(Document {
version: DOCUMENT_VERSION,
project_id,
tasks: Vec::new(),
audit: Vec::new(),
})
}
Err(err) => return Err(store_err(err)),
};
let document: Document = serde_json::from_slice(&bytes)
.map_err(|err| ScheduledTaskStoreError::Store(err.to_string()))?;
if document.version != DOCUMENT_VERSION || document.project_id != project_id {
return Err(ScheduledTaskStoreError::Invalid(format!(
"unexpected scheduled task document identity/version in {}",
path.display()
)));
}
for task in &document.tasks {
if task.project_id != project_id {
return Err(ScheduledTaskStoreError::Invalid(
"foreign task in project document".to_owned(),
));
}
task.validate()
.map_err(|err| ScheduledTaskStoreError::Invalid(err.to_string()))?;
}
Ok(document)
}
async fn write_unlocked(&self, document: &Document) -> Result<(), ScheduledTaskStoreError> {
tokio::fs::create_dir_all(&self.dir)
.await
.map_err(store_err)?;
let path = self.path(document.project_id);
let tmp = self.dir.join(format!(
".{}.{}.tmp",
document.project_id,
uuid::Uuid::new_v4()
));
let bytes = serde_json::to_vec_pretty(document)
.map_err(|err| ScheduledTaskStoreError::Store(err.to_string()))?;
tokio::fs::write(&tmp, bytes).await.map_err(store_err)?;
if let Err(err) = tokio::fs::rename(&tmp, &path).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(store_err(err));
}
Ok(())
}
}
fn store_err(err: std::io::Error) -> ScheduledTaskStoreError {
ScheduledTaskStoreError::Store(err.to_string())
}
#[async_trait]
impl ScheduledProjectTaskStore for FsScheduledProjectTaskStore {
async fn list(
&self,
project_id: ProjectId,
) -> Result<Vec<ScheduledProjectTask>, ScheduledTaskStoreError> {
let _guard = self.lock.lock().await;
let mut tasks = self.read_unlocked(project_id).await?.tasks;
tasks.sort_by_key(|task| (task.created_at_ms, task.id));
Ok(tasks)
}
async fn get(
&self,
project_id: ProjectId,
scheduled_task_id: ScheduledTaskId,
) -> Result<Option<ScheduledProjectTask>, ScheduledTaskStoreError> {
let _guard = self.lock.lock().await;
Ok(self
.read_unlocked(project_id)
.await?
.tasks
.into_iter()
.find(|task| task.id == scheduled_task_id))
}
async fn save(
&self,
task: &ScheduledProjectTask,
audit: ScheduledTaskAuditEntry,
) -> Result<(), ScheduledTaskStoreError> {
task.validate()
.map_err(|err| ScheduledTaskStoreError::Invalid(err.to_string()))?;
if audit.project_id != task.project_id
|| audit.scheduled_task_id != task.id
|| audit.revision != task.audit_revision
{
return Err(ScheduledTaskStoreError::Invalid(
"audit identity/revision mismatch".to_owned(),
));
}
let _guard = self.lock.lock().await;
let mut document = self.read_unlocked(task.project_id).await?;
if let Some(existing) = document
.tasks
.iter_mut()
.find(|existing| existing.id == task.id)
{
if task.audit_revision <= existing.audit_revision {
return Err(ScheduledTaskStoreError::Invalid(
"stale scheduled task revision".to_owned(),
));
}
*existing = task.clone();
} else {
document.tasks.push(task.clone());
}
document.audit.push(audit);
self.write_unlocked(&document).await
}
async fn delete(
&self,
project_id: ProjectId,
scheduled_task_id: ScheduledTaskId,
audit: ScheduledTaskAuditEntry,
) -> Result<(), ScheduledTaskStoreError> {
let _guard = self.lock.lock().await;
let mut document = self.read_unlocked(project_id).await?;
let before = document.tasks.len();
document.tasks.retain(|task| task.id != scheduled_task_id);
if before == document.tasks.len() {
return Err(ScheduledTaskStoreError::NotFound);
}
document.audit.push(audit);
self.write_unlocked(&document).await
}
async fn list_due(
&self,
now_ms: u64,
) -> Result<Vec<ScheduledProjectTask>, ScheduledTaskStoreError> {
let _guard = self.lock.lock().await;
let mut due = Vec::new();
let mut entries = match tokio::fs::read_dir(&self.dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(store_err(err)),
};
while let Some(entry) = entries.next_entry().await.map_err(store_err)? {
if entry.path().extension().and_then(|v| v.to_str()) != Some("json") {
continue;
}
let bytes = tokio::fs::read(entry.path()).await.map_err(store_err)?;
let document: Document = serde_json::from_slice(&bytes)
.map_err(|err| ScheduledTaskStoreError::Store(err.to_string()))?;
due.extend(document.tasks.into_iter().filter(|task| {
task.enabled
&& task.approval == ScheduledTaskApproval::Approved
&& task.next_run_at_ms.is_some_and(|at| at <= now_ms)
}));
}
Ok(due)
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::{ScheduledTaskActor, ScheduledTaskCommand, ScheduledTaskSchedule};
fn task(project_id: ProjectId) -> ScheduledProjectTask {
ScheduledProjectTask {
id: ScheduledTaskId::new_random(),
project_id,
name: "cleanup".to_owned(),
description: None,
enabled: true,
approval: ScheduledTaskApproval::Approved,
sandbox: domain::ScheduledTaskSandbox::ProjectReadWrite,
command: ScheduledTaskCommand {
command: "find".to_owned(),
args: vec!["build".to_owned()],
cwd: Some(".".to_owned()),
},
schedule: ScheduledTaskSchedule::Interval { every_minutes: 15 },
timezone: "UTC".to_owned(),
next_run_at_ms: Some(1_000),
last_run_at_ms: None,
last_task_id: None,
created_by: ScheduledTaskActor::Human,
updated_by: ScheduledTaskActor::Human,
created_at_ms: 1,
updated_at_ms: 1,
audit_revision: 1,
timeout_ms: None,
}
}
#[tokio::test]
async fn persists_in_app_data_project_document_and_lists_due() {
let root =
std::env::temp_dir().join(format!("idea-scheduled-store-{}", uuid::Uuid::new_v4()));
let store = FsScheduledProjectTaskStore::new(&root);
let project_id = ProjectId::new_random();
let task = task(project_id);
store
.save(
&task,
ScheduledTaskAuditEntry {
revision: 1,
project_id,
scheduled_task_id: task.id,
actor: ScheduledTaskActor::Human,
action: "create".to_owned(),
summary: "created".to_owned(),
at_ms: 1,
},
)
.await
.unwrap();
assert_eq!(store.dir(), root.join("scheduled-project-tasks"));
assert_eq!(store.list(project_id).await.unwrap(), vec![task.clone()]);
assert_eq!(store.list_due(999).await.unwrap(), Vec::new());
assert_eq!(store.list_due(1_000).await.unwrap(), vec![task]);
let _ = tokio::fs::remove_dir_all(root).await;
}
}

View File

@ -70,6 +70,23 @@ fn running_task(id: u128) -> BackgroundTask {
.unwrap()
}
fn running_record_only_task(id: u128) -> BackgroundTask {
BackgroundTask::new(
task_id(id),
project_id(10),
agent_id(100),
BackgroundTaskKind::Command {
label: format!("task-{id}"),
},
BackgroundTaskWakePolicy::RecordOnly,
1_000,
None,
)
.unwrap()
.transition(BackgroundTaskState::Running, 1_010)
.unwrap()
}
fn success_completion(id: u128) -> BackgroundTaskCompletion {
BackgroundTaskCompletion {
task_id: task_id(id),
@ -138,6 +155,30 @@ async fn persistence_happens_before_ready_signal() {
assert_eq!(*order.lock().unwrap(), vec!["save", "ready"]);
}
#[tokio::test]
async fn record_only_completion_is_persisted_delivered_without_signal() {
let tmp = TempDir::new();
let store = Arc::new(FsBackgroundTaskStore::new(&tmp.project_path()));
let task = running_record_only_task(2);
store.create(&task).await.unwrap();
let (ready_tx, mut ready_rx) = unbounded_channel();
let sink = BackgroundCompletionSink::new(store.clone(), ready_tx);
let outcome = sink
.process_completion(success_completion(2))
.await
.unwrap();
assert_eq!(
outcome,
BackgroundCompletionSinkOutcome::PersistedRecordOnly { task_id: task.id }
);
assert!(ready_rx.try_recv().is_err());
let persisted = store.get(task.id).await.unwrap().unwrap();
assert_eq!(persisted.state, BackgroundTaskState::Completed);
assert!(persisted.completion_delivered);
}
#[tokio::test]
async fn duplicate_completion_same_task_signals_once() {
let tmp = TempDir::new();

View File

@ -77,17 +77,19 @@ use domain::{
use backend::dto::{
create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_attachment_id_dto,
parse_layout_id, parse_link_kind_dto, parse_node_id, parse_profile_id, parse_project_id,
parse_ref_dto, parse_session_id, parse_skill_id, parse_sprint_id_dto, parse_sprint_status_dto,
parse_task_id, parse_template_id, sort_ticket_rows, sprint_version_dto, update_input,
version_dto, AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto,
AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto,
ChangeAgentProfileDto, ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
parse_ref_dto, parse_scheduled_task_id, parse_session_id, parse_skill_id, parse_sprint_id_dto,
parse_sprint_status_dto, parse_task_id, parse_template_id, sort_ticket_rows,
sprint_version_dto, update_input, version_dto, AgentDriftListDto, AgentDto, AgentListDto,
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
ConversationTranscriptUnavailableReasonDto, CreateAgentFromTemplateRequestDto,
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
CreateSkillRequestDto, CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
CreateScheduledProjectTaskRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
DeleteLayoutRequestDto, DeleteLayoutResultDto, DetectProfilesRequestDto,
DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto,
GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto,
@ -117,7 +119,8 @@ use backend::dto::{
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
UpdateProjectSettingsRequestDto, UpdateProjectSystemPermissionsRequestDto,
UpdateSkillRequestDto, UpdateTemplateRequestDto, UserSettingsDto,
UpdateScheduledProjectTaskRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
UserSettingsDto,
};
use backend::events::DomainEventDto;
type PtyChunk = Vec<u8>;
@ -1587,11 +1590,21 @@ async fn send_launch_agent_attached(
output: application::LaunchAgentOutput,
) -> Result<(), ErrorDto> {
if let Some(structured) = output.structured.as_ref() {
let transcript_unavailable_reason =
ConversationTranscriptUnavailableReasonDto::for_scrollback(
true,
state
.app
.structured_sessions
.transcript_restore_expected(&structured.session_id),
);
tx.send(ServerFrame::chat_attached(
&frame.id,
structured,
output.assigned_conversation_id.as_deref(),
output.engine_session_id.as_deref(),
Vec::new(),
transcript_unavailable_reason,
))
.await
.map_err(|_| ErrorDto {
@ -1868,6 +1881,13 @@ async fn ws_attach_chat(
})?;
(sid, session)
};
let transcript_unavailable_reason = ConversationTranscriptUnavailableReasonDto::for_scrollback(
true,
state
.app
.structured_sessions
.transcript_restore_expected(&sid),
);
tx.send(ServerFrame::chat_attached(
&frame.id,
&application::StructuredSessionDescriptor {
@ -1878,6 +1898,8 @@ async fn ws_attach_chat(
},
None,
session.conversation_id().as_deref(),
Vec::new(),
transcript_unavailable_reason,
))
.await
.map_err(|_| ErrorDto {
@ -3513,6 +3535,24 @@ async fn invoke(
"list_background_tasks" => invoke_list_background_tasks(&request.args, &state.app).await,
"cancel_background_task" => invoke_cancel_background_task(&request.args, &state.app).await,
"retry_background_task" => invoke_retry_background_task(&request.args, &state.app).await,
"list_scheduled_project_tasks" => {
invoke_list_scheduled_project_tasks(&request.args, &state.app).await
}
"create_scheduled_project_task" => {
invoke_create_scheduled_project_task(&request.args, &state.app).await
}
"update_scheduled_project_task" => {
invoke_update_scheduled_project_task(&request.args, &state.app).await
}
"delete_scheduled_project_task" => {
invoke_delete_scheduled_project_task(&request.args, &state.app).await
}
"run_scheduled_project_task_now" => {
invoke_run_scheduled_project_task_now(&request.args, &state.app).await
}
"set_scheduled_project_task_enabled" => {
invoke_set_scheduled_project_task_enabled(&request.args, &state.app).await
}
"ticket_create" => invoke_ticket_create(&request.args, &state.app).await,
"ticket_read" => invoke_ticket_read(&request.args, &state.app).await,
"ticket_list" => invoke_ticket_list(&request.args, &state.app).await,
@ -4280,6 +4320,142 @@ async fn invoke_retry_background_task(
serde_json::to_value(BackgroundTaskDto::from(output.task)).map_err(serialization_error)
}
fn required_string_arg<'a>(
args: &'a Value,
field: &str,
command: &str,
) -> Result<&'a str, ErrorDto> {
args.get(field)
.and_then(Value::as_str)
.ok_or_else(|| ErrorDto {
code: "INVALID".to_owned(),
message: format!("{command} requires args.{field}"),
})
}
async fn invoke_list_scheduled_project_tasks(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let project_id = required_string_arg(args, "projectId", "list_scheduled_project_tasks")?;
let tasks = state
.scheduled_project_tasks
.list(parse_project_id(project_id)?)
.await
.map_err(ErrorDto::from)?;
serde_json::to_value(tasks).map_err(serialization_error)
}
async fn invoke_create_scheduled_project_task(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<CreateScheduledProjectTaskRequestDto>(
"create_scheduled_project_task",
args,
)?;
let task = state
.scheduled_project_tasks
.create(application::CreateScheduledProjectTaskInput {
project_id: parse_project_id(&request.project_id)?,
name: request.name,
description: request.description,
command: request.command,
schedule: request.schedule,
timezone: request.timezone,
enabled: request.enabled,
timeout_ms: request.timeout_ms,
actor: domain::ScheduledTaskActor::Human,
})
.await
.map_err(ErrorDto::from)?;
serde_json::to_value(task).map_err(serialization_error)
}
async fn invoke_update_scheduled_project_task(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<UpdateScheduledProjectTaskRequestDto>(
"update_scheduled_project_task",
args,
)?;
let task = state
.scheduled_project_tasks
.update(application::UpdateScheduledProjectTaskInput {
project_id: parse_project_id(&request.project_id)?,
scheduled_task_id: parse_scheduled_task_id(&request.scheduled_task_id)?,
name: request.name,
description: request.description,
command: request.command,
schedule: request.schedule,
timezone: request.timezone,
enabled: request.enabled,
timeout_ms: request.timeout_ms,
audit_revision: request.audit_revision,
actor: domain::ScheduledTaskActor::Human,
})
.await
.map_err(ErrorDto::from)?;
serde_json::to_value(task).map_err(serialization_error)
}
fn scheduled_identity(
args: &Value,
command: &str,
) -> Result<(domain::ProjectId, domain::ScheduledTaskId), ErrorDto> {
Ok((
parse_project_id(required_string_arg(args, "projectId", command)?)?,
parse_scheduled_task_id(required_string_arg(args, "scheduledTaskId", command)?)?,
))
}
async fn invoke_delete_scheduled_project_task(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let (project_id, id) = scheduled_identity(args, "delete_scheduled_project_task")?;
state
.scheduled_project_tasks
.delete(project_id, id, domain::ScheduledTaskActor::Human)
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_run_scheduled_project_task_now(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let (project_id, id) = scheduled_identity(args, "run_scheduled_project_task_now")?;
let task = state
.scheduled_project_tasks
.run_now(project_id, id, domain::ScheduledTaskActor::Human)
.await
.map_err(ErrorDto::from)?;
serde_json::to_value(task).map_err(serialization_error)
}
async fn invoke_set_scheduled_project_task_enabled(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let (project_id, id) = scheduled_identity(args, "set_scheduled_project_task_enabled")?;
let enabled = args
.get("enabled")
.and_then(Value::as_bool)
.ok_or_else(|| ErrorDto {
code: "INVALID".to_owned(),
message: "set_scheduled_project_task_enabled requires args.enabled".to_owned(),
})?;
let task = state
.scheduled_project_tasks
.set_enabled(project_id, id, enabled, domain::ScheduledTaskActor::Human)
.await
.map_err(ErrorDto::from)?;
serde_json::to_value(task).map_err(serialization_error)
}
async fn invoke_ticket_create(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketCreateRequestDto>("ticket_create", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
@ -6068,6 +6244,8 @@ impl ServerFrame {
session: &application::StructuredSessionDescriptor,
assigned_conversation_id: Option<&str>,
engine_session_id: Option<&str>,
scrollback: Vec<ChatChunk>,
transcript_unavailable_reason: Option<ConversationTranscriptUnavailableReasonDto>,
) -> Self {
Self {
kind: "chat.attached".to_owned(),
@ -6081,6 +6259,8 @@ impl ServerFrame {
"cellKind": "chat",
"assignedConversationId": assigned_conversation_id,
"engineSessionId": engine_session_id,
"scrollback": scrollback,
"transcriptUnavailableReason": transcript_unavailable_reason,
}),
}
}
@ -7924,6 +8104,11 @@ mod tests {
"pair-conversation"
);
assert_eq!(attached.payload["engineSessionId"], "engine-session");
assert_eq!(attached.payload["scrollback"], json!([]));
assert_eq!(
attached.payload["transcriptUnavailableReason"],
"emptyConversation"
);
assert_eq!(state.ws_pty_bridge.active_sessions(), 0);
}
@ -7973,6 +8158,11 @@ mod tests {
);
assert_eq!(attached.payload["cellKind"], "chat");
assert_eq!(attached.payload["engineSessionId"], "engine-chat-session");
assert_eq!(attached.payload["scrollback"], json!([]));
assert_eq!(
attached.payload["transcriptUnavailableReason"],
"emptyConversation"
);
assert_eq!(state.ws_chat_bridge.active_sessions(), 1);
}
@ -7996,6 +8186,10 @@ mod tests {
agent_id,
node_id,
);
state
.app
.structured_sessions
.set_transcript_restore_expected(live_session_id, true);
let (tx, mut rx) = mpsc::channel(8);
let owned = Arc::new(Mutex::new(Vec::new()));
@ -8022,6 +8216,11 @@ mod tests {
attached.payload["session"]["sessionId"],
live_session_id.to_string()
);
assert_eq!(attached.payload["scrollback"], json!([]));
assert_eq!(
attached.payload["transcriptUnavailableReason"],
"missingTranscript"
);
assert!(state.ws_chat_bridge.send_output(
&live_session_id,
json!({ "kind": "textDelta", "text": "after restart" })
@ -10347,6 +10546,46 @@ mod tests {
assert_eq!(tasks[0]["state"], "queued");
}
#[tokio::test]
async fn authorized_scheduled_task_invokes_use_camel_case_contract() {
let state = state();
let project_id = create_project_for_test(&state, "Scheduled HTTP").await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (status, created) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"create_scheduled_project_task",
json!({
"request": {
"projectId": project_id,
"name": "cleanup",
"command": { "command": "true", "args": [], "cwd": "." },
"schedule": { "kind": "interval", "everyMinutes": 15 },
"timezone": "UTC",
"enabled": false
}
}),
)
.await;
assert_eq!(status, StatusCode::OK, "create failed: {created}");
assert_eq!(created["projectId"], project_id);
assert_eq!(created["approval"], "approved");
assert_eq!(created["schedule"]["everyMinutes"], 15);
assert!(created.get("scheduled_task_id").is_none());
let (status, listed) = invoke_request_for_test(
state,
&cookie,
"list_scheduled_project_tasks",
json!({ "projectId": project_id }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(listed.as_array().map(Vec::len), Some(1));
assert_eq!(listed[0]["id"], created["id"]);
}
#[tokio::test]
async fn authorized_settings_invoke_round_trips_user_and_project_contracts() {
let state = state();