feat(domain,application,infrastructure,backend,app-tauri,web-server,frontend): restaurer les projets ouverts au redémarrage dans l'ordre mémorisé — #303 (état versionné OpenProjectRestoreState persisté sous <app_data_dir>/open-projects.json via port dédié OpenProjectRestoreStore, distinct du registre projects.json et sans recycler workspace.json; sept use cases cadrés: read/record opened/record closed/save order/restore/retry/remove, mutations sérialisées par mutex applicatif contre les lost updates, tombstones conservés à leur position avec lastFailure et codes stables notFound/inaccessible/store/invalid; prévalidation root par metadata+listing sans interrompre les autres restaurations; activation centralisée BackendCore::activate_project réutilisée par ouverture Tauri/HTTP, création, restore et retry: reconciliation layouts/live-state/background tasks, watcher orchestrateur + serveur MCP, réparation run dirs Claude; cinq commandes exposées Tauri et HTTP avec contrats DTO identiques: restore_open_projects, retry_open_project_restore, remove_project_from_restore, save_open_project_restore_order, read_open_project_restore_state; frontend: DTO de reprise + ProjectGateway étendu, useProjects initialise par listProjects+restoreOpenProjects et reconstruit openTabs dans l'ordre opened sans notification quand tout réussit, notification unique singulier/pluriel avec Voir les détails/Réessayer, détail par projet avec cause française et Retirer de la reprise, retry réussi réinséré à la position mémorisée, onglet conservé si closeProject échoué; saveOpenProjectRestoreOrder exposé non appelé car l'UI ne permet pas encore de réordonnancer; QA verte reconfirmée par Git: rustup run stable cargo fmt --all -- --check, git diff --check, cargo test -p backend open_project_restore_tests 1/1 couvrant absence initiale/ordre/fermeture/inaccessible/retry/notFound/remove/watcher+MCP, cargo test -p web-server authorized_open_project_restore_commands_share_the_tauri_contract 1/1, cargo test -p domain 311+ suites vertes, cargo check -p application -p infrastructure -p backend -p app-tauri -p web-server OK, npm run typecheck OK, npm test ciblé 3 fichiers/37 tests OK, npm run build OK; réserves hors périmètre documentées au carnet: application tests/layout_usecases.rs 2 échecs fixtures plugin package NotFound, infrastructure 1 timing test cancellation process, npm test large 32 échecs web shell/LS7/tickets web — préexistants sans lien avec #303; restant: rebuild AppImage demandé après validation puis validation live fermeture/relance; fichiers tickets/296/*, tickets/299-303/*, counter/index, attachments/ et scripts/ hors périmètre; aucun push)

This commit is contained in:
2026-09-10 08:14:28 +02:00
parent 3911ce1f4d
commit 8df80ae67e
25 changed files with 1812 additions and 134 deletions

View File

@ -53,17 +53,16 @@ use application::{
PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput,
ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput,
ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput,
RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput,
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
RotateConversationLogInput, SetActiveLayoutInput, SetAgentMcpToolAllowedInput,
StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput,
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContactableInput,
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput,
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput,
UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
WriteToTerminalInput,
RenameDeviceInput, RenameLayoutInput, RenameSprintInput, ReorderSprintsInput,
ResizeTerminalInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput,
ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput,
SetAgentMcpToolAllowedInput, StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput,
UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput,
UpdateAgentContactableInput, UpdateAgentContextInput, UpdateAgentEffortInput,
UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput,
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
UpdateProjectSystemPermissionsInput, UpdateSkillInput, WriteToTerminalInput,
};
use domain::ports::{
PtyHandle, ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource,
@ -92,19 +91,21 @@ use backend::dto::{
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto,
OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
ReadConversationPageRequestDto, RecallMemoryRequestDto, RenameLayoutRequestDto,
OpenProjectRestoreStateDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto,
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
RemoveOpenProjectRestoreEntryRequestDto, RenameLayoutRequestDto,
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto,
SetAgentMcpToolAllowedRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto,
SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto,
SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto,
RestoreOpenProjectsOutputDto, ResumableAgentListDto, RetryOpenProjectRestoreRequestDto,
SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
SaveOpenProjectRestoreOrderRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
SetActiveLayoutResultDto, SetAgentMcpToolAllowedRequestDto, SkillDto, SkillListDto,
SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto,
SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto,
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto,
TicketAttachmentContentDto, TicketAttachmentMarkSummarizedRequestDto,
TicketAttachmentReadRequestDto, TicketCarnetDto, TicketCreateRequestDto,
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
@ -3346,6 +3347,19 @@ async fn invoke(
"health" => invoke_health(&request.args, &state.app),
"list_projects" => invoke_list_projects(&state.app).await,
"open_project" => invoke_open_project(&request.args, &state.app).await,
"read_open_project_restore_state" => {
invoke_read_open_project_restore_state(&state.app).await
}
"restore_open_projects" => invoke_restore_open_projects(&state.app).await,
"retry_open_project_restore" => {
invoke_retry_open_project_restore(&request.args, &state.app).await
}
"remove_project_from_restore" => {
invoke_remove_project_from_restore(&request.args, &state.app).await
}
"save_open_project_restore_order" => {
invoke_save_open_project_restore_order(&request.args, &state.app).await
}
"load_layout" => invoke_load_layout(&request.args, &state.app).await,
"mutate_layout" => invoke_mutate_layout(&request.args, &state.app).await,
"list_layouts" => invoke_list_layouts(&request.args, &state.app).await,
@ -3544,24 +3558,89 @@ async fn open_project_for_web(
) -> Result<ProjectDto, ErrorDto> {
let id = parse_project_id(project_id)?;
let output = state
.open_project
.execute(OpenProjectInput { project_id: id })
.open_project_and_activate(id)
.await
.map_err(ErrorDto::from)?;
let _ = state
.reconcile_layouts
.execute(ReconcileLayoutsInput { project_id: id })
.await;
let _ = state
.reconcile_live_state
.execute(ReconcileLiveStateInput { project_id: id })
.await;
let _ = state.reconcile_background_tasks.execute(id).await;
state.ensure_orchestrator_watch(&output.project);
state.reconcile_claude_run_dirs(&output.project).await;
Ok(ProjectDto::from(output))
}
async fn invoke_read_open_project_restore_state(state: &BackendCore) -> Result<Value, ErrorDto> {
let output = state
.read_open_project_restore_state
.execute()
.await
.map(OpenProjectRestoreStateDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_restore_open_projects(state: &BackendCore) -> Result<Value, ErrorDto> {
let output = state
.restore_open_projects_and_activate()
.await
.map(RestoreOpenProjectsOutputDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_retry_open_project_restore(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = optional_request::<RetryOpenProjectRestoreRequestDto>(args)?.unwrap_or_default();
let project_id = request
.project_id
.as_deref()
.map(parse_project_id)
.transpose()?;
let output = state
.retry_open_project_restore_and_activate(project_id)
.await
.map(RestoreOpenProjectsOutputDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_remove_project_from_restore(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<RemoveOpenProjectRestoreEntryRequestDto>(
"remove_project_from_restore",
args,
)?;
let project_id = parse_project_id(&request.project_id)?;
let output = state
.remove_project_from_restore
.execute(project_id)
.await
.map(OpenProjectRestoreStateDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_save_open_project_restore_order(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<SaveOpenProjectRestoreOrderRequestDto>(
"save_open_project_restore_order",
args,
)?;
let ordered_project_ids = request
.ordered_project_ids
.iter()
.map(|id| parse_project_id(id))
.collect::<Result<Vec<_>, _>>()?;
let output = state
.save_open_project_restore_order
.execute(ordered_project_ids)
.await
.map(OpenProjectRestoreStateDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_load_layout(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<LoadLayoutInvokeArgs>("load_layout", args)?;
let project_id = parse_project_id(&request.project_id)?;
@ -9538,6 +9617,51 @@ mod tests {
.is_some_and(|root| root.contains("idea-server-project-")));
}
#[tokio::test]
async fn authorized_open_project_restore_commands_share_the_tauri_contract() {
let state = state();
let project_id = create_project_for_test(&state, "Restore Web").await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let opened = request(
Arc::clone(&state),
Method::POST,
"/api/invoke",
json!({ "command": "open_project", "args": { "projectId": project_id } }),
&[("cookie", &cookie)],
)
.await;
assert_eq!(response_json(opened).await.0, StatusCode::OK);
let restored = request(
Arc::clone(&state),
Method::POST,
"/api/invoke",
json!({ "command": "restore_open_projects", "args": {} }),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(restored).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["opened"][0]["id"], project_id);
assert_eq!(body["failures"], json!([]));
let removed = request(
state,
Method::POST,
"/api/invoke",
json!({
"command": "remove_project_from_restore",
"args": { "request": { "projectId": project_id } }
}),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(removed).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["entries"], json!([]));
}
#[tokio::test]
async fn authorized_open_project_returns_readonly_project_dto() {
let state = state();