From 1270a08810cb3a9f69750fda5f3cce1859a07899 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:25:37 +0300 Subject: [PATCH] feat(#1220): complete Kanban management operations parity (#1234) --- .../ui/kanban/KanbanScreenTest.kt | 5 + .../data/model/KanbanActivityModels.kt | 74 +++++ .../data/model/KanbanTaskModels.kt | 32 ++ .../data/remote/KanbanApiService.kt | 64 ++++ .../data/repository/KanbanRepository.kt | 115 ++++++- .../hermescontrol/ui/kanban/KanbanScreen.kt | 79 ++++- .../ui/kanban/KanbanTaskScreen.kt | 311 ++++++++++++++++-- .../ui/kanban/KanbanTaskViewModel.kt | 272 +++++++++++++-- .../ui/kanban/KanbanTransitionRefusal.kt | 30 ++ .../ui/kanban/KanbanViewModel.kt | 92 +++++- .../ui/kanban/components/KanbanFilterSheet.kt | 43 +++ app/src/main/res/values-ar/strings.xml | 27 ++ app/src/main/res/values-ko/strings.xml | 27 ++ app/src/main/res/values-zh/strings.xml | 27 ++ app/src/main/res/values/strings.xml | 27 ++ .../m57/hermescontrol/E2eIntegrationTest.kt | 11 + .../data/model/KanbanSerializationTest.kt | 53 +++ .../data/remote/KanbanApiServiceTest.kt | 154 +++++++++ .../data/repository/KanbanRepositoryTest.kt | 28 ++ .../ui/kanban/KanbanMutationTest.kt | 10 + .../ui/kanban/KanbanTaskViewModelTest.kt | 174 +++++++++- .../ui/kanban/KanbanTransitionRefusalTest.kt | 34 ++ .../ui/kanban/KanbanViewModelTest.kt | 139 ++++++++ 23 files changed, 1777 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTransitionRefusal.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTransitionRefusalTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanScreenTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanScreenTest.kt index 52c65fde..4de2b210 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanScreenTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanScreenTest.kt @@ -34,12 +34,17 @@ class KanbanScreenTest { KanbanFilterSheet( assignees = emptyList(), tenants = emptyList(), + workflowTemplateIds = emptyList(), + currentStepKeys = emptyList(), selectedAssignee = null, selectedTenant = null, + selectedWorkflowTemplateId = null, + selectedCurrentStepKey = null, includeArchived = false, groupRunning = false, onSelectAssignee = {}, onSelectTenant = {}, + onSelectWorkflowFilters = { _, _ -> }, onToggleIncludeArchived = { toggled = it }, onToggleGroupRunning = {}, onClearFilters = {}, diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanActivityModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanActivityModels.kt index 0b6a8974..e27170ba 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanActivityModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanActivityModels.kt @@ -22,8 +22,14 @@ data class KanbanDetailEvent( @Serializable data class KanbanRun( val id: Long, + val taskId: String? = null, val profile: String? = null, + val stepKey: String? = null, val status: String, + val claimLock: String? = null, + val claimExpires: Long? = null, + val maxRuntimeSeconds: Int? = null, + val lastHeartbeatAt: Long? = null, val outcome: String? = null, val summary: String? = null, val error: String? = null, @@ -59,3 +65,71 @@ data class KanbanAttachment( data class AttachmentUploadResponse( val attachment: KanbanAttachment? = null, ) + +@Serializable +data class AttachmentListResponse( + val attachments: List = emptyList(), +) + +@Serializable +data class DeleteAttachmentResponse( + val ok: Boolean, + val id: Long, +) + +@Serializable +data class KanbanRunResponse( + val run: KanbanRun, +) + +@Serializable +data class KanbanRunInspection( + val runId: Long, + val alive: Boolean, + val pid: Int? = null, + val reason: String? = null, + val cpuPercent: Double? = null, + val memoryRssBytes: Long? = null, + val memoryVmsBytes: Long? = null, + val numThreads: Int? = null, + val numFds: Int? = null, + val status: String? = null, + val createTime: Double? = null, + val cmdline: List? = null, + val error: String? = null, +) + +@Serializable +data class TerminateRunBody( + val reason: String? = null, +) + +@Serializable +data class TerminateRunResponse( + val ok: Boolean, + val runId: Long, + val taskId: String, +) + +@Serializable +data class KanbanActiveWorker( + val runId: Long, + val taskId: String, + val taskTitle: String, + val taskStatus: String, + val taskAssignee: String? = null, + val profile: String? = null, + val workerPid: Int, + val startedAt: Long? = null, + val claimLock: String? = null, + val claimExpires: Long? = null, + val lastHeartbeatAt: Long? = null, + val maxRuntimeSeconds: Int? = null, +) + +@Serializable +data class ActiveWorkersResponse( + val workers: List = emptyList(), + val count: Int = workers.size, + val checkedAt: Long, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt index 6ec97abb..17fd8c8a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt @@ -48,6 +48,18 @@ data class TaskLinks( val children: List = emptyList(), ) +@Serializable +data class TaskLinkBody( + val parentId: String, + val childId: String, +) + +@Serializable +data class TaskLinkResponse( + val ok: Boolean, + val gated: Boolean = false, +) + @Serializable data class ChildTaskResult( val id: String, @@ -75,6 +87,9 @@ data class KanbanTask( val startedAt: Long? = null, val workerPid: Int? = null, val lastHeartbeatAt: Long? = null, + val currentRunId: Long? = null, + val workflowTemplateId: String? = null, + val currentStepKey: String? = null, ) { val description: String? get() = body val assignedTo: String? get() = assignee @@ -112,6 +127,10 @@ data class KanbanTaskFull( val startedAt: Long? = null, val workerPid: Int? = null, val lastHeartbeatAt: Long? = null, + val currentRunId: Long? = null, + val workflowTemplateId: String? = null, + val currentStepKey: String? = null, + val maxRuntimeSeconds: Int? = null, val result: String? = null, val createdBy: String? = null, val modelOverride: String? = null, @@ -245,6 +264,19 @@ data class ReclaimTaskResponse( val taskId: String, ) +@Serializable +data class SpecifyTaskBody( + val author: String? = null, +) + +@Serializable +data class SpecifyTaskResponse( + val ok: Boolean, + val taskId: String, + val reason: String? = null, + val newTitle: String? = null, +) + @Serializable data class TaskEstimate( val ok: Boolean, diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt index bd4a7648..ceaa6935 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt @@ -1,5 +1,7 @@ package com.m57.hermescontrol.data.remote +import com.m57.hermescontrol.data.model.ActiveWorkersResponse +import com.m57.hermescontrol.data.model.AttachmentListResponse import com.m57.hermescontrol.data.model.AttachmentUploadResponse import com.m57.hermescontrol.data.model.AutoDescribeResponse import com.m57.hermescontrol.data.model.BoardExportResult @@ -10,6 +12,7 @@ import com.m57.hermescontrol.data.model.CreateBoardBody import com.m57.hermescontrol.data.model.CreateBoardResponse import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateTaskResponse +import com.m57.hermescontrol.data.model.DeleteAttachmentResponse import com.m57.hermescontrol.data.model.DeleteBoardResponse import com.m57.hermescontrol.data.model.DispatchResult import com.m57.hermescontrol.data.model.ExportBoardBody @@ -18,6 +21,8 @@ import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardsResponse import com.m57.hermescontrol.data.model.KanbanProfilesResponse import com.m57.hermescontrol.data.model.KanbanProjectsResponse +import com.m57.hermescontrol.data.model.KanbanRunInspection +import com.m57.hermescontrol.data.model.KanbanRunResponse import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse import com.m57.hermescontrol.data.model.OrchestrationSettings import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate @@ -27,7 +32,13 @@ import com.m57.hermescontrol.data.model.ReclaimTaskBody import com.m57.hermescontrol.data.model.ReclaimTaskResponse import com.m57.hermescontrol.data.model.RenameBoardBody import com.m57.hermescontrol.data.model.RenameBoardResponse +import com.m57.hermescontrol.data.model.SpecifyTaskBody +import com.m57.hermescontrol.data.model.SpecifyTaskResponse import com.m57.hermescontrol.data.model.TaskEstimate +import com.m57.hermescontrol.data.model.TaskLinkBody +import com.m57.hermescontrol.data.model.TaskLinkResponse +import com.m57.hermescontrol.data.model.TerminateRunBody +import com.m57.hermescontrol.data.model.TerminateRunResponse import com.m57.hermescontrol.data.model.UpdateTaskBody import com.m57.hermescontrol.data.model.UpdateTaskResponse import com.m57.hermescontrol.data.model.WorkerLog @@ -57,6 +68,8 @@ interface KanbanApiService { @Query("board") board: String? = null, @Query("include_archived") includeArchived: Boolean = false, @Query("tenant") tenant: String? = null, + @Query("workflow_template_id") workflowTemplateId: String? = null, + @Query("current_step_key") currentStepKey: String? = null, ): Response @POST("api/plugins/kanban/boards") @@ -96,6 +109,8 @@ interface KanbanApiService { suspend fun getTask( @Path("id") taskId: String, @Query("board") board: String? = null, + @Query("run_state_type") runStateType: String? = null, + @Query("run_state_name") runStateName: String? = null, ): Response @POST("api/plugins/kanban/tasks") @@ -170,6 +185,12 @@ interface KanbanApiService { @Part file: MultipartBody.Part, ): Response + @GET("api/plugins/kanban/tasks/{id}/attachments") + suspend fun listAttachments( + @Path("id") taskId: String, + @Query("board") board: String? = null, + ): Response + @Streaming @GET("api/plugins/kanban/attachments/{id}") suspend fun downloadAttachment( @@ -177,6 +198,49 @@ interface KanbanApiService { @Query("board") board: String? = null, ): Response + @DELETE("api/plugins/kanban/attachments/{id}") + suspend fun deleteAttachment( + @Path("id") attachmentId: Long, + @Query("board") board: String? = null, + ): Response + + @GET("api/plugins/kanban/workers/active") + suspend fun getActiveWorkers( + @Query("board") board: String? = null, + ): Response + + @GET("api/plugins/kanban/runs/{id}") + suspend fun getRun( + @Path("id") runId: Long, + @Query("board") board: String? = null, + ): Response + + @GET("api/plugins/kanban/runs/{id}/inspect") + suspend fun inspectRun( + @Path("id") runId: Long, + @Query("board") board: String? = null, + ): Response + + @POST("api/plugins/kanban/runs/{id}/terminate") + suspend fun terminateRun( + @Path("id") runId: Long, + @Query("board") board: String? = null, + @Body body: TerminateRunBody = TerminateRunBody(), + ): Response + + @POST("api/plugins/kanban/tasks/{id}/specify") + suspend fun specifyTask( + @Path("id") taskId: String, + @Query("board") board: String? = null, + @Body body: SpecifyTaskBody = SpecifyTaskBody(), + ): Response + + @POST("api/plugins/kanban/links") + suspend fun createTaskLink( + @Query("board") board: String? = null, + @Body body: TaskLinkBody, + ): Response + @GET("api/plugins/kanban/profiles") suspend fun getKanbanProfiles(): Response diff --git a/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt index 422a7e27..73af1470 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt @@ -1,5 +1,7 @@ package com.m57.hermescontrol.data.repository +import com.m57.hermescontrol.data.model.ActiveWorkersResponse +import com.m57.hermescontrol.data.model.AttachmentListResponse import com.m57.hermescontrol.data.model.AttachmentUploadResponse import com.m57.hermescontrol.data.model.AutoDescribeResponse import com.m57.hermescontrol.data.model.BoardExportResult @@ -10,6 +12,7 @@ import com.m57.hermescontrol.data.model.CreateBoardBody import com.m57.hermescontrol.data.model.CreateBoardResponse import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateTaskResponse +import com.m57.hermescontrol.data.model.DeleteAttachmentResponse import com.m57.hermescontrol.data.model.DeleteBoardResponse import com.m57.hermescontrol.data.model.DispatchResult import com.m57.hermescontrol.data.model.ExportBoardBody @@ -18,6 +21,8 @@ import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardsResponse import com.m57.hermescontrol.data.model.KanbanProfilesResponse import com.m57.hermescontrol.data.model.KanbanProjectsResponse +import com.m57.hermescontrol.data.model.KanbanRunInspection +import com.m57.hermescontrol.data.model.KanbanRunResponse import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse import com.m57.hermescontrol.data.model.OrchestrationSettings import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate @@ -27,7 +32,13 @@ import com.m57.hermescontrol.data.model.ReclaimTaskBody import com.m57.hermescontrol.data.model.ReclaimTaskResponse import com.m57.hermescontrol.data.model.RenameBoardBody import com.m57.hermescontrol.data.model.RenameBoardResponse +import com.m57.hermescontrol.data.model.SpecifyTaskBody +import com.m57.hermescontrol.data.model.SpecifyTaskResponse import com.m57.hermescontrol.data.model.TaskEstimate +import com.m57.hermescontrol.data.model.TaskLinkBody +import com.m57.hermescontrol.data.model.TaskLinkResponse +import com.m57.hermescontrol.data.model.TerminateRunBody +import com.m57.hermescontrol.data.model.TerminateRunResponse import com.m57.hermescontrol.data.model.UpdateTaskBody import com.m57.hermescontrol.data.model.UpdateTaskResponse import com.m57.hermescontrol.data.model.WorkerLog @@ -48,6 +59,8 @@ interface KanbanRepository { board: String, includeArchived: Boolean = false, tenant: String? = null, + workflowTemplateId: String? = null, + currentStepKey: String? = null, ): NetworkResult suspend fun createBoard(body: CreateBoardBody): NetworkResult @@ -72,6 +85,8 @@ interface KanbanRepository { suspend fun getTask( taskId: String, board: String?, + runStateType: String? = null, + runStateName: String? = null, ): NetworkResult suspend fun createTask( @@ -136,11 +151,49 @@ interface KanbanRepository { file: MultipartBody.Part, ): NetworkResult + suspend fun listAttachments( + taskId: String, + board: String, + ): NetworkResult + + suspend fun deleteAttachment( + attachmentId: Long, + board: String, + ): NetworkResult + suspend fun downloadAttachment( attachmentId: Long, board: String?, ): NetworkResult + suspend fun getActiveWorkers(board: String): NetworkResult + + suspend fun getRun( + runId: Long, + board: String, + ): NetworkResult + + suspend fun inspectRun( + runId: Long, + board: String, + ): NetworkResult + + suspend fun terminateRun( + runId: Long, + board: String, + ): NetworkResult + + suspend fun specifyTask( + taskId: String, + board: String, + ): NetworkResult + + suspend fun createTaskLink( + board: String, + parentId: String, + childId: String, + ): NetworkResult + suspend fun getProfiles(): NetworkResult suspend fun updateProfileDescription( @@ -173,9 +226,11 @@ class KanbanRepositoryImpl( board: String, includeArchived: Boolean, tenant: String?, + workflowTemplateId: String?, + currentStepKey: String?, ): NetworkResult = withContext(ioDispatcher) { - safeApiCall { apiProvider().getBoard(board = board, includeArchived = includeArchived, tenant = tenant) } + safeApiCall { apiProvider().getBoard(board, includeArchived, tenant, workflowTemplateId, currentStepKey) } } override suspend fun createBoard(body: CreateBoardBody): NetworkResult = @@ -215,9 +270,11 @@ class KanbanRepositoryImpl( override suspend fun getTask( taskId: String, board: String?, + runStateType: String?, + runStateName: String?, ): NetworkResult = withContext(ioDispatcher) { - safeApiCall { apiProvider().getTask(taskId = taskId, board = board) } + safeApiCall { apiProvider().getTask(taskId, board, runStateType, runStateName) } } override suspend fun createTask( @@ -364,6 +421,18 @@ class KanbanRepositoryImpl( safeApiCall { apiProvider().uploadAttachment(taskId = taskId, board = board, file = file) } } + override suspend fun listAttachments( + taskId: String, + board: String, + ): NetworkResult = + withContext(ioDispatcher) { safeApiCall { apiProvider().listAttachments(taskId, board) } } + + override suspend fun deleteAttachment( + attachmentId: Long, + board: String, + ): NetworkResult = + withContext(ioDispatcher) { safeApiCall { apiProvider().deleteAttachment(attachmentId, board) } } + override suspend fun downloadAttachment( attachmentId: Long, board: String?, @@ -372,6 +441,48 @@ class KanbanRepositoryImpl( safeApiCall { apiProvider().downloadAttachment(attachmentId = attachmentId, board = board) } } + override suspend fun getActiveWorkers(board: String): NetworkResult = + withContext(ioDispatcher) { safeApiCall { apiProvider().getActiveWorkers(board) } } + + override suspend fun getRun( + runId: Long, + board: String, + ): NetworkResult = + withContext(ioDispatcher) { safeApiCall { apiProvider().getRun(runId, board) } } + + override suspend fun inspectRun( + runId: Long, + board: String, + ): NetworkResult = + withContext(ioDispatcher) { safeApiCall { apiProvider().inspectRun(runId, board) } } + + override suspend fun terminateRun( + runId: Long, + board: String, + ): NetworkResult = + withContext(ioDispatcher) { + val result = safeApiCall { apiProvider().terminateRun(runId, board, TerminateRunBody(reason = "mobile")) } + if (result is NetworkResult.Success && result.data.ok) nudger.scheduleNudge(board) + result + } + + override suspend fun specifyTask( + taskId: String, + board: String, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().specifyTask(taskId, board, SpecifyTaskBody(author = "mobile")) } + } + + override suspend fun createTaskLink( + board: String, + parentId: String, + childId: String, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().createTaskLink(board, TaskLinkBody(parentId, childId)) } + } + override suspend fun getProfiles(): NetworkResult = withContext(ioDispatcher) { safeApiCall { apiProvider().getKanbanProfiles() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt index 68565cc7..80942f25 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -119,10 +120,22 @@ fun KanbanScreen( var selectedAssignee by remember { mutableStateOf(null) } var selectedTenant by remember { mutableStateOf(null) } var showFilterSheet by remember { mutableStateOf(false) } + var showWorkersDialog by remember { mutableStateOf(false) } val filteredTasks = - remember(query, state.tasks, selectedAssignee, selectedTenant) { - state.tasks.filter { task -> + remember( + query, + state.tasks, + selectedAssignee, + selectedTenant, + state.selectedWorkflowTemplateId, + state.selectedCurrentStepKey, + ) { + filterKanbanTasks( + state.tasks, + state.selectedWorkflowTemplateId, + state.selectedCurrentStepKey, + ).filter { task -> val matchesQuery = query.isBlank() || task.title.contains(query, ignoreCase = true) || @@ -331,6 +344,14 @@ fun KanbanScreen( }, ) if (state.selectedBoard != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.kanban_active_workers)) }, + onClick = { + showBoardMenu = false + showWorkersDialog = true + viewModel.loadActiveWorkers() + }, + ) DropdownMenuItem( text = { Text(stringResource(R.string.kanban_board_settings)) }, onClick = { @@ -917,10 +938,23 @@ fun KanbanScreen( tenants = tenants, selectedAssignee = selectedAssignee, selectedTenant = selectedTenant, + workflowTemplateIds = + state.tasks + .mapNotNull { it.workflowTemplateId } + .distinct() + .sorted(), + currentStepKeys = + state.tasks + .mapNotNull { it.currentStepKey } + .distinct() + .sorted(), + selectedWorkflowTemplateId = state.selectedWorkflowTemplateId, + selectedCurrentStepKey = state.selectedCurrentStepKey, includeArchived = state.includeArchived, groupRunning = state.groupRunning, onSelectAssignee = { selectedAssignee = it }, onSelectTenant = { selectedTenant = it }, + onSelectWorkflowFilters = viewModel::setWorkflowFilters, onToggleIncludeArchived = viewModel::setIncludeArchived, onToggleGroupRunning = viewModel::setGroupRunning, onClearFilters = { @@ -928,6 +962,7 @@ fun KanbanScreen( selectedTenant = null viewModel.setIncludeArchived(false) viewModel.setGroupRunning(false) + viewModel.setWorkflowFilters(null, null) }, onDismiss = { showFilterSheet = false }, ) @@ -1291,6 +1326,46 @@ fun KanbanScreen( ) } + if (showWorkersDialog) { + AlertDialog( + onDismissRequest = { showWorkersDialog = false }, + title = { Text(stringResource(R.string.kanban_active_workers)) }, + text = { + if (state.isLoadingWorkers) { + CircularProgressIndicator() + } else if (state.activeWorkers.isEmpty()) { + Text(stringResource(R.string.kanban_no_active_workers)) + } else { + LazyColumn(modifier = Modifier.heightIn(max = 320.dp)) { + items(state.activeWorkers, key = { it.runId }) { worker -> + Text( + "${worker.taskTitle} · ${worker.profile ?: worker.taskAssignee.orEmpty()}", + modifier = + Modifier + .fillMaxWidth() + .clickable { + val board = state.selectedBoard ?: return@clickable + showWorkersDialog = false + NavigationController.navigateTo( + KanbanTaskDetailKey( + boardSlug = board.id, + taskId = worker.taskId, + ), + ) + }.padding(vertical = 8.dp), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { showWorkersDialog = false }) { + Text(stringResource(R.string.action_close)) + } + }, + ) + } + if (showDeleteBoardDialog && state.selectedBoard != null) { AlertDialog( onDismissRequest = { showDeleteBoardDialog = false }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt index 98404bea..51d9ce8f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.RestartAlt @@ -62,9 +63,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily @@ -75,12 +78,14 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.KanbanTaskDetailKey import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.KanbanAttachment import com.m57.hermescontrol.data.model.KanbanProfile import com.m57.hermescontrol.data.model.KanbanTaskFull import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.model.TaskEstimate import com.m57.hermescontrol.data.model.TaskLinks +import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.chat.MarkdownText import com.m57.hermescontrol.ui.common.ErrorState @@ -89,6 +94,13 @@ import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.kanban.components.KanbanModelOverrideEditor +import com.m57.hermescontrol.util.StreamingUriRequestBody +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -110,6 +122,7 @@ fun KanbanTaskScreen( stringResource(R.string.kanban_tab_files), ) var showDeleteDialog by remember { mutableStateOf(false) } + var runToTerminate by remember { mutableStateOf(null) } LaunchedEffect(boardSlug, taskId) { viewModel.loadTask(boardSlug, taskId) @@ -208,6 +221,10 @@ fun KanbanTaskScreen( onReclaim = { viewModel.reclaim(boardSlug, taskId) }, + isSpecifying = state.isSpecifying, + isLinking = state.isLinking, + onSpecify = { viewModel.specifyTask(boardSlug, taskId) }, + onAddParent = { viewModel.createParentLink(boardSlug, taskId, it) }, ) } @@ -229,6 +246,10 @@ fun KanbanTaskScreen( TaskRunsTab( runs = detail.runs, workerLog = state.workerLog, + runStateType = state.runStateType, + runStateName = state.runStateName, + onFilter = { type, name -> viewModel.setRunFilter(boardSlug, taskId, type, name) }, + onInspect = { viewModel.inspectRun(boardSlug, taskId, it) }, ) } @@ -242,9 +263,10 @@ fun KanbanTaskScreen( TaskFilesTab( attachments = detail.attachments ?: emptyList(), isUploading = state.isUploadingAttachment, - onUpload = { filename, mimeType, bytes -> - viewModel.uploadAttachment(boardSlug, taskId, filename, mimeType, bytes) - }, + isDeleting = state.isDeletingAttachment, + board = boardSlug, + taskId = taskId, + viewModel = viewModel, ) } } @@ -276,6 +298,68 @@ fun KanbanTaskScreen( }, ) } + + val selectedRun = state.selectedRun + if (selectedRun != null) { + AlertDialog( + onDismissRequest = viewModel::clearRunInspection, + title = { Text(stringResource(R.string.kanban_run_details)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + MetaRow(stringResource(R.string.kanban_run_number, selectedRun.id), selectedRun.status) + selectedRun.profile?.let { MetaRow(stringResource(R.string.kanban_assignee), it) } + selectedRun.stepKey?.let { MetaRow("Step", it) } + selectedRun.workerPid?.let { MetaRow("PID", it.toString()) } + selectedRun.summary?.let { Text(it) } + selectedRun.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + state.runInspection?.let { inspection -> + Text( + if (inspection.alive) { + inspection.status ?: selectedRun.status + } else { + inspection.reason ?: stringResource(R.string.kanban_run_inspection_unavailable) + }, + ) + inspection.cpuPercent?.let { MetaRow("CPU", "$it%") } + inspection.memoryRssBytes?.let { MetaRow("Memory", "${it / 1024} KiB") } + inspection.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + } + }, + confirmButton = { + if (selectedRun.endedAt == null && selectedRun.status == "running" && + state.detail?.task?.status == "running" + ) { + TextButton(onClick = { runToTerminate = selectedRun.id }, enabled = !state.isTerminatingRun) { + Text(stringResource(R.string.kanban_terminate_run)) + } + } + }, + dismissButton = { + TextButton(onClick = viewModel::clearRunInspection) { Text(stringResource(R.string.action_cancel)) } + }, + ) + } + + if (runToTerminate != null) { + AlertDialog( + onDismissRequest = { runToTerminate = null }, + title = { Text(stringResource(R.string.kanban_terminate_run)) }, + text = { Text(stringResource(R.string.kanban_terminate_run_confirm)) }, + confirmButton = { + Button( + onClick = { + runToTerminate?.let { viewModel.terminateRun(boardSlug, taskId, it) } + runToTerminate = null + }, + enabled = !state.isTerminatingRun, + ) { Text(stringResource(R.string.kanban_terminate_run)) } + }, + dismissButton = { + TextButton(onClick = { runToTerminate = null }) { Text(stringResource(R.string.action_cancel)) } + }, + ) + } } @Composable @@ -293,11 +377,16 @@ private fun TaskOverviewTab( onNavigateToTask: (String) -> Unit, onSaveDescription: (String) -> Unit, onReclaim: () -> Unit, + isSpecifying: Boolean, + isLinking: Boolean, + onSpecify: () -> Unit, + onAddParent: (String) -> Unit, modifier: Modifier = Modifier, ) { var isEditingDescription by remember { mutableStateOf(false) } var descriptionDraft by remember(task.body) { mutableStateOf(task.body ?: "") } var assigneeMenuExpanded by remember { mutableStateOf(false) } + var parentDraft by remember { mutableStateOf("") } val statusColors = LocalHermesStatusColors.current val currentModelOverride = @@ -430,6 +519,8 @@ private fun TaskOverviewTab( task.tenant?.let { MetaRow(label = "Tenant", value = it) } task.workspaceKind?.let { MetaRow(label = "Workspace", value = "$it ${task.workspacePath ?: ""}") } task.workerPid?.let { MetaRow(label = "Worker PID", value = it.toString()) } + task.workflowTemplateId?.let { MetaRow(label = "Workflow", value = it) } + task.currentStepKey?.let { MetaRow(label = "Step", value = it) } Spacer(modifier = Modifier.height(8.dp)) HorizontalDivider() @@ -446,7 +537,7 @@ private fun TaskOverviewTab( } // Dependencies Section - if (links.parents.isNotEmpty() || links.children.isNotEmpty()) { + run { Card( shape = RoundedCornerShape(8.dp), modifier = @@ -461,6 +552,22 @@ private fun TaskOverviewTab( fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, ) + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = parentDraft, + onValueChange = { parentDraft = it }, + label = { Text(stringResource(R.string.kanban_parent_id)) }, + modifier = Modifier.weight(1f), + singleLine = true, + ) + TextButton( + onClick = { + onAddParent(parentDraft) + parentDraft = "" + }, + enabled = parentDraft.isNotBlank() && !isLinking, + ) { Text(stringResource(R.string.kanban_add_parent)) } + } if (links.parents.isNotEmpty()) { Spacer(modifier = Modifier.height(6.dp)) Text( @@ -506,6 +613,13 @@ private fun TaskOverviewTab( } // Effort Estimation Section + if (task.status == "triage") { + OutlinedButton( + onClick = onSpecify, + enabled = !isSpecifying, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) { Text(stringResource(R.string.kanban_specify_task)) } + } Card( shape = RoundedCornerShape(8.dp), modifier = @@ -778,8 +892,15 @@ private fun formatRunDuration(durationSeconds: Long): String = private fun TaskRunsTab( runs: List, workerLog: com.m57.hermescontrol.data.model.WorkerLog?, + runStateType: String?, + runStateName: String?, + onFilter: (String?, String?) -> Unit, + onInspect: (Long) -> Unit, modifier: Modifier = Modifier, ) { + var filterType by remember(runStateType) { mutableStateOf(runStateType ?: "status") } + var filterName by remember(runStateName) { mutableStateOf(runStateName ?: "") } + var filterMenuExpanded by remember { mutableStateOf(false) } LazyColumn( modifier = modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), @@ -790,6 +911,34 @@ private fun TaskRunsTab( style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, ) + Row(verticalAlignment = Alignment.CenterVertically) { + Box { + TextButton(onClick = { filterMenuExpanded = true }) { + Text(if (filterType == "status") "Status" else "Outcome") + } + DropdownMenu(expanded = filterMenuExpanded, onDismissRequest = { filterMenuExpanded = false }) { + listOf("status", "outcome").forEach { type -> + DropdownMenuItem(text = { Text(type) }, onClick = { + filterType = type + filterMenuExpanded = false + }) + } + } + } + OutlinedTextField( + value = filterName, + onValueChange = { filterName = it }, + modifier = Modifier.weight(1f), + label = { Text(stringResource(R.string.kanban_run_state_filter)) }, + singleLine = true, + ) + TextButton(onClick = { onFilter(filterType, filterName.trim()) }, enabled = filterName.isNotBlank()) { + Text(stringResource(R.string.kanban_filter)) + } + } + if (runStateName != null) { + TextButton(onClick = { onFilter(null, null) }) { Text(stringResource(R.string.kanban_all_runs)) } + } } if (runs.isEmpty()) { @@ -858,6 +1007,9 @@ private fun TaskRunsTab( style = MaterialTheme.typography.bodySmall, ) } + OutlinedButton(onClick = { onInspect(run.id) }) { + Text(stringResource(R.string.kanban_inspect_run)) + } } } } @@ -1058,31 +1210,107 @@ private fun MetaRow( private fun TaskFilesTab( attachments: List, isUploading: Boolean, - onUpload: (filename: String, mimeType: String, bytes: ByteArray) -> Unit, + isDeleting: Boolean, + board: String, + taskId: String, + viewModel: KanbanTaskViewModel, modifier: Modifier = Modifier, ) { val context = LocalContext.current - val launcher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent(), - ) { uri: Uri? -> - if (uri != null) { - var filename = "attachment" - context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> - val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (nameIndex != -1 && cursor.moveToFirst()) { - filename = cursor.getString(nameIndex) ?: "attachment" + val configuration = LocalConfiguration.current + val resources = remember(context, configuration) { context.createConfigurationContext(configuration).resources } + val scope = rememberCoroutineScope() + var attachmentToDelete by remember { mutableStateOf(null) } + var attachmentToDownload by remember { mutableStateOf(null) } + val downloadLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> + val selected = attachmentToDownload + attachmentToDownload = null + if (uri != null && selected != null) { + scope.launch { + when (val result = viewModel.downloadAttachment(board, selected.id)) { + is NetworkResult.Success -> { + try { + withContext(Dispatchers.IO) { + result.data.use { body -> + context.contentResolver.openOutputStream(uri)?.use { output -> + body.byteStream().use { input -> input.copyTo(output) } + } ?: error("Could not open the selected destination") + } + } + viewModel.showToast(resources.getString(R.string.kanban_attachment_saved)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + viewModel.showToast( + resources.getString(R.string.kanban_attachment_error, e.message ?: "Unknown error"), + ) + } + } + + is NetworkResult.Failure -> { + viewModel.showToast(result.error.message) + } } } - val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream" - val bytes = - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - ?: byteArrayOf() - if (bytes.isNotEmpty()) { - onUpload(filename, mimeType, bytes) - } } } + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> + if (uri != null) { + scope.launch { + try { + val part = + withContext(Dispatchers.IO) { + var filename = "attachment" + var contentLength = -1L + context.contentResolver + .query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), + null, + null, + null, + )?.use { c -> + if (c.moveToFirst()) { + c.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }?.let { + filename = c.getString(it) ?: filename + } + c + .getColumnIndex( + OpenableColumns.SIZE, + ).takeIf { it >= 0 && !c.isNull(it) } + ?.let { + contentLength = c.getLong(it) + } + } + } + val mime = context.contentResolver.getType(uri) ?: "application/octet-stream" + val body = + StreamingUriRequestBody( + context.contentResolver, + uri, + mime.toMediaTypeOrNull(), + contentLength, + ) + MultipartBody.Part.createFormData("file", filename, body) + } + viewModel.uploadAttachment(board, taskId, part) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + viewModel.showToast( + resources.getString( + R.string.kanban_attachment_error, + e.message ?: "Unknown error", + ), + ) + } + } + } + } + + LaunchedEffect(board, taskId) { viewModel.refreshAttachments(board, taskId) } Column(modifier = modifier.fillMaxSize().padding(16.dp)) { Row( @@ -1096,7 +1324,7 @@ private fun TaskFilesTab( fontWeight = FontWeight.Bold, ) Button( - onClick = { launcher.launch("*/*") }, + onClick = { launcher.launch(arrayOf("*/*")) }, enabled = !isUploading, ) { if (isUploading) { @@ -1136,22 +1364,55 @@ private fun TaskFilesTab( Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = att.filename.ifBlank { "File #${att.id}" }, + att.filename.ifBlank { + "File #${att.id}" + }, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, ) att.size?.let { Text( - text = "${it / 1024} KB", + "${it / 1024} KB", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } + IconButton(onClick = { + attachmentToDownload = att + downloadLauncher.launch(att.filename) + }) { + Icon( + Icons.Filled.Download, + contentDescription = stringResource(R.string.kanban_download_attachment), + ) + } + IconButton(onClick = { attachmentToDelete = att }, enabled = !isDeleting) { + Icon( + Icons.Filled.Delete, + contentDescription = stringResource(R.string.kanban_delete_attachment), + ) + } } } } } } } + attachmentToDelete?.let { attachment -> + AlertDialog( + onDismissRequest = { attachmentToDelete = null }, + title = { Text(stringResource(R.string.kanban_delete_attachment)) }, + text = { Text(stringResource(R.string.kanban_delete_attachment_confirm)) }, + confirmButton = { + TextButton(onClick = { + attachmentToDelete = null + viewModel.deleteAttachment(board, taskId, attachment.id) + }) { Text(stringResource(R.string.kanban_delete_attachment)) } + }, + dismissButton = { + TextButton(onClick = { attachmentToDelete = null }) { Text(stringResource(R.string.action_cancel)) } + }, + ) + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt index b9b6249e..03ce28cc 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.KanbanProfile +import com.m57.hermescontrol.data.model.KanbanRun +import com.m57.hermescontrol.data.model.KanbanRunInspection import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel @@ -25,9 +27,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody -import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.ResponseBody data class KanbanTaskUiState( val isLoading: Boolean = false, @@ -47,6 +48,15 @@ data class KanbanTaskUiState( val isReassigning: Boolean = false, val isUpdatingModel: Boolean = false, val isUploadingAttachment: Boolean = false, + val isDeletingAttachment: Boolean = false, + val isSpecifying: Boolean = false, + val isLinking: Boolean = false, + val isInspectingRun: Boolean = false, + val isTerminatingRun: Boolean = false, + val selectedRun: KanbanRun? = null, + val runInspection: KanbanRunInspection? = null, + val runStateType: String? = null, + val runStateName: String? = null, ) class KanbanTaskViewModel( @@ -61,6 +71,7 @@ class KanbanTaskViewModel( private var eventsClient: KanbanEventsClient? = null private var eventsBoard: String? = null private var activeTaskId: String? = null + private var activeBoard: String? = null private var currentLoadGen: Int = 0 private var reloadJob: Job? = null private var logPollJob: Job? = null @@ -74,7 +85,18 @@ class KanbanTaskViewModel( reloadJob?.cancel() stopLogPolling() detailPollJob?.cancel() + if (activeBoard != board || activeTaskId != taskId) { + _uiState.update { + it.copy( + runStateType = null, + runStateName = null, + selectedRun = null, + runInspection = null, + ) + } + } activeTaskId = taskId + activeBoard = board _uiState.update { it.copy( isLoading = true, @@ -90,7 +112,15 @@ class KanbanTaskViewModel( startDetailPolling(board, taskId) viewModelScope.launch { - when (val result = repository.getTask(taskId = taskId, board = board)) { + when ( + val result = + repository.getTask( + taskId, + board, + _uiState.value.runStateType, + _uiState.value.runStateName, + ) + ) { is NetworkResult.Success -> { if (gen != currentLoadGen) return@launch val taskData = result.data @@ -254,11 +284,13 @@ class KanbanTaskViewModel( taskId: String, ) { val gen = currentLoadGen - val result = repository.getTask(taskId = taskId, board = board) - if (gen != currentLoadGen || taskId != activeTaskId) return + val result = repository.getTask(taskId, board, _uiState.value.runStateType, _uiState.value.runStateName) + if (gen != currentLoadGen || taskId != activeTaskId || board != activeBoard) return if (result is NetworkResult.Success) { val taskData = result.data - _uiState.update { it.copy(detail = taskData) } + _uiState.update { state -> + state.copy(detail = taskData.copy(attachments = taskData.attachments ?: state.detail?.attachments)) + } if (taskData.task.status.equals("running", ignoreCase = true)) { if (logPollJob == null) startLogPolling(board, taskId) } else { @@ -521,18 +553,6 @@ class KanbanTaskViewModel( } } - fun uploadAttachment( - board: String, - taskId: String, - filename: String, - mimeType: String, - bytes: ByteArray, - ) { - val requestBody = bytes.toRequestBody(mimeType.toMediaTypeOrNull()) - val part = MultipartBody.Part.createFormData("file", filename, requestBody) - uploadAttachment(board, taskId, part) - } - fun uploadAttachment( board: String, taskId: String, @@ -548,7 +568,10 @@ class KanbanTaskViewModel( toastMessage = "Attachment uploaded", ) } - loadTask(board, taskId) + if (activeTaskId == taskId && activeBoard == board) { + loadTaskSilently(board, taskId) + refreshAttachments(board, taskId) + } } is NetworkResult.Failure -> { @@ -563,6 +586,217 @@ class KanbanTaskViewModel( } } + fun refreshAttachments( + board: String, + taskId: String, + ) { + viewModelScope.launch { + when (val result = repository.listAttachments(taskId, board)) { + is NetworkResult.Success -> { + _uiState.update { state -> + if (activeTaskId != taskId || activeBoard != board) { + state + } else { + state.copy(detail = state.detail?.copy(attachments = result.data.attachments)) + } + } + } + + is NetworkResult.Failure -> { + _uiState.update { it.copy(toastMessage = result.error.message) } + } + } + } + } + + fun deleteAttachment( + board: String, + taskId: String, + attachmentId: Long, + ) { + if (_uiState.value.isDeletingAttachment) return + _uiState.update { it.copy(isDeletingAttachment = true) } + viewModelScope.launch { + when (val result = repository.deleteAttachment(attachmentId, board)) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isDeletingAttachment = false, + toastMessage = if (result.data.ok) "Attachment deleted" else "Attachment was not deleted", + ) + } + if (result.data.ok) refreshAttachments(board, taskId) + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy(isDeletingAttachment = false, toastMessage = result.error.message) + } + } + } + } + } + + suspend fun downloadAttachment( + board: String, + attachmentId: Long, + ): NetworkResult = repository.downloadAttachment(attachmentId, board) + + fun showToast(message: String) { + _uiState.update { it.copy(toastMessage = message) } + } + + fun inspectRun( + board: String, + taskId: String, + runId: Long, + ) { + if (_uiState.value.isInspectingRun) return + _uiState.update { it.copy(isInspectingRun = true, selectedRun = null, runInspection = null) } + viewModelScope.launch { + val run = repository.getRun(runId, board) + val inspection = if (run is NetworkResult.Success) repository.inspectRun(runId, board) else null + if (activeTaskId != null && (activeTaskId != taskId || activeBoard != board)) return@launch + _uiState.update { + it.copy( + isInspectingRun = false, + selectedRun = (run as? NetworkResult.Success)?.data?.run, + runInspection = (inspection as? NetworkResult.Success)?.data, + toastMessage = + (run as? NetworkResult.Failure)?.error?.message + ?: (inspection as? NetworkResult.Failure)?.error?.message, + ) + } + } + } + + fun clearRunInspection() { + _uiState.update { it.copy(selectedRun = null, runInspection = null) } + } + + fun terminateRun( + board: String, + taskId: String, + runId: Long, + ) { + if (_uiState.value.isTerminatingRun) return + _uiState.update { it.copy(isTerminatingRun = true) } + viewModelScope.launch { + when (val result = repository.terminateRun(runId, board)) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isTerminatingRun = false, + toastMessage = if (result.data.ok) "Run terminated" else "Run was not terminated", + ) + } + if (result.data.ok && result.data.taskId == taskId && + activeTaskId == taskId && activeBoard == board + ) { + val latestRun = repository.getRun(runId, board) + _uiState.update { it.copy(selectedRun = (latestRun as? NetworkResult.Success)?.data?.run) } + loadTaskSilently(board, taskId) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy(isTerminatingRun = false, toastMessage = result.error.message) + } + } + } + } + } + + fun specifyTask( + board: String, + taskId: String, + ) { + if (_uiState.value.isSpecifying) return + _uiState.update { it.copy(isSpecifying = true) } + viewModelScope.launch { + when (val result = repository.specifyTask(taskId, board)) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isSpecifying = false, + toastMessage = + if (result.data.ok) { + "Task specified" + } else { + result.data.reason + ?: "Specification failed" + }, + ) + } + if (result.data.ok && activeTaskId == taskId && + activeBoard == board + ) { + loadTaskSilently(board, taskId) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy(isSpecifying = false, toastMessage = result.error.message) + } + } + } + } + } + + fun createParentLink( + board: String, + childId: String, + parentId: String, + ) { + if (parentId.isBlank() || _uiState.value.isLinking) return + _uiState.update { it.copy(isLinking = true) } + viewModelScope.launch { + when (val result = repository.createTaskLink(board, parentId.trim(), childId)) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isLinking = false, + toastMessage = + when { + !result.data.ok -> "Parent link was not created" + result.data.gated -> "Linked; task gated by parent" + else -> "Parent linked" + }, + ) + } + if (result.data.ok) { + if (activeTaskId == childId && activeBoard == board) { + loadTaskSilently(board, childId) + } else if (activeTaskId == null) { + loadTask(board, childId) + } + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy(isLinking = false, toastMessage = result.error.message) + } + } + } + } + } + + fun setRunFilter( + board: String, + taskId: String, + type: String?, + name: String?, + ) { + if ((type == null) != (name == null) || type !in setOf(null, "status", "outcome")) return + _uiState.update { it.copy(runStateType = type, runStateName = name) } + if (activeBoard == board && activeTaskId == taskId) { + viewModelScope.launch { loadTaskSilently(board, taskId) } + } + } + public override fun onCleared() { stopLogPolling() detailPollJob?.cancel() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTransitionRefusal.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTransitionRefusal.kt new file mode 100644 index 00000000..f0a02dff --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTransitionRefusal.kt @@ -0,0 +1,30 @@ +package com.m57.hermescontrol.ui.kanban + +/** The dashboard currently sends dependency refusals as a 409 string detail, not JSON fields. */ +data class KanbanTransitionRefusal( + val blockingParentIds: List, + val blockingParentStatuses: List, +) + +fun kanbanTransitionRefusal(message: String): KanbanTransitionRefusal? { + val (parentList, pattern) = + when { + message.contains("blocked by parent(s) not done") -> { + message.substringAfter("blocked by parent(s) not done") to Regex("\\(([^(),\\s]+), status=([^()]*)\\)") + } + + message.contains("unsatisfied parent dependencies:") -> { + message.substringAfter("unsatisfied parent dependencies:").substringBefore(';') to + Regex("([^,;()\\s]+) \\(([^()]*)\\)") + } + + else -> { + return null + } + } + val blockers = + pattern.findAll(parentList).map { match -> match.groupValues[1] to match.groupValues[2] }.toList() + return blockers.takeIf { it.isNotEmpty() }?.let { + KanbanTransitionRefusal(it.map { pair -> pair.first }, it.map { pair -> pair.second }) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt index 874f7ee4..54e3faec 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt @@ -12,6 +12,7 @@ import com.m57.hermescontrol.data.model.CreateBoardBody import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.ExportBoardBody import com.m57.hermescontrol.data.model.ImportBoardBody +import com.m57.hermescontrol.data.model.KanbanActiveWorker import com.m57.hermescontrol.data.model.KanbanBoard import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanColumn @@ -26,6 +27,7 @@ import com.m57.hermescontrol.data.model.RenameBoardBody import com.m57.hermescontrol.data.model.TaskEstimate import com.m57.hermescontrol.data.model.UpdateTaskBody import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.repository.KanbanRepository @@ -64,8 +66,22 @@ data class KanbanUiState( val columnCollapseOverrides: Map = emptyMap(), val errorMessage: String? = null, val toastMessage: String? = null, + val selectedWorkflowTemplateId: String? = null, + val selectedCurrentStepKey: String? = null, + val activeWorkers: List = emptyList(), + val isLoadingWorkers: Boolean = false, ) +fun filterKanbanTasks( + tasks: List, + workflowTemplateId: String?, + currentStepKey: String?, +): List = + tasks.filter { task -> + (workflowTemplateId == null || task.workflowTemplateId.equals(workflowTemplateId, ignoreCase = true)) && + (currentStepKey == null || task.currentStepKey.equals(currentStepKey, ignoreCase = true)) + } + /** * Task actions mirroring the desktop kanban's transition-gated buttons. * Only moves the backend PATCH route accepts are offered: never `running` @@ -250,7 +266,15 @@ class KanbanViewModel( preferences.setSelectedBoard(endpoint, board.id) val gen = ++currentLoadGen - _uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) } + _uiState.update { + it.copy( + selectedBoard = board, + isLoading = true, + errorMessage = null, + activeWorkers = emptyList(), + isLoadingWorkers = false, + ) + } viewModelScope.launch { loadBoardIntoState(board, gen) connectEvents(board) @@ -272,6 +296,54 @@ class KanbanViewModel( _uiState.update { it.copy(groupRunning = group) } } + fun setWorkflowFilters( + workflowTemplateId: String?, + currentStepKey: String?, + ) { + _uiState.update { + it.copy( + selectedWorkflowTemplateId = workflowTemplateId, + selectedCurrentStepKey = currentStepKey, + ) + } + _uiState.value.selectedBoard?.let { board -> + val gen = ++currentLoadGen + viewModelScope.launch { loadBoardIntoState(board, gen) } + } + } + + fun loadActiveWorkers() { + val board = _uiState.value.selectedBoard ?: return + _uiState.update { it.copy(isLoadingWorkers = true) } + viewModelScope.launch { + when (val result = repository.getActiveWorkers(board.id)) { + is NetworkResult.Success -> { + _uiState.update { + if (it.selectedBoard?.id == board.id) { + it.copy(activeWorkers = result.data.workers, isLoadingWorkers = false) + } else { + it + } + } + } + + is NetworkResult.Failure -> { + _uiState.update { + if (it.selectedBoard?.id == board.id) { + it.copy( + isLoadingWorkers = false, + activeWorkers = emptyList(), + toastMessage = "Failed to load active workers: ${result.error.message}", + ) + } else { + it + } + } + } + } + } + } + fun toggleColumnCollapse( columnName: String, boardHasWork: Boolean, @@ -860,10 +932,22 @@ class KanbanViewModel( operatingTaskIds = state.operatingTaskIds - task.id, ) } + reloadBoardSilently() } is NetworkResult.Failure -> { - revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}") + val refusal = + (result.error as? NetworkError.Http) + ?.takeIf { it.code == 409 } + ?.let { kanbanTransitionRefusal(it.message) } + val message = + if (refusal != null) { + "Move blocked by parents ${refusal.blockingParentIds.joinToString()} " + + "(${refusal.blockingParentStatuses.joinToString()}): ${result.error.message}" + } else { + "Move failed: ${result.error.message}" + } + revertTaskMove(task.id, originalStatus, message) } } } @@ -945,6 +1029,8 @@ class KanbanViewModel( repository.getBoard( board = board.id, includeArchived = _uiState.value.includeArchived, + workflowTemplateId = _uiState.value.selectedWorkflowTemplateId, + currentStepKey = _uiState.value.selectedCurrentStepKey, ) if (gen != currentLoadGen) return@launch if (result is NetworkResult.Success) { @@ -1010,6 +1096,8 @@ class KanbanViewModel( repository.getBoard( board = board.id, includeArchived = _uiState.value.includeArchived, + workflowTemplateId = _uiState.value.selectedWorkflowTemplateId, + currentStepKey = _uiState.value.selectedCurrentStepKey, ) applyBoardResult(board, result, gen) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanFilterSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanFilterSheet.kt index bf9a9cd9..b8986d8f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanFilterSheet.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanFilterSheet.kt @@ -34,10 +34,15 @@ fun KanbanFilterSheet( tenants: List, selectedAssignee: String?, selectedTenant: String?, + workflowTemplateIds: List, + currentStepKeys: List, + selectedWorkflowTemplateId: String?, + selectedCurrentStepKey: String?, includeArchived: Boolean, groupRunning: Boolean, onSelectAssignee: (String?) -> Unit, onSelectTenant: (String?) -> Unit, + onSelectWorkflowFilters: (String?, String?) -> Unit, onToggleIncludeArchived: (Boolean) -> Unit, onToggleGroupRunning: (Boolean) -> Unit, onClearFilters: () -> Unit, @@ -105,6 +110,29 @@ fun KanbanFilterSheet( HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text( + "Workflow template", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + FilterChoiceRow("All templates", selectedWorkflowTemplateId == null) { + onSelectWorkflowFilters(null, selectedCurrentStepKey) + } + workflowTemplateIds.forEach { value -> + FilterChoiceRow(value, selectedWorkflowTemplateId == value) { + onSelectWorkflowFilters(value, selectedCurrentStepKey) + } + } + Text("Current step", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + FilterChoiceRow("All steps", selectedCurrentStepKey == null) { + onSelectWorkflowFilters(selectedWorkflowTemplateId, null) + } + currentStepKeys.forEach { value -> + FilterChoiceRow(value, selectedCurrentStepKey == value) { + onSelectWorkflowFilters(selectedWorkflowTemplateId, value) + } + } + // Assignee Filter Text( text = stringResource(R.string.kanban_assignee), @@ -192,3 +220,18 @@ fun KanbanFilterSheet( } } } + +@Composable +private fun FilterChoiceRow( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp), + ) { + RadioButton(selected = selected, onClick = onClick) + Text(label, style = MaterialTheme.typography.bodyMedium) + } +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 5b7a442f..1f8b9619 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1495,6 +1495,33 @@ إغلاق تكاملات الجلسة + + تحديد المهمة + جارٍ التحديد… + تم تحديد المهمة بنجاح + تعذر تحديد المهمة: %1$s + إضافة مهمة أصل + معرّف المهمة الأصل + لا يمكن إنشاء الرابط قبل تحديد المهمة + تم إنشاء رابط المهمة + تفاصيل التشغيل + فحص التشغيل + إنهاء التشغيل + هل تريد إنهاء هذا التشغيل؟ + تم إنهاء التشغيل + التشغيل غير نشط + فحص التشغيل غير متاح + حالة التشغيل + كل عمليات التشغيل + حذف المرفق + هل تريد حذف هذا المرفق؟ + تنزيل المرفق + تم حفظ المرفق + تم حذف المرفق + خطأ في المرفق: %1$s + العاملون النشطون + لا يوجد عاملون نشطون + تشغيل إيقاف مؤقت diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 20b1d7fc..1d7a72c3 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1212,4 +1212,31 @@ %1$s은(는) 필수입니다 설정 응답을 보낼 수 없습니다. 다시 시도하세요. Hermes를 기다리는 중… + + + 작업 지정 + 지정 중… + 작업이 지정되었습니다 + 작업 지정 실패: %1$s + 상위 작업 추가 + 상위 작업 ID + 작업을 지정하기 전에는 연결할 수 없습니다 + 작업 링크가 생성되었습니다 + 실행 상세 정보 + 실행 검사 + 실행 종료 + 이 실행을 종료하시겠습니까? + 실행이 종료되었습니다 + 실행이 비활성 상태입니다 + 실행 검사를 사용할 수 없습니다 + 실행 상태 + 모든 실행 + 첨부 파일 삭제 + 이 첨부 파일을 삭제하시겠습니까? + 첨부 파일 다운로드 + 첨부 파일이 저장되었습니다 + 첨부 파일이 삭제되었습니다 + 첨부 파일 오류: %1$s + 활성 작업자 + 활성 작업자가 없습니다 \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index f72c2a80..01a08977 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1469,6 +1469,33 @@ 播放视频 正在准备会话… 会话未就绪,请重试以继续 + + + 指定任务 + 正在指定… + 任务指定成功 + 指定任务失败:%1$s + 添加父任务 + 父任务 ID + 指定任务前无法创建链接 + 任务链接已创建 + 运行详情 + 检查运行 + 终止运行 + 确定要终止此运行吗? + 运行已终止 + 运行未处于活动状态 + 无法检查运行 + 运行状态 + 所有运行 + 删除附件 + 确定要删除此附件吗? + 下载附件 + 附件已保存 + 附件已删除 + 附件错误:%1$s + 活跃工作进程 + 没有活跃工作进程 设置%1$s 第%1$d项,共%2$d项 已授权,但工具不可用 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c3dffc60..1074ec6e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1710,4 +1710,31 @@ %1$s is required Couldn’t send the setup response. Try again. Waiting for Hermes… + + + Specify task + Specifying… + Task specified successfully + Failed to specify task: %1$s + Add parent + Parent task ID + Linking is unavailable until the task is specified + Task link created + Run details + Inspect run + Terminate run + Terminate this run? + Run terminated + Run is inactive + Run inspection is unavailable + Run state + All runs + Delete attachment + Delete this attachment? + Download attachment + Attachment saved + Attachment deleted + Attachment error: %1$s + Active workers + No active workers diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 961ae46f..d575c7c0 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -1256,6 +1256,17 @@ class E2eIntegrationTest { com.m57.hermescontrol.data.model .UpdateTaskResponse(task = task.copy(status = "ready")), ) + coEvery { mockApiService.getBoard("board-1", any(), any()) } returns + Response.success( + KanbanBoardResponse( + listOf( + KanbanColumn("todo", emptyList()), + KanbanColumn("ready", listOf(task.copy(status = "ready"))), + ), + null, + null, + ), + ) viewModel.moveTask(viewModel.uiState.value.tasks[0], KanbanTaskAction.READY) advanceUntilIdle() assertEquals( diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/KanbanSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/KanbanSerializationTest.kt index 876b69de..a63a4a32 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/model/KanbanSerializationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/model/KanbanSerializationTest.kt @@ -314,4 +314,57 @@ class KanbanSerializationTest { assertTrue(unassignJson.contains("\"assignee\":\"\"")) assertTrue(unassignJson.contains("\"reclaim_first\":true")) } + + @Test + fun testRunAndWorkerWireShapesRetainScopeAndOptionalInspectionState() { + val run = + json + .decodeFromString( + """{"run":{"id":7,"task_id":"t_1","profile":"builder","step_key":"implement", "status":"running","worker_pid":42,"started_at":100,"max_runtime_seconds":3600,"last_heartbeat_at":110}}""", + ).run + assertEquals("t_1", run.taskId) + assertEquals("implement", run.stepKey) + assertEquals(3600, run.maxRuntimeSeconds) + assertNull(run.endedAt) + + val workers = + json.decodeFromString( + """{"workers":[{"run_id":7,"task_id":"t_1","task_title":"Build", "task_status":"running","worker_pid":42}],"count":1,"checked_at":123}""", + ) + assertEquals(7L, workers.workers.single().runId) + assertEquals(123L, workers.checkedAt) + + val unavailable = + json.decodeFromString( + """{"run_id":7,"alive":false,"reason":"psutil not available"}""", + ) + assertFalse(unavailable.alive) + assertEquals("psutil not available", unavailable.reason) + assertNull(unavailable.cpuPercent) + } + + @Test + fun testBoardTaskWorkflowFieldsAndNullableAttachments() { + val task = + json.decodeFromString( + """{"id":"t_1","title":"Build","status":"running","workflow_template_id":"release","current_step_key":"verify","current_run_id":7}""", + ) + assertEquals("release", task.workflowTemplateId) + assertEquals("verify", task.currentStepKey) + assertEquals(7L, task.currentRunId) + + val detail = + json.decodeFromString( + """{"task":{"id":"t_1","title":"Build","status":"running"},"attachments":null,"runs":[]}""", + ) + assertNull(detail.attachments) + assertTrue(detail.runs.isEmpty()) + } + + @Test + fun testTaskLinkPayloadUsesBackendSnakeCase() { + val wire = json.encodeToString(TaskLinkBody(parentId = "t_parent", childId = "t_child")) + assertTrue(wire.contains("\"parent_id\":\"t_parent\"")) + assertTrue(wire.contains("\"child_id\":\"t_child\"")) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/KanbanApiServiceTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/KanbanApiServiceTest.kt index 22f62c43..09be1912 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/KanbanApiServiceTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/KanbanApiServiceTest.kt @@ -7,6 +7,9 @@ import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate import com.m57.hermescontrol.data.model.ReassignTaskBody import com.m57.hermescontrol.data.model.ReclaimTaskBody import com.m57.hermescontrol.data.model.RenameBoardBody +import com.m57.hermescontrol.data.model.SpecifyTaskBody +import com.m57.hermescontrol.data.model.TaskLinkBody +import com.m57.hermescontrol.data.model.TerminateRunBody import com.m57.hermescontrol.data.model.UpdateTaskBody import kotlinx.coroutines.runBlocking import okhttp3.MediaType.Companion.toMediaType @@ -308,4 +311,155 @@ class KanbanApiServiceTest { assertEquals("POST", nudgeReq.method) assertEquals("/api/plugins/kanban/dispatch?board=dev", nudgeReq.path) } + + @Test + fun testBoardAndTaskFiltersAreExplicitlyScoped() = + runBlocking { + mockServer.enqueue(MockResponse().setResponseCode(200).setBody("""{ "columns": [] }""")) + + api.getBoard( + board = "dev", + includeArchived = false, + tenant = null, + workflowTemplateId = "release", + currentStepKey = "verify", + ) + + val boardRequest = mockServer.takeRequest() + assertEquals("GET", boardRequest.method) + assertTrue(boardRequest.path?.contains("board=dev") == true) + assertTrue(boardRequest.path?.contains("workflow_template_id=release") == true) + assertTrue(boardRequest.path?.contains("current_step_key=verify") == true) + + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{ "task": { "id": "t_1", "title": "Task", "status": "done" } }"""), + ) + + api.getTask( + taskId = "t_1", + board = "dev", + runStateType = "outcome", + runStateName = "completed", + ) + + val taskRequest = mockServer.takeRequest() + assertEquals("GET", taskRequest.method) + assertTrue(taskRequest.path?.contains("board=dev") == true) + assertTrue(taskRequest.path?.contains("run_state_type=outcome") == true) + assertTrue(taskRequest.path?.contains("run_state_name=completed") == true) + } + + @Test + fun testAttachmentListAndDeleteAreBoardScoped() = + runBlocking { + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{ "attachments": [{ "id": 9, "filename": "proof.txt" }] }"""), + ) + + val listed = api.listAttachments(taskId = "t_1", board = "dev") + assertEquals( + "proof.txt", + listed + .body() + ?.attachments + ?.single() + ?.filename, + ) + assertEquals("/api/plugins/kanban/tasks/t_1/attachments?board=dev", mockServer.takeRequest().path) + + mockServer.enqueue(MockResponse().setResponseCode(200).setBody("""{ "ok": true, "id": 9 }""")) + val deleted = api.deleteAttachment(attachmentId = 9L, board = "dev") + assertTrue(deleted.body()?.ok == true) + val request = mockServer.takeRequest() + assertEquals("DELETE", request.method) + assertEquals("/api/plugins/kanban/attachments/9?board=dev", request.path) + } + + @Test + fun testWorkerRunAndSpecificationOperationsAreBoardScoped() = + runBlocking { + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """{ "workers": [{ "run_id": 7, "task_id": "t_1", "task_title": "Task", "task_status": "running", "worker_pid": 42 }], "count": 1, "checked_at": 123 }""", + ), + ) + val workers = api.getActiveWorkers(board = "dev") + assertEquals( + 7L, + workers + .body() + ?.workers + ?.single() + ?.runId, + ) + assertEquals("/api/plugins/kanban/workers/active?board=dev", mockServer.takeRequest().path) + + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{ "run": { "id": 7, "task_id": "t_1", "status": "running", "started_at": 100 } }"""), + ) + val run = api.getRun(runId = 7L, board = "dev") + assertEquals("t_1", run.body()?.run?.taskId) + assertEquals("/api/plugins/kanban/runs/7?board=dev", mockServer.takeRequest().path) + + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{ "run_id": 7, "alive": true, "pid": 42, "cpu_percent": 1.5 }"""), + ) + val inspection = api.inspectRun(runId = 7L, board = "dev") + assertTrue(inspection.body()?.alive == true) + assertEquals("/api/plugins/kanban/runs/7/inspect?board=dev", mockServer.takeRequest().path) + + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{ "ok": true, "run_id": 7, "task_id": "t_1" }"""), + ) + val terminated = + api.terminateRun( + runId = 7L, + board = "dev", + body = TerminateRunBody(reason = "Operator requested stop"), + ) + assertTrue(terminated.body()?.ok == true) + val terminateRequest = mockServer.takeRequest() + assertEquals("POST", terminateRequest.method) + assertEquals("/api/plugins/kanban/runs/7/terminate?board=dev", terminateRequest.path) + assertTrue(terminateRequest.body.readUtf8().contains("Operator requested stop")) + + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{ "ok": true, "task_id": "t_1", "new_title": "Specified task" }"""), + ) + val specified = api.specifyTask("t_1", "dev", SpecifyTaskBody(author = "mobile")) + assertEquals("Specified task", specified.body()?.newTitle) + assertEquals("/api/plugins/kanban/tasks/t_1/specify?board=dev", mockServer.takeRequest().path) + } + + @Test + fun testTaskLinkConsumesGatingResult() = + runBlocking { + mockServer.enqueue(MockResponse().setResponseCode(200).setBody("""{ "ok": true, "gated": true }""")) + + val response = + api.createTaskLink( + board = "dev", + body = TaskLinkBody(parentId = "parent", childId = "child"), + ) + + assertTrue(response.body()?.ok == true) + assertTrue(response.body()?.gated == true) + val request = mockServer.takeRequest() + assertEquals("POST", request.method) + assertEquals("/api/plugins/kanban/links?board=dev", request.path) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/repository/KanbanRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/repository/KanbanRepositoryTest.kt index 393fffc2..7d571931 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/repository/KanbanRepositoryTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/repository/KanbanRepositoryTest.kt @@ -2,9 +2,13 @@ package com.m57.hermescontrol.data.repository import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateTaskResponse +import com.m57.hermescontrol.data.model.DeleteAttachmentResponse import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardsResponse import com.m57.hermescontrol.data.model.KanbanTask +import com.m57.hermescontrol.data.model.SpecifyTaskResponse +import com.m57.hermescontrol.data.model.TaskLinkResponse +import com.m57.hermescontrol.data.model.TerminateRunResponse import com.m57.hermescontrol.data.model.UpdateTaskBody import com.m57.hermescontrol.data.model.UpdateTaskResponse import com.m57.hermescontrol.data.remote.KanbanApiService @@ -107,4 +111,28 @@ class KanbanRepositoryTest { val result = repository.getBoard("missing") assertTrue(result is NetworkResult.Failure) } + + @Test + fun testNewTaskOperationsCarryBoardAndTypedResults() = + runTest(testDispatcher) { + coEvery { mockApi.deleteAttachment(9L, "ops") } returns + Response.success(DeleteAttachmentResponse(ok = true, id = 9L)) + coEvery { mockApi.terminateRun(7L, "ops", any()) } returns + Response.success(TerminateRunResponse(ok = true, runId = 7L, taskId = "t_1")) + coEvery { mockApi.specifyTask("t_1", "ops", any()) } returns + Response.success(SpecifyTaskResponse(ok = false, taskId = "t_1", reason = "Not configured")) + coEvery { mockApi.createTaskLink("ops", any()) } returns + Response.success(TaskLinkResponse(ok = true, gated = true)) + + assertTrue((repository.deleteAttachment(9L, "ops") as NetworkResult.Success).data.ok) + assertEquals("t_1", (repository.terminateRun(7L, "ops") as NetworkResult.Success).data.taskId) + assertEquals("Not configured", (repository.specifyTask("t_1", "ops") as NetworkResult.Success).data.reason) + assertTrue((repository.createTaskLink("ops", "parent", "t_1") as NetworkResult.Success).data.gated) + coVerify { mockApi.deleteAttachment(9L, "ops") } + coVerify { mockApi.terminateRun(7L, "ops", any()) } + coVerify { mockApi.specifyTask("t_1", "ops", any()) } + coVerify { + mockApi.createTaskLink("ops", match { it.parentId == "parent" && it.childId == "t_1" }) + } + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt index 89aad88e..de218f32 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt @@ -161,6 +161,16 @@ class KanbanMutationTest { // Complete network call deferred.complete(NetworkResult.Success(UpdateTaskResponse(task = task.copy(status = "ready")))) + coEvery { mockRepository.getBoard("dev") } returns + NetworkResult.Success( + KanbanBoardResponse( + columns = + listOf( + KanbanColumn("todo", emptyList()), + KanbanColumn("ready", listOf(task.copy(status = "ready"))), + ), + ), + ) testDispatcher.scheduler.advanceUntilIdle() assertFalse( diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt index e56d010e..acf9f4f8 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt @@ -1,11 +1,21 @@ package com.m57.hermescontrol.ui.kanban +import com.m57.hermescontrol.data.model.AttachmentListResponse +import com.m57.hermescontrol.data.model.DeleteAttachmentResponse +import com.m57.hermescontrol.data.model.KanbanAttachment +import com.m57.hermescontrol.data.model.KanbanRun +import com.m57.hermescontrol.data.model.KanbanRunInspection +import com.m57.hermescontrol.data.model.KanbanRunResponse import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse import com.m57.hermescontrol.data.model.KanbanTaskFull import com.m57.hermescontrol.data.model.ReassignTaskResponse import com.m57.hermescontrol.data.model.ReclaimTaskResponse +import com.m57.hermescontrol.data.model.SpecifyTaskResponse +import com.m57.hermescontrol.data.model.TaskLinkResponse +import com.m57.hermescontrol.data.model.TerminateRunResponse import com.m57.hermescontrol.data.model.UpdateTaskResponse import com.m57.hermescontrol.data.model.WorkerLog +import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.repository.KanbanRepository import com.m57.hermescontrol.data.ws.KanbanEventsClient @@ -19,6 +29,8 @@ import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -197,7 +209,11 @@ class KanbanTaskViewModelTest { ) val vm = createViewModel() - vm.uploadAttachment("dev", "t_1", "test.png", "image/png", byteArrayOf(1, 2, 3)) + vm.uploadAttachment( + "dev", + "t_1", + MultipartBody.Part.createFormData("file", "test.png", byteArrayOf(1, 2, 3).toRequestBody()), + ) testDispatcher.scheduler.advanceUntilIdle() coVerify { mockRepository.uploadAttachment("t_1", "dev", any()) } @@ -321,4 +337,160 @@ class KanbanTaskViewModelTest { coVerify(exactly = 2) { mockRepository.getTask("t_1", "dev") } vm.onCleared() } + + @Test + fun testTerminateRunRefreshesRunAndExactTask() = + runTest(testDispatcher) { + val running = KanbanTaskFull(id = "t_1", title = "Task", status = "running") + val ready = running.copy(status = "ready") + coEvery { mockRepository.getTask("t_1", "dev", any(), any()) } returnsMany + listOf( + NetworkResult.Success(KanbanTaskDetailResponse(task = running)), + NetworkResult.Success(KanbanTaskDetailResponse(task = ready)), + ) + coEvery { mockRepository.terminateRun(7L, "dev") } returns + NetworkResult.Success(TerminateRunResponse(ok = true, runId = 7L, taskId = "t_1")) + coEvery { mockRepository.getRun(7L, "dev") } returns + NetworkResult.Success(KanbanRunResponse(KanbanRun(id = 7L, status = "reclaimed"))) + + val vm = createViewModel() + vm.loadTask("dev", "t_1") + testDispatcher.scheduler.runCurrent() + vm.terminateRun("dev", "t_1", 7L) + testDispatcher.scheduler.runCurrent() + + assertEquals( + "ready", + vm.uiState.value.detail + ?.task + ?.status, + ) + assertEquals( + "reclaimed", + vm.uiState.value.selectedRun + ?.status, + ) + coVerify(exactly = 2) { mockRepository.getTask("t_1", "dev", any(), any()) } + coVerify { mockRepository.getRun(7L, "dev") } + vm.onCleared() + } + + @Test + fun testRunInspectionUnavailableIsRepresentedHonestly() = + runTest(testDispatcher) { + coEvery { mockRepository.getRun(7L, "dev") } returns + NetworkResult.Success(KanbanRunResponse(KanbanRun(id = 7L, status = "running"))) + coEvery { mockRepository.inspectRun(7L, "dev") } returns + NetworkResult.Success(KanbanRunInspection(runId = 7L, alive = false, reason = "psutil not available")) + + val vm = createViewModel() + vm.inspectRun("dev", "t_1", 7L) + testDispatcher.scheduler.runCurrent() + + assertEquals( + "psutil not available", + vm.uiState.value.runInspection + ?.reason, + ) + assertEquals( + 7L, + vm.uiState.value.selectedRun + ?.id, + ) + } + + @Test + fun testSpecifyFailureUsesTypedReasonWithoutRefreshingTask() = + runTest(testDispatcher) { + coEvery { mockRepository.specifyTask("t_1", "dev") } returns + NetworkResult.Success(SpecifyTaskResponse(ok = false, taskId = "t_1", reason = "Not configured")) + + val vm = createViewModel() + vm.specifyTask("dev", "t_1") + testDispatcher.scheduler.runCurrent() + + assertTrue( + vm.uiState.value.toastMessage + ?.contains("Not configured") == true, + ) + coVerify(exactly = 0) { mockRepository.getTask("t_1", "dev", any(), any()) } + } + + @Test + fun testLinkGatingOutcomeIsVisibleAndReloadsTask() = + runTest(testDispatcher) { + coEvery { mockRepository.createTaskLink("dev", "parent", "t_1") } returns + NetworkResult.Success(TaskLinkResponse(ok = true, gated = true)) + coEvery { mockRepository.getTask("t_1", "dev", any(), any()) } returns + NetworkResult.Success( + KanbanTaskDetailResponse(task = KanbanTaskFull(id = "t_1", title = "Child", status = "todo")), + ) + + val vm = createViewModel() + vm.createParentLink("dev", "t_1", "parent") + testDispatcher.scheduler.runCurrent() + + assertTrue( + vm.uiState.value.toastMessage + ?.contains("gated") == true, + ) + coVerify { mockRepository.getTask("t_1", "dev", any(), any()) } + } + + @Test + fun testDeleteAttachmentReloadsExactTaskAttachmentList() = + runTest(testDispatcher) { + val task = KanbanTaskFull(id = "t_1", title = "Task", status = "todo") + coEvery { mockRepository.getTask("t_1", "dev", any(), any()) } returns + NetworkResult.Success( + KanbanTaskDetailResponse(task = task, attachments = listOf(KanbanAttachment(9L, filename = "old"))), + ) + coEvery { mockRepository.deleteAttachment(9L, "dev") } returns + NetworkResult.Success(DeleteAttachmentResponse(ok = true, id = 9L)) + coEvery { mockRepository.listAttachments("t_1", "dev") } returns + NetworkResult.Success(AttachmentListResponse(emptyList())) + + val vm = createViewModel() + vm.loadTask("dev", "t_1") + testDispatcher.scheduler.runCurrent() + vm.deleteAttachment("dev", "t_1", 9L) + testDispatcher.scheduler.runCurrent() + + assertTrue( + vm.uiState.value.detail + ?.attachments + ?.isEmpty() == true, + ) + coVerify { mockRepository.listAttachments("t_1", "dev") } + } + + @Test + fun testRunFilterPassesPairedBackendParameters() = + runTest(testDispatcher) { + val detail = KanbanTaskDetailResponse(task = KanbanTaskFull("t_1", "Task", status = "done")) + coEvery { mockRepository.getTask("t_1", "dev", any(), any()) } returns NetworkResult.Success(detail) + + val vm = createViewModel() + vm.loadTask("dev", "t_1") + testDispatcher.scheduler.runCurrent() + vm.setRunFilter("dev", "t_1", "status", "reclaimed") + testDispatcher.scheduler.runCurrent() + + coVerify { mockRepository.getTask("t_1", "dev", "status", "reclaimed") } + assertEquals("reclaimed", vm.uiState.value.runStateName) + } + + @Test + fun testTerminateRefusalPreservesTaskAndReportsReason() = + runTest(testDispatcher) { + coEvery { mockRepository.terminateRun(7L, "dev") } returns + NetworkResult.Failure(NetworkError.Http(409, "run 7 already ended")) + val vm = createViewModel() + vm.terminateRun("dev", "t_1", 7L) + testDispatcher.scheduler.runCurrent() + + assertEquals("run 7 already ended", vm.uiState.value.toastMessage) + assertTrue(!vm.uiState.value.isTerminatingRun) + coVerify(exactly = 0) { mockRepository.getRun(7L, "dev") } + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTransitionRefusalTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTransitionRefusalTest.kt new file mode 100644 index 00000000..32606df8 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTransitionRefusalTest.kt @@ -0,0 +1,34 @@ +package com.m57.hermescontrol.ui.kanban + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class KanbanTransitionRefusalTest { + @Test + fun `ready refusal names blocking parent ids and statuses`() { + val refusal = + kanbanTransitionRefusal( + "Cannot move to 'ready': blocked by parent(s) not done — 'Build' (t_a, status=ready), " + + "'Review' (t_b, status=review)", + ) + assertEquals(listOf("t_a", "t_b"), refusal?.blockingParentIds) + assertEquals(listOf("ready", "review"), refusal?.blockingParentStatuses) + } + + @Test + fun `done refusal identifies unsatisfied parent relationship`() { + val refusal = + kanbanTransitionRefusal( + "cannot move t_c to 'done': unsatisfied parent dependencies: t_a (ready), t_b (running); " + + "complete the parents first (done or archived)", + ) + assertEquals(listOf("t_a", "t_b"), refusal?.blockingParentIds) + assertEquals(listOf("ready", "running"), refusal?.blockingParentStatuses) + } + + @Test + fun `generic conflict is not falsely classified as dependency refusal`() { + assertNull(kanbanTransitionRefusal("status transition to 'done' not valid from current state")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt index e378863e..c6b00bec 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt @@ -223,6 +223,19 @@ class KanbanViewModelTest { val vm = createViewModel() vm.loadBoards() settle() + coEvery { mockApi.getBoard(board = "work", any(), any()) } returns + Response.success( + KanbanBoardResponse( + columns = + listOf( + KanbanColumn(name = "todo", tasks = emptyList()), + KanbanColumn( + name = "ready", + tasks = listOf(KanbanTask(id = "t1", title = "Task 1", status = "ready")), + ), + ), + ), + ) coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.success(UpdateTaskResponse()) vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY) settle() @@ -255,6 +268,132 @@ class KanbanViewModelTest { ) } + @Test + fun `moveTask dependency conflict surfaces blocking parent ids`() { + val vm = createViewModel() + vm.loadBoards() + settle() + coEvery { mockApi.updateTask(any(), any(), any()) } returns + Response.error( + 409, + """{"detail":"Cannot move to 'ready': blocked by parent(s) not done — 'Build' (t_parent, status=running)"}""" + .toResponseBody(), + ) + vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY) + settle() + assertEquals( + "todo", + vm.uiState.value.tasks + .single() + .status, + ) + assertTrue( + vm.uiState.value.toastMessage + .orEmpty() + .contains("t_parent"), + ) + } + + @Test + fun `workflow filters retain only matching tasks`() { + val tasks = + listOf( + KanbanTask("a", "A", status = "todo", workflowTemplateId = "wf", currentStepKey = "build"), + KanbanTask("b", "B", status = "todo", workflowTemplateId = "other", currentStepKey = "test"), + ) + assertEquals(listOf("a"), filterKanbanTasks(tasks, "wf", "build").map { it.id }) + assertEquals(listOf("a"), filterKanbanTasks(tasks, "wf", null).map { it.id }) + } + + @Test + fun `active workers are loaded for selected board`() { + val vm = createViewModel() + vm.loadBoards() + settle() + coEvery { mockApi.getActiveWorkers("work") } returns + Response.success( + com.m57.hermescontrol.data.model.ActiveWorkersResponse( + workers = + listOf( + com.m57.hermescontrol.data.model.KanbanActiveWorker( + runId = 1, + taskId = "t1", + taskTitle = "Task 1", + taskStatus = "running", + workerPid = 42, + ), + ), + checkedAt = 1, + ), + ) + vm.loadActiveWorkers() + settle() + assertEquals(1, vm.uiState.value.activeWorkers.size) + assertEquals( + "t1", + vm.uiState.value.activeWorkers + .single() + .taskId, + ) + vm.selectBoard(KanbanBoard(id = "ops", name = "Ops")) + settle() + assertTrue( + vm.uiState.value.activeWorkers + .isEmpty(), + ) + } + + @Test + fun `successful move refreshes board to reconcile done ordering`() { + coEvery { mockApi.getBoard(board = "work", any(), any()) } returns + Response.success( + KanbanBoardResponse( + columns = + listOf( + KanbanColumn( + name = "todo", + tasks = listOf(KanbanTask(id = "t1", title = "Task 1", status = "todo")), + ), + KanbanColumn( + name = "done", + tasks = listOf(KanbanTask(id = "older", title = "Older", status = "done")), + ), + ), + ), + ) + val vm = createViewModel() + vm.loadBoards() + settle() + coEvery { mockApi.getBoard(board = "work", any(), any()) } returns + Response.success( + KanbanBoardResponse( + columns = + listOf( + KanbanColumn(name = "todo", tasks = emptyList()), + KanbanColumn( + name = "done", + tasks = + listOf( + KanbanTask(id = "t1", title = "Task 1", status = "done"), + KanbanTask(id = "older", title = "Older", status = "done"), + ), + ), + ), + ), + ) + coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.success(UpdateTaskResponse()) + vm.moveTask(KanbanTask("t1", "Task 1", status = "todo"), KanbanTaskAction.COMPLETE, "done") + settle() + coVerify(atLeast = 2) { mockApi.getBoard(board = "work", any(), any()) } + assertEquals( + listOf("t1", "older"), + vm.uiState.value.columns + .single { it.name == "done" } + .tasks + .map { it.id }, + ) + } + @Test fun `moveTask complete sends the completion summary`() { val vm = createViewModel()