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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user