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:
50
Cargo.lock
generated
50
Cargo.lock
generated
@ -98,6 +98,8 @@ name = "application"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"dirs",
|
||||
"domain",
|
||||
"serde",
|
||||
@ -448,6 +450,16 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono-tz"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"phf 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@ -625,7 +637,7 @@ dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf",
|
||||
"phf 0.13.1",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
@ -2963,6 +2975,15 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
|
||||
dependencies = [
|
||||
"phf_shared 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
@ -2970,7 +2991,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
"phf_shared 0.13.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@ -2981,7 +3002,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_shared 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2991,7 +3012,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared",
|
||||
"phf_shared 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3001,12 +3022,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_shared 0.13.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
@ -3714,7 +3744,7 @@ dependencies = [
|
||||
"derive_more",
|
||||
"log",
|
||||
"new_debug_unreachable",
|
||||
"phf",
|
||||
"phf 0.13.1",
|
||||
"phf_codegen",
|
||||
"precomputed-hash",
|
||||
"rustc-hash",
|
||||
@ -4087,7 +4117,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared",
|
||||
"phf_shared 0.13.1",
|
||||
"precomputed-hash",
|
||||
]
|
||||
|
||||
@ -4098,7 +4128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_shared 0.13.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
@ -4477,7 +4507,7 @@ dependencies = [
|
||||
"json-patch",
|
||||
"log",
|
||||
"memchr",
|
||||
"phf",
|
||||
"phf 0.13.1",
|
||||
"plist",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -5348,7 +5378,7 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
|
||||
dependencies = [
|
||||
"phf",
|
||||
"phf 0.13.1",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
|
||||
@ -33,6 +33,8 @@ http = "1"
|
||||
# only local operations (status/commit/branch/checkout/log) are in scope; remote
|
||||
# push/pull and static vendoring for the AppImage are deferred to L9/L11.
|
||||
git2 = { version = "0.20", default-features = false }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
chrono-tz = "0.10"
|
||||
|
||||
# Internal crates
|
||||
domain = { path = "crates/domain" }
|
||||
|
||||
@ -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();
|
||||
|
||||
31
docs/scheduled-project-tasks.md
Normal file
31
docs/scheduled-project-tasks.md
Normal file
@ -0,0 +1,31 @@
|
||||
# Scheduled project tasks
|
||||
|
||||
IdeA owns recurring project maintenance. Agents and users should use scheduled
|
||||
project tasks instead of installing `cron`, `systemd` timers, or executable files
|
||||
inside a repository.
|
||||
|
||||
Definitions are machine-local and persist in
|
||||
`<app_data_dir>/scheduled-project-tasks/<projectId>.json`. They are distinct from
|
||||
`BackgroundTask`: every manual or scheduled occurrence creates a new background
|
||||
task carrying an `origin` (`manual` or `scheduled` with the definition id and
|
||||
planned timestamp).
|
||||
|
||||
Scheduled runs use `RecordOnly`: their terminal state and output remain durable,
|
||||
but a successful completion does not wake an agent or enqueue a completion
|
||||
message.
|
||||
|
||||
V1 accepts only intervals of at least five minutes, daily `HH:mm`, and weekly
|
||||
day/`HH:mm` schedules with an IANA timezone. Missed occurrences are skipped at
|
||||
startup; only the next future occurrence is armed, and only while the project is
|
||||
open. IdeA must be running to trigger tasks.
|
||||
|
||||
The MCP surface is `idea_scheduled_task_list`, `read`, `create`, `update`,
|
||||
`delete`, `run_now`, and `set_enabled`. Read operations are allowed by the native
|
||||
read policy; mutations are write actions. An agent-created or agent-modified
|
||||
definition is always disabled and `pending` human approval. MCP enable cannot
|
||||
grant that approval.
|
||||
|
||||
Commands persist no free-form environment. Their working directory is relative
|
||||
to the project, checked lexically on write and canonically before every run to
|
||||
reject symlink escapes. Runs use a deny-by-default OS sandbox: the project is the
|
||||
only writable root and system toolchain paths are read/execute-only.
|
||||
@ -328,6 +328,7 @@ describe("TauriAgentGateway invoke payloads", () => {
|
||||
cols: 80,
|
||||
cellKind: "chat",
|
||||
assignedConversationId: "conversation-1",
|
||||
transcriptUnavailableReason: "missingTranscript",
|
||||
});
|
||||
|
||||
const out = await new TauriAgentGateway().launchAgentChat("proj-1", "agent-2", {
|
||||
@ -355,6 +356,7 @@ describe("TauriAgentGateway invoke payloads", () => {
|
||||
sessionId: "chat-session-1",
|
||||
cellKind: "chat",
|
||||
assignedConversationId: "conversation-1",
|
||||
transcriptUnavailableReason: "missingTranscript",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -52,6 +52,7 @@ interface LaunchAgentResponse {
|
||||
cellKind?: "chat" | "pty";
|
||||
/** Conversation id minted by this launch (omitted when nothing was assigned). */
|
||||
assignedConversationId?: string;
|
||||
transcriptUnavailableReason?: AgentChatHandle["transcriptUnavailableReason"];
|
||||
}
|
||||
|
||||
function structuredLaunchRoutedToPtyError(
|
||||
@ -269,6 +270,9 @@ export class TauriAgentGateway implements AgentGateway {
|
||||
...(res.assignedConversationId
|
||||
? { assignedConversationId: res.assignedConversationId }
|
||||
: {}),
|
||||
...(res.transcriptUnavailableReason
|
||||
? { transcriptUnavailableReason: res.transcriptUnavailableReason }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -294,12 +294,14 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
|
||||
sessionId: "chat-1",
|
||||
cellKind: "chat",
|
||||
assignedConversationId: "conv-1",
|
||||
transcriptUnavailableReason: "emptyConversation",
|
||||
},
|
||||
}));
|
||||
await expect(launched).resolves.toEqual({
|
||||
sessionId: "chat-1",
|
||||
cellKind: "chat",
|
||||
assignedConversationId: "conv-1",
|
||||
transcriptUnavailableReason: "emptyConversation",
|
||||
});
|
||||
expect(launch.kind).toBe("agent.launch");
|
||||
expect(launch.payload).toEqual({
|
||||
@ -324,12 +326,14 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
|
||||
sessionId: "chat-1",
|
||||
conversationId: "conv-replacement",
|
||||
scrollback: [{ kind: "final", content: "Recovered reply" }],
|
||||
transcriptUnavailableReason: "missingTranscript",
|
||||
},
|
||||
}));
|
||||
await expect(reattached).resolves.toEqual({
|
||||
sessionId: "chat-1",
|
||||
conversationId: "conv-replacement",
|
||||
scrollback: [{ kind: "final", content: "Recovered reply" }],
|
||||
transcriptUnavailableReason: "missingTranscript",
|
||||
});
|
||||
expect(attach.kind).toBe("chat.attach");
|
||||
expect(attach.payload).toEqual({
|
||||
|
||||
@ -155,4 +155,5 @@ export interface ChatAttachedPayload {
|
||||
conversationId?: string;
|
||||
engineSessionId?: string;
|
||||
scrollback?: ReplyChunk[];
|
||||
transcriptUnavailableReason?: "missingTranscript" | "emptyConversation";
|
||||
}
|
||||
|
||||
@ -35,6 +35,7 @@ import {
|
||||
HttpSkillGateway,
|
||||
HttpTemplateGateway,
|
||||
HttpWorkStateGateway,
|
||||
HttpScheduledTaskGateway,
|
||||
} from "./requestResponseGateways";
|
||||
import {
|
||||
HttpAgentGateway,
|
||||
@ -140,6 +141,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
|
||||
device: new HttpDeviceGateway(http),
|
||||
permission: new HttpPermissionGateway(http),
|
||||
workState: new HttpWorkStateGateway(http),
|
||||
scheduledTask: new HttpScheduledTaskGateway(http),
|
||||
conversation: new HttpConversationGateway(http),
|
||||
ticket: new HttpTicketGateway(http, ws),
|
||||
window: new WebWindowGateway(),
|
||||
|
||||
@ -49,6 +49,7 @@ import type {
|
||||
ProfileModelCatalog,
|
||||
ResolvedAgentPermissions,
|
||||
ResolvedAgentSystemPermissions,
|
||||
ScheduledProjectTask,
|
||||
SystemPermissionSet,
|
||||
Skill,
|
||||
SkillScope,
|
||||
@ -78,6 +79,8 @@ import type {
|
||||
SkillGateway,
|
||||
TemplateGateway,
|
||||
WorkStateGateway,
|
||||
ScheduledTaskDefinitionInput,
|
||||
ScheduledTaskGateway,
|
||||
BackgroundTaskAttachment,
|
||||
} from "@/ports";
|
||||
import { normalizeProjectWorkState } from "../workStateNormalization";
|
||||
@ -577,6 +580,45 @@ export class HttpWorkStateGateway implements WorkStateGateway {
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpScheduledTaskGateway implements ScheduledTaskGateway {
|
||||
constructor(private readonly http: HttpInvoker) {}
|
||||
list(projectId: string): Promise<ScheduledProjectTask[]> {
|
||||
return this.http.invoke("list_scheduled_project_tasks", { projectId });
|
||||
}
|
||||
create(projectId: string, input: ScheduledTaskDefinitionInput): Promise<ScheduledProjectTask> {
|
||||
return this.http.invoke("create_scheduled_project_task", {
|
||||
request: { projectId, ...input },
|
||||
});
|
||||
}
|
||||
update(
|
||||
projectId: string,
|
||||
scheduledTaskId: string,
|
||||
auditRevision: number,
|
||||
input: ScheduledTaskDefinitionInput,
|
||||
): Promise<ScheduledProjectTask> {
|
||||
return this.http.invoke("update_scheduled_project_task", {
|
||||
request: { projectId, scheduledTaskId, auditRevision, ...input },
|
||||
});
|
||||
}
|
||||
async delete(projectId: string, scheduledTaskId: string): Promise<void> {
|
||||
await this.http.invoke("delete_scheduled_project_task", { projectId, scheduledTaskId });
|
||||
}
|
||||
runNow(projectId: string, scheduledTaskId: string): Promise<ScheduledProjectTask> {
|
||||
return this.http.invoke("run_scheduled_project_task_now", { projectId, scheduledTaskId });
|
||||
}
|
||||
setEnabled(
|
||||
projectId: string,
|
||||
scheduledTaskId: string,
|
||||
enabled: boolean,
|
||||
): Promise<ScheduledProjectTask> {
|
||||
return this.http.invoke("set_scheduled_project_task_enabled", {
|
||||
projectId,
|
||||
scheduledTaskId,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpConversationGateway implements ConversationGateway {
|
||||
constructor(private readonly http: HttpInvoker) {}
|
||||
async readPage(
|
||||
|
||||
19
frontend/src/adapters/http/scheduledTaskGateway.test.ts
Normal file
19
frontend/src/adapters/http/scheduledTaskGateway.test.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { HttpInvoker } from "./httpInvoker";
|
||||
import { HttpScheduledTaskGateway } from "./requestResponseGateways";
|
||||
|
||||
describe("HttpScheduledTaskGateway", () => {
|
||||
it("forwards the shared HTTP command contract", async () => {
|
||||
const invoke = vi.fn().mockResolvedValue([]);
|
||||
const gateway = new HttpScheduledTaskGateway({ invoke } as unknown as HttpInvoker);
|
||||
await gateway.list("project-1");
|
||||
await gateway.setEnabled("project-1", "schedule-1", true);
|
||||
expect(invoke).toHaveBeenNthCalledWith(1, "list_scheduled_project_tasks", { projectId: "project-1" });
|
||||
expect(invoke).toHaveBeenNthCalledWith(2, "set_scheduled_project_task_enabled", {
|
||||
projectId: "project-1",
|
||||
scheduledTaskId: "schedule-1",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -375,6 +375,9 @@ export class HttpAgentGateway implements AgentGateway {
|
||||
...(payload.assignedConversationId
|
||||
? { assignedConversationId: payload.assignedConversationId }
|
||||
: {}),
|
||||
...(payload.transcriptUnavailableReason
|
||||
? { transcriptUnavailableReason: payload.transcriptUnavailableReason }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -404,6 +407,9 @@ export class HttpAgentGateway implements AgentGateway {
|
||||
}
|
||||
: {}),
|
||||
scrollback: payload.scrollback ?? [],
|
||||
...(payload.transcriptUnavailableReason
|
||||
? { transcriptUnavailableReason: payload.transcriptUnavailableReason }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@ import { TauriDeviceGateway } from "./device";
|
||||
import { TauriGitGateway } from "./git";
|
||||
import { TauriPermissionGateway } from "./permission";
|
||||
import { TauriWorkStateGateway } from "./workState";
|
||||
import { TauriScheduledTaskGateway } from "./scheduledTask";
|
||||
import { TauriConversationGateway } from "./conversation";
|
||||
import { TauriTicketGateway } from "./ticket";
|
||||
import { TauriWindowGateway } from "./window";
|
||||
@ -78,6 +79,7 @@ export function createTauriGateways(): Gateways {
|
||||
device: new TauriDeviceGateway(),
|
||||
permission: new TauriPermissionGateway(),
|
||||
workState: new TauriWorkStateGateway(),
|
||||
scheduledTask: new TauriScheduledTaskGateway(),
|
||||
conversation: new TauriConversationGateway(),
|
||||
ticket: new TauriTicketGateway(),
|
||||
window: new TauriWindowGateway(),
|
||||
@ -112,6 +114,7 @@ export {
|
||||
TauriGitGateway,
|
||||
TauriPermissionGateway,
|
||||
TauriWorkStateGateway,
|
||||
TauriScheduledTaskGateway,
|
||||
TauriConversationGateway,
|
||||
TauriTicketGateway,
|
||||
TauriWindowGateway,
|
||||
|
||||
@ -83,6 +83,7 @@ import type {
|
||||
ResumableAgent,
|
||||
ResolvedAgentPermissions,
|
||||
ResolvedAgentSystemPermissions,
|
||||
ScheduledProjectTask,
|
||||
ServerExposurePreview,
|
||||
ServerExposureSettings,
|
||||
Skill,
|
||||
@ -168,6 +169,8 @@ import type {
|
||||
SetProjectPluginEnabledInput,
|
||||
SaveOpenCodeProviderProfileInput,
|
||||
SendAgentChatOptions,
|
||||
ScheduledTaskDefinitionInput,
|
||||
ScheduledTaskGateway,
|
||||
SkillGateway,
|
||||
StoppedLiveAgent,
|
||||
SystemGateway,
|
||||
@ -3247,6 +3250,112 @@ export class MockPermissionGateway implements PermissionGateway {
|
||||
}
|
||||
}
|
||||
|
||||
export class MockScheduledTaskGateway implements ScheduledTaskGateway {
|
||||
private readonly tasks = new Map<string, ScheduledProjectTask[]>();
|
||||
private nextId = 1;
|
||||
|
||||
/** Test/dev helper for agent-originated pending proposals. */
|
||||
_seed(task: ScheduledProjectTask): void {
|
||||
this.tasks.set(task.projectId, [
|
||||
...(this.tasks.get(task.projectId) ?? []).filter((item) => item.id !== task.id),
|
||||
structuredClone(task),
|
||||
]);
|
||||
}
|
||||
|
||||
async list(projectId: string): Promise<ScheduledProjectTask[]> {
|
||||
return structuredClone(this.tasks.get(projectId) ?? []);
|
||||
}
|
||||
|
||||
async create(
|
||||
projectId: string,
|
||||
input: ScheduledTaskDefinitionInput,
|
||||
): Promise<ScheduledProjectTask> {
|
||||
const now = Date.now();
|
||||
const task: ScheduledProjectTask = {
|
||||
id: `mock-scheduled-${this.nextId++}`,
|
||||
projectId,
|
||||
...structuredClone(input),
|
||||
approval: "approved",
|
||||
sandbox: "projectReadWrite",
|
||||
nextRunAtMs: input.enabled ? now + 3_600_000 : null,
|
||||
createdBy: { kind: "human" },
|
||||
updatedBy: { kind: "human" },
|
||||
createdAtMs: now,
|
||||
updatedAtMs: now,
|
||||
auditRevision: 1,
|
||||
};
|
||||
this.tasks.set(projectId, [...(this.tasks.get(projectId) ?? []), task]);
|
||||
return structuredClone(task);
|
||||
}
|
||||
|
||||
async update(
|
||||
projectId: string,
|
||||
scheduledTaskId: string,
|
||||
auditRevision: number,
|
||||
input: ScheduledTaskDefinitionInput,
|
||||
): Promise<ScheduledProjectTask> {
|
||||
const rows = this.tasks.get(projectId) ?? [];
|
||||
const index = rows.findIndex((task) => task.id === scheduledTaskId);
|
||||
if (index < 0) throw { code: "NOT_FOUND", message: "scheduled task not found" };
|
||||
if (rows[index].auditRevision !== auditRevision) {
|
||||
throw { code: "INVALID", message: "scheduled task revision conflict" };
|
||||
}
|
||||
const updated: ScheduledProjectTask = {
|
||||
...rows[index],
|
||||
...structuredClone(input),
|
||||
approval: "approved",
|
||||
nextRunAtMs: input.enabled ? Date.now() + 3_600_000 : null,
|
||||
updatedBy: { kind: "human" },
|
||||
updatedAtMs: Date.now(),
|
||||
auditRevision: auditRevision + 1,
|
||||
};
|
||||
rows[index] = updated;
|
||||
return structuredClone(updated);
|
||||
}
|
||||
|
||||
async delete(projectId: string, scheduledTaskId: string): Promise<void> {
|
||||
this.tasks.set(
|
||||
projectId,
|
||||
(this.tasks.get(projectId) ?? []).filter((task) => task.id !== scheduledTaskId),
|
||||
);
|
||||
}
|
||||
|
||||
async runNow(projectId: string, scheduledTaskId: string): Promise<ScheduledProjectTask> {
|
||||
const rows = this.tasks.get(projectId) ?? [];
|
||||
const task = rows.find((row) => row.id === scheduledTaskId);
|
||||
if (!task) throw { code: "NOT_FOUND", message: "scheduled task not found" };
|
||||
const updated = {
|
||||
...task,
|
||||
lastRunAtMs: Date.now(),
|
||||
lastTaskId: `mock-background-${scheduledTaskId}`,
|
||||
auditRevision: task.auditRevision + 1,
|
||||
};
|
||||
rows[rows.indexOf(task)] = updated;
|
||||
return structuredClone(updated);
|
||||
}
|
||||
|
||||
async setEnabled(
|
||||
projectId: string,
|
||||
scheduledTaskId: string,
|
||||
enabled: boolean,
|
||||
): Promise<ScheduledProjectTask> {
|
||||
const rows = this.tasks.get(projectId) ?? [];
|
||||
const task = rows.find((row) => row.id === scheduledTaskId);
|
||||
if (!task) throw { code: "NOT_FOUND", message: "scheduled task not found" };
|
||||
const updated: ScheduledProjectTask = {
|
||||
...task,
|
||||
enabled,
|
||||
approval: enabled ? "approved" : task.approval === "pending" ? "rejected" : task.approval,
|
||||
nextRunAtMs: enabled ? Date.now() + 3_600_000 : null,
|
||||
updatedBy: { kind: "human" },
|
||||
updatedAtMs: Date.now(),
|
||||
auditRevision: task.auditRevision + 1,
|
||||
};
|
||||
rows[rows.indexOf(task)] = updated;
|
||||
return structuredClone(updated);
|
||||
}
|
||||
}
|
||||
|
||||
export class MockWorkStateGateway implements WorkStateGateway {
|
||||
private states = new Map<string, ProjectWorkState>();
|
||||
private backgroundTaskStreams = new Map<
|
||||
@ -4854,6 +4963,7 @@ export function createMockGateways(): Gateways {
|
||||
device: new MockDeviceGateway(),
|
||||
permission: new MockPermissionGateway(),
|
||||
workState: new MockWorkStateGateway(),
|
||||
scheduledTask: new MockScheduledTaskGateway(),
|
||||
conversation: new MockConversationGateway(),
|
||||
ticket: new MockTicketGateway(systemGateway),
|
||||
window: new MockWindowGateway(),
|
||||
|
||||
@ -36,6 +36,7 @@ describe("createMockGateways", () => {
|
||||
"profile",
|
||||
"project",
|
||||
"remote",
|
||||
"scheduledTask",
|
||||
"skill",
|
||||
"system",
|
||||
"template",
|
||||
|
||||
39
frontend/src/adapters/scheduledTask.test.ts
Normal file
39
frontend/src/adapters/scheduledTask.test.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const invoke = vi.fn();
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: (...args: unknown[]) => invoke(...args) }));
|
||||
|
||||
import { TauriScheduledTaskGateway } from "./scheduledTask";
|
||||
|
||||
const input = {
|
||||
name: "Nettoyage",
|
||||
command: { command: "find", args: ["dist", "-delete"], cwd: "." },
|
||||
schedule: { kind: "interval" as const, everyMinutes: 60 },
|
||||
timezone: "Europe/Paris",
|
||||
enabled: true,
|
||||
timeoutMs: 60_000,
|
||||
};
|
||||
|
||||
describe("TauriScheduledTaskGateway", () => {
|
||||
beforeEach(() => invoke.mockReset().mockResolvedValue({}));
|
||||
|
||||
it("uses the backend command envelopes for list/create/update", async () => {
|
||||
const gateway = new TauriScheduledTaskGateway();
|
||||
await gateway.list("p1");
|
||||
await gateway.create("p1", input);
|
||||
await gateway.update("p1", "s1", 4, input);
|
||||
expect(invoke).toHaveBeenNthCalledWith(1, "list_scheduled_project_tasks", { projectId: "p1" });
|
||||
expect(invoke).toHaveBeenNthCalledWith(2, "create_scheduled_project_task", { request: { projectId: "p1", ...input } });
|
||||
expect(invoke).toHaveBeenNthCalledWith(3, "update_scheduled_project_task", { request: { projectId: "p1", scheduledTaskId: "s1", auditRevision: 4, ...input } });
|
||||
});
|
||||
|
||||
it("uses flat identity arguments for actions", async () => {
|
||||
const gateway = new TauriScheduledTaskGateway();
|
||||
await gateway.runNow("p1", "s1");
|
||||
await gateway.setEnabled("p1", "s1", false);
|
||||
await gateway.delete("p1", "s1");
|
||||
expect(invoke).toHaveBeenNthCalledWith(1, "run_scheduled_project_task_now", { projectId: "p1", scheduledTaskId: "s1" });
|
||||
expect(invoke).toHaveBeenNthCalledWith(2, "set_scheduled_project_task_enabled", { projectId: "p1", scheduledTaskId: "s1", enabled: false });
|
||||
expect(invoke).toHaveBeenNthCalledWith(3, "delete_scheduled_project_task", { projectId: "p1", scheduledTaskId: "s1" });
|
||||
});
|
||||
});
|
||||
45
frontend/src/adapters/scheduledTask.ts
Normal file
45
frontend/src/adapters/scheduledTask.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { ScheduledProjectTask } from "@/domain";
|
||||
import type { ScheduledTaskDefinitionInput, ScheduledTaskGateway } from "@/ports";
|
||||
|
||||
export class TauriScheduledTaskGateway implements ScheduledTaskGateway {
|
||||
list(projectId: string): Promise<ScheduledProjectTask[]> {
|
||||
return invoke("list_scheduled_project_tasks", { projectId });
|
||||
}
|
||||
|
||||
create(projectId: string, input: ScheduledTaskDefinitionInput): Promise<ScheduledProjectTask> {
|
||||
return invoke("create_scheduled_project_task", { request: { projectId, ...input } });
|
||||
}
|
||||
|
||||
update(
|
||||
projectId: string,
|
||||
scheduledTaskId: string,
|
||||
auditRevision: number,
|
||||
input: ScheduledTaskDefinitionInput,
|
||||
): Promise<ScheduledProjectTask> {
|
||||
return invoke("update_scheduled_project_task", {
|
||||
request: { projectId, scheduledTaskId, auditRevision, ...input },
|
||||
});
|
||||
}
|
||||
|
||||
async delete(projectId: string, scheduledTaskId: string): Promise<void> {
|
||||
await invoke("delete_scheduled_project_task", { projectId, scheduledTaskId });
|
||||
}
|
||||
|
||||
runNow(projectId: string, scheduledTaskId: string): Promise<ScheduledProjectTask> {
|
||||
return invoke("run_scheduled_project_task_now", { projectId, scheduledTaskId });
|
||||
}
|
||||
|
||||
setEnabled(
|
||||
projectId: string,
|
||||
scheduledTaskId: string,
|
||||
enabled: boolean,
|
||||
): Promise<ScheduledProjectTask> {
|
||||
return invoke("set_scheduled_project_task_enabled", {
|
||||
projectId,
|
||||
scheduledTaskId,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -114,12 +114,24 @@ function normalizeBackgroundTask(
|
||||
task.conversationId ?? task.conversation_id,
|
||||
);
|
||||
const parentTaskId = optionalString(task.parentTaskId ?? task.parent_task_id);
|
||||
const rawOrigin = isRecord(task.origin) ? task.origin : null;
|
||||
const origin = rawOrigin?.kind === "scheduled"
|
||||
? {
|
||||
kind: "scheduled" as const,
|
||||
scheduledTaskId: stringValue(rawOrigin.scheduledTaskId ?? rawOrigin.scheduled_task_id),
|
||||
plannedAtMs: numberValue(rawOrigin.plannedAtMs ?? rawOrigin.planned_at_ms),
|
||||
}
|
||||
: rawOrigin?.kind === "manual"
|
||||
? { kind: "manual" as const }
|
||||
: undefined;
|
||||
const createdAtMs = task.createdAtMs ?? task.created_at_ms;
|
||||
return {
|
||||
taskId: stringValue(task.taskId, `legacy-task-${index}`),
|
||||
ownerAgentId: stringValue(task.ownerAgentId, fallbackAgentId),
|
||||
projectId: stringValue(task.projectId),
|
||||
...(parentTaskId ? { parentTaskId } : {}),
|
||||
kind: stringValue(task.kind, "background"),
|
||||
...(origin ? { origin } : {}),
|
||||
status: normalizeBackgroundStatus(task.status ?? task.state),
|
||||
exitCode: nullableNumber(task.exitCode),
|
||||
summary: nullableString(task.summary),
|
||||
@ -129,6 +141,7 @@ function normalizeBackgroundTask(
|
||||
...(targetAgentId ? { targetAgentId } : {}),
|
||||
...(conversationId ? { conversationId } : {}),
|
||||
updatedAtMs: numberValue(task.updatedAtMs),
|
||||
...(typeof createdAtMs === "number" ? { createdAtMs } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -657,6 +657,57 @@ export interface GatewayError {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduled project tasks (#305)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ScheduledTaskWeekday =
|
||||
| "monday"
|
||||
| "tuesday"
|
||||
| "wednesday"
|
||||
| "thursday"
|
||||
| "friday"
|
||||
| "saturday"
|
||||
| "sunday";
|
||||
|
||||
export type ScheduledTaskSchedule =
|
||||
| { kind: "interval"; everyMinutes: number }
|
||||
| { kind: "daily"; time: string }
|
||||
| { kind: "weekly"; days: ScheduledTaskWeekday[]; time: string };
|
||||
|
||||
export interface ScheduledTaskCommand {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export type ScheduledTaskActor =
|
||||
| { kind: "human" }
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "system" };
|
||||
|
||||
export interface ScheduledProjectTask {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
approval: "approved" | "pending" | "rejected";
|
||||
sandbox: "projectReadWrite";
|
||||
command: ScheduledTaskCommand;
|
||||
schedule: ScheduledTaskSchedule;
|
||||
timezone: string;
|
||||
nextRunAtMs: number | null;
|
||||
lastRunAtMs?: number;
|
||||
lastTaskId?: string;
|
||||
createdBy: ScheduledTaskActor;
|
||||
updatedBy: ScheduledTaskActor;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
auditRevision: number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Work state (UX conversations/delegations live read-model)
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -707,6 +758,9 @@ export interface BackgroundCompletion {
|
||||
projectId: string;
|
||||
parentTaskId?: string;
|
||||
kind: string;
|
||||
origin?:
|
||||
| { kind: "manual" }
|
||||
| { kind: "scheduled"; scheduledTaskId: string; plannedAtMs: number };
|
||||
status:
|
||||
| "running"
|
||||
| "cancelling"
|
||||
@ -726,6 +780,7 @@ export interface BackgroundCompletion {
|
||||
conversationId?: string;
|
||||
/** Last-update timestamp (epoch ms); chronological ordering key. */
|
||||
updatedAtMs: number;
|
||||
createdAtMs?: number;
|
||||
}
|
||||
|
||||
/** One item currently pending in an agent's inbox. */
|
||||
|
||||
@ -197,7 +197,7 @@ describe("CustomAgentChatView", () => {
|
||||
__resetProjectCliInteractionActivityForTests();
|
||||
});
|
||||
|
||||
it("settles an isolated conversation id without recovered turns as unavailable", async () => {
|
||||
it("settles an isolated conversation id without recovered turns as an actionable unavailable state (#307/#314)", async () => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(async () => ({ sessionId: "fresh-session" })),
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||
@ -209,6 +209,7 @@ describe("CustomAgentChatView", () => {
|
||||
closeAgentChat: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const onConversationId = vi.fn();
|
||||
render(
|
||||
<DIProvider
|
||||
gateways={{
|
||||
@ -226,7 +227,7 @@ describe("CustomAgentChatView", () => {
|
||||
sessionId={null}
|
||||
conversationId="orphan-conversation"
|
||||
onSessionId={vi.fn()}
|
||||
onConversationId={vi.fn()}
|
||||
onConversationId={onConversationId}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
@ -239,13 +240,59 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText("Conversation liée — transcript indisponible."),
|
||||
await screen.findByText("La conversation liée n’a pas pu être restaurée."),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("Son transcript n’est plus disponible après la reprise.")).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByText("Conversation liée — récupération du transcript en cours."),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole("progressbar")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Réessayer" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Délier la conversation" }));
|
||||
expect(onConversationId).toHaveBeenCalledWith(null);
|
||||
expect(await screen.findByText("Envoyez un message pour démarrer la conversation structurée.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("treats emptyConversation as a normal fresh conversation", async () => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(async () => ({
|
||||
sessionId: "fresh-session",
|
||||
transcriptUnavailableReason: "emptyConversation" as const,
|
||||
})),
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
scrollback: [],
|
||||
transcriptUnavailableReason: "emptyConversation" as const,
|
||||
})),
|
||||
sendAgentChat: vi.fn(async () => {}),
|
||||
cancelAgentChat: vi.fn(async () => {}),
|
||||
closeAgentChat: vi.fn(async () => {}),
|
||||
};
|
||||
render(
|
||||
<DIProvider gateways={{ agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
|
||||
<CustomAgentChatView projectId="project-1" agentId="agent-1" agentName="Worker" profile={profile} cwd="/repo" nodeId="node-1" sessionId={null} conversationId="conversation-1" onSessionId={vi.fn()} onConversationId={vi.fn()} />
|
||||
</DIProvider>,
|
||||
);
|
||||
expect(await screen.findByText("Envoyez un message pour démarrer la conversation structurée.")).toBeTruthy();
|
||||
expect(screen.queryByText("La conversation liée n’a pas pu être restaurée.")).toBeNull();
|
||||
});
|
||||
|
||||
it("maps NOT_FOUND to an actionable missing conversation without automatic looping", async () => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(async () => { throw { code: "NOT_FOUND", message: "conversation not found" }; }),
|
||||
reattachAgentChat: vi.fn(),
|
||||
sendAgentChat: vi.fn(async () => {}),
|
||||
cancelAgentChat: vi.fn(async () => {}),
|
||||
closeAgentChat: vi.fn(async () => {}),
|
||||
};
|
||||
render(
|
||||
<DIProvider gateways={{ agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
|
||||
<CustomAgentChatView projectId="project-1" agentId="agent-1" agentName="Worker" profile={profile} cwd="/repo" nodeId="node-1" sessionId={null} conversationId="missing-conversation" onSessionId={vi.fn()} onConversationId={vi.fn()} />
|
||||
</DIProvider>,
|
||||
);
|
||||
expect(await screen.findByText("Cette conversation est introuvable dans le stockage disponible.")).toBeTruthy();
|
||||
expect(agent.launchAgentChat).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("projects self-restart lifecycle separators in the custom CLI only once", async () => {
|
||||
|
||||
@ -43,7 +43,11 @@ import {
|
||||
markProjectCliInteractionStopped,
|
||||
} from "@/features/projects/projectCliInteractionActivity";
|
||||
import { Button, EntryTimestamp, Spinner, cn, normalizeTimestamp } from "@/shared";
|
||||
import type { ChatAttachmentInput, ChatDraftScope } from "@/ports";
|
||||
import type {
|
||||
ChatAttachmentInput,
|
||||
ChatDraftScope,
|
||||
ConversationTranscriptUnavailableReason,
|
||||
} from "@/ports";
|
||||
|
||||
export interface CustomAgentChatViewProps {
|
||||
projectId: string;
|
||||
@ -968,6 +972,9 @@ export function CustomAgentChatView({
|
||||
useState<ProfileCommandDialogState | null>(null);
|
||||
const [opening, setOpening] = useState(Boolean(sessionId || conversationId));
|
||||
const [openRetryNonce, setOpenRetryNonce] = useState(0);
|
||||
const [transcriptUnavailableReason, setTranscriptUnavailableReason] = useState<
|
||||
ConversationTranscriptUnavailableReason | "conversationNotFound" | null
|
||||
>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [error, setError] = useState<ChatErrorState | null>(null);
|
||||
@ -1373,6 +1380,7 @@ export function CustomAgentChatView({
|
||||
|
||||
const receive = useCallback(
|
||||
(chunk: ReplyChunk) => {
|
||||
setTranscriptUnavailableReason(null);
|
||||
scrollbackChunkCountRef.current += 1;
|
||||
const activeInteractionId = activeInteractionIdRef.current;
|
||||
if (
|
||||
@ -1604,6 +1612,11 @@ export function CustomAgentChatView({
|
||||
applyChatViewState(replayed);
|
||||
setBusy(turnsIndicateBusy(replayed));
|
||||
}
|
||||
if (reattached.scrollback.length > 0) {
|
||||
setTranscriptUnavailableReason(null);
|
||||
} else if (reattached.transcriptUnavailableReason) {
|
||||
setTranscriptUnavailableReason(reattached.transcriptUnavailableReason);
|
||||
}
|
||||
return reattached.sessionId;
|
||||
},
|
||||
[agent, agentId, applyChatViewState, projectId, publishSessionId, receive],
|
||||
@ -1700,6 +1713,9 @@ export function CustomAgentChatView({
|
||||
if (launched.assignedConversationId) {
|
||||
onConversationIdRef.current(launched.assignedConversationId);
|
||||
}
|
||||
if (launched.transcriptUnavailableReason) {
|
||||
setTranscriptUnavailableReason(launched.transcriptUnavailableReason);
|
||||
}
|
||||
try {
|
||||
const recoveredSessionId = await reattachStructuredSession(launched.sessionId, {
|
||||
applyScrollback: options.applyScrollback,
|
||||
@ -1771,6 +1787,7 @@ export function CustomAgentChatView({
|
||||
});
|
||||
setOpening(true);
|
||||
setError(null);
|
||||
setTranscriptUnavailableReason(null);
|
||||
try {
|
||||
const localSession = sessionRef.current;
|
||||
if (!externalSessionId && localSession) {
|
||||
@ -1875,7 +1892,11 @@ export function CustomAgentChatView({
|
||||
sessionId: externalSessionId,
|
||||
error: instrumentationError(e),
|
||||
});
|
||||
if (!cancelled) {
|
||||
if (!cancelled && conversationId && isNotFound(e)) {
|
||||
setTranscriptUnavailableReason("conversationNotFound");
|
||||
setCurrentSession(null);
|
||||
publishSessionId(null);
|
||||
} else if (!cancelled) {
|
||||
setError({ message: describeOpenFailure(e), openFailure: true });
|
||||
}
|
||||
} finally {
|
||||
@ -1909,10 +1930,37 @@ export function CustomAgentChatView({
|
||||
);
|
||||
const hasLinkedConversation = Boolean(
|
||||
conversationId ||
|
||||
transcriptUnavailableReason === "missingTranscript" ||
|
||||
transcriptUnavailableReason === "conversationNotFound" ||
|
||||
requesterDelegation.currentAgentBusy ||
|
||||
restart ||
|
||||
delegationActive,
|
||||
);
|
||||
const unavailableLinkedTranscript =
|
||||
displayTurns.length === 0 &&
|
||||
hasLinkedConversation &&
|
||||
transcriptUnavailableReason !== "emptyConversation"
|
||||
? transcriptUnavailableReason ?? "missingTranscript"
|
||||
: null;
|
||||
|
||||
function unlinkUnavailableConversation() {
|
||||
onConversationIdRef.current(null);
|
||||
setTranscriptUnavailableReason("emptyConversation");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function retryUnavailableTranscript() {
|
||||
const sid = sessionRef.current;
|
||||
setTranscriptUnavailableReason(null);
|
||||
setError(null);
|
||||
if (sid) {
|
||||
// Force the next user-triggered pass through the attach path. Keeping the
|
||||
// local session here would trip the #307/idempotency short-circuit.
|
||||
setCurrentSession(null);
|
||||
setExternalSessionId(sid);
|
||||
}
|
||||
setOpenRetryNonce((value) => value + 1);
|
||||
}
|
||||
|
||||
async function openProfileDialog() {
|
||||
const availableProfiles = (await profileGateway.listProfiles()).filter((candidate) =>
|
||||
@ -2490,10 +2538,30 @@ export function CustomAgentChatView({
|
||||
{externalSessionId ? "Reprise en cours..." : "Ouverture de la session structurée…"}
|
||||
</span>
|
||||
</div>
|
||||
) : displayTurns.length === 0 && hasLinkedConversation ? (
|
||||
<p className="m-auto text-xs text-muted">
|
||||
Conversation liée — transcript indisponible.
|
||||
</p>
|
||||
) : unavailableLinkedTranscript ? (
|
||||
<div className="m-auto flex max-w-md flex-col items-center gap-3 rounded-md border border-warning/40 bg-warning/10 p-4 text-center">
|
||||
<p className="text-sm font-medium text-content">
|
||||
La conversation liée n’a pas pu être restaurée.
|
||||
</p>
|
||||
<p className="text-xs text-muted">
|
||||
{unavailableLinkedTranscript === "conversationNotFound"
|
||||
? "Cette conversation est introuvable dans le stockage disponible."
|
||||
: "Son transcript n’est plus disponible après la reprise."}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
disabled={opening}
|
||||
onClick={retryUnavailableTranscript}
|
||||
>
|
||||
Réessayer
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={unlinkUnavailableConversation}>
|
||||
Délier la conversation
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : displayTurns.length === 0 ? (
|
||||
<p className="m-auto text-xs text-muted">
|
||||
Envoyez un message pour démarrer la conversation structurée.
|
||||
|
||||
@ -103,6 +103,7 @@ const PANEL_SIZE: Record<PanelId, FloatingWindowSize> = {
|
||||
context: "lg",
|
||||
projectSettings: "lg",
|
||||
work: "lg",
|
||||
scheduledTasks: "xl",
|
||||
tickets: "lg",
|
||||
agents: "lg",
|
||||
templates: "lg",
|
||||
@ -649,6 +650,7 @@ export function ProjectsView() {
|
||||
"projectSettings",
|
||||
"context",
|
||||
"work",
|
||||
"scheduledTasks",
|
||||
"tickets",
|
||||
"agents",
|
||||
"templates",
|
||||
@ -865,6 +867,7 @@ export function ProjectsView() {
|
||||
projectId={active.id}
|
||||
projectRoot={active.root}
|
||||
onOpenConversation={openConversation}
|
||||
onOpenWork={() => setPlacement("work", "floating")}
|
||||
/>
|
||||
);
|
||||
return panel === "agents" ? (
|
||||
|
||||
@ -18,6 +18,7 @@ import { MemoryPanel } from "@/features/memory";
|
||||
import { EmbedderSettings } from "@/features/embedder";
|
||||
import { McpPanel, PermissionsPanel } from "@/features/permissions";
|
||||
import { ProjectWorkStatePanel } from "@/features/workstate";
|
||||
import { ScheduledTasksPanel } from "@/features/scheduled-tasks";
|
||||
import { TicketsView } from "@/features/tickets";
|
||||
import { GitPanel } from "@/features/git";
|
||||
import { ProjectContextPanel } from "./ProjectContextPanel";
|
||||
@ -34,6 +35,8 @@ export interface ViewPanelBodyProps {
|
||||
projectRoot: string;
|
||||
/** Optional: open a conversation viewer (only wired inside the main window). */
|
||||
onOpenConversation?: (conversationId: string) => void;
|
||||
/** Optional main-window navigation to the run details in Work. */
|
||||
onOpenWork?: () => void;
|
||||
}
|
||||
|
||||
/** Renders the requested view panel for the given project. */
|
||||
@ -42,6 +45,7 @@ export function ViewPanelBody({
|
||||
projectId,
|
||||
projectRoot,
|
||||
onOpenConversation,
|
||||
onOpenWork,
|
||||
}: ViewPanelBodyProps): ReactNode {
|
||||
switch (panel) {
|
||||
case "context":
|
||||
@ -55,6 +59,8 @@ export function ViewPanelBody({
|
||||
onOpenConversation={onOpenConversation}
|
||||
/>
|
||||
);
|
||||
case "scheduledTasks":
|
||||
return <ScheduledTasksPanel projectId={projectId} onOpenWork={onOpenWork} />;
|
||||
case "tickets":
|
||||
return <TicketsView projectId={projectId} />;
|
||||
case "agents":
|
||||
|
||||
@ -25,6 +25,7 @@ export type PanelId =
|
||||
| "context"
|
||||
| "projectSettings"
|
||||
| "work"
|
||||
| "scheduledTasks"
|
||||
| "tickets"
|
||||
| "agents"
|
||||
| "templates"
|
||||
@ -53,6 +54,7 @@ export const PANEL_TITLE: Record<PanelId, string> = {
|
||||
context: "Project context",
|
||||
projectSettings: "Paramètres du projet",
|
||||
work: "Work state",
|
||||
scheduledTasks: "Tâches planifiées",
|
||||
tickets: "Tickets",
|
||||
agents: "Agents",
|
||||
templates: "Templates",
|
||||
|
||||
185
frontend/src/features/scheduled-tasks/ScheduledTaskForm.tsx
Normal file
185
frontend/src/features/scheduled-tasks/ScheduledTaskForm.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type {
|
||||
ScheduledProjectTask,
|
||||
ScheduledTaskSchedule,
|
||||
ScheduledTaskWeekday,
|
||||
} from "@/domain";
|
||||
import type { ScheduledTaskDefinitionInput } from "@/ports";
|
||||
import { Button, Field, Input } from "@/shared";
|
||||
|
||||
type Preset = "hourly" | "daily" | "weekly" | "custom";
|
||||
|
||||
const DAYS: Array<[ScheduledTaskWeekday, string]> = [
|
||||
["monday", "Lun"], ["tuesday", "Mar"], ["wednesday", "Mer"],
|
||||
["thursday", "Jeu"], ["friday", "Ven"], ["saturday", "Sam"], ["sunday", "Dim"],
|
||||
];
|
||||
|
||||
const control =
|
||||
"h-9 rounded-md border border-border bg-raised px-3 text-sm text-content outline-none focus:border-primary";
|
||||
|
||||
function initialPreset(schedule?: ScheduledTaskSchedule): Preset {
|
||||
if (!schedule) return "daily";
|
||||
if (schedule.kind === "interval" && schedule.everyMinutes === 60) return "hourly";
|
||||
if (schedule.kind === "interval") return "custom";
|
||||
return schedule.kind;
|
||||
}
|
||||
|
||||
function shellWord(value: string): string {
|
||||
return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value)
|
||||
? value
|
||||
: `'${value.split("'").join(`'\\''`)}'`;
|
||||
}
|
||||
|
||||
export function exactCommand(input: ScheduledTaskDefinitionInput): string {
|
||||
return [input.command.command, ...input.command.args].map(shellWord).join(" ");
|
||||
}
|
||||
|
||||
export function scheduleLabel(schedule: ScheduledTaskSchedule): string {
|
||||
if (schedule.kind === "interval") {
|
||||
return schedule.everyMinutes === 60
|
||||
? "Toutes les heures"
|
||||
: `Toutes les ${schedule.everyMinutes} minutes`;
|
||||
}
|
||||
if (schedule.kind === "daily") return `Chaque jour à ${schedule.time}`;
|
||||
const labels = schedule.days.map((day) => DAYS.find(([key]) => key === day)?.[1] ?? day);
|
||||
return `Chaque semaine (${labels.join(", ")}) à ${schedule.time}`;
|
||||
}
|
||||
|
||||
export function ScheduledTaskForm({
|
||||
task,
|
||||
saving,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: {
|
||||
task?: ScheduledProjectTask;
|
||||
saving: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (input: ScheduledTaskDefinitionInput) => void;
|
||||
}) {
|
||||
const [name, setName] = useState(task?.name ?? "");
|
||||
const [description, setDescription] = useState(task?.description ?? "");
|
||||
const [command, setCommand] = useState(task?.command.command ?? "");
|
||||
const [args, setArgs] = useState(task?.command.args.join("\n") ?? "");
|
||||
const [cwd, setCwd] = useState(task?.command.cwd ?? ".");
|
||||
const [preset, setPreset] = useState<Preset>(initialPreset(task?.schedule));
|
||||
const [minutes, setMinutes] = useState(
|
||||
task?.schedule.kind === "interval" ? String(task.schedule.everyMinutes) : "15",
|
||||
);
|
||||
const [time, setTime] = useState(
|
||||
task?.schedule.kind === "daily" || task?.schedule.kind === "weekly"
|
||||
? task.schedule.time
|
||||
: "09:00",
|
||||
);
|
||||
const [days, setDays] = useState<ScheduledTaskWeekday[]>(
|
||||
task?.schedule.kind === "weekly" ? task.schedule.days : ["monday"],
|
||||
);
|
||||
const [timezone, setTimezone] = useState(
|
||||
task?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC",
|
||||
);
|
||||
const [timeoutMinutes, setTimeoutMinutes] = useState(
|
||||
task?.timeoutMs ? String(Math.round(task.timeoutMs / 60_000)) : "",
|
||||
);
|
||||
const [enabled, setEnabled] = useState(task?.approval === "pending" ? true : task?.enabled ?? true);
|
||||
const [review, setReview] = useState(false);
|
||||
|
||||
const input = useMemo<ScheduledTaskDefinitionInput>(() => {
|
||||
const schedule: ScheduledTaskSchedule = preset === "hourly"
|
||||
? { kind: "interval", everyMinutes: 60 }
|
||||
: preset === "custom"
|
||||
? { kind: "interval", everyMinutes: Number(minutes) }
|
||||
: preset === "weekly"
|
||||
? { kind: "weekly", days, time }
|
||||
: { kind: "daily", time };
|
||||
return {
|
||||
name: name.trim(),
|
||||
...(description.trim() ? { description: description.trim() } : {}),
|
||||
command: {
|
||||
command: command.trim(),
|
||||
args: args.split("\n").map((arg) => arg.trim()).filter(Boolean),
|
||||
...(cwd.trim() && cwd.trim() !== "." ? { cwd: cwd.trim() } : {}),
|
||||
},
|
||||
schedule,
|
||||
timezone: timezone.trim(),
|
||||
enabled,
|
||||
...(timeoutMinutes ? { timeoutMs: Number(timeoutMinutes) * 60_000 } : {}),
|
||||
};
|
||||
}, [args, command, cwd, days, description, enabled, minutes, name, preset, time, timeoutMinutes, timezone]);
|
||||
|
||||
const valid = input.name.length > 0 && input.command.command.length > 0 &&
|
||||
input.timezone.length > 0 &&
|
||||
(input.schedule.kind !== "interval" || input.schedule.everyMinutes >= 5) &&
|
||||
(input.schedule.kind !== "weekly" || input.schedule.days.length > 0) &&
|
||||
(!timeoutMinutes || Number(timeoutMinutes) > 0);
|
||||
|
||||
if (review) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm text-content">
|
||||
Cette tâche pourra modifier ou supprimer des fichiers automatiquement.
|
||||
</div>
|
||||
<dl className="grid gap-2 text-sm sm:grid-cols-[9rem_1fr]">
|
||||
<dt className="text-muted">Commande exacte</dt><dd><code className="break-all">{exactCommand(input)}</code></dd>
|
||||
<dt className="text-muted">Dossier</dt><dd><code>{input.command.cwd ?? "."}</code> (relatif au projet)</dd>
|
||||
<dt className="text-muted">Fréquence</dt><dd>{scheduleLabel(input.schedule)}</dd>
|
||||
<dt className="text-muted">Prochaine exécution</dt><dd>Calculée par IdeA lors de l’activation</dd>
|
||||
<dt className="text-muted">Fuseau horaire</dt><dd>{input.timezone}</dd>
|
||||
<dt className="text-muted">Permissions</dt><dd>Lecture, modification et suppression dans le projet</dd>
|
||||
<dt className="text-muted">Sandbox</dt><dd>Projet — lecture/écriture; aucun accès en écriture hors projet</dd>
|
||||
</dl>
|
||||
<p className="text-xs text-muted">IdeA doit fonctionner et le projet doit être ouvert pour déclencher cette tâche.</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button onClick={() => setReview(false)}>Retour</Button>
|
||||
<Button variant="primary" loading={saving} onClick={() => onSubmit(input)}>
|
||||
{task?.approval === "pending" ? "Approuver et activer" : task ? "Enregistrer" : "Créer la tâche"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); enabled ? setReview(true) : onSubmit(input); }}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Nom">{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={name} onChange={(e) => setName(e.target.value)} />}</Field>
|
||||
<Field label="Dossier de travail" hint="Chemin relatif au projet; « . » utilise sa racine.">
|
||||
{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={cwd} onChange={(e) => setCwd(e.target.value)} />}
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={task?.approval === "pending" ? "Motif de la demande" : "Description (facultative)"}>
|
||||
{({ id, describedBy }) => <textarea id={id} aria-describedby={describedBy} rows={2} className={`${control} h-auto py-2`} value={description} onChange={(e) => setDescription(e.target.value)} />}
|
||||
</Field>
|
||||
<Field label="Commande" hint="Exécutable exact, sans interpolation shell.">
|
||||
{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={command} onChange={(e) => setCommand(e.target.value)} placeholder="npm" />}
|
||||
</Field>
|
||||
<Field label="Arguments" hint="Un argument littéral par ligne.">
|
||||
{({ id, describedBy }) => <textarea id={id} aria-describedby={describedBy} rows={3} className={`${control} h-auto py-2 font-mono`} value={args} onChange={(e) => setArgs(e.target.value)} placeholder={'run\nbuild'} />}
|
||||
</Field>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Planification">
|
||||
{({ id }) => <select id={id} className={control} value={preset} onChange={(e) => setPreset(e.target.value as Preset)}>
|
||||
<option value="hourly">Toutes les heures</option><option value="daily">Chaque jour</option>
|
||||
<option value="weekly">Chaque semaine</option><option value="custom">Personnalisée</option>
|
||||
</select>}
|
||||
</Field>
|
||||
{preset === "custom" ? (
|
||||
<Field label="Intervalle (minutes)" error={Number(minutes) < 5 ? "Minimum : 5 minutes." : undefined}>
|
||||
{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="number" min={5} value={minutes} onChange={(e) => setMinutes(e.target.value)} />}
|
||||
</Field>
|
||||
) : preset === "daily" || preset === "weekly" ? (
|
||||
<Field label="Heure locale">{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="time" value={time} onChange={(e) => setTime(e.target.value)} />}</Field>
|
||||
) : <div />}
|
||||
</div>
|
||||
{preset === "weekly" && <fieldset><legend className="mb-1 text-xs font-medium text-muted">Jours</legend><div className="flex flex-wrap gap-2">
|
||||
{DAYS.map(([key, label]) => <label key={key} className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs"><input type="checkbox" checked={days.includes(key)} onChange={() => setDays((current) => current.includes(key) ? current.filter((day) => day !== key) : [...current, key])} />{label}</label>)}
|
||||
</div></fieldset>}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Fuseau horaire" hint="Identifiant IANA, par exemple Europe/Paris.">{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={timezone} onChange={(e) => setTimezone(e.target.value)} />}</Field>
|
||||
<Field label="Limite de durée (minutes)" hint="Vide : aucune limite.">{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="number" min={1} value={timeoutMinutes} onChange={(e) => setTimeoutMinutes(e.target.value)} />}</Field>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-raised p-3 text-sm"><div className="font-medium">Permissions et sandbox</div><p className="mt-1 text-muted">Projet — lecture/écriture. La commande peut modifier ou supprimer des fichiers dans le projet; l’écriture hors projet est refusée. Le réseau n’est pas isolé par ce profil V1.</p></div>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />Activée</label>
|
||||
<div className="flex justify-end gap-2"><Button onClick={onCancel}>Annuler</Button><Button type="submit" variant="primary" disabled={!valid} loading={saving}>{enabled ? "Vérifier avant activation" : "Enregistrer"}</Button></div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
146
frontend/src/features/scheduled-tasks/ScheduledTasksPanel.tsx
Normal file
146
frontend/src/features/scheduled-tasks/ScheduledTasksPanel.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import type { BackgroundCompletion, ScheduledProjectTask } from "@/domain";
|
||||
import { Button, FloatingWindow, Panel, Spinner, cn } from "@/shared";
|
||||
import { exactCommand, scheduleLabel, ScheduledTaskForm } from "./ScheduledTaskForm";
|
||||
import { useScheduledTasks } from "./useScheduledTasks";
|
||||
|
||||
type Editor = { kind: "create" } | { kind: "edit"; task: ScheduledProjectTask };
|
||||
|
||||
function dateTime(value?: number | null): string {
|
||||
if (value == null) return "Indisponible";
|
||||
return new Intl.DateTimeFormat("fr-FR", { dateStyle: "medium", timeStyle: "short" }).format(value);
|
||||
}
|
||||
|
||||
function duration(run: BackgroundCompletion): string {
|
||||
if (run.createdAtMs && run.updatedAtMs >= run.createdAtMs && !["running", "pending", "cancelling"].includes(run.status)) {
|
||||
const seconds = Math.round((run.updatedAtMs - run.createdAtMs) / 1000);
|
||||
return seconds < 60 ? `${seconds} s` : `${Math.round(seconds / 60)} min`;
|
||||
}
|
||||
return run.status === "running" || run.status === "pending" || run.status === "cancelling"
|
||||
? "En cours"
|
||||
: "Terminée";
|
||||
}
|
||||
|
||||
function displayStatus(task: ScheduledProjectTask, run?: BackgroundCompletion) {
|
||||
if (task.approval === "pending") return { label: "À approuver", tone: "warning" } as const;
|
||||
if (run && ["running", "pending", "cancelling"].includes(run.status)) return { label: "En cours", tone: "primary" } as const;
|
||||
if (run?.status === "failed" || run?.status === "cancelFailed") {
|
||||
const inaccessible = `${run.summary ?? ""} ${run.stderrTail ?? ""}`.toLowerCase().includes("inaccessible");
|
||||
return { label: inaccessible ? "Projet inaccessible" : "Échec", tone: "danger" } as const;
|
||||
}
|
||||
if (!task.enabled) return { label: "Suspendue", tone: "muted" } as const;
|
||||
return { label: "Active", tone: "success" } as const;
|
||||
}
|
||||
|
||||
function actorLabel(task: ScheduledProjectTask, names: ReadonlyMap<string, string>): string {
|
||||
if (task.createdBy.kind === "agent") {
|
||||
return `Créée par ${names.get(task.createdBy.agentId) ?? task.createdBy.agentId}`;
|
||||
}
|
||||
return task.createdBy.kind === "human" ? "Créée par vous" : "Créée par IdeA";
|
||||
}
|
||||
|
||||
export function ScheduledTasksPanel({
|
||||
projectId,
|
||||
onOpenWork,
|
||||
}: {
|
||||
projectId: string;
|
||||
onOpenWork?: () => void;
|
||||
}) {
|
||||
const vm = useScheduledTasks(projectId);
|
||||
const [editor, setEditor] = useState<Editor | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ScheduledProjectTask | null>(null);
|
||||
const [approveTarget, setApproveTarget] = useState<ScheduledProjectTask | null>(null);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
async function remove(task: ScheduledProjectTask, stop = false) {
|
||||
if (stop) await vm.cancelRun(task);
|
||||
const removed = await vm.remove(task);
|
||||
if (removed) setDeleteTarget(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-content">Tâches planifiées</h2>
|
||||
<p className="mt-1 text-xs text-muted">IdeA doit fonctionner et le projet rester ouvert pour déclencher les tâches. Les occurrences manquées ne sont pas rattrapées en rafale.</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => setEditor({ kind: "create" })}>Nouvelle tâche planifiée</Button>
|
||||
</div>
|
||||
|
||||
{vm.error && <div role="alert" className="flex items-center justify-between gap-3 rounded-md border border-danger/40 bg-danger/10 p-3 text-sm text-danger"><span><strong>{vm.error.toLowerCase().includes("inaccessible") ? "Projet inaccessible" : "Erreur"}</strong> — Impossible de charger ou modifier les tâches planifiées. {vm.error}</span><Button size="sm" onClick={() => void vm.refresh()}>Réessayer</Button></div>}
|
||||
{vm.tasks.some((task) => {
|
||||
const run = task.lastTaskId ? vm.runsById.get(task.lastTaskId) : undefined;
|
||||
return run?.status === "failed" || run?.status === "cancelFailed";
|
||||
}) && <div role="status" className="rounded-md border border-danger/30 bg-danger/5 px-3 py-2 text-sm text-danger">Une tâche planifiée a échoué. Utilisez « Voir les détails » sur la tâche concernée.</div>}
|
||||
{vm.loading && vm.tasks.length === 0 ? <div className="flex justify-center p-8"><Spinner /></div> : vm.tasks.length === 0 ? (
|
||||
<Panel><p className="text-sm font-medium text-content">Aucune tâche planifiée pour ce projet</p><p className="mt-1 text-sm text-muted">Créez une tâche pour automatiser une commande récurrente sans utiliser le cron du système.</p></Panel>
|
||||
) : (
|
||||
<ul className="space-y-3" aria-label="Tâches planifiées du projet">
|
||||
{vm.tasks.map((task) => {
|
||||
const run = task.lastTaskId ? vm.runsById.get(task.lastTaskId) : undefined;
|
||||
const status = displayStatus(task, run);
|
||||
const requestingAgentId = task.createdBy.kind === "agent" ? task.createdBy.agentId : null;
|
||||
const pendingAgent = task.approval === "pending" && requestingAgentId !== null;
|
||||
const running = run && ["running", "pending", "cancelling"].includes(run.status);
|
||||
return <li key={task.id} className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2"><span className="font-medium text-content">{task.name}</span><span className={cn("rounded-full px-2 py-0.5 text-xs font-medium", status.tone === "danger" && "bg-danger/10 text-danger", status.tone === "warning" && "bg-warning/10 text-warning", status.tone === "success" && "bg-success/10 text-success", status.tone === "primary" && "bg-primary/10 text-primary", status.tone === "muted" && "bg-raised text-muted")}>{status.label}</span></div>
|
||||
<p className="mt-1 text-xs text-muted">{actorLabel(task, vm.agentNames)} · {scheduleLabel(task.schedule)} · {task.timezone}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{pendingAgent ? <><Button size="sm" variant="primary" onClick={() => setApproveTarget(task)}>Approuver et activer</Button><Button size="sm" onClick={() => setEditor({ kind: "edit", task })}>Modifier avant d’activer</Button><Button size="sm" variant="danger" loading={vm.busyId === task.id} onClick={() => void vm.setEnabled(task, false)}>Refuser</Button></> : <><Button size="sm" onClick={() => setEditor({ kind: "edit", task })}>Modifier</Button><Button size="sm" disabled={task.approval !== "approved" || Boolean(running)} loading={vm.busyId === task.id} onClick={() => void vm.runNow(task)}>Exécuter maintenant</Button><Button size="sm" loading={vm.busyId === task.id} onClick={() => void vm.setEnabled(task, !task.enabled)}>{task.enabled ? "Suspendre" : "Reprendre"}</Button><Button size="sm" variant="danger" onClick={() => setDeleteTarget(task)}>Supprimer</Button></>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingAgent && <div className="mt-3 rounded-md border border-warning/50 bg-warning/10 p-3 text-sm"><p className="font-medium">Demandée par {vm.agentNames.get(requestingAgentId) ?? requestingAgentId}</p><p className="mt-1 text-muted">{dateTime(task.createdAtMs)}{task.description ? ` · Motif : ${task.description}` : " · Aucun motif fourni"}</p><p className="mt-2 text-xs">Cette tâche ne s’exécutera pas avant votre approbation.</p></div>}
|
||||
|
||||
<dl className="mt-3 grid gap-x-4 gap-y-1 text-xs sm:grid-cols-[8rem_1fr_8rem_1fr]">
|
||||
<dt className="text-muted">Commande</dt><dd className="break-all font-mono text-content">{exactCommand({ name: task.name, command: task.command, schedule: task.schedule, timezone: task.timezone, enabled: task.enabled })}</dd>
|
||||
<dt className="text-muted">Dossier</dt><dd className="font-mono">{task.command.cwd ?? "."} (projet)</dd>
|
||||
<dt className="text-muted">Prochaine exécution</dt><dd>{dateTime(task.nextRunAtMs)}</dd>
|
||||
<dt className="text-muted">Dernière exécution</dt><dd>{dateTime(task.lastRunAtMs)}</dd>
|
||||
<dt className="text-muted">Permissions</dt><dd>Modifier/supprimer dans le projet</dd>
|
||||
<dt className="text-muted">Sandbox</dt><dd>Projet — lecture/écriture; réseau non isolé en V1</dd>
|
||||
<dt className="text-muted">Limite de durée</dt><dd>{task.timeoutMs ? `${Math.round(task.timeoutMs / 60_000)} min` : "Aucune"}</dd>
|
||||
<dt className="text-muted">Déclenchement</dt><dd>{run?.origin?.kind === "scheduled" ? "Planifié" : run ? "Manuel" : "Aucune exécution"}</dd>
|
||||
</dl>
|
||||
{task.lastTaskId && <div className="mt-3 flex flex-wrap items-center gap-2"><Button size="sm" variant="ghost" onClick={() => setExpanded(expanded === task.id ? null : task.id)}>{expanded === task.id ? "Masquer les détails" : "Voir les détails"}</Button>{onOpenWork && <Button size="sm" variant="ghost" onClick={onOpenWork}>Ouvrir la tâche de fond {task.lastTaskId.slice(0, 8)}</Button>}</div>}
|
||||
{expanded === task.id && <div className="mt-2 rounded-md bg-raised p-3 text-xs"><p><span className="text-muted">BackgroundTask :</span> {task.lastTaskId}</p>{run ? <><p className="mt-1"><span className="text-muted">Statut :</span> {run.status} · {duration(run)} · code de sortie {run.exitCode ?? "indisponible"}</p><p className="mt-1"><span className="text-muted">Permissions réellement utilisées :</span> sandbox projet — lecture/écriture</p>{run.summary && <p className="mt-2 whitespace-pre-wrap">{run.summary}</p>}{run.stdoutTail && <pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-canvas p-2">{run.stdoutTail}</pre>}{run.stderrTail && <pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-danger/5 p-2 text-danger">{run.stderrTail}</pre>}</> : <p className="mt-1 text-muted">Les détails de cette exécution ne sont plus présents dans le WorkState courant.</p>}</div>}
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<FloatingWindow open={editor !== null} title={editor?.kind === "edit" ? (editor.task.approval === "pending" ? "Modifier avant d’activer" : "Modifier la tâche planifiée") : "Nouvelle tâche planifiée"} size="lg" onClose={() => setEditor(null)}>
|
||||
{editor && <ScheduledTaskForm task={editor.kind === "edit" ? editor.task : undefined} saving={vm.busyId === (editor.kind === "edit" ? editor.task.id : "new")} onCancel={() => setEditor(null)} onSubmit={(input) => void (editor.kind === "edit" ? vm.update(editor.task, input) : vm.create(input)).then((saved) => { if (saved) setEditor(null); })} />}
|
||||
</FloatingWindow>
|
||||
|
||||
<FloatingWindow open={deleteTarget !== null} title="Supprimer la tâche planifiée" size="sm" onClose={() => setDeleteTarget(null)}>
|
||||
{deleteTarget && (() => {
|
||||
const run = deleteTarget.lastTaskId ? vm.runsById.get(deleteTarget.lastTaskId) : undefined;
|
||||
const running = run && ["running", "pending", "cancelling"].includes(run.status);
|
||||
return <div className="space-y-4"><p className="text-sm">Supprimer définitivement cette tâche planifiée ?</p>{running ? <><p className="text-xs text-muted">Une exécution est en cours. La première action retire la planification mais laisse cette exécution se terminer.</p><div className="flex flex-col gap-2"><Button variant="danger" loading={vm.busyId === deleteTarget.id} onClick={() => void remove(deleteTarget)}>Supprimer après l’exécution</Button><Button variant="danger" loading={vm.busyId === deleteTarget.id} onClick={() => void remove(deleteTarget, true)}>Arrêter puis supprimer</Button><Button onClick={() => setDeleteTarget(null)}>Annuler</Button></div></> : <div className="flex justify-end gap-2"><Button onClick={() => setDeleteTarget(null)}>Annuler</Button><Button variant="danger" loading={vm.busyId === deleteTarget.id} onClick={() => void remove(deleteTarget)}>Supprimer définitivement</Button></div>}</div>;
|
||||
})()}
|
||||
</FloatingWindow>
|
||||
|
||||
<FloatingWindow open={approveTarget !== null} title="Approuver la tâche planifiée" size="md" onClose={() => setApproveTarget(null)}>
|
||||
{approveTarget && <div className="space-y-4">
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm">Cette tâche pourra modifier ou supprimer des fichiers automatiquement.</div>
|
||||
<dl className="grid gap-2 text-sm sm:grid-cols-[8rem_1fr]">
|
||||
<dt className="text-muted">Commande exacte</dt><dd className="break-all font-mono">{exactCommand({ name: approveTarget.name, command: approveTarget.command, schedule: approveTarget.schedule, timezone: approveTarget.timezone, enabled: true })}</dd>
|
||||
<dt className="text-muted">Dossier</dt><dd>{approveTarget.command.cwd ?? "."} (relatif au projet)</dd>
|
||||
<dt className="text-muted">Fréquence</dt><dd>{scheduleLabel(approveTarget.schedule)}</dd>
|
||||
<dt className="text-muted">Fuseau</dt><dd>{approveTarget.timezone}</dd>
|
||||
<dt className="text-muted">Prochaine exécution</dt><dd>Calculée par IdeA lors de l’activation</dd>
|
||||
<dt className="text-muted">Permissions</dt><dd>Lecture, modification et suppression dans le projet</dd>
|
||||
<dt className="text-muted">Sandbox</dt><dd>Projet — lecture/écriture</dd>
|
||||
</dl>
|
||||
<div className="flex justify-end gap-2"><Button onClick={() => setApproveTarget(null)}>Annuler</Button><Button variant="primary" loading={vm.busyId === approveTarget.id} onClick={() => void vm.setEnabled(approveTarget, true).then((ok) => { if (ok) setApproveTarget(null); })}>Approuver et activer</Button></div>
|
||||
</div>}
|
||||
</FloatingWindow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
frontend/src/features/scheduled-tasks/index.ts
Normal file
2
frontend/src/features/scheduled-tasks/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { ScheduledTasksPanel } from "./ScheduledTasksPanel";
|
||||
export { exactCommand, scheduleLabel } from "./ScheduledTaskForm";
|
||||
@ -0,0 +1,64 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DIProvider } from "@/app/di";
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockScheduledTaskGateway,
|
||||
MockSystemGateway,
|
||||
MockWorkStateGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { ScheduledProjectTask } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { ScheduledTasksPanel } from "./ScheduledTasksPanel";
|
||||
|
||||
const PROJECT = "project-schedules";
|
||||
|
||||
function renderPanel(scheduledTask = new MockScheduledTaskGateway()) {
|
||||
const gateways = {
|
||||
scheduledTask,
|
||||
agent: new MockAgentGateway(),
|
||||
system: new MockSystemGateway(),
|
||||
workState: new MockWorkStateGateway(),
|
||||
} as unknown as Gateways;
|
||||
return { scheduledTask, ...render(<DIProvider gateways={gateways}><ScheduledTasksPanel projectId={PROJECT} /></DIProvider>) };
|
||||
}
|
||||
|
||||
describe("ScheduledTasksPanel", () => {
|
||||
it("shows the prescribed empty state and creates only after security review", async () => {
|
||||
const { scheduledTask } = renderPanel();
|
||||
expect(await screen.findByText("Aucune tâche planifiée pour ce projet")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Nouvelle tâche planifiée" }));
|
||||
const dialog = screen.getByRole("dialog", { name: "Nouvelle tâche planifiée" });
|
||||
fireEvent.change(within(dialog).getByLabelText("Nom"), { target: { value: "Nettoyage" } });
|
||||
fireEvent.change(within(dialog).getByLabelText("Commande"), { target: { value: "npm" } });
|
||||
fireEvent.change(within(dialog).getByLabelText("Arguments"), { target: { value: "run\nclean" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Vérifier avant activation" }));
|
||||
expect(within(dialog).getByText("Cette tâche pourra modifier ou supprimer des fichiers automatiquement.")).toBeTruthy();
|
||||
expect(within(dialog).getByText("npm run clean")).toBeTruthy();
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Créer la tâche" }));
|
||||
await waitFor(async () => expect(await scheduledTask.list(PROJECT)).toHaveLength(1));
|
||||
expect(await screen.findByText("Nettoyage")).toBeTruthy();
|
||||
expect(screen.getByText("Active")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("surfaces an agent proposal and supports refusal", async () => {
|
||||
const scheduledTask = new MockScheduledTaskGateway();
|
||||
const now = Date.now();
|
||||
const pending: ScheduledProjectTask = {
|
||||
id: "agent-request", projectId: PROJECT, name: "Purge", description: "Limiter la taille du build",
|
||||
enabled: false, approval: "pending", sandbox: "projectReadWrite",
|
||||
command: { command: "find", args: ["dist", "-delete"] },
|
||||
schedule: { kind: "daily", time: "02:00" }, timezone: "Europe/Paris",
|
||||
nextRunAtMs: null, createdBy: { kind: "agent", agentId: "agent-1" },
|
||||
updatedBy: { kind: "agent", agentId: "agent-1" }, createdAtMs: now,
|
||||
updatedAtMs: now, auditRevision: 1,
|
||||
};
|
||||
scheduledTask._seed(pending);
|
||||
renderPanel(scheduledTask);
|
||||
expect(await screen.findByText("À approuver")).toBeTruthy();
|
||||
expect(screen.getByText(/Cette tâche ne s’exécutera pas avant votre approbation/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refuser" }));
|
||||
await waitFor(() => expect(screen.getByText("Suspendue")).toBeTruthy());
|
||||
});
|
||||
});
|
||||
109
frontend/src/features/scheduled-tasks/useScheduledTasks.ts
Normal file
109
frontend/src/features/scheduled-tasks/useScheduledTasks.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useGateways } from "@/app/di";
|
||||
import type { Agent, BackgroundCompletion, GatewayError, ScheduledProjectTask } from "@/domain";
|
||||
import type { ScheduledTaskDefinitionInput } from "@/ports";
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error && typeof error === "object" && "message" in error
|
||||
? String((error as GatewayError).message)
|
||||
: String(error);
|
||||
}
|
||||
|
||||
export function useScheduledTasks(projectId: string) {
|
||||
const { scheduledTask, workState, agent, system } = useGateways();
|
||||
const [tasks, setTasks] = useState<ScheduledProjectTask[]>([]);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [runs, setRuns] = useState<BackgroundCompletion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [definitions, roster, state] = await Promise.all([
|
||||
scheduledTask.list(projectId),
|
||||
agent.listAgents(projectId).catch(() => []),
|
||||
workState.getProjectWorkState(projectId).catch(() => null),
|
||||
]);
|
||||
setTasks(definitions);
|
||||
setAgents(roster);
|
||||
setRuns(state?.agents.flatMap((item) => item.backgroundTasks ?? []) ?? []);
|
||||
setError(null);
|
||||
} catch (cause) {
|
||||
setError(message(cause));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [agent, projectId, scheduledTask, workState]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
void system.onDomainEvent((event) => {
|
||||
if (event.type === "backgroundTaskChanged" && event.projectId === projectId) {
|
||||
void refresh();
|
||||
}
|
||||
}).then((stop) => {
|
||||
if (cancelled) stop();
|
||||
else unsubscribe = stop;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [projectId, refresh, system]);
|
||||
|
||||
const runsById = useMemo(
|
||||
() => new Map(runs.map((run) => [run.taskId, run])),
|
||||
[runs],
|
||||
);
|
||||
const agentNames = useMemo(
|
||||
() => new Map(agents.map((item) => [item.id, item.name])),
|
||||
[agents],
|
||||
);
|
||||
|
||||
async function mutate(id: string, action: () => Promise<unknown>): Promise<boolean> {
|
||||
setBusyId(id);
|
||||
setError(null);
|
||||
try {
|
||||
await action();
|
||||
await refresh();
|
||||
return true;
|
||||
} catch (cause) {
|
||||
setError(message(cause));
|
||||
return false;
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tasks,
|
||||
loading,
|
||||
busyId,
|
||||
error,
|
||||
runsById,
|
||||
agentNames,
|
||||
refresh,
|
||||
create: (input: ScheduledTaskDefinitionInput) =>
|
||||
mutate("new", () => scheduledTask.create(projectId, input)),
|
||||
update: (task: ScheduledProjectTask, input: ScheduledTaskDefinitionInput) =>
|
||||
mutate(task.id, () => scheduledTask.update(projectId, task.id, task.auditRevision, input)),
|
||||
remove: (task: ScheduledProjectTask) =>
|
||||
mutate(task.id, () => scheduledTask.delete(projectId, task.id)),
|
||||
runNow: (task: ScheduledProjectTask) =>
|
||||
mutate(task.id, () => scheduledTask.runNow(projectId, task.id)),
|
||||
setEnabled: (task: ScheduledProjectTask, enabled: boolean) =>
|
||||
mutate(task.id, () => scheduledTask.setEnabled(projectId, task.id, enabled)),
|
||||
cancelRun: (task: ScheduledProjectTask) =>
|
||||
task.lastTaskId
|
||||
? mutate(task.id, () => workState.cancelBackgroundTask(task.lastTaskId!))
|
||||
: Promise.resolve(undefined),
|
||||
};
|
||||
}
|
||||
@ -29,6 +29,7 @@ import type {
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
||||
import { ScheduledTasksPanel } from "@/features/scheduled-tasks";
|
||||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||
import { ProjectContextPanel } from "@/features/projects/ProjectContextPanel";
|
||||
import { isDockedTo, type ViewPlacement } from "@/features/projects/viewPlacement";
|
||||
@ -204,6 +205,7 @@ const WEB_PANEL_SIZE: Record<WebDesktopPanelId, FloatingWindowSize> = {
|
||||
projects: "md",
|
||||
context: "lg",
|
||||
work: "lg",
|
||||
scheduledTasks: "xl",
|
||||
tickets: "lg",
|
||||
sprints: "lg",
|
||||
agents: "lg",
|
||||
@ -332,6 +334,7 @@ function WebDesktopWorkspace({
|
||||
const panelOrder: WebDesktopPanelId[] = [
|
||||
"context",
|
||||
"work",
|
||||
"scheduledTasks",
|
||||
"tickets",
|
||||
"sprints",
|
||||
"agents",
|
||||
@ -867,6 +870,8 @@ export function WebProjectPanelBody({
|
||||
return <ProjectContextPanel projectId={projectId} />;
|
||||
case "work":
|
||||
return <LiveProjectPanel projectId={projectId} root={root} mobile={mobile} />;
|
||||
case "scheduledTasks":
|
||||
return <ScheduledTasksPanel projectId={projectId} />;
|
||||
case "tickets":
|
||||
return <WebTicketsView projectId={projectId} focusSprintId={focusSprintId} />;
|
||||
case "sprints":
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
export type WebProjectPanelId =
|
||||
| "context"
|
||||
| "work"
|
||||
| "scheduledTasks"
|
||||
| "tickets"
|
||||
| "sprints"
|
||||
| "agents"
|
||||
@ -31,6 +32,7 @@ export interface WebPanelMeta {
|
||||
export const WEB_PANEL_ORDER: WebProjectPanelId[] = [
|
||||
"context",
|
||||
"work",
|
||||
"scheduledTasks",
|
||||
"tickets",
|
||||
"sprints",
|
||||
"agents",
|
||||
@ -59,6 +61,13 @@ export const WEB_PANEL_META: Record<WebProjectPanelId, WebPanelMeta> = {
|
||||
description: "État live des agents et tâches",
|
||||
placement: "primary",
|
||||
},
|
||||
scheduledTasks: {
|
||||
id: "scheduledTasks",
|
||||
label: "Tâches planifiées",
|
||||
shortLabel: "Planifiées",
|
||||
description: "Automatisations récurrentes du projet",
|
||||
placement: "primary",
|
||||
},
|
||||
tickets: {
|
||||
id: "tickets",
|
||||
label: "Tickets",
|
||||
|
||||
@ -81,6 +81,9 @@ import type {
|
||||
ResumableAgent,
|
||||
ResolvedAgentPermissions,
|
||||
ResolvedAgentSystemPermissions,
|
||||
ScheduledProjectTask,
|
||||
ScheduledTaskCommand,
|
||||
ScheduledTaskSchedule,
|
||||
ReplyChunk,
|
||||
SlashCommand,
|
||||
ExecuteSlashCommandResult,
|
||||
@ -203,8 +206,14 @@ export interface AgentChatHandle {
|
||||
readonly cellKind: "chat";
|
||||
/** Conversation id assigned by launch when the backend minted one. */
|
||||
readonly assignedConversationId?: string;
|
||||
/** Why a successful structured open has no replayable transcript. */
|
||||
readonly transcriptUnavailableReason?: ConversationTranscriptUnavailableReason;
|
||||
}
|
||||
|
||||
export type ConversationTranscriptUnavailableReason =
|
||||
| "missingTranscript"
|
||||
| "emptyConversation";
|
||||
|
||||
/** Result of re-attaching a custom chat view to a live structured session. */
|
||||
export interface ReattachAgentChatResult {
|
||||
/** Effective live structured session id (may replace the requested id after restart). */
|
||||
@ -213,6 +222,8 @@ export interface ReattachAgentChatResult {
|
||||
conversationId?: string;
|
||||
/** Retained chunks already streamed for the session. */
|
||||
scrollback: ReplyChunk[];
|
||||
/** Additive reason when `scrollback` is empty. */
|
||||
transcriptUnavailableReason?: ConversationTranscriptUnavailableReason;
|
||||
}
|
||||
|
||||
/** Stable owner identity used to reconcile a stale transport id after restart. */
|
||||
@ -1207,6 +1218,35 @@ export interface WorkStateGateway {
|
||||
retryBackgroundTask(taskId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ScheduledTaskDefinitionInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
command: ScheduledTaskCommand;
|
||||
schedule: ScheduledTaskSchedule;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Durable project-scoped schedules; concrete executions remain in WorkState. */
|
||||
export interface ScheduledTaskGateway {
|
||||
list(projectId: string): Promise<ScheduledProjectTask[]>;
|
||||
create(projectId: string, input: ScheduledTaskDefinitionInput): Promise<ScheduledProjectTask>;
|
||||
update(
|
||||
projectId: string,
|
||||
scheduledTaskId: string,
|
||||
auditRevision: number,
|
||||
input: ScheduledTaskDefinitionInput,
|
||||
): Promise<ScheduledProjectTask>;
|
||||
delete(projectId: string, scheduledTaskId: string): Promise<void>;
|
||||
runNow(projectId: string, scheduledTaskId: string): Promise<ScheduledProjectTask>;
|
||||
setEnabled(
|
||||
projectId: string,
|
||||
scheduledTaskId: string,
|
||||
enabled: boolean,
|
||||
): Promise<ScheduledProjectTask>;
|
||||
}
|
||||
|
||||
/** One paginated page request over a conversation transcript (LS7). */
|
||||
export interface ConversationPageRequest {
|
||||
/** Turn id to paginate around; omit to start from a thread end. */
|
||||
@ -1804,6 +1844,7 @@ export interface Gateways {
|
||||
device: DeviceGateway;
|
||||
permission: PermissionGateway;
|
||||
workState: WorkStateGateway;
|
||||
scheduledTask: ScheduledTaskGateway;
|
||||
conversation: ConversationGateway;
|
||||
ticket: TicketGateway;
|
||||
window: WindowGateway;
|
||||
|
||||
Submodule sdk/IdeaSDK updated: 436ed59b41...047c38b7ca
Reference in New Issue
Block a user