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:
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
762
crates/application/src/scheduled_task/mod.rs
Normal file
762
crates/application/src/scheduled_task/mod.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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.
|
||||
///
|
||||
|
||||
@ -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 {
|
||||
|
||||
288
crates/domain/src/scheduled_task.rs
Normal file
288
crates/domain/src/scheduled_task.rs
Normal 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,
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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`].
|
||||
///
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
282
crates/infrastructure/src/store/scheduled_task.rs
Normal file
282
crates/infrastructure/src/store/scheduled_task.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
Reference in New Issue
Block a user