merge(backend,frontend): tâches planifiées projet #305 + fix transcript lié après reboot #314 (QA verte)
Source: feature/305-scheduled-project-tasks-mcp (7007b2cfeat +686cf31chore tickets; sous-module sdk/IdeaSDK 047c38b docs services.md). #305: agrégat ScheduledProjectTask persisté <app_data_dir>/scheduled-project-tasks/<projectId>.json, schedules interval/daily/weekly, approbation obligatoire des tâches agent, 7 tools MCP idea_scheduled_task_*, commandes Tauri/HTTP contrats identiques, surface projet 'Tâches planifiées', reconcile boot sans rattrapage rafale, docs dédiée. #314: transcript lié restauré après reboot côté application et frontend avec état/action utile. QA verte sans bloquant rapportée par QA. Restant: rebuild AppImage depuis develop (commit dédié + tag, convention y: v0.47.0 -> v0.48.0) puis validation live; aucun push.
This commit is contained in:
@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#305"
|
issueRef: "#305"
|
||||||
version: 4
|
version: 6
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
updatedAt: 1789024612856
|
updatedAt: 1789115266263
|
||||||
---
|
---
|
||||||
# Carnet #305 - Tâches planifiées projet avec outil MCP et menu utilisateur
|
# Carnet #305 - Tâches planifiées projet avec outil MCP et menu utilisateur
|
||||||
|
|
||||||
@ -187,3 +187,47 @@ Fichiers probables:
|
|||||||
- #304 est implémenté et QA vert, mais non committé/mergé: trois délégations Git pour le commit/merge #304 ont expiré à 600s malgré relance/rattachement.
|
- #304 est implémenté et QA vert, mais non committé/mergé: trois délégations Git pour le commit/merge #304 ont expiré à 600s malgré relance/rattachement.
|
||||||
- Le worktree courant porte encore les deux fichiers frontend #304 modifiés. Main ne doit pas démarrer #305 ni changer de branche tant que cette topologie n'est pas résolue, pour éviter de mélanger les changements.
|
- Le worktree courant porte encore les deux fichiers frontend #304 modifiés. Main ne doit pas démarrer #305 ni changer de branche tant que cette topologie n'est pas résolue, pour éviter de mélanger les changements.
|
||||||
- Prochaine reprise: débloquer Git ou faire exécuter par l'agent Git le commit/merge #304, puis checkout/rebase/reset de `feature/305-scheduled-project-tasks-mcp` par Git et lancer DevBackend #305.
|
- Prochaine reprise: débloquer Git ou faire exécuter par l'agent Git le commit/merge #304, puis checkout/rebase/reset de `feature/305-scheduled-project-tasks-mcp` par Git et lancer DevBackend #305.
|
||||||
|
|
||||||
|
## Validation QA — 2026-09-11
|
||||||
|
|
||||||
|
Verdict: VERT pour le périmètre #305 sur `feature/305-scheduled-project-tasks-mcp`.
|
||||||
|
|
||||||
|
Diff inspecté: nouvel agrégat durable `ScheduledProjectTask`, store machine-local `<app_data_dir>/scheduled-project-tasks/<projectId>.json`, service application de création/update/enable/delete/run/fire/reconcile, origine `BackgroundTaskOrigin::Scheduled`, tools MCP `idea_scheduled_task_*`, commandes Tauri/HTTP, surface frontend `Tâches planifiées`, adapters Tauri/HTTP/mock et doc `docs/scheduled-project-tasks.md`.
|
||||||
|
|
||||||
|
Preuves de commandes réelles:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CARGO_HOME=/home/anthony/.local/share/app.idea.ide/managed-tmp/1d97033d-7b81-4f12-9b27-fe5a3842c5cc/data/cargo-home cargo test -p domain -- --nocapture
|
||||||
|
=> test result: ok. 311 passed; 0 failed; intégrations domain 11+14+2+12+11+49+6+72+16+21+16+5 passed; doc-tests 0.
|
||||||
|
|
||||||
|
CARGO_HOME=/home/anthony/.local/share/app.idea.ide/managed-tmp/1d97033d-7b81-4f12-9b27-fe5a3842c5cc/data/cargo-home cargo test -p application scheduled_task -- --nocapture
|
||||||
|
=> test result: ok. 3 passed; 0 failed; 185 filtered out.
|
||||||
|
|
||||||
|
CARGO_HOME=/home/anthony/.local/share/app.idea.ide/managed-tmp/1d97033d-7b81-4f12-9b27-fe5a3842c5cc/data/cargo-home cargo test -p infrastructure scheduled_task -- --nocapture
|
||||||
|
=> test result: ok. 1 passed; 0 failed; 380 filtered out.
|
||||||
|
|
||||||
|
CARGO_HOME=/home/anthony/.local/share/app.idea.ide/managed-tmp/1d97033d-7b81-4f12-9b27-fe5a3842c5cc/data/cargo-home cargo test -p infrastructure scheduled_create -- --nocapture
|
||||||
|
=> test result: ok. 1 passed; 0 failed; 380 filtered out.
|
||||||
|
|
||||||
|
CARGO_HOME=/home/anthony/.local/share/app.idea.ide/managed-tmp/1d97033d-7b81-4f12-9b27-fe5a3842c5cc/data/cargo-home cargo test -p infrastructure scheduled_read -- --nocapture
|
||||||
|
=> test result: ok. 1 passed; 0 failed; 380 filtered out.
|
||||||
|
|
||||||
|
CARGO_HOME=/home/anthony/.local/share/app.idea.ide/managed-tmp/1d97033d-7b81-4f12-9b27-fe5a3842c5cc/data/cargo-home cargo test -p backend scheduled_task -- --nocapture
|
||||||
|
=> test result: ok. 1 passed; 0 failed; 103 filtered out.
|
||||||
|
|
||||||
|
CARGO_HOME=/home/anthony/.local/share/app.idea.ide/managed-tmp/1d97033d-7b81-4f12-9b27-fe5a3842c5cc/data/cargo-home cargo test -p web-server scheduled_task -- --nocapture
|
||||||
|
=> test result: ok. 1 passed; 0 failed; 116 filtered out.
|
||||||
|
|
||||||
|
npm --prefix frontend test -- --run src/features/scheduled-tasks/scheduledTasks.test.tsx src/adapters/scheduledTask.test.ts src/adapters/http/scheduledTaskGateway.test.ts src/features/agents/CustomAgentChatView.test.tsx src/adapters/agent.test.ts src/adapters/http/agentGateway.test.ts
|
||||||
|
=> Test Files 6 passed (6); Tests 107 passed (107).
|
||||||
|
|
||||||
|
npm --prefix frontend run typecheck
|
||||||
|
=> tsc --noEmit passed.
|
||||||
|
|
||||||
|
npm --prefix frontend run build
|
||||||
|
=> tsc --noEmit && vite build passed; Vite emitted only the existing chunk-size warning.
|
||||||
|
```
|
||||||
|
|
||||||
|
Risques résiduels non bloquants:
|
||||||
|
- Pas de test live AppImage/desktop scheduler réel dans cette passe QA; validation limitée aux tests automatisés Rust/TS et inspection du câblage.
|
||||||
|
- Les critères UX exhaustifs comme notifications discrètes et parcours complet créer UI -> lire MCP / créer MCP -> lire UI sont couverts par contrat/adapters/tests unitaires ciblés, pas par un e2e navigateur/Tauri complet.
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
id: "6b6abd28-59c9-4f07-a19c-fb3770ef1415"
|
id: "6b6abd28-59c9-4f07-a19c-fb3770ef1415"
|
||||||
number: 305
|
number: 305
|
||||||
title: "Tâches planifiées projet avec outil MCP et menu utilisateur"
|
title: "Tâches planifiées projet avec outil MCP et menu utilisateur"
|
||||||
status: "inProgress"
|
status: "qa"
|
||||||
priority: "high"
|
priority: "high"
|
||||||
sprint: null
|
sprint: null
|
||||||
links: []
|
links: []
|
||||||
@ -11,7 +11,7 @@ attachments: []
|
|||||||
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
createdAt: 1789021772418
|
createdAt: 1789021772418
|
||||||
updatedAt: 1789024612856
|
updatedAt: 1789114711757
|
||||||
version: 4
|
version: 5
|
||||||
---
|
---
|
||||||
Demande utilisateur du 2026-09-10 : ajouter à IdeA des tâches planifiées liées au projet, gérables par IdeA plutôt que par le cron Linux. Les agents doivent disposer d'un tool MCP pour créer des tâches récurrentes/projet. L'utilisateur doit avoir un menu par projet pour créer, supprimer et modifier ces tasks. Motivation : éviter qu'un orchestrateur crée un cron système Linux pour nettoyer régulièrement un dossier projet qui grossit build après build. Une fois livré et validé, rebuild l'AppImage.
|
Demande utilisateur du 2026-09-10 : ajouter à IdeA des tâches planifiées liées au projet, gérables par IdeA plutôt que par le cron Linux. Les agents doivent disposer d'un tool MCP pour créer des tâches récurrentes/projet. L'utilisateur doit avoir un menu par projet pour créer, supprimer et modifier ces tasks. Motivation : éviter qu'un orchestrateur crée un cron système Linux pour nettoyer régulièrement un dossier projet qui grossit build après build. Une fois livré et validé, rebuild l'AppImage.
|
||||||
54
.ideai/tickets/314/carnet.md
Normal file
54
.ideai/tickets/314/carnet.md
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#314"
|
||||||
|
version: 5
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
|
updatedAt: 1789115266263
|
||||||
|
---
|
||||||
|
# Carnet #314 - Conversation liée après reboot affiche transcript indisponible
|
||||||
|
|
||||||
|
## Signalement
|
||||||
|
- Date: 2026-09-11.
|
||||||
|
- Contexte: utilisateur vient d'allumer le PC et d'ouvrir IdeA.
|
||||||
|
- Symptôme visible dans la fenêtre Main: `Conversation liée — transcript indisponible.`
|
||||||
|
- Capture importée par IdeA: `/home/anthony/Documents/Projects/IdeA/.ideai/attachments/agent-chat/f9088cc7-5c7d-425d-86a4-178b1857ebdb/cfd2c4b3-37a0-4288-abe9-7f31b32d38fc-clipboard-image-1789108822637.png`.
|
||||||
|
|
||||||
|
## Première lecture Main
|
||||||
|
- Le message correspond au garde-fou livré dans #307: au lieu d'un spinner infini quand une conversation liée ne rend aucun tour après `openOrAttach`, le frontend affiche un état terminal `transcript indisponible`.
|
||||||
|
- Reste à qualifier si, après reboot, c'est un état attendu mais mal actionnable ou une régression de restauration/lecture du transcript.
|
||||||
|
- À traiter séparément de #305 pour éviter de mélanger la feature tâches planifiées avec ce bug de restauration conversationnelle.
|
||||||
|
|
||||||
|
## Validation QA — 2026-09-11
|
||||||
|
|
||||||
|
Verdict: VERT pour #314 sur `feature/305-scheduled-project-tasks-mcp`.
|
||||||
|
|
||||||
|
Diff inspecté: `ReattachChatDto` expose `transcriptUnavailableReason` (`missingTranscript` vs `emptyConversation`), Tauri et WebSocket remplissent la raison depuis `StructuredSessions::transcript_restore_expected`, le frontend `CustomAgentChatView` affiche un état terminal actionnable avec `Réessayer` et `Délier la conversation`, et le cas `NOT_FOUND` devient actionnable sans boucle automatique.
|
||||||
|
|
||||||
|
Preuves de commandes réelles:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CARGO_HOME=/home/anthony/.local/share/app.idea.ide/managed-tmp/1d97033d-7b81-4f12-9b27-fe5a3842c5cc/data/cargo-home cargo test -p app-tauri --test dto_chat -- --nocapture
|
||||||
|
=> test result: ok. 26 passed; 0 failed.
|
||||||
|
|
||||||
|
CARGO_HOME=/home/anthony/.local/share/app.idea.ide/managed-tmp/1d97033d-7b81-4f12-9b27-fe5a3842c5cc/data/cargo-home cargo test -p web-server chat_attached -- --nocapture
|
||||||
|
=> test result: ok. 2 passed; 0 failed; 115 filtered out.
|
||||||
|
|
||||||
|
CARGO_HOME=/home/anthony/.local/share/app.idea.ide/managed-tmp/1d97033d-7b81-4f12-9b27-fe5a3842c5cc/data/cargo-home cargo test -p web-server websocket_chat_attach_recovers_stale_session_from_live_project_agent -- --nocapture
|
||||||
|
=> test result: ok. 1 passed; 0 failed; 116 filtered out.
|
||||||
|
|
||||||
|
CARGO_HOME=/home/anthony/.local/share/app.idea.ide/managed-tmp/1d97033d-7b81-4f12-9b27-fe5a3842c5cc/data/cargo-home cargo test -p application --test structured_registry_d1 -- --nocapture
|
||||||
|
=> test result: ok. 14 passed; 0 failed.
|
||||||
|
|
||||||
|
CARGO_HOME=/home/anthony/.local/share/app.idea.ide/managed-tmp/1d97033d-7b81-4f12-9b27-fe5a3842c5cc/data/cargo-home cargo test -p application --test agent_lifecycle -- --nocapture
|
||||||
|
=> test result: ok. 111 passed; 0 failed.
|
||||||
|
|
||||||
|
npm --prefix frontend test -- --run src/features/scheduled-tasks/scheduledTasks.test.tsx src/adapters/scheduledTask.test.ts src/adapters/http/scheduledTaskGateway.test.ts src/features/agents/CustomAgentChatView.test.tsx src/adapters/agent.test.ts src/adapters/http/agentGateway.test.ts
|
||||||
|
=> Test Files 6 passed (6); Tests 107 passed (107).
|
||||||
|
|
||||||
|
npm --prefix frontend run typecheck
|
||||||
|
=> tsc --noEmit passed.
|
||||||
|
|
||||||
|
npm --prefix frontend run build
|
||||||
|
=> tsc --noEmit && vite build passed; Vite emitted only the existing chunk-size warning.
|
||||||
|
```
|
||||||
|
|
||||||
|
Risque résiduel non bloquant: pas de repro manuelle après reboot réel dans l'AppImage; la validation prouve le contrat backend/DTO/frontend automatisé et le scénario WebSocket de session stale récupérée avec `missingTranscript`.
|
||||||
17
.ideai/tickets/314/issue.md
Normal file
17
.ideai/tickets/314/issue.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
id: "dc01987e-3002-447f-821b-4fc2cb71a28c"
|
||||||
|
number: 314
|
||||||
|
title: "Conversation liée après reboot affiche transcript indisponible"
|
||||||
|
status: "qa"
|
||||||
|
priority: "high"
|
||||||
|
sprint: null
|
||||||
|
links: [{"target":"#305","kind":"relatesTo"}]
|
||||||
|
agentRefs: []
|
||||||
|
attachments: []
|
||||||
|
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
|
createdAt: 1789108874927
|
||||||
|
updatedAt: 1789114712037
|
||||||
|
version: 4
|
||||||
|
---
|
||||||
|
Signalement utilisateur du 2026-09-11 au démarrage d'IdeA après allumage du PC : la fenêtre Main affiche « Conversation liée — transcript indisponible. » au centre de la cellule. Capture jointe côté conversation utilisateur. À investiguer : distinguer garde-fou attendu de #307 vs régression de restauration/lecture du transcript après reboot, et faire en sorte que l'utilisateur récupère soit le transcript, soit un état/action utile.
|
||||||
@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"nextNumber": 308
|
"nextNumber": 315
|
||||||
}
|
}
|
||||||
@ -4184,7 +4184,7 @@
|
|||||||
"issueRef": "#305",
|
"issueRef": "#305",
|
||||||
"path": "305",
|
"path": "305",
|
||||||
"title": "Tâches planifiées projet avec outil MCP et menu utilisateur",
|
"title": "Tâches planifiées projet avec outil MCP et menu utilisateur",
|
||||||
"status": "inProgress",
|
"status": "qa",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"sprint": null,
|
"sprint": null,
|
||||||
"assignedAgentIds": [],
|
"assignedAgentIds": [],
|
||||||
@ -4192,7 +4192,7 @@
|
|||||||
"kind": "agent",
|
"kind": "agent",
|
||||||
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||||
},
|
},
|
||||||
"updatedAt": 1789024612856
|
"updatedAt": 1789114711757
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"issueRef": "#306",
|
"issueRef": "#306",
|
||||||
@ -4221,6 +4221,116 @@
|
|||||||
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||||
},
|
},
|
||||||
"updatedAt": 1789034059104
|
"updatedAt": 1789034059104
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#308",
|
||||||
|
"path": "308",
|
||||||
|
"title": "Permettre la suppression d'un projet IdeA depuis l'application",
|
||||||
|
"status": "closed",
|
||||||
|
"priority": "high",
|
||||||
|
"sprint": null,
|
||||||
|
"assignedAgentIds": [],
|
||||||
|
"createdBy": {
|
||||||
|
"kind": "agent",
|
||||||
|
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||||
|
},
|
||||||
|
"updatedAt": 1789057719878
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#309",
|
||||||
|
"path": "309",
|
||||||
|
"title": "Corriger les writable roots Codex sur projets non-IdeA",
|
||||||
|
"status": "closed",
|
||||||
|
"priority": "critical",
|
||||||
|
"sprint": null,
|
||||||
|
"assignedAgentIds": [],
|
||||||
|
"createdBy": {
|
||||||
|
"kind": "agent",
|
||||||
|
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||||
|
},
|
||||||
|
"updatedAt": 1789060217658
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#310",
|
||||||
|
"path": "310",
|
||||||
|
"title": "Horodater tous les messages et événements de conversation visibles",
|
||||||
|
"status": "closed",
|
||||||
|
"priority": "high",
|
||||||
|
"sprint": null,
|
||||||
|
"assignedAgentIds": [
|
||||||
|
"fe887179-933f-47d4-960f-c3b06827f86c",
|
||||||
|
"8f7da528-58df-4315-97e9-0562230ecc19"
|
||||||
|
],
|
||||||
|
"createdBy": {
|
||||||
|
"kind": "agent",
|
||||||
|
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||||
|
},
|
||||||
|
"updatedAt": 1789068406431
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#311",
|
||||||
|
"path": "311",
|
||||||
|
"title": "Rendre le stop d'agent robuste pour PTY non monté, headless et CLI custom",
|
||||||
|
"status": "closed",
|
||||||
|
"priority": "critical",
|
||||||
|
"sprint": null,
|
||||||
|
"assignedAgentIds": [
|
||||||
|
"fe887179-933f-47d4-960f-c3b06827f86c",
|
||||||
|
"8f7da528-58df-4315-97e9-0562230ecc19"
|
||||||
|
],
|
||||||
|
"createdBy": {
|
||||||
|
"kind": "agent",
|
||||||
|
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||||
|
},
|
||||||
|
"updatedAt": 1789068406452
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#312",
|
||||||
|
"path": "312",
|
||||||
|
"title": "Ajouter une préférence globale pour l'interface terminal par défaut",
|
||||||
|
"status": "closed",
|
||||||
|
"priority": "medium",
|
||||||
|
"sprint": null,
|
||||||
|
"assignedAgentIds": [
|
||||||
|
"fe887179-933f-47d4-960f-c3b06827f86c",
|
||||||
|
"8f7da528-58df-4315-97e9-0562230ecc19"
|
||||||
|
],
|
||||||
|
"createdBy": {
|
||||||
|
"kind": "agent",
|
||||||
|
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||||
|
},
|
||||||
|
"updatedAt": 1789068406473
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#313",
|
||||||
|
"path": "313",
|
||||||
|
"title": "Ajouter Paramètres du projet avec contexte et couleur de thème par projet",
|
||||||
|
"status": "closed",
|
||||||
|
"priority": "high",
|
||||||
|
"sprint": null,
|
||||||
|
"assignedAgentIds": [
|
||||||
|
"fe887179-933f-47d4-960f-c3b06827f86c",
|
||||||
|
"8f7da528-58df-4315-97e9-0562230ecc19"
|
||||||
|
],
|
||||||
|
"createdBy": {
|
||||||
|
"kind": "agent",
|
||||||
|
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||||
|
},
|
||||||
|
"updatedAt": 1789068406493
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#314",
|
||||||
|
"path": "314",
|
||||||
|
"title": "Conversation liée après reboot affiche transcript indisponible",
|
||||||
|
"status": "qa",
|
||||||
|
"priority": "high",
|
||||||
|
"sprint": null,
|
||||||
|
"assignedAgentIds": [],
|
||||||
|
"createdBy": {
|
||||||
|
"kind": "agent",
|
||||||
|
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||||
|
},
|
||||||
|
"updatedAt": 1789114712037
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
50
Cargo.lock
generated
50
Cargo.lock
generated
@ -98,6 +98,8 @@ name = "application"
|
|||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"chrono",
|
||||||
|
"chrono-tz",
|
||||||
"dirs",
|
"dirs",
|
||||||
"domain",
|
"domain",
|
||||||
"serde",
|
"serde",
|
||||||
@ -448,6 +450,16 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@ -625,7 +637,7 @@ dependencies = [
|
|||||||
"cssparser-macros",
|
"cssparser-macros",
|
||||||
"dtoa-short",
|
"dtoa-short",
|
||||||
"itoa",
|
"itoa",
|
||||||
"phf",
|
"phf 0.13.1",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -2963,6 +2975,15 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
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]]
|
[[package]]
|
||||||
name = "phf"
|
name = "phf"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@ -2970,7 +2991,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_macros",
|
"phf_macros",
|
||||||
"phf_shared",
|
"phf_shared 0.13.1",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -2981,7 +3002,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_generator",
|
"phf_generator",
|
||||||
"phf_shared",
|
"phf_shared 0.13.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2991,7 +3012,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"phf_shared",
|
"phf_shared 0.13.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -3001,12 +3022,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_generator",
|
"phf_generator",
|
||||||
"phf_shared",
|
"phf_shared 0.13.1",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "phf_shared"
|
name = "phf_shared"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@ -3714,7 +3744,7 @@ dependencies = [
|
|||||||
"derive_more",
|
"derive_more",
|
||||||
"log",
|
"log",
|
||||||
"new_debug_unreachable",
|
"new_debug_unreachable",
|
||||||
"phf",
|
"phf 0.13.1",
|
||||||
"phf_codegen",
|
"phf_codegen",
|
||||||
"precomputed-hash",
|
"precomputed-hash",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
@ -4087,7 +4117,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"new_debug_unreachable",
|
"new_debug_unreachable",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"phf_shared",
|
"phf_shared 0.13.1",
|
||||||
"precomputed-hash",
|
"precomputed-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -4098,7 +4128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_generator",
|
"phf_generator",
|
||||||
"phf_shared",
|
"phf_shared 0.13.1",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
]
|
]
|
||||||
@ -4477,7 +4507,7 @@ dependencies = [
|
|||||||
"json-patch",
|
"json-patch",
|
||||||
"log",
|
"log",
|
||||||
"memchr",
|
"memchr",
|
||||||
"phf",
|
"phf 0.13.1",
|
||||||
"plist",
|
"plist",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@ -5348,7 +5378,7 @@ version = "0.2.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
|
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf",
|
"phf 0.13.1",
|
||||||
"phf_codegen",
|
"phf_codegen",
|
||||||
"string_cache",
|
"string_cache",
|
||||||
"string_cache_codegen",
|
"string_cache_codegen",
|
||||||
|
|||||||
@ -33,6 +33,8 @@ http = "1"
|
|||||||
# only local operations (status/commit/branch/checkout/log) are in scope; remote
|
# 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.
|
# push/pull and static vendoring for the AppImage are deferred to L9/L11.
|
||||||
git2 = { version = "0.20", default-features = false }
|
git2 = { version = "0.20", default-features = false }
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
|
chrono-tz = "0.10"
|
||||||
|
|
||||||
# Internal crates
|
# Internal crates
|
||||||
domain = { path = "crates/domain" }
|
domain = { path = "crates/domain" }
|
||||||
|
|||||||
@ -3719,11 +3719,12 @@ pub fn reattach_agent_chat(
|
|||||||
// this live channel — generation supersede, no double emission).
|
// this live channel — generation supersede, no double emission).
|
||||||
let _gen = state.chat_bridge.register(sid, on_reply);
|
let _gen = state.chat_bridge.register(sid, on_reply);
|
||||||
|
|
||||||
Ok(ReattachChatDto {
|
Ok(ReattachChatDto::from_scrollback(
|
||||||
session_id: sid.to_string(),
|
sid.to_string(),
|
||||||
conversation_id,
|
conversation_id,
|
||||||
scrollback,
|
scrollback,
|
||||||
})
|
state.structured_sessions.transcript_restore_expected(&sid),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `detach_agent_chat` — detach only the custom chat view transport from a live
|
/// `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,
|
rendezvous: None,
|
||||||
parent_task_id: None,
|
parent_task_id: None,
|
||||||
deadline_ms: request.deadline_ms,
|
deadline_ms: request.deadline_ms,
|
||||||
|
origin: domain::BackgroundTaskOrigin::Manual,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map(|out| BackgroundTaskDto::from(out.task))
|
.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));
|
tasks.sort_by_key(|task| (task.created_at_ms, task.id));
|
||||||
Ok(tasks.into_iter().map(BackgroundTaskDto::from).collect())
|
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::retry_background_task,
|
||||||
commands::attach_background_task,
|
commands::attach_background_task,
|
||||||
commands::list_background_tasks,
|
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_plugins,
|
||||||
plugins::plugin_list_project_settings,
|
plugins::plugin_list_project_settings,
|
||||||
plugins::plugin_set_project_enabled,
|
plugins::plugin_set_project_enabled,
|
||||||
|
|||||||
@ -8,9 +8,10 @@
|
|||||||
|
|
||||||
use app_tauri_lib::dto::{
|
use app_tauri_lib::dto::{
|
||||||
CellKind, ChatAttachmentDto, ChatAttachmentInputDto, ChatDraftScopeRequestDto,
|
CellKind, ChatAttachmentDto, ChatAttachmentInputDto, ChatDraftScopeRequestDto,
|
||||||
ChatPromptAttachmentDto, ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto,
|
ChatPromptAttachmentDto, ConversationTranscriptUnavailableReasonDto,
|
||||||
ReadChatDraftResponseDto, ReattachChatDto, ReplyChunk, ReplyProgressDto, ReplyProgressKindDto,
|
ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto, ReadChatDraftResponseDto,
|
||||||
ReplyProgressSourceDto, ReplyProgressStageDto, SaveChatDraftRequestDto, TerminalSessionDto,
|
ReattachChatDto, ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, ReplyProgressSourceDto,
|
||||||
|
ReplyProgressStageDto, SaveChatDraftRequestDto, TerminalSessionDto,
|
||||||
};
|
};
|
||||||
use application::{LaunchAgentOutput, StructuredSessionDescriptor};
|
use application::{LaunchAgentOutput, StructuredSessionDescriptor};
|
||||||
use domain::project::ProjectPath;
|
use domain::project::ProjectPath;
|
||||||
@ -407,10 +408,10 @@ fn import_chat_attachments_request_response_use_camel_case() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
|
fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
|
||||||
let dto = ReattachChatDto {
|
let dto = ReattachChatDto::from_scrollback(
|
||||||
session_id: "sess-1".into(),
|
"sess-1".into(),
|
||||||
conversation_id: Some("conv-1".into()),
|
Some("conv-1".into()),
|
||||||
scrollback: vec![
|
vec![
|
||||||
ReplyChunk::UserPrompt {
|
ReplyChunk::UserPrompt {
|
||||||
at_ms: None,
|
at_ms: None,
|
||||||
text: "Hi".into(),
|
text: "Hi".into(),
|
||||||
@ -426,7 +427,8 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
|
|||||||
content: "Hi".into(),
|
content: "Hi".into(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
true,
|
||||||
|
);
|
||||||
let v = serde_json::to_value(&dto).unwrap();
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
v,
|
v,
|
||||||
@ -445,14 +447,22 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reattach_chat_dto_empty_scrollback_is_empty_array() {
|
fn reattach_chat_dto_empty_scrollback_is_empty_array() {
|
||||||
let dto = ReattachChatDto {
|
let dto = ReattachChatDto::from_scrollback("s".into(), None, vec![], true);
|
||||||
session_id: "s".into(),
|
|
||||||
conversation_id: None,
|
|
||||||
scrollback: vec![],
|
|
||||||
};
|
|
||||||
let v = serde_json::to_value(&dto).unwrap();
|
let v = serde_json::to_value(&dto).unwrap();
|
||||||
assert_eq!(v["scrollback"], json!([]));
|
assert_eq!(v["scrollback"], json!([]));
|
||||||
assert!(v.get("conversationId").is_none());
|
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 }
|
dirs = { workspace = true }
|
||||||
# `v5` derives stable reference-profile ids from a fixed namespace (catalogue).
|
# `v5` derives stable reference-profile ids from a fixed namespace (catalogue).
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
chrono-tz = { workspace = true }
|
||||||
# `time` feature only : borne le rendez-vous synchrone `send_blocking` (§17.4).
|
# `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.
|
# Déjà le runtime async du workspace ; pas une nouvelle dépendance externe.
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@ -2752,6 +2752,7 @@ impl LaunchAgent {
|
|||||||
&input.project.root,
|
&input.project.root,
|
||||||
input.node_id,
|
input.node_id,
|
||||||
size,
|
size,
|
||||||
|
launch_conversation_id.is_some(),
|
||||||
&spec.env,
|
&spec.env,
|
||||||
None,
|
None,
|
||||||
structured_policy.as_ref(),
|
structured_policy.as_ref(),
|
||||||
@ -2855,6 +2856,7 @@ impl LaunchAgent {
|
|||||||
root: &ProjectPath,
|
root: &ProjectPath,
|
||||||
node_id: Option<NodeId>,
|
node_id: Option<NodeId>,
|
||||||
size: PtySize,
|
size: PtySize,
|
||||||
|
transcript_restore_expected: bool,
|
||||||
env: &[(String, String)],
|
env: &[(String, String)],
|
||||||
sandbox: Option<&SandboxPlan>,
|
sandbox: Option<&SandboxPlan>,
|
||||||
structured_policy: Option<&StructuredProviderLaunchPolicy>,
|
structured_policy: Option<&StructuredProviderLaunchPolicy>,
|
||||||
@ -2898,6 +2900,7 @@ impl LaunchAgent {
|
|||||||
model_server_guard,
|
model_server_guard,
|
||||||
launch_managed_temp.map(|temp| temp.handle),
|
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) ──
|
// ── SÉPARATION DES DEUX CLÉS (ARCHITECTURE §19.7, lot P8a) ──
|
||||||
// - **id de paire** (`pair_conversation_id`) : clé **logique** persistée sur
|
// - **id de paire** (`pair_conversation_id`) : clé **logique** persistée sur
|
||||||
|
|||||||
@ -243,7 +243,12 @@ impl SessionLimitService {
|
|||||||
agent_id,
|
agent_id,
|
||||||
node_id,
|
node_id,
|
||||||
conversation_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).
|
// 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));
|
self.disarm(RuntimeAgentKey::new(project_id, agent_id));
|
||||||
|
|||||||
@ -21,9 +21,9 @@ use domain::ports::{
|
|||||||
EventBus, IdGenerator, SpawnSpec,
|
EventBus, IdGenerator, SpawnSpec,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskRendezvousLink,
|
AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskOrigin,
|
||||||
BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, DomainEvent, ProjectId,
|
BackgroundTaskRendezvousLink, BackgroundTaskResult, BackgroundTaskState,
|
||||||
TaskId,
|
BackgroundTaskWakePolicy, DomainEvent, ProjectId, TaskId,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@ -60,6 +60,8 @@ pub struct SpawnBackgroundCommandInput {
|
|||||||
pub parent_task_id: Option<TaskId>,
|
pub parent_task_id: Option<TaskId>,
|
||||||
/// Optional absolute deadline, epoch milliseconds.
|
/// Optional absolute deadline, epoch milliseconds.
|
||||||
pub deadline_ms: Option<u64>,
|
pub deadline_ms: Option<u64>,
|
||||||
|
/// Manual or scheduled occurrence provenance.
|
||||||
|
pub origin: BackgroundTaskOrigin,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Output of [`SpawnBackgroundCommand`] / [`RetryBackgroundTask`].
|
/// Output of [`SpawnBackgroundCommand`] / [`RetryBackgroundTask`].
|
||||||
@ -121,6 +123,7 @@ impl SpawnBackgroundCommand {
|
|||||||
input.rendezvous,
|
input.rendezvous,
|
||||||
input.parent_task_id,
|
input.parent_task_id,
|
||||||
input.deadline_ms,
|
input.deadline_ms,
|
||||||
|
input.origin,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(SpawnBackgroundCommandOutput { task })
|
Ok(SpawnBackgroundCommandOutput { task })
|
||||||
@ -137,6 +140,7 @@ impl SpawnBackgroundCommand {
|
|||||||
rendezvous: Option<BackgroundTaskRendezvousLink>,
|
rendezvous: Option<BackgroundTaskRendezvousLink>,
|
||||||
parent_task_id: Option<TaskId>,
|
parent_task_id: Option<TaskId>,
|
||||||
deadline_ms: Option<u64>,
|
deadline_ms: Option<u64>,
|
||||||
|
origin: BackgroundTaskOrigin,
|
||||||
) -> Result<BackgroundTask, AppError> {
|
) -> Result<BackgroundTask, AppError> {
|
||||||
let task_id = TaskId::from_uuid(self.ids.new_uuid());
|
let task_id = TaskId::from_uuid(self.ids.new_uuid());
|
||||||
let now = u64::try_from(self.clock.now_millis().max(0)).unwrap_or(0);
|
let now = u64::try_from(self.clock.now_millis().max(0)).unwrap_or(0);
|
||||||
@ -151,6 +155,7 @@ impl SpawnBackgroundCommand {
|
|||||||
deadline_ms,
|
deadline_ms,
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
task = task.with_origin(origin);
|
||||||
if let Some(link) = rendezvous {
|
if let Some(link) = rendezvous {
|
||||||
task = task.with_rendezvous(link);
|
task = task.with_rendezvous(link);
|
||||||
}
|
}
|
||||||
@ -485,6 +490,7 @@ impl RetryBackgroundTask {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
BackgroundTaskOrigin::Manual,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(SpawnBackgroundCommandOutput { task })
|
Ok(SpawnBackgroundCommandOutput { task })
|
||||||
|
|||||||
@ -35,6 +35,7 @@ pub mod plugin;
|
|||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod project_plugin;
|
pub mod project_plugin;
|
||||||
pub mod remote;
|
pub mod remote;
|
||||||
|
pub mod scheduled_task;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
pub mod slash_command;
|
pub mod slash_command;
|
||||||
@ -220,6 +221,10 @@ pub use project_plugin::{
|
|||||||
ProjectPluginSettingsView, SetProjectPluginEnabled,
|
ProjectPluginSettingsView, SetProjectPluginEnabled,
|
||||||
};
|
};
|
||||||
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
|
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
|
||||||
|
pub use scheduled_task::{
|
||||||
|
next_occurrence, CreateScheduledProjectTaskInput, ScheduledProjectTaskService,
|
||||||
|
UpdateScheduledProjectTaskInput,
|
||||||
|
};
|
||||||
pub use settings::{
|
pub use settings::{
|
||||||
ReadProjectSettings, ReadUserSettings, UpdateProjectSettings, UpdateUserSettings,
|
ReadProjectSettings, ReadUserSettings, UpdateProjectSettings, UpdateUserSettings,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -54,6 +54,9 @@ use crate::orchestrator::{
|
|||||||
ReadMemoryInput, UpdateProjectContext, UpdateProjectContextInput, WriteMemory,
|
ReadMemoryInput, UpdateProjectContext, UpdateProjectContextInput, WriteMemory,
|
||||||
WriteMemoryInput,
|
WriteMemoryInput,
|
||||||
};
|
};
|
||||||
|
use crate::scheduled_task::{
|
||||||
|
CreateScheduledProjectTaskInput, ScheduledProjectTaskService, UpdateScheduledProjectTaskInput,
|
||||||
|
};
|
||||||
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
|
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
|
||||||
use crate::terminal::{
|
use crate::terminal::{
|
||||||
stop_live_agent_runtime_in_project, CloseTerminal, LiveSessions, StructuredSessions,
|
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
|
/// 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é.
|
/// agent MCP `idea_run_in_background`. `None` ⇒ outil non configuré.
|
||||||
spawn_background_command: Option<Arc<SpawnBackgroundCommand>>,
|
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.
|
/// Detached self-restart coordinator, wired at the composition root.
|
||||||
self_restart: Option<Arc<dyn AgentSelfRestartScheduler>>,
|
self_restart: Option<Arc<dyn AgentSelfRestartScheduler>>,
|
||||||
/// Stable control-plane handoff between an active ask and a detached restart.
|
/// Stable control-plane handoff between an active ask and a detached restart.
|
||||||
@ -1308,6 +1313,7 @@ impl OrchestratorService {
|
|||||||
background_runner: None,
|
background_runner: None,
|
||||||
session_limits: None,
|
session_limits: None,
|
||||||
spawn_background_command: None,
|
spawn_background_command: None,
|
||||||
|
scheduled_project_tasks: None,
|
||||||
self_restart: None,
|
self_restart: None,
|
||||||
restart_rendezvous: Arc::new(RestartRendezvousCoordinator::new()),
|
restart_rendezvous: Arc::new(RestartRendezvousCoordinator::new()),
|
||||||
}
|
}
|
||||||
@ -1334,6 +1340,16 @@ impl OrchestratorService {
|
|||||||
self
|
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.
|
/// Wires the requester-scoped detached self-restart coordinator.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_self_restart(mut self, restart: Arc<dyn AgentSelfRestartScheduler>) -> Self {
|
pub fn with_self_restart(mut self, restart: Arc<dyn AgentSelfRestartScheduler>) -> Self {
|
||||||
@ -2112,7 +2128,156 @@ impl OrchestratorService {
|
|||||||
self.run_in_background(project, owner, label, command, args, cwd, deadline_ms)
|
self.run_in_background(project, owner, label, command, args, cwd, deadline_ms)
|
||||||
.await
|
.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> {
|
fn require_project_store(&self) -> Result<&Arc<dyn ProjectStore>, AppError> {
|
||||||
@ -2341,6 +2506,7 @@ impl OrchestratorService {
|
|||||||
rendezvous: rendezvous.clone(),
|
rendezvous: rendezvous.clone(),
|
||||||
parent_task_id,
|
parent_task_id,
|
||||||
deadline_ms,
|
deadline_ms,
|
||||||
|
origin: domain::BackgroundTaskOrigin::Manual,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
if let Some(link) = rendezvous {
|
if let Some(link) = rendezvous {
|
||||||
|
|||||||
@ -2048,6 +2048,7 @@ impl PluginCommandTasks {
|
|||||||
rendezvous: None,
|
rendezvous: None,
|
||||||
parent_task_id: None,
|
parent_task_id: None,
|
||||||
deadline_ms: input.deadline_ms,
|
deadline_ms: input.deadline_ms,
|
||||||
|
origin: domain::BackgroundTaskOrigin::Manual,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map(|out| out.task)
|
.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,
|
agent_id: AgentId,
|
||||||
/// La cellule (feuille de layout) qui héberge actuellement la vue.
|
/// La cellule (feuille de layout) qui héberge actuellement la vue.
|
||||||
node_id: NodeId,
|
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.
|
/// Launch-scoped managed-temp handle retained for release.
|
||||||
managed_temp_handle: Option<String>,
|
managed_temp_handle: Option<String>,
|
||||||
/// Optional local-model-server usage guard retained for the live session.
|
/// Optional local-model-server usage guard retained for the live session.
|
||||||
@ -654,6 +657,7 @@ impl StructuredSessions {
|
|||||||
session,
|
session,
|
||||||
agent_id,
|
agent_id,
|
||||||
node_id,
|
node_id,
|
||||||
|
transcript_restore_expected: false,
|
||||||
managed_temp_handle,
|
managed_temp_handle,
|
||||||
_model_server_guard: model_server_guard,
|
_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`.
|
/// 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
|
/// 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);
|
let mut input = launch_input_with_project_root(agent.id, &root);
|
||||||
input.require_structured = true;
|
input.require_structured = true;
|
||||||
input.conversation_id = Some("pair-conversation-306".to_owned());
|
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!(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 = factory.envs().remove(0);
|
||||||
let env: HashMap<_, _> = env.into_iter().collect();
|
let env: HashMap<_, _> = env.into_iter().collect();
|
||||||
assert_eq!(env.get("TMPDIR"), Some(&tmp));
|
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());
|
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]
|
#[test]
|
||||||
fn structured_meta_for_session_resolves_agent_node_and_conversation() {
|
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
|
// 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
|
/// Response DTO for `reattach_agent_chat`: the retained **conversation
|
||||||
/// scrollback** of a still-live structured session, replayed into the
|
/// scrollback** of a still-live structured session, replayed before the new
|
||||||
/// re-mounting `AgentChatView` before the new reply stream is wired (§17.6). The
|
/// reply stream is wired. Unlike a transport error, an empty successful result
|
||||||
/// typed twin of [`ReattachResultDto`] (PTY scrollback bytes) — here the
|
/// carries a typed reason so the UI can offer an appropriate recovery action.
|
||||||
/// scrollback is the ordered list of [`ReplyChunk`]s already streamed.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ReattachChatDto {
|
pub struct ReattachChatDto {
|
||||||
@ -3936,6 +3957,32 @@ pub struct ReattachChatDto {
|
|||||||
/// replays them to rebuild the visible turns, then receives subsequent chunks
|
/// replays them to rebuild the visible turns, then receives subsequent chunks
|
||||||
/// over the freshly-registered channel.
|
/// over the freshly-registered channel.
|
||||||
pub scrollback: Vec<ReplyChunk>,
|
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).
|
/// Request DTO for `inspect_conversation` (T7).
|
||||||
@ -5751,7 +5798,7 @@ mod ls6_pagination_tests {
|
|||||||
turns: vec![
|
turns: vec![
|
||||||
TurnView {
|
TurnView {
|
||||||
id: tid(1),
|
id: tid(1),
|
||||||
at_ms: 1_700_000_000_000,
|
at_ms: Some(1_700_000_000_000),
|
||||||
role: TurnRole::Prompt,
|
role: TurnRole::Prompt,
|
||||||
source: TurnSource::Human,
|
source: TurnSource::Human,
|
||||||
text: big.clone(),
|
text: big.clone(),
|
||||||
@ -5759,7 +5806,7 @@ mod ls6_pagination_tests {
|
|||||||
},
|
},
|
||||||
TurnView {
|
TurnView {
|
||||||
id: tid(2),
|
id: tid(2),
|
||||||
at_ms: 1_700_000_000_001,
|
at_ms: Some(1_700_000_000_001),
|
||||||
role: TurnRole::Response,
|
role: TurnRole::Response,
|
||||||
source: TurnSource::Agent {
|
source: TurnSource::Agent {
|
||||||
agent_id: domain::AgentId::from_uuid(uuid::Uuid::from_u128(7)),
|
agent_id: domain::AgentId::from_uuid(uuid::Uuid::from_u128(7)),
|
||||||
@ -5822,7 +5869,8 @@ mod ls6_pagination_tests {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
use domain::{
|
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).
|
/// 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"`,
|
/// Kind discriminant (`"command"`, `"headlessRendezvous"`, `"sessionResume"`,
|
||||||
/// `"maintenance"`).
|
/// `"maintenance"`).
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
|
/// Manual launch or scheduled occurrence provenance.
|
||||||
|
pub origin: BackgroundTaskOrigin,
|
||||||
/// Lifecycle state (`"queued"`, `"running"`, `"waiting"`, `"completed"`,
|
/// Lifecycle state (`"queued"`, `"running"`, `"waiting"`, `"completed"`,
|
||||||
/// `"failed"`, `"cancelled"`, `"expired"`).
|
/// `"failed"`, `"cancelled"`, `"expired"`).
|
||||||
pub state: String,
|
pub state: String,
|
||||||
@ -5955,6 +6005,7 @@ impl From<BackgroundTask> for BackgroundTaskDto {
|
|||||||
project_id: task.project_id.to_string(),
|
project_id: task.project_id.to_string(),
|
||||||
parent_task_id: task.parent_task_id.map(|id| id.to_string()),
|
parent_task_id: task.parent_task_id.map(|id| id.to_string()),
|
||||||
kind: background_kind_label(&task.kind).to_owned(),
|
kind: background_kind_label(&task.kind).to_owned(),
|
||||||
|
origin: task.origin.clone(),
|
||||||
state: background_state_label(task.state).to_owned(),
|
state: background_state_label(task.state).to_owned(),
|
||||||
exit_code,
|
exit_code,
|
||||||
summary,
|
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`.
|
/// Request DTO for `spawn_background_command`.
|
||||||
///
|
///
|
||||||
/// Builds a command-backed [`BackgroundTask`]: the command line runs under the
|
/// Builds a command-backed [`BackgroundTask`]: the command line runs under the
|
||||||
|
|||||||
@ -55,17 +55,18 @@ use application::{
|
|||||||
RestoreOpenProjectsOutput, RestoreOpenWindows, RetryBackgroundTask, RetryOpenProjectRestore,
|
RestoreOpenProjectsOutput, RestoreOpenWindows, RetryBackgroundTask, RetryOpenProjectRestore,
|
||||||
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft,
|
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft,
|
||||||
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveOpenProjectRestoreOrder,
|
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveOpenProjectRestoreOrder,
|
||||||
SaveProfile, SessionLimitService, SetActiveLayout, SetAgentMcpToolAllowed, SetPluginEnabled,
|
SaveProfile, ScheduledProjectTaskService, SessionLimitService, SetActiveLayout,
|
||||||
SetProjectPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents,
|
SetAgentMcpToolAllowed, SetPluginEnabled, SetProjectPluginEnabled, SlashCommandRegistry,
|
||||||
SnapshotRunningAgentsInput, SpawnBackgroundCommand, StopAgentRuntime, StructuredRoutingMode,
|
SnapshotOpenWindows, SnapshotRunningAgents, SnapshotRunningAgentsInput, SpawnBackgroundCommand,
|
||||||
StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
StopAgentRuntime, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
|
||||||
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
|
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
|
||||||
UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions,
|
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContactable,
|
||||||
UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet,
|
UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
|
||||||
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions,
|
UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
|
||||||
UpdateProjectOrchestrator, UpdateProjectPermissions, UpdateProjectSettings,
|
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectOrchestrator,
|
||||||
UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, UpdateUserSettings,
|
UpdateProjectPermissions, UpdateProjectSettings, UpdateProjectSystemPermissions, UpdateSkill,
|
||||||
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
UpdateTemplate, UpdateUserSettings, WakeSessionProvider, WriteMemory, WriteToTerminal,
|
||||||
|
AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::events::AgentRestartPhase;
|
use domain::events::AgentRestartPhase;
|
||||||
@ -107,9 +108,9 @@ use infrastructure::{
|
|||||||
FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOpenProjectRestoreStore,
|
FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOpenProjectRestoreStore,
|
||||||
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
|
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
|
||||||
FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectSettingsStore,
|
FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectSettingsStore,
|
||||||
FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore,
|
FsProjectStore, FsProviderSessionStore, FsScheduledProjectTaskStore, FsSecretStore,
|
||||||
FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, FsWindowStateStore,
|
FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore,
|
||||||
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
||||||
HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
|
HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
|
||||||
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
|
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
|
||||||
LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner,
|
LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner,
|
||||||
@ -2165,6 +2166,8 @@ pub struct BackendCore {
|
|||||||
pub background_command_runner: Arc<CommandBackgroundRunner>,
|
pub background_command_runner: Arc<CommandBackgroundRunner>,
|
||||||
/// Store handle used by `list_background_tasks` to read the task read-model.
|
/// Store handle used by `list_background_tasks` to read the task read-model.
|
||||||
pub background_task_store: Arc<dyn BackgroundTaskStore>,
|
pub background_task_store: Arc<dyn BackgroundTaskStore>,
|
||||||
|
/// Durable definitions plus their runtime one-shot reconciliation.
|
||||||
|
pub scheduled_project_tasks: Arc<ScheduledProjectTaskService>,
|
||||||
// --- Plugins (#43) ---
|
// --- Plugins (#43) ---
|
||||||
/// Review a local plugin package before install.
|
/// Review a local plugin package before install.
|
||||||
pub review_plugin_package: Arc<ReviewPluginPackage>,
|
pub review_plugin_package: Arc<ReviewPluginPackage>,
|
||||||
@ -2470,6 +2473,9 @@ impl BackendCore {
|
|||||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||||
app_data_dir.to_string_lossy().into_owned(),
|
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(
|
let pair_attempt_limiter = Arc::new(InMemoryPairAttemptLimiter::new(
|
||||||
Arc::clone(&clock) as Arc<dyn Clock>
|
Arc::clone(&clock) as Arc<dyn Clock>
|
||||||
));
|
));
|
||||||
@ -3945,10 +3951,19 @@ impl BackendCore {
|
|||||||
}) as Arc<dyn AgentResumer>;
|
}) as Arc<dyn AgentResumer>;
|
||||||
let session_limit_service = Arc::new(SessionLimitService::new(
|
let session_limit_service = Arc::new(SessionLimitService::new(
|
||||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
scheduler,
|
Arc::clone(&scheduler),
|
||||||
Arc::clone(&events_port),
|
Arc::clone(&events_port),
|
||||||
resumer,
|
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 restart_rendezvous = Arc::new(application::RestartRendezvousCoordinator::new());
|
||||||
let self_restart = Arc::new(AppAgentSelfRestarter {
|
let self_restart = Arc::new(AppAgentSelfRestarter {
|
||||||
launch_agent: Arc::clone(&launch_agent),
|
launch_agent: Arc::clone(&launch_agent),
|
||||||
@ -3967,9 +3982,24 @@ impl BackendCore {
|
|||||||
// peut tourner sans runtime ambiant). Detache ⇒ vit autant que l'app.
|
// peut tourner sans runtime ambiant). Detache ⇒ vit autant que l'app.
|
||||||
{
|
{
|
||||||
let service = Arc::clone(&session_limit_service);
|
let service = Arc::clone(&session_limit_service);
|
||||||
|
let scheduled = Arc::clone(&scheduled_project_tasks);
|
||||||
spawn_detached(async move {
|
spawn_detached(async move {
|
||||||
while let Some(task) = resume_rx.recv().await {
|
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.
|
// Best-effort : une relance qui échoue ne fige pas le drain.
|
||||||
eprintln!("[session-limit] reprise auto échouée : {e}");
|
eprintln!("[session-limit] reprise auto échouée : {e}");
|
||||||
}
|
}
|
||||||
@ -4129,7 +4159,8 @@ impl BackendCore {
|
|||||||
.with_session_limits(Arc::clone(&session_limit_service))
|
.with_session_limits(Arc::clone(&session_limit_service))
|
||||||
// Producteur B8 côté agent MCP : `idea_run_in_background` passe par le
|
// 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.
|
// 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(
|
openai_tool_invoker.bind(Arc::new(AppOpenAiToolInvoker::new(
|
||||||
Arc::clone(&orchestrator_service),
|
Arc::clone(&orchestrator_service),
|
||||||
@ -4375,6 +4406,7 @@ impl BackendCore {
|
|||||||
retry_background_task,
|
retry_background_task,
|
||||||
background_command_runner: Arc::clone(&background_runner),
|
background_command_runner: Arc::clone(&background_runner),
|
||||||
background_task_store: Arc::clone(&background_tasks_port),
|
background_task_store: Arc::clone(&background_tasks_port),
|
||||||
|
scheduled_project_tasks,
|
||||||
review_plugin_package,
|
review_plugin_package,
|
||||||
install_plugin_from_archive,
|
install_plugin_from_archive,
|
||||||
install_plugin_from_directory,
|
install_plugin_from_directory,
|
||||||
@ -4435,6 +4467,10 @@ impl BackendCore {
|
|||||||
.execute(application::ReconcileLiveStateInput { project_id })
|
.execute(application::ReconcileLiveStateInput { project_id })
|
||||||
.await;
|
.await;
|
||||||
let _ = self.reconcile_background_tasks.execute(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.ensure_orchestrator_watch(project);
|
||||||
self.reconcile_claude_run_dirs(project).await;
|
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
|
/// Symmetrically stops the project's MCP server (its twin) so both entry doors
|
||||||
/// are torn down together.
|
/// are torn down together.
|
||||||
pub fn stop_orchestrator_watch(&self, project_id: &ProjectId) {
|
pub fn stop_orchestrator_watch(&self, project_id: &ProjectId) {
|
||||||
|
self.scheduled_project_tasks.cancel_project(*project_id);
|
||||||
if let Some(handle) = self
|
if let Some(handle) = self
|
||||||
.orchestrator_watchers
|
.orchestrator_watchers
|
||||||
.lock()
|
.lock()
|
||||||
@ -8797,3 +8834,107 @@ mod open_project_restore_tests {
|
|||||||
let _ = std::fs::remove_dir_all(&root);
|
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 thiserror::Error;
|
||||||
|
|
||||||
use crate::conversation::ConversationId;
|
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;
|
use crate::mailbox::TicketId;
|
||||||
|
|
||||||
/// Maximum length for human-facing task labels.
|
/// Maximum length for human-facing task labels.
|
||||||
@ -63,6 +63,26 @@ pub enum BackgroundTaskWakePolicy {
|
|||||||
RecordOnly,
|
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.
|
/// Structural link from a background task to a composite inter-agent rendezvous.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -137,6 +157,9 @@ pub struct BackgroundTask {
|
|||||||
pub state: BackgroundTaskState,
|
pub state: BackgroundTaskState,
|
||||||
/// Completion wake policy.
|
/// Completion wake policy.
|
||||||
pub wake_policy: BackgroundTaskWakePolicy,
|
pub wake_policy: BackgroundTaskWakePolicy,
|
||||||
|
/// Manual launch or scheduled occurrence provenance.
|
||||||
|
#[serde(default)]
|
||||||
|
pub origin: BackgroundTaskOrigin,
|
||||||
/// Creation timestamp, epoch milliseconds.
|
/// Creation timestamp, epoch milliseconds.
|
||||||
pub created_at_ms: u64,
|
pub created_at_ms: u64,
|
||||||
/// Last update timestamp, epoch milliseconds.
|
/// Last update timestamp, epoch milliseconds.
|
||||||
@ -173,6 +196,7 @@ impl BackgroundTask {
|
|||||||
parent_task_id: None,
|
parent_task_id: None,
|
||||||
state: BackgroundTaskState::Queued,
|
state: BackgroundTaskState::Queued,
|
||||||
wake_policy,
|
wake_policy,
|
||||||
|
origin: BackgroundTaskOrigin::Manual,
|
||||||
created_at_ms: now_ms,
|
created_at_ms: now_ms,
|
||||||
updated_at_ms: now_ms,
|
updated_at_ms: now_ms,
|
||||||
deadline_ms,
|
deadline_ms,
|
||||||
@ -183,6 +207,13 @@ impl BackgroundTask {
|
|||||||
Ok(task)
|
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.
|
/// Returns a copy linked to a composite inter-agent rendezvous.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_rendezvous(mut self, link: BackgroundTaskRendezvousLink) -> Self {
|
pub fn with_rendezvous(mut self, link: BackgroundTaskRendezvousLink) -> Self {
|
||||||
|
|||||||
@ -111,6 +111,10 @@ typed_id!(
|
|||||||
/// [`crate::ports::Scheduler::cancel`].
|
/// [`crate::ports::Scheduler::cancel`].
|
||||||
ScheduleId
|
ScheduleId
|
||||||
);
|
);
|
||||||
|
typed_id!(
|
||||||
|
/// Identifies a durable project-scoped scheduled task definition.
|
||||||
|
ScheduledTaskId
|
||||||
|
);
|
||||||
typed_id!(
|
typed_id!(
|
||||||
/// Identifies a first-class background task.
|
/// Identifies a first-class background task.
|
||||||
TaskId
|
TaskId
|
||||||
|
|||||||
@ -66,6 +66,7 @@ pub mod project_restore;
|
|||||||
pub mod readiness;
|
pub mod readiness;
|
||||||
pub mod remote;
|
pub mod remote;
|
||||||
pub mod sandbox;
|
pub mod sandbox;
|
||||||
|
pub mod scheduled_task;
|
||||||
pub mod session_limit;
|
pub mod session_limit;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
@ -86,7 +87,8 @@ pub use events::RendezvousContext;
|
|||||||
|
|
||||||
pub use ids::{
|
pub use ids::{
|
||||||
AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, RendezvousId,
|
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};
|
pub use project::{Project, ProjectPath};
|
||||||
@ -105,10 +107,10 @@ pub use mcp_tool_permissions::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub use background_task::{
|
pub use background_task::{
|
||||||
BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskRendezvousLink,
|
BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskOrigin,
|
||||||
BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy,
|
BackgroundTaskRendezvousLink, BackgroundTaskResult, BackgroundTaskState,
|
||||||
BACKGROUND_TASK_LABEL_MAX_CHARS, BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES,
|
BackgroundTaskWakePolicy, BACKGROUND_TASK_LABEL_MAX_CHARS,
|
||||||
BACKGROUND_TASK_TEXT_MAX_BYTES,
|
BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES, BACKGROUND_TASK_TEXT_MAX_BYTES,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use chat_attachment::{
|
pub use chat_attachment::{
|
||||||
@ -259,6 +261,12 @@ pub use sandbox::{
|
|||||||
SandboxKind, SandboxPlan, SandboxStatus,
|
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 settings::{DefaultAgentTerminalInterface, IdeaUserSettings, ProjectSettings, ThemeColor};
|
||||||
|
|
||||||
pub use orchestrator::{
|
pub use orchestrator::{
|
||||||
@ -282,8 +290,8 @@ pub use ports::{
|
|||||||
PluginStorageStore, PluginStoreError, PreparedContext, ProcessError, ProcessSpawner,
|
PluginStorageStore, PluginStoreError, PreparedContext, ProcessError, ProcessSpawner,
|
||||||
ProfileStore, ProjectPluginSettingsStore, ProjectSettingsStore, ProjectStore,
|
ProfileStore, ProjectPluginSettingsStore, ProjectSettingsStore, ProjectStore,
|
||||||
ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath,
|
ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath,
|
||||||
RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler, SpawnSpec, SprintStore,
|
RuntimeError, RuntimePermissionProbe, ScheduledProjectTaskStore, ScheduledTask,
|
||||||
SprintStoreError, StoreError, StructuredSessionEnvironment,
|
ScheduledTaskStoreError, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
|
||||||
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, UserSettingsStore,
|
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore,
|
||||||
WindowStateStore,
|
TemplateStore, UserSettingsStore, WindowStateStore,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -14,10 +14,11 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::conversation::ConversationParty;
|
use crate::conversation::ConversationParty;
|
||||||
use crate::ids::{AgentId, NodeId, SkillId};
|
use crate::ids::{AgentId, NodeId, ScheduledTaskId, SkillId};
|
||||||
use crate::live_state::WorkStatus;
|
use crate::live_state::WorkStatus;
|
||||||
use crate::mailbox::TicketId;
|
use crate::mailbox::TicketId;
|
||||||
use crate::plugin::{PluginId, PluginSkillId};
|
use crate::plugin::{PluginId, PluginSkillId};
|
||||||
|
use crate::scheduled_task::{ScheduledTaskCommand, ScheduledTaskSchedule};
|
||||||
use crate::skill::{SkillKind, SkillScope};
|
use crate::skill::{SkillKind, SkillScope};
|
||||||
|
|
||||||
/// Plugin origin for a resolved assigned skill. `None` on
|
/// Plugin origin for a resolved assigned skill. `None` on
|
||||||
@ -252,6 +253,27 @@ pub struct OrchestratorRequest {
|
|||||||
/// Optional absolute deadline for `background.run`, epoch milliseconds.
|
/// Optional absolute deadline for `background.run`, epoch milliseconds.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub deadline_ms: Option<u64>,
|
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.
|
/// A validated orchestrator command — the only thing the application layer acts on.
|
||||||
@ -454,6 +476,74 @@ pub enum OrchestratorCommand {
|
|||||||
/// Optional absolute deadline, epoch milliseconds.
|
/// Optional absolute deadline, epoch milliseconds.
|
||||||
deadline_ms: Option<u64>,
|
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.
|
/// Where IdeA should place a launched agent session.
|
||||||
@ -593,6 +683,73 @@ impl OrchestratorRequest {
|
|||||||
.map(str::to_owned),
|
.map(str::to_owned),
|
||||||
deadline_ms: self.deadline_ms,
|
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),
|
"list_agents" | "agent.list" => Ok(OrchestratorCommand::ListAgents),
|
||||||
"create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill {
|
"create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill {
|
||||||
name: self.require_name(action)?,
|
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
|
/// Parses the optional `scope` field into a [`SkillScope`], defaulting to
|
||||||
/// [`SkillScope::Project`] when absent or empty.
|
/// [`SkillScope::Project`] when absent or empty.
|
||||||
///
|
///
|
||||||
|
|||||||
@ -1037,6 +1037,16 @@ pub enum ScheduledTask {
|
|||||||
/// id, comme le reste du chemin de reprise §15).
|
/// id, comme le reste du chemin de reprise §15).
|
||||||
conversation_id: Option<String>,
|
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),
|
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.
|
/// Errors from the owner wake port.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
pub enum WakeError {
|
pub enum WakeError {
|
||||||
@ -2790,6 +2814,40 @@ pub trait BackgroundTaskStore: Send + Sync {
|
|||||||
) -> Result<(), BackgroundTaskPortError>;
|
) -> 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.
|
/// Execution port for first-class background tasks.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait BackgroundTaskRunner: Send + Sync {
|
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
|
//! The sink is the durable boundary between a runner completion and later
|
||||||
//! mailbox/wake work: it writes the terminal task state to the
|
//! mailbox/wake work: it writes the terminal task state to the
|
||||||
//! [`BackgroundTaskStore`](domain::ports::BackgroundTaskStore) first, then emits
|
//! [`BackgroundTaskStore`](domain::ports::BackgroundTaskStore) first, then emits
|
||||||
//! a lightweight "ready to deliver" signal. It never wakes an agent and never
|
//! a lightweight "ready to deliver" signal only for `WakeOwner` tasks.
|
||||||
//! enqueues mailbox items; B4/B5 consume the ready signal.
|
//! `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::collections::HashSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@ -36,6 +37,11 @@ pub struct BackgroundTaskReadyToDeliver {
|
|||||||
pub enum BackgroundCompletionSinkOutcome {
|
pub enum BackgroundCompletionSinkOutcome {
|
||||||
/// Completion was persisted and signalled as ready to deliver.
|
/// Completion was persisted and signalled as ready to deliver.
|
||||||
PersistedAndSignalled(BackgroundTaskReadyToDeliver),
|
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.
|
/// Task was already terminal, so the duplicate completion was ignored.
|
||||||
IgnoredAlreadyTerminal {
|
IgnoredAlreadyTerminal {
|
||||||
/// Ignored task id.
|
/// Ignored task id.
|
||||||
@ -64,7 +70,7 @@ pub enum BackgroundCompletionSinkError {
|
|||||||
ReadySignalClosed,
|
ReadySignalClosed,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consumes runner completions, persists terminal state, then signals delivery.
|
/// Consumes runner completions, persists terminal state, then applies wake policy.
|
||||||
pub struct BackgroundCompletionSink {
|
pub struct BackgroundCompletionSink {
|
||||||
store: Arc<dyn BackgroundTaskStore>,
|
store: Arc<dyn BackgroundTaskStore>,
|
||||||
ready: UnboundedSender<BackgroundTaskReadyToDeliver>,
|
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()))
|
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?;
|
self.store.save(&terminal).await?;
|
||||||
|
|
||||||
let ready = BackgroundTaskReadyToDeliver {
|
let ready = BackgroundTaskReadyToDeliver {
|
||||||
|
|||||||
@ -120,9 +120,9 @@ pub use store::{
|
|||||||
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
|
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
|
||||||
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore,
|
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore,
|
||||||
FsMcpToolPermissionStore, FsMemoryStore, FsOpenProjectRestoreStore, FsPermissionStore,
|
FsMcpToolPermissionStore, FsMemoryStore, FsOpenProjectRestoreStore, FsPermissionStore,
|
||||||
FsProfileStore, FsProjectSettingsStore, FsProjectStore, FsSecretStore, FsSkillStore,
|
FsProfileStore, FsProjectSettingsStore, FsProjectStore, FsScheduledProjectTaskStore,
|
||||||
FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, FsWindowStateStore,
|
FsSecretStore, FsSkillStore, FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore,
|
||||||
HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder,
|
FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo,
|
||||||
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||||
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
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
|
/// Compact MCP instructions advertised at initialization. The first 512 chars are
|
||||||
/// intentionally self-contained because some clients truncate this field.
|
/// 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`].
|
/// 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_list",
|
||||||
"idea_template_read",
|
"idea_template_read",
|
||||||
"idea_managed_temp_resolve",
|
"idea_managed_temp_resolve",
|
||||||
|
"idea_scheduled_task_list",
|
||||||
|
"idea_scheduled_task_read",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Canonical write/action MCP tools denied by default.
|
/// Canonical write/action MCP tools denied by default.
|
||||||
@ -99,6 +101,11 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
|
|||||||
"idea_managed_temp_gc",
|
"idea_managed_temp_gc",
|
||||||
"idea_plugin_install_from_directory",
|
"idea_plugin_install_from_directory",
|
||||||
"idea_plugin_reload",
|
"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.
|
/// 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_run_in_background"
|
||||||
| "idea_restart_self"
|
| "idea_restart_self"
|
||||||
| "idea_workstate_read"
|
| "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_ticket_tool(tool)
|
||||||
|| is_template_tool(tool)
|
|| is_template_tool(tool)
|
||||||
|| is_managed_temp_tool(tool)
|
|| is_managed_temp_tool(tool)
|
||||||
@ -342,6 +356,41 @@ pub fn catalogue() -> Vec<ToolDef> {
|
|||||||
"additionalProperties": false
|
"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 {
|
ToolDef {
|
||||||
name: "idea_launch_agent",
|
name: "idea_launch_agent",
|
||||||
description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \
|
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)?,
|
deadline_ms: optional_u64(args.get("deadline_ms"), name)?,
|
||||||
..base()
|
..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 {
|
"idea_stop_agent" => OrchestratorRequest {
|
||||||
request_type: Some("agent.stop".to_owned()),
|
request_type: Some("agent.stop".to_owned()),
|
||||||
requested_by: Some(requester.to_owned()),
|
requested_by: Some(requester.to_owned()),
|
||||||
@ -713,9 +817,44 @@ fn base() -> OrchestratorRequest {
|
|||||||
args: Vec::new(),
|
args: Vec::new(),
|
||||||
cwd: None,
|
cwd: None,
|
||||||
deadline_ms: 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(
|
fn reject_unknown_keys(
|
||||||
tool: &str,
|
tool: &str,
|
||||||
args: &serde_json::Map<String, Value>,
|
args: &serde_json::Map<String, Value>,
|
||||||
@ -781,6 +920,38 @@ mod tests {
|
|||||||
map_tool_call(name, args, "")
|
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]
|
#[test]
|
||||||
fn catalogue_tools_have_explicit_read_or_write_access() {
|
fn catalogue_tools_have_explicit_read_or_write_access() {
|
||||||
let catalogue_names = catalogue()
|
let catalogue_names = catalogue()
|
||||||
|
|||||||
@ -258,7 +258,10 @@ mod tests {
|
|||||||
// `ScheduledTask` est mono-variante : déstructuration directe (pas de `if let`).
|
// `ScheduledTask` est mono-variante : déstructuration directe (pas de `if let`).
|
||||||
let ScheduledTask::ResumeAgent {
|
let ScheduledTask::ResumeAgent {
|
||||||
conversation_id, ..
|
conversation_id, ..
|
||||||
} = task;
|
} = task
|
||||||
|
else {
|
||||||
|
panic!("expected resume task")
|
||||||
|
};
|
||||||
got.push(conversation_id.expect("conv présent"));
|
got.push(conversation_id.expect("conv présent"));
|
||||||
}
|
}
|
||||||
got.sort();
|
got.sort();
|
||||||
@ -288,7 +291,10 @@ mod tests {
|
|||||||
.expect("canal ouvert");
|
.expect("canal ouvert");
|
||||||
let ScheduledTask::ResumeAgent {
|
let ScheduledTask::ResumeAgent {
|
||||||
conversation_id, ..
|
conversation_id, ..
|
||||||
} = task;
|
} = task
|
||||||
|
else {
|
||||||
|
panic!("expected resume task")
|
||||||
|
};
|
||||||
got.push(conversation_id.expect("conv présent"));
|
got.push(conversation_id.expect("conv présent"));
|
||||||
}
|
}
|
||||||
got.sort();
|
got.sort();
|
||||||
|
|||||||
@ -16,6 +16,7 @@ mod permission;
|
|||||||
mod profile;
|
mod profile;
|
||||||
mod project;
|
mod project;
|
||||||
mod project_plugin;
|
mod project_plugin;
|
||||||
|
mod scheduled_task;
|
||||||
mod secrets;
|
mod secrets;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod skill;
|
mod skill;
|
||||||
@ -44,6 +45,7 @@ pub use permission::FsPermissionStore;
|
|||||||
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
||||||
pub use project::FsProjectStore;
|
pub use project::FsProjectStore;
|
||||||
pub use project_plugin::FsProjectPluginSettingsStore;
|
pub use project_plugin::FsProjectPluginSettingsStore;
|
||||||
|
pub use scheduled_task::FsScheduledProjectTaskStore;
|
||||||
pub use secrets::FsSecretStore;
|
pub use secrets::FsSecretStore;
|
||||||
pub use settings::{FsProjectSettingsStore, FsUserSettingsStore};
|
pub use settings::{FsProjectSettingsStore, FsUserSettingsStore};
|
||||||
pub use skill::FsSkillStore;
|
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()
|
.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 {
|
fn success_completion(id: u128) -> BackgroundTaskCompletion {
|
||||||
BackgroundTaskCompletion {
|
BackgroundTaskCompletion {
|
||||||
task_id: task_id(id),
|
task_id: task_id(id),
|
||||||
@ -138,6 +155,30 @@ async fn persistence_happens_before_ready_signal() {
|
|||||||
assert_eq!(*order.lock().unwrap(), vec!["save", "ready"]);
|
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]
|
#[tokio::test]
|
||||||
async fn duplicate_completion_same_task_signals_once() {
|
async fn duplicate_completion_same_task_signals_once() {
|
||||||
let tmp = TempDir::new();
|
let tmp = TempDir::new();
|
||||||
|
|||||||
@ -77,17 +77,19 @@ use domain::{
|
|||||||
use backend::dto::{
|
use backend::dto::{
|
||||||
create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_attachment_id_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_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_ref_dto, parse_scheduled_task_id, parse_session_id, parse_skill_id, parse_sprint_id_dto,
|
||||||
parse_task_id, parse_template_id, sort_ticket_rows, sprint_version_dto, update_input,
|
parse_sprint_status_dto, parse_task_id, parse_template_id, sort_ticket_rows,
|
||||||
version_dto, AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto,
|
sprint_version_dto, update_input, version_dto, AgentDriftListDto, AgentDto, AgentListDto,
|
||||||
AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto,
|
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
|
||||||
ChangeAgentProfileDto, ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
|
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
|
||||||
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
|
CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||||
|
ConversationTranscriptUnavailableReasonDto, CreateAgentFromTemplateRequestDto,
|
||||||
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
|
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
|
||||||
CreateSkillRequestDto, CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
CreateScheduledProjectTaskRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
||||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
|
DeleteLayoutRequestDto, DeleteLayoutResultDto, DetectProfilesRequestDto,
|
||||||
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
|
DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
|
||||||
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto,
|
||||||
|
GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
||||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto,
|
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto,
|
||||||
@ -117,7 +119,8 @@ use backend::dto::{
|
|||||||
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
||||||
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
|
||||||
UpdateProjectSettingsRequestDto, UpdateProjectSystemPermissionsRequestDto,
|
UpdateProjectSettingsRequestDto, UpdateProjectSystemPermissionsRequestDto,
|
||||||
UpdateSkillRequestDto, UpdateTemplateRequestDto, UserSettingsDto,
|
UpdateScheduledProjectTaskRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
||||||
|
UserSettingsDto,
|
||||||
};
|
};
|
||||||
use backend::events::DomainEventDto;
|
use backend::events::DomainEventDto;
|
||||||
type PtyChunk = Vec<u8>;
|
type PtyChunk = Vec<u8>;
|
||||||
@ -1587,11 +1590,21 @@ async fn send_launch_agent_attached(
|
|||||||
output: application::LaunchAgentOutput,
|
output: application::LaunchAgentOutput,
|
||||||
) -> Result<(), ErrorDto> {
|
) -> Result<(), ErrorDto> {
|
||||||
if let Some(structured) = output.structured.as_ref() {
|
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(
|
tx.send(ServerFrame::chat_attached(
|
||||||
&frame.id,
|
&frame.id,
|
||||||
structured,
|
structured,
|
||||||
output.assigned_conversation_id.as_deref(),
|
output.assigned_conversation_id.as_deref(),
|
||||||
output.engine_session_id.as_deref(),
|
output.engine_session_id.as_deref(),
|
||||||
|
Vec::new(),
|
||||||
|
transcript_unavailable_reason,
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ErrorDto {
|
.map_err(|_| ErrorDto {
|
||||||
@ -1868,6 +1881,13 @@ async fn ws_attach_chat(
|
|||||||
})?;
|
})?;
|
||||||
(sid, session)
|
(sid, session)
|
||||||
};
|
};
|
||||||
|
let transcript_unavailable_reason = ConversationTranscriptUnavailableReasonDto::for_scrollback(
|
||||||
|
true,
|
||||||
|
state
|
||||||
|
.app
|
||||||
|
.structured_sessions
|
||||||
|
.transcript_restore_expected(&sid),
|
||||||
|
);
|
||||||
tx.send(ServerFrame::chat_attached(
|
tx.send(ServerFrame::chat_attached(
|
||||||
&frame.id,
|
&frame.id,
|
||||||
&application::StructuredSessionDescriptor {
|
&application::StructuredSessionDescriptor {
|
||||||
@ -1878,6 +1898,8 @@ async fn ws_attach_chat(
|
|||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
session.conversation_id().as_deref(),
|
session.conversation_id().as_deref(),
|
||||||
|
Vec::new(),
|
||||||
|
transcript_unavailable_reason,
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ErrorDto {
|
.map_err(|_| ErrorDto {
|
||||||
@ -3513,6 +3535,24 @@ async fn invoke(
|
|||||||
"list_background_tasks" => invoke_list_background_tasks(&request.args, &state.app).await,
|
"list_background_tasks" => invoke_list_background_tasks(&request.args, &state.app).await,
|
||||||
"cancel_background_task" => invoke_cancel_background_task(&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,
|
"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_create" => invoke_ticket_create(&request.args, &state.app).await,
|
||||||
"ticket_read" => invoke_ticket_read(&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,
|
"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)
|
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> {
|
async fn invoke_ticket_create(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
let request = required_request::<TicketCreateRequestDto>("ticket_create", args)?;
|
let request = required_request::<TicketCreateRequestDto>("ticket_create", args)?;
|
||||||
let project = resolve_project_readonly(&request.project_id, state).await?;
|
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||||
@ -6068,6 +6244,8 @@ impl ServerFrame {
|
|||||||
session: &application::StructuredSessionDescriptor,
|
session: &application::StructuredSessionDescriptor,
|
||||||
assigned_conversation_id: Option<&str>,
|
assigned_conversation_id: Option<&str>,
|
||||||
engine_session_id: Option<&str>,
|
engine_session_id: Option<&str>,
|
||||||
|
scrollback: Vec<ChatChunk>,
|
||||||
|
transcript_unavailable_reason: Option<ConversationTranscriptUnavailableReasonDto>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
kind: "chat.attached".to_owned(),
|
kind: "chat.attached".to_owned(),
|
||||||
@ -6081,6 +6259,8 @@ impl ServerFrame {
|
|||||||
"cellKind": "chat",
|
"cellKind": "chat",
|
||||||
"assignedConversationId": assigned_conversation_id,
|
"assignedConversationId": assigned_conversation_id,
|
||||||
"engineSessionId": engine_session_id,
|
"engineSessionId": engine_session_id,
|
||||||
|
"scrollback": scrollback,
|
||||||
|
"transcriptUnavailableReason": transcript_unavailable_reason,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -7924,6 +8104,11 @@ mod tests {
|
|||||||
"pair-conversation"
|
"pair-conversation"
|
||||||
);
|
);
|
||||||
assert_eq!(attached.payload["engineSessionId"], "engine-session");
|
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);
|
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["cellKind"], "chat");
|
||||||
assert_eq!(attached.payload["engineSessionId"], "engine-chat-session");
|
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);
|
assert_eq!(state.ws_chat_bridge.active_sessions(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -7996,6 +8186,10 @@ mod tests {
|
|||||||
agent_id,
|
agent_id,
|
||||||
node_id,
|
node_id,
|
||||||
);
|
);
|
||||||
|
state
|
||||||
|
.app
|
||||||
|
.structured_sessions
|
||||||
|
.set_transcript_restore_expected(live_session_id, true);
|
||||||
let (tx, mut rx) = mpsc::channel(8);
|
let (tx, mut rx) = mpsc::channel(8);
|
||||||
let owned = Arc::new(Mutex::new(Vec::new()));
|
let owned = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
|
||||||
@ -8022,6 +8216,11 @@ mod tests {
|
|||||||
attached.payload["session"]["sessionId"],
|
attached.payload["session"]["sessionId"],
|
||||||
live_session_id.to_string()
|
live_session_id.to_string()
|
||||||
);
|
);
|
||||||
|
assert_eq!(attached.payload["scrollback"], json!([]));
|
||||||
|
assert_eq!(
|
||||||
|
attached.payload["transcriptUnavailableReason"],
|
||||||
|
"missingTranscript"
|
||||||
|
);
|
||||||
assert!(state.ws_chat_bridge.send_output(
|
assert!(state.ws_chat_bridge.send_output(
|
||||||
&live_session_id,
|
&live_session_id,
|
||||||
json!({ "kind": "textDelta", "text": "after restart" })
|
json!({ "kind": "textDelta", "text": "after restart" })
|
||||||
@ -10347,6 +10546,46 @@ mod tests {
|
|||||||
assert_eq!(tasks[0]["state"], "queued");
|
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]
|
#[tokio::test]
|
||||||
async fn authorized_settings_invoke_round_trips_user_and_project_contracts() {
|
async fn authorized_settings_invoke_round_trips_user_and_project_contracts() {
|
||||||
let state = state();
|
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,
|
cols: 80,
|
||||||
cellKind: "chat",
|
cellKind: "chat",
|
||||||
assignedConversationId: "conversation-1",
|
assignedConversationId: "conversation-1",
|
||||||
|
transcriptUnavailableReason: "missingTranscript",
|
||||||
});
|
});
|
||||||
|
|
||||||
const out = await new TauriAgentGateway().launchAgentChat("proj-1", "agent-2", {
|
const out = await new TauriAgentGateway().launchAgentChat("proj-1", "agent-2", {
|
||||||
@ -355,6 +356,7 @@ describe("TauriAgentGateway invoke payloads", () => {
|
|||||||
sessionId: "chat-session-1",
|
sessionId: "chat-session-1",
|
||||||
cellKind: "chat",
|
cellKind: "chat",
|
||||||
assignedConversationId: "conversation-1",
|
assignedConversationId: "conversation-1",
|
||||||
|
transcriptUnavailableReason: "missingTranscript",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -52,6 +52,7 @@ interface LaunchAgentResponse {
|
|||||||
cellKind?: "chat" | "pty";
|
cellKind?: "chat" | "pty";
|
||||||
/** Conversation id minted by this launch (omitted when nothing was assigned). */
|
/** Conversation id minted by this launch (omitted when nothing was assigned). */
|
||||||
assignedConversationId?: string;
|
assignedConversationId?: string;
|
||||||
|
transcriptUnavailableReason?: AgentChatHandle["transcriptUnavailableReason"];
|
||||||
}
|
}
|
||||||
|
|
||||||
function structuredLaunchRoutedToPtyError(
|
function structuredLaunchRoutedToPtyError(
|
||||||
@ -269,6 +270,9 @@ export class TauriAgentGateway implements AgentGateway {
|
|||||||
...(res.assignedConversationId
|
...(res.assignedConversationId
|
||||||
? { assignedConversationId: res.assignedConversationId }
|
? { assignedConversationId: res.assignedConversationId }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(res.transcriptUnavailableReason
|
||||||
|
? { transcriptUnavailableReason: res.transcriptUnavailableReason }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -294,12 +294,14 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
|
|||||||
sessionId: "chat-1",
|
sessionId: "chat-1",
|
||||||
cellKind: "chat",
|
cellKind: "chat",
|
||||||
assignedConversationId: "conv-1",
|
assignedConversationId: "conv-1",
|
||||||
|
transcriptUnavailableReason: "emptyConversation",
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
await expect(launched).resolves.toEqual({
|
await expect(launched).resolves.toEqual({
|
||||||
sessionId: "chat-1",
|
sessionId: "chat-1",
|
||||||
cellKind: "chat",
|
cellKind: "chat",
|
||||||
assignedConversationId: "conv-1",
|
assignedConversationId: "conv-1",
|
||||||
|
transcriptUnavailableReason: "emptyConversation",
|
||||||
});
|
});
|
||||||
expect(launch.kind).toBe("agent.launch");
|
expect(launch.kind).toBe("agent.launch");
|
||||||
expect(launch.payload).toEqual({
|
expect(launch.payload).toEqual({
|
||||||
@ -324,12 +326,14 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
|
|||||||
sessionId: "chat-1",
|
sessionId: "chat-1",
|
||||||
conversationId: "conv-replacement",
|
conversationId: "conv-replacement",
|
||||||
scrollback: [{ kind: "final", content: "Recovered reply" }],
|
scrollback: [{ kind: "final", content: "Recovered reply" }],
|
||||||
|
transcriptUnavailableReason: "missingTranscript",
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
await expect(reattached).resolves.toEqual({
|
await expect(reattached).resolves.toEqual({
|
||||||
sessionId: "chat-1",
|
sessionId: "chat-1",
|
||||||
conversationId: "conv-replacement",
|
conversationId: "conv-replacement",
|
||||||
scrollback: [{ kind: "final", content: "Recovered reply" }],
|
scrollback: [{ kind: "final", content: "Recovered reply" }],
|
||||||
|
transcriptUnavailableReason: "missingTranscript",
|
||||||
});
|
});
|
||||||
expect(attach.kind).toBe("chat.attach");
|
expect(attach.kind).toBe("chat.attach");
|
||||||
expect(attach.payload).toEqual({
|
expect(attach.payload).toEqual({
|
||||||
|
|||||||
@ -155,4 +155,5 @@ export interface ChatAttachedPayload {
|
|||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
engineSessionId?: string;
|
engineSessionId?: string;
|
||||||
scrollback?: ReplyChunk[];
|
scrollback?: ReplyChunk[];
|
||||||
|
transcriptUnavailableReason?: "missingTranscript" | "emptyConversation";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import {
|
|||||||
HttpSkillGateway,
|
HttpSkillGateway,
|
||||||
HttpTemplateGateway,
|
HttpTemplateGateway,
|
||||||
HttpWorkStateGateway,
|
HttpWorkStateGateway,
|
||||||
|
HttpScheduledTaskGateway,
|
||||||
} from "./requestResponseGateways";
|
} from "./requestResponseGateways";
|
||||||
import {
|
import {
|
||||||
HttpAgentGateway,
|
HttpAgentGateway,
|
||||||
@ -140,6 +141,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
|
|||||||
device: new HttpDeviceGateway(http),
|
device: new HttpDeviceGateway(http),
|
||||||
permission: new HttpPermissionGateway(http),
|
permission: new HttpPermissionGateway(http),
|
||||||
workState: new HttpWorkStateGateway(http),
|
workState: new HttpWorkStateGateway(http),
|
||||||
|
scheduledTask: new HttpScheduledTaskGateway(http),
|
||||||
conversation: new HttpConversationGateway(http),
|
conversation: new HttpConversationGateway(http),
|
||||||
ticket: new HttpTicketGateway(http, ws),
|
ticket: new HttpTicketGateway(http, ws),
|
||||||
window: new WebWindowGateway(),
|
window: new WebWindowGateway(),
|
||||||
|
|||||||
@ -49,6 +49,7 @@ import type {
|
|||||||
ProfileModelCatalog,
|
ProfileModelCatalog,
|
||||||
ResolvedAgentPermissions,
|
ResolvedAgentPermissions,
|
||||||
ResolvedAgentSystemPermissions,
|
ResolvedAgentSystemPermissions,
|
||||||
|
ScheduledProjectTask,
|
||||||
SystemPermissionSet,
|
SystemPermissionSet,
|
||||||
Skill,
|
Skill,
|
||||||
SkillScope,
|
SkillScope,
|
||||||
@ -78,6 +79,8 @@ import type {
|
|||||||
SkillGateway,
|
SkillGateway,
|
||||||
TemplateGateway,
|
TemplateGateway,
|
||||||
WorkStateGateway,
|
WorkStateGateway,
|
||||||
|
ScheduledTaskDefinitionInput,
|
||||||
|
ScheduledTaskGateway,
|
||||||
BackgroundTaskAttachment,
|
BackgroundTaskAttachment,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
import { normalizeProjectWorkState } from "../workStateNormalization";
|
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 {
|
export class HttpConversationGateway implements ConversationGateway {
|
||||||
constructor(private readonly http: HttpInvoker) {}
|
constructor(private readonly http: HttpInvoker) {}
|
||||||
async readPage(
|
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
|
...(payload.assignedConversationId
|
||||||
? { assignedConversationId: payload.assignedConversationId }
|
? { assignedConversationId: payload.assignedConversationId }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(payload.transcriptUnavailableReason
|
||||||
|
? { transcriptUnavailableReason: payload.transcriptUnavailableReason }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -404,6 +407,9 @@ export class HttpAgentGateway implements AgentGateway {
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
scrollback: payload.scrollback ?? [],
|
scrollback: payload.scrollback ?? [],
|
||||||
|
...(payload.transcriptUnavailableReason
|
||||||
|
? { transcriptUnavailableReason: payload.transcriptUnavailableReason }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import { TauriDeviceGateway } from "./device";
|
|||||||
import { TauriGitGateway } from "./git";
|
import { TauriGitGateway } from "./git";
|
||||||
import { TauriPermissionGateway } from "./permission";
|
import { TauriPermissionGateway } from "./permission";
|
||||||
import { TauriWorkStateGateway } from "./workState";
|
import { TauriWorkStateGateway } from "./workState";
|
||||||
|
import { TauriScheduledTaskGateway } from "./scheduledTask";
|
||||||
import { TauriConversationGateway } from "./conversation";
|
import { TauriConversationGateway } from "./conversation";
|
||||||
import { TauriTicketGateway } from "./ticket";
|
import { TauriTicketGateway } from "./ticket";
|
||||||
import { TauriWindowGateway } from "./window";
|
import { TauriWindowGateway } from "./window";
|
||||||
@ -78,6 +79,7 @@ export function createTauriGateways(): Gateways {
|
|||||||
device: new TauriDeviceGateway(),
|
device: new TauriDeviceGateway(),
|
||||||
permission: new TauriPermissionGateway(),
|
permission: new TauriPermissionGateway(),
|
||||||
workState: new TauriWorkStateGateway(),
|
workState: new TauriWorkStateGateway(),
|
||||||
|
scheduledTask: new TauriScheduledTaskGateway(),
|
||||||
conversation: new TauriConversationGateway(),
|
conversation: new TauriConversationGateway(),
|
||||||
ticket: new TauriTicketGateway(),
|
ticket: new TauriTicketGateway(),
|
||||||
window: new TauriWindowGateway(),
|
window: new TauriWindowGateway(),
|
||||||
@ -112,6 +114,7 @@ export {
|
|||||||
TauriGitGateway,
|
TauriGitGateway,
|
||||||
TauriPermissionGateway,
|
TauriPermissionGateway,
|
||||||
TauriWorkStateGateway,
|
TauriWorkStateGateway,
|
||||||
|
TauriScheduledTaskGateway,
|
||||||
TauriConversationGateway,
|
TauriConversationGateway,
|
||||||
TauriTicketGateway,
|
TauriTicketGateway,
|
||||||
TauriWindowGateway,
|
TauriWindowGateway,
|
||||||
|
|||||||
@ -83,6 +83,7 @@ import type {
|
|||||||
ResumableAgent,
|
ResumableAgent,
|
||||||
ResolvedAgentPermissions,
|
ResolvedAgentPermissions,
|
||||||
ResolvedAgentSystemPermissions,
|
ResolvedAgentSystemPermissions,
|
||||||
|
ScheduledProjectTask,
|
||||||
ServerExposurePreview,
|
ServerExposurePreview,
|
||||||
ServerExposureSettings,
|
ServerExposureSettings,
|
||||||
Skill,
|
Skill,
|
||||||
@ -168,6 +169,8 @@ import type {
|
|||||||
SetProjectPluginEnabledInput,
|
SetProjectPluginEnabledInput,
|
||||||
SaveOpenCodeProviderProfileInput,
|
SaveOpenCodeProviderProfileInput,
|
||||||
SendAgentChatOptions,
|
SendAgentChatOptions,
|
||||||
|
ScheduledTaskDefinitionInput,
|
||||||
|
ScheduledTaskGateway,
|
||||||
SkillGateway,
|
SkillGateway,
|
||||||
StoppedLiveAgent,
|
StoppedLiveAgent,
|
||||||
SystemGateway,
|
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 {
|
export class MockWorkStateGateway implements WorkStateGateway {
|
||||||
private states = new Map<string, ProjectWorkState>();
|
private states = new Map<string, ProjectWorkState>();
|
||||||
private backgroundTaskStreams = new Map<
|
private backgroundTaskStreams = new Map<
|
||||||
@ -4854,6 +4963,7 @@ export function createMockGateways(): Gateways {
|
|||||||
device: new MockDeviceGateway(),
|
device: new MockDeviceGateway(),
|
||||||
permission: new MockPermissionGateway(),
|
permission: new MockPermissionGateway(),
|
||||||
workState: new MockWorkStateGateway(),
|
workState: new MockWorkStateGateway(),
|
||||||
|
scheduledTask: new MockScheduledTaskGateway(),
|
||||||
conversation: new MockConversationGateway(),
|
conversation: new MockConversationGateway(),
|
||||||
ticket: new MockTicketGateway(systemGateway),
|
ticket: new MockTicketGateway(systemGateway),
|
||||||
window: new MockWindowGateway(),
|
window: new MockWindowGateway(),
|
||||||
|
|||||||
@ -36,6 +36,7 @@ describe("createMockGateways", () => {
|
|||||||
"profile",
|
"profile",
|
||||||
"project",
|
"project",
|
||||||
"remote",
|
"remote",
|
||||||
|
"scheduledTask",
|
||||||
"skill",
|
"skill",
|
||||||
"system",
|
"system",
|
||||||
"template",
|
"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,
|
task.conversationId ?? task.conversation_id,
|
||||||
);
|
);
|
||||||
const parentTaskId = optionalString(task.parentTaskId ?? task.parent_task_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 {
|
return {
|
||||||
taskId: stringValue(task.taskId, `legacy-task-${index}`),
|
taskId: stringValue(task.taskId, `legacy-task-${index}`),
|
||||||
ownerAgentId: stringValue(task.ownerAgentId, fallbackAgentId),
|
ownerAgentId: stringValue(task.ownerAgentId, fallbackAgentId),
|
||||||
projectId: stringValue(task.projectId),
|
projectId: stringValue(task.projectId),
|
||||||
...(parentTaskId ? { parentTaskId } : {}),
|
...(parentTaskId ? { parentTaskId } : {}),
|
||||||
kind: stringValue(task.kind, "background"),
|
kind: stringValue(task.kind, "background"),
|
||||||
|
...(origin ? { origin } : {}),
|
||||||
status: normalizeBackgroundStatus(task.status ?? task.state),
|
status: normalizeBackgroundStatus(task.status ?? task.state),
|
||||||
exitCode: nullableNumber(task.exitCode),
|
exitCode: nullableNumber(task.exitCode),
|
||||||
summary: nullableString(task.summary),
|
summary: nullableString(task.summary),
|
||||||
@ -129,6 +141,7 @@ function normalizeBackgroundTask(
|
|||||||
...(targetAgentId ? { targetAgentId } : {}),
|
...(targetAgentId ? { targetAgentId } : {}),
|
||||||
...(conversationId ? { conversationId } : {}),
|
...(conversationId ? { conversationId } : {}),
|
||||||
updatedAtMs: numberValue(task.updatedAtMs),
|
updatedAtMs: numberValue(task.updatedAtMs),
|
||||||
|
...(typeof createdAtMs === "number" ? { createdAtMs } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -657,6 +657,57 @@ export interface GatewayError {
|
|||||||
message: string;
|
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)
|
// Work state (UX conversations/delegations live read-model)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -707,6 +758,9 @@ export interface BackgroundCompletion {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
parentTaskId?: string;
|
parentTaskId?: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
|
origin?:
|
||||||
|
| { kind: "manual" }
|
||||||
|
| { kind: "scheduled"; scheduledTaskId: string; plannedAtMs: number };
|
||||||
status:
|
status:
|
||||||
| "running"
|
| "running"
|
||||||
| "cancelling"
|
| "cancelling"
|
||||||
@ -726,6 +780,7 @@ export interface BackgroundCompletion {
|
|||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
/** Last-update timestamp (epoch ms); chronological ordering key. */
|
/** Last-update timestamp (epoch ms); chronological ordering key. */
|
||||||
updatedAtMs: number;
|
updatedAtMs: number;
|
||||||
|
createdAtMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One item currently pending in an agent's inbox. */
|
/** One item currently pending in an agent's inbox. */
|
||||||
|
|||||||
@ -197,7 +197,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
__resetProjectCliInteractionActivityForTests();
|
__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 = {
|
const agent = {
|
||||||
launchAgentChat: vi.fn(async () => ({ sessionId: "fresh-session" })),
|
launchAgentChat: vi.fn(async () => ({ sessionId: "fresh-session" })),
|
||||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||||
@ -209,6 +209,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
closeAgentChat: vi.fn(async () => {}),
|
closeAgentChat: vi.fn(async () => {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onConversationId = vi.fn();
|
||||||
render(
|
render(
|
||||||
<DIProvider
|
<DIProvider
|
||||||
gateways={{
|
gateways={{
|
||||||
@ -226,7 +227,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
sessionId={null}
|
sessionId={null}
|
||||||
conversationId="orphan-conversation"
|
conversationId="orphan-conversation"
|
||||||
onSessionId={vi.fn()}
|
onSessionId={vi.fn()}
|
||||||
onConversationId={vi.fn()}
|
onConversationId={onConversationId}
|
||||||
/>
|
/>
|
||||||
</DIProvider>,
|
</DIProvider>,
|
||||||
);
|
);
|
||||||
@ -239,13 +240,59 @@ describe("CustomAgentChatView", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText("Conversation liée — transcript indisponible."),
|
await screen.findByText("La conversation liée n’a pas pu être restaurée."),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
|
expect(screen.getByText("Son transcript n’est plus disponible après la reprise.")).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText("Conversation liée — récupération du transcript en cours."),
|
screen.queryByText("Conversation liée — récupération du transcript en cours."),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
expect(screen.queryByRole("progressbar")).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 () => {
|
it("projects self-restart lifecycle separators in the custom CLI only once", async () => {
|
||||||
|
|||||||
@ -43,7 +43,11 @@ import {
|
|||||||
markProjectCliInteractionStopped,
|
markProjectCliInteractionStopped,
|
||||||
} from "@/features/projects/projectCliInteractionActivity";
|
} from "@/features/projects/projectCliInteractionActivity";
|
||||||
import { Button, EntryTimestamp, Spinner, cn, normalizeTimestamp } from "@/shared";
|
import { Button, EntryTimestamp, Spinner, cn, normalizeTimestamp } from "@/shared";
|
||||||
import type { ChatAttachmentInput, ChatDraftScope } from "@/ports";
|
import type {
|
||||||
|
ChatAttachmentInput,
|
||||||
|
ChatDraftScope,
|
||||||
|
ConversationTranscriptUnavailableReason,
|
||||||
|
} from "@/ports";
|
||||||
|
|
||||||
export interface CustomAgentChatViewProps {
|
export interface CustomAgentChatViewProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@ -968,6 +972,9 @@ export function CustomAgentChatView({
|
|||||||
useState<ProfileCommandDialogState | null>(null);
|
useState<ProfileCommandDialogState | null>(null);
|
||||||
const [opening, setOpening] = useState(Boolean(sessionId || conversationId));
|
const [opening, setOpening] = useState(Boolean(sessionId || conversationId));
|
||||||
const [openRetryNonce, setOpenRetryNonce] = useState(0);
|
const [openRetryNonce, setOpenRetryNonce] = useState(0);
|
||||||
|
const [transcriptUnavailableReason, setTranscriptUnavailableReason] = useState<
|
||||||
|
ConversationTranscriptUnavailableReason | "conversationNotFound" | null
|
||||||
|
>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [stopping, setStopping] = useState(false);
|
const [stopping, setStopping] = useState(false);
|
||||||
const [error, setError] = useState<ChatErrorState | null>(null);
|
const [error, setError] = useState<ChatErrorState | null>(null);
|
||||||
@ -1373,6 +1380,7 @@ export function CustomAgentChatView({
|
|||||||
|
|
||||||
const receive = useCallback(
|
const receive = useCallback(
|
||||||
(chunk: ReplyChunk) => {
|
(chunk: ReplyChunk) => {
|
||||||
|
setTranscriptUnavailableReason(null);
|
||||||
scrollbackChunkCountRef.current += 1;
|
scrollbackChunkCountRef.current += 1;
|
||||||
const activeInteractionId = activeInteractionIdRef.current;
|
const activeInteractionId = activeInteractionIdRef.current;
|
||||||
if (
|
if (
|
||||||
@ -1604,6 +1612,11 @@ export function CustomAgentChatView({
|
|||||||
applyChatViewState(replayed);
|
applyChatViewState(replayed);
|
||||||
setBusy(turnsIndicateBusy(replayed));
|
setBusy(turnsIndicateBusy(replayed));
|
||||||
}
|
}
|
||||||
|
if (reattached.scrollback.length > 0) {
|
||||||
|
setTranscriptUnavailableReason(null);
|
||||||
|
} else if (reattached.transcriptUnavailableReason) {
|
||||||
|
setTranscriptUnavailableReason(reattached.transcriptUnavailableReason);
|
||||||
|
}
|
||||||
return reattached.sessionId;
|
return reattached.sessionId;
|
||||||
},
|
},
|
||||||
[agent, agentId, applyChatViewState, projectId, publishSessionId, receive],
|
[agent, agentId, applyChatViewState, projectId, publishSessionId, receive],
|
||||||
@ -1700,6 +1713,9 @@ export function CustomAgentChatView({
|
|||||||
if (launched.assignedConversationId) {
|
if (launched.assignedConversationId) {
|
||||||
onConversationIdRef.current(launched.assignedConversationId);
|
onConversationIdRef.current(launched.assignedConversationId);
|
||||||
}
|
}
|
||||||
|
if (launched.transcriptUnavailableReason) {
|
||||||
|
setTranscriptUnavailableReason(launched.transcriptUnavailableReason);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const recoveredSessionId = await reattachStructuredSession(launched.sessionId, {
|
const recoveredSessionId = await reattachStructuredSession(launched.sessionId, {
|
||||||
applyScrollback: options.applyScrollback,
|
applyScrollback: options.applyScrollback,
|
||||||
@ -1771,6 +1787,7 @@ export function CustomAgentChatView({
|
|||||||
});
|
});
|
||||||
setOpening(true);
|
setOpening(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setTranscriptUnavailableReason(null);
|
||||||
try {
|
try {
|
||||||
const localSession = sessionRef.current;
|
const localSession = sessionRef.current;
|
||||||
if (!externalSessionId && localSession) {
|
if (!externalSessionId && localSession) {
|
||||||
@ -1875,7 +1892,11 @@ export function CustomAgentChatView({
|
|||||||
sessionId: externalSessionId,
|
sessionId: externalSessionId,
|
||||||
error: instrumentationError(e),
|
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 });
|
setError({ message: describeOpenFailure(e), openFailure: true });
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@ -1909,10 +1930,37 @@ export function CustomAgentChatView({
|
|||||||
);
|
);
|
||||||
const hasLinkedConversation = Boolean(
|
const hasLinkedConversation = Boolean(
|
||||||
conversationId ||
|
conversationId ||
|
||||||
|
transcriptUnavailableReason === "missingTranscript" ||
|
||||||
|
transcriptUnavailableReason === "conversationNotFound" ||
|
||||||
requesterDelegation.currentAgentBusy ||
|
requesterDelegation.currentAgentBusy ||
|
||||||
restart ||
|
restart ||
|
||||||
delegationActive,
|
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() {
|
async function openProfileDialog() {
|
||||||
const availableProfiles = (await profileGateway.listProfiles()).filter((candidate) =>
|
const availableProfiles = (await profileGateway.listProfiles()).filter((candidate) =>
|
||||||
@ -2490,10 +2538,30 @@ export function CustomAgentChatView({
|
|||||||
{externalSessionId ? "Reprise en cours..." : "Ouverture de la session structurée…"}
|
{externalSessionId ? "Reprise en cours..." : "Ouverture de la session structurée…"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : displayTurns.length === 0 && hasLinkedConversation ? (
|
) : unavailableLinkedTranscript ? (
|
||||||
<p className="m-auto text-xs text-muted">
|
<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">
|
||||||
Conversation liée — transcript indisponible.
|
<p className="text-sm font-medium text-content">
|
||||||
|
La conversation liée n’a pas pu être restaurée.
|
||||||
</p>
|
</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 ? (
|
) : displayTurns.length === 0 ? (
|
||||||
<p className="m-auto text-xs text-muted">
|
<p className="m-auto text-xs text-muted">
|
||||||
Envoyez un message pour démarrer la conversation structurée.
|
Envoyez un message pour démarrer la conversation structurée.
|
||||||
|
|||||||
@ -103,6 +103,7 @@ const PANEL_SIZE: Record<PanelId, FloatingWindowSize> = {
|
|||||||
context: "lg",
|
context: "lg",
|
||||||
projectSettings: "lg",
|
projectSettings: "lg",
|
||||||
work: "lg",
|
work: "lg",
|
||||||
|
scheduledTasks: "xl",
|
||||||
tickets: "lg",
|
tickets: "lg",
|
||||||
agents: "lg",
|
agents: "lg",
|
||||||
templates: "lg",
|
templates: "lg",
|
||||||
@ -649,6 +650,7 @@ export function ProjectsView() {
|
|||||||
"projectSettings",
|
"projectSettings",
|
||||||
"context",
|
"context",
|
||||||
"work",
|
"work",
|
||||||
|
"scheduledTasks",
|
||||||
"tickets",
|
"tickets",
|
||||||
"agents",
|
"agents",
|
||||||
"templates",
|
"templates",
|
||||||
@ -865,6 +867,7 @@ export function ProjectsView() {
|
|||||||
projectId={active.id}
|
projectId={active.id}
|
||||||
projectRoot={active.root}
|
projectRoot={active.root}
|
||||||
onOpenConversation={openConversation}
|
onOpenConversation={openConversation}
|
||||||
|
onOpenWork={() => setPlacement("work", "floating")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return panel === "agents" ? (
|
return panel === "agents" ? (
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { MemoryPanel } from "@/features/memory";
|
|||||||
import { EmbedderSettings } from "@/features/embedder";
|
import { EmbedderSettings } from "@/features/embedder";
|
||||||
import { McpPanel, PermissionsPanel } from "@/features/permissions";
|
import { McpPanel, PermissionsPanel } from "@/features/permissions";
|
||||||
import { ProjectWorkStatePanel } from "@/features/workstate";
|
import { ProjectWorkStatePanel } from "@/features/workstate";
|
||||||
|
import { ScheduledTasksPanel } from "@/features/scheduled-tasks";
|
||||||
import { TicketsView } from "@/features/tickets";
|
import { TicketsView } from "@/features/tickets";
|
||||||
import { GitPanel } from "@/features/git";
|
import { GitPanel } from "@/features/git";
|
||||||
import { ProjectContextPanel } from "./ProjectContextPanel";
|
import { ProjectContextPanel } from "./ProjectContextPanel";
|
||||||
@ -34,6 +35,8 @@ export interface ViewPanelBodyProps {
|
|||||||
projectRoot: string;
|
projectRoot: string;
|
||||||
/** Optional: open a conversation viewer (only wired inside the main window). */
|
/** Optional: open a conversation viewer (only wired inside the main window). */
|
||||||
onOpenConversation?: (conversationId: string) => void;
|
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. */
|
/** Renders the requested view panel for the given project. */
|
||||||
@ -42,6 +45,7 @@ export function ViewPanelBody({
|
|||||||
projectId,
|
projectId,
|
||||||
projectRoot,
|
projectRoot,
|
||||||
onOpenConversation,
|
onOpenConversation,
|
||||||
|
onOpenWork,
|
||||||
}: ViewPanelBodyProps): ReactNode {
|
}: ViewPanelBodyProps): ReactNode {
|
||||||
switch (panel) {
|
switch (panel) {
|
||||||
case "context":
|
case "context":
|
||||||
@ -55,6 +59,8 @@ export function ViewPanelBody({
|
|||||||
onOpenConversation={onOpenConversation}
|
onOpenConversation={onOpenConversation}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "scheduledTasks":
|
||||||
|
return <ScheduledTasksPanel projectId={projectId} onOpenWork={onOpenWork} />;
|
||||||
case "tickets":
|
case "tickets":
|
||||||
return <TicketsView projectId={projectId} />;
|
return <TicketsView projectId={projectId} />;
|
||||||
case "agents":
|
case "agents":
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export type PanelId =
|
|||||||
| "context"
|
| "context"
|
||||||
| "projectSettings"
|
| "projectSettings"
|
||||||
| "work"
|
| "work"
|
||||||
|
| "scheduledTasks"
|
||||||
| "tickets"
|
| "tickets"
|
||||||
| "agents"
|
| "agents"
|
||||||
| "templates"
|
| "templates"
|
||||||
@ -53,6 +54,7 @@ export const PANEL_TITLE: Record<PanelId, string> = {
|
|||||||
context: "Project context",
|
context: "Project context",
|
||||||
projectSettings: "Paramètres du projet",
|
projectSettings: "Paramètres du projet",
|
||||||
work: "Work state",
|
work: "Work state",
|
||||||
|
scheduledTasks: "Tâches planifiées",
|
||||||
tickets: "Tickets",
|
tickets: "Tickets",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
templates: "Templates",
|
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";
|
} from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
||||||
|
import { ScheduledTasksPanel } from "@/features/scheduled-tasks";
|
||||||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||||
import { ProjectContextPanel } from "@/features/projects/ProjectContextPanel";
|
import { ProjectContextPanel } from "@/features/projects/ProjectContextPanel";
|
||||||
import { isDockedTo, type ViewPlacement } from "@/features/projects/viewPlacement";
|
import { isDockedTo, type ViewPlacement } from "@/features/projects/viewPlacement";
|
||||||
@ -204,6 +205,7 @@ const WEB_PANEL_SIZE: Record<WebDesktopPanelId, FloatingWindowSize> = {
|
|||||||
projects: "md",
|
projects: "md",
|
||||||
context: "lg",
|
context: "lg",
|
||||||
work: "lg",
|
work: "lg",
|
||||||
|
scheduledTasks: "xl",
|
||||||
tickets: "lg",
|
tickets: "lg",
|
||||||
sprints: "lg",
|
sprints: "lg",
|
||||||
agents: "lg",
|
agents: "lg",
|
||||||
@ -332,6 +334,7 @@ function WebDesktopWorkspace({
|
|||||||
const panelOrder: WebDesktopPanelId[] = [
|
const panelOrder: WebDesktopPanelId[] = [
|
||||||
"context",
|
"context",
|
||||||
"work",
|
"work",
|
||||||
|
"scheduledTasks",
|
||||||
"tickets",
|
"tickets",
|
||||||
"sprints",
|
"sprints",
|
||||||
"agents",
|
"agents",
|
||||||
@ -867,6 +870,8 @@ export function WebProjectPanelBody({
|
|||||||
return <ProjectContextPanel projectId={projectId} />;
|
return <ProjectContextPanel projectId={projectId} />;
|
||||||
case "work":
|
case "work":
|
||||||
return <LiveProjectPanel projectId={projectId} root={root} mobile={mobile} />;
|
return <LiveProjectPanel projectId={projectId} root={root} mobile={mobile} />;
|
||||||
|
case "scheduledTasks":
|
||||||
|
return <ScheduledTasksPanel projectId={projectId} />;
|
||||||
case "tickets":
|
case "tickets":
|
||||||
return <WebTicketsView projectId={projectId} focusSprintId={focusSprintId} />;
|
return <WebTicketsView projectId={projectId} focusSprintId={focusSprintId} />;
|
||||||
case "sprints":
|
case "sprints":
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
export type WebProjectPanelId =
|
export type WebProjectPanelId =
|
||||||
| "context"
|
| "context"
|
||||||
| "work"
|
| "work"
|
||||||
|
| "scheduledTasks"
|
||||||
| "tickets"
|
| "tickets"
|
||||||
| "sprints"
|
| "sprints"
|
||||||
| "agents"
|
| "agents"
|
||||||
@ -31,6 +32,7 @@ export interface WebPanelMeta {
|
|||||||
export const WEB_PANEL_ORDER: WebProjectPanelId[] = [
|
export const WEB_PANEL_ORDER: WebProjectPanelId[] = [
|
||||||
"context",
|
"context",
|
||||||
"work",
|
"work",
|
||||||
|
"scheduledTasks",
|
||||||
"tickets",
|
"tickets",
|
||||||
"sprints",
|
"sprints",
|
||||||
"agents",
|
"agents",
|
||||||
@ -59,6 +61,13 @@ export const WEB_PANEL_META: Record<WebProjectPanelId, WebPanelMeta> = {
|
|||||||
description: "État live des agents et tâches",
|
description: "État live des agents et tâches",
|
||||||
placement: "primary",
|
placement: "primary",
|
||||||
},
|
},
|
||||||
|
scheduledTasks: {
|
||||||
|
id: "scheduledTasks",
|
||||||
|
label: "Tâches planifiées",
|
||||||
|
shortLabel: "Planifiées",
|
||||||
|
description: "Automatisations récurrentes du projet",
|
||||||
|
placement: "primary",
|
||||||
|
},
|
||||||
tickets: {
|
tickets: {
|
||||||
id: "tickets",
|
id: "tickets",
|
||||||
label: "Tickets",
|
label: "Tickets",
|
||||||
|
|||||||
@ -81,6 +81,9 @@ import type {
|
|||||||
ResumableAgent,
|
ResumableAgent,
|
||||||
ResolvedAgentPermissions,
|
ResolvedAgentPermissions,
|
||||||
ResolvedAgentSystemPermissions,
|
ResolvedAgentSystemPermissions,
|
||||||
|
ScheduledProjectTask,
|
||||||
|
ScheduledTaskCommand,
|
||||||
|
ScheduledTaskSchedule,
|
||||||
ReplyChunk,
|
ReplyChunk,
|
||||||
SlashCommand,
|
SlashCommand,
|
||||||
ExecuteSlashCommandResult,
|
ExecuteSlashCommandResult,
|
||||||
@ -203,8 +206,14 @@ export interface AgentChatHandle {
|
|||||||
readonly cellKind: "chat";
|
readonly cellKind: "chat";
|
||||||
/** Conversation id assigned by launch when the backend minted one. */
|
/** Conversation id assigned by launch when the backend minted one. */
|
||||||
readonly assignedConversationId?: string;
|
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. */
|
/** Result of re-attaching a custom chat view to a live structured session. */
|
||||||
export interface ReattachAgentChatResult {
|
export interface ReattachAgentChatResult {
|
||||||
/** Effective live structured session id (may replace the requested id after restart). */
|
/** Effective live structured session id (may replace the requested id after restart). */
|
||||||
@ -213,6 +222,8 @@ export interface ReattachAgentChatResult {
|
|||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
/** Retained chunks already streamed for the session. */
|
/** Retained chunks already streamed for the session. */
|
||||||
scrollback: ReplyChunk[];
|
scrollback: ReplyChunk[];
|
||||||
|
/** Additive reason when `scrollback` is empty. */
|
||||||
|
transcriptUnavailableReason?: ConversationTranscriptUnavailableReason;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stable owner identity used to reconcile a stale transport id after restart. */
|
/** Stable owner identity used to reconcile a stale transport id after restart. */
|
||||||
@ -1207,6 +1218,35 @@ export interface WorkStateGateway {
|
|||||||
retryBackgroundTask(taskId: string): Promise<void>;
|
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). */
|
/** One paginated page request over a conversation transcript (LS7). */
|
||||||
export interface ConversationPageRequest {
|
export interface ConversationPageRequest {
|
||||||
/** Turn id to paginate around; omit to start from a thread end. */
|
/** Turn id to paginate around; omit to start from a thread end. */
|
||||||
@ -1804,6 +1844,7 @@ export interface Gateways {
|
|||||||
device: DeviceGateway;
|
device: DeviceGateway;
|
||||||
permission: PermissionGateway;
|
permission: PermissionGateway;
|
||||||
workState: WorkStateGateway;
|
workState: WorkStateGateway;
|
||||||
|
scheduledTask: ScheduledTaskGateway;
|
||||||
conversation: ConversationGateway;
|
conversation: ConversationGateway;
|
||||||
ticket: TicketGateway;
|
ticket: TicketGateway;
|
||||||
window: WindowGateway;
|
window: WindowGateway;
|
||||||
|
|||||||
Submodule sdk/IdeaSDK updated: 436ed59b41...047c38b7ca
Reference in New Issue
Block a user