feat(#1220): complete Kanban management operations parity (#1234)

This commit is contained in:
m57 authored and GitHub committed 2026-09-23 12:25:37 +03:00
1 parent 33d87a11ec
commit 1270a08810
23 files changed
+1777 -51

No files matched your search

@@ -34,12 +34,17 @@ class KanbanScreenTest {
KanbanFilterSheet( KanbanFilterSheet(
assignees = emptyList(), assignees = emptyList(),
tenants = emptyList(), tenants = emptyList(),
workflowTemplateIds = emptyList(),
currentStepKeys = emptyList(),
selectedAssignee = null, selectedAssignee = null,
selectedTenant = null, selectedTenant = null,
selectedWorkflowTemplateId = null,
selectedCurrentStepKey = null,
includeArchived = false, includeArchived = false,
groupRunning = false, groupRunning = false,
onSelectAssignee = {}, onSelectAssignee = {},
onSelectTenant = {}, onSelectTenant = {},
onSelectWorkflowFilters = { _, _ -> },
onToggleIncludeArchived = { toggled = it }, onToggleIncludeArchived = { toggled = it },
onToggleGroupRunning = {}, onToggleGroupRunning = {},
onClearFilters = {}, onClearFilters = {},
@@ -22,8 +22,14 @@ data class KanbanDetailEvent(
@Serializable @Serializable
data class KanbanRun( data class KanbanRun(
val id: Long, val id: Long,
val taskId: String? = null,
val profile: String? = null, val profile: String? = null,
val stepKey: String? = null,
val status: String, val status: String,
val claimLock: String? = null,
val claimExpires: Long? = null,
val maxRuntimeSeconds: Int? = null,
val lastHeartbeatAt: Long? = null,
val outcome: String? = null, val outcome: String? = null,
val summary: String? = null, val summary: String? = null,
val error: String? = null, val error: String? = null,
@@ -59,3 +65,71 @@ data class KanbanAttachment(
data class AttachmentUploadResponse( data class AttachmentUploadResponse(
val attachment: KanbanAttachment? = null, val attachment: KanbanAttachment? = null,
) )
@Serializable
data class AttachmentListResponse(
val attachments: List<KanbanAttachment> = 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<String>? = 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<KanbanActiveWorker> = emptyList(),
val count: Int = workers.size,
val checkedAt: Long,
)
@@ -48,6 +48,18 @@ data class TaskLinks(
val children: List<String> = emptyList(), val children: List<String> = emptyList(),
) )
@Serializable
data class TaskLinkBody(
val parentId: String,
val childId: String,
)
@Serializable
data class TaskLinkResponse(
val ok: Boolean,
val gated: Boolean = false,
)
@Serializable @Serializable
data class ChildTaskResult( data class ChildTaskResult(
val id: String, val id: String,
@@ -75,6 +87,9 @@ data class KanbanTask(
val startedAt: Long? = null, val startedAt: Long? = null,
val workerPid: Int? = null, val workerPid: Int? = null,
val lastHeartbeatAt: Long? = null, val lastHeartbeatAt: Long? = null,
val currentRunId: Long? = null,
val workflowTemplateId: String? = null,
val currentStepKey: String? = null,
) { ) {
val description: String? get() = body val description: String? get() = body
val assignedTo: String? get() = assignee val assignedTo: String? get() = assignee
@@ -112,6 +127,10 @@ data class KanbanTaskFull(
val startedAt: Long? = null, val startedAt: Long? = null,
val workerPid: Int? = null, val workerPid: Int? = null,
val lastHeartbeatAt: Long? = 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 result: String? = null,
val createdBy: String? = null, val createdBy: String? = null,
val modelOverride: String? = null, val modelOverride: String? = null,
@@ -245,6 +264,19 @@ data class ReclaimTaskResponse(
val taskId: String, 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 @Serializable
data class TaskEstimate( data class TaskEstimate(
val ok: Boolean, val ok: Boolean,
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.remote 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.AttachmentUploadResponse
import com.m57.hermescontrol.data.model.AutoDescribeResponse import com.m57.hermescontrol.data.model.AutoDescribeResponse
import com.m57.hermescontrol.data.model.BoardExportResult 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.CreateBoardResponse
import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateTaskResponse 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.DeleteBoardResponse
import com.m57.hermescontrol.data.model.DispatchResult import com.m57.hermescontrol.data.model.DispatchResult
import com.m57.hermescontrol.data.model.ExportBoardBody 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.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanProfilesResponse import com.m57.hermescontrol.data.model.KanbanProfilesResponse
import com.m57.hermescontrol.data.model.KanbanProjectsResponse 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.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.OrchestrationSettings import com.m57.hermescontrol.data.model.OrchestrationSettings
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate 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.ReclaimTaskResponse
import com.m57.hermescontrol.data.model.RenameBoardBody import com.m57.hermescontrol.data.model.RenameBoardBody
import com.m57.hermescontrol.data.model.RenameBoardResponse 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.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.UpdateTaskBody
import com.m57.hermescontrol.data.model.UpdateTaskResponse import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.model.WorkerLog import com.m57.hermescontrol.data.model.WorkerLog
@@ -57,6 +68,8 @@ interface KanbanApiService {
@Query("board") board: String? = null, @Query("board") board: String? = null,
@Query("include_archived") includeArchived: Boolean = false, @Query("include_archived") includeArchived: Boolean = false,
@Query("tenant") tenant: String? = null, @Query("tenant") tenant: String? = null,
@Query("workflow_template_id") workflowTemplateId: String? = null,
@Query("current_step_key") currentStepKey: String? = null,
): Response<KanbanBoardResponse> ): Response<KanbanBoardResponse>
@POST("api/plugins/kanban/boards") @POST("api/plugins/kanban/boards")
@@ -96,6 +109,8 @@ interface KanbanApiService {
suspend fun getTask( suspend fun getTask(
@Path("id") taskId: String, @Path("id") taskId: String,
@Query("board") board: String? = null, @Query("board") board: String? = null,
@Query("run_state_type") runStateType: String? = null,
@Query("run_state_name") runStateName: String? = null,
): Response<KanbanTaskDetailResponse> ): Response<KanbanTaskDetailResponse>
@POST("api/plugins/kanban/tasks") @POST("api/plugins/kanban/tasks")
@@ -170,6 +185,12 @@ interface KanbanApiService {
@Part file: MultipartBody.Part, @Part file: MultipartBody.Part,
): Response<AttachmentUploadResponse> ): Response<AttachmentUploadResponse>
@GET("api/plugins/kanban/tasks/{id}/attachments")
suspend fun listAttachments(
@Path("id") taskId: String,
@Query("board") board: String? = null,
): Response<AttachmentListResponse>
@Streaming @Streaming
@GET("api/plugins/kanban/attachments/{id}") @GET("api/plugins/kanban/attachments/{id}")
suspend fun downloadAttachment( suspend fun downloadAttachment(
@@ -177,6 +198,49 @@ interface KanbanApiService {
@Query("board") board: String? = null, @Query("board") board: String? = null,
): Response<ResponseBody> ): Response<ResponseBody>
@DELETE("api/plugins/kanban/attachments/{id}")
suspend fun deleteAttachment(
@Path("id") attachmentId: Long,
@Query("board") board: String? = null,
): Response<DeleteAttachmentResponse>
@GET("api/plugins/kanban/workers/active")
suspend fun getActiveWorkers(
@Query("board") board: String? = null,
): Response<ActiveWorkersResponse>
@GET("api/plugins/kanban/runs/{id}")
suspend fun getRun(
@Path("id") runId: Long,
@Query("board") board: String? = null,
): Response<KanbanRunResponse>
@GET("api/plugins/kanban/runs/{id}/inspect")
suspend fun inspectRun(
@Path("id") runId: Long,
@Query("board") board: String? = null,
): Response<KanbanRunInspection>
@POST("api/plugins/kanban/runs/{id}/terminate")
suspend fun terminateRun(
@Path("id") runId: Long,
@Query("board") board: String? = null,
@Body body: TerminateRunBody = TerminateRunBody(),
): Response<TerminateRunResponse>
@POST("api/plugins/kanban/tasks/{id}/specify")
suspend fun specifyTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
@Body body: SpecifyTaskBody = SpecifyTaskBody(),
): Response<SpecifyTaskResponse>
@POST("api/plugins/kanban/links")
suspend fun createTaskLink(
@Query("board") board: String? = null,
@Body body: TaskLinkBody,
): Response<TaskLinkResponse>
@GET("api/plugins/kanban/profiles") @GET("api/plugins/kanban/profiles")
suspend fun getKanbanProfiles(): Response<KanbanProfilesResponse> suspend fun getKanbanProfiles(): Response<KanbanProfilesResponse>
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.repository 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.AttachmentUploadResponse
import com.m57.hermescontrol.data.model.AutoDescribeResponse import com.m57.hermescontrol.data.model.AutoDescribeResponse
import com.m57.hermescontrol.data.model.BoardExportResult 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.CreateBoardResponse
import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateTaskResponse 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.DeleteBoardResponse
import com.m57.hermescontrol.data.model.DispatchResult import com.m57.hermescontrol.data.model.DispatchResult
import com.m57.hermescontrol.data.model.ExportBoardBody 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.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanProfilesResponse import com.m57.hermescontrol.data.model.KanbanProfilesResponse
import com.m57.hermescontrol.data.model.KanbanProjectsResponse 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.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.OrchestrationSettings import com.m57.hermescontrol.data.model.OrchestrationSettings
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate 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.ReclaimTaskResponse
import com.m57.hermescontrol.data.model.RenameBoardBody import com.m57.hermescontrol.data.model.RenameBoardBody
import com.m57.hermescontrol.data.model.RenameBoardResponse 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.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.UpdateTaskBody
import com.m57.hermescontrol.data.model.UpdateTaskResponse import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.model.WorkerLog import com.m57.hermescontrol.data.model.WorkerLog
@@ -48,6 +59,8 @@ interface KanbanRepository {
board: String, board: String,
includeArchived: Boolean = false, includeArchived: Boolean = false,
tenant: String? = null, tenant: String? = null,
workflowTemplateId: String? = null,
currentStepKey: String? = null,
): NetworkResult<KanbanBoardResponse> ): NetworkResult<KanbanBoardResponse>
suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse> suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse>
@@ -72,6 +85,8 @@ interface KanbanRepository {
suspend fun getTask( suspend fun getTask(
taskId: String, taskId: String,
board: String?, board: String?,
runStateType: String? = null,
runStateName: String? = null,
): NetworkResult<KanbanTaskDetailResponse> ): NetworkResult<KanbanTaskDetailResponse>
suspend fun createTask( suspend fun createTask(
@@ -136,11 +151,49 @@ interface KanbanRepository {
file: MultipartBody.Part, file: MultipartBody.Part,
): NetworkResult<AttachmentUploadResponse> ): NetworkResult<AttachmentUploadResponse>
suspend fun listAttachments(
taskId: String,
board: String,
): NetworkResult<AttachmentListResponse>
suspend fun deleteAttachment(
attachmentId: Long,
board: String,
): NetworkResult<DeleteAttachmentResponse>
suspend fun downloadAttachment( suspend fun downloadAttachment(
attachmentId: Long, attachmentId: Long,
board: String?, board: String?,
): NetworkResult<ResponseBody> ): NetworkResult<ResponseBody>
suspend fun getActiveWorkers(board: String): NetworkResult<ActiveWorkersResponse>
suspend fun getRun(
runId: Long,
board: String,
): NetworkResult<KanbanRunResponse>
suspend fun inspectRun(
runId: Long,
board: String,
): NetworkResult<KanbanRunInspection>
suspend fun terminateRun(
runId: Long,
board: String,
): NetworkResult<TerminateRunResponse>
suspend fun specifyTask(
taskId: String,
board: String,
): NetworkResult<SpecifyTaskResponse>
suspend fun createTaskLink(
board: String,
parentId: String,
childId: String,
): NetworkResult<TaskLinkResponse>
suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse> suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse>
suspend fun updateProfileDescription( suspend fun updateProfileDescription(
@@ -173,9 +226,11 @@ class KanbanRepositoryImpl(
board: String, board: String,
includeArchived: Boolean, includeArchived: Boolean,
tenant: String?, tenant: String?,
workflowTemplateId: String?,
currentStepKey: String?,
): NetworkResult<KanbanBoardResponse> = ): NetworkResult<KanbanBoardResponse> =
withContext(ioDispatcher) { 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<CreateBoardResponse> = override suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse> =
@@ -215,9 +270,11 @@ class KanbanRepositoryImpl(
override suspend fun getTask( override suspend fun getTask(
taskId: String, taskId: String,
board: String?, board: String?,
runStateType: String?,
runStateName: String?,
): NetworkResult<KanbanTaskDetailResponse> = ): NetworkResult<KanbanTaskDetailResponse> =
withContext(ioDispatcher) { withContext(ioDispatcher) {
safeApiCall { apiProvider().getTask(taskId = taskId, board = board) } safeApiCall { apiProvider().getTask(taskId, board, runStateType, runStateName) }
} }
override suspend fun createTask( override suspend fun createTask(
@@ -364,6 +421,18 @@ class KanbanRepositoryImpl(
safeApiCall { apiProvider().uploadAttachment(taskId = taskId, board = board, file = file) } safeApiCall { apiProvider().uploadAttachment(taskId = taskId, board = board, file = file) }
} }
override suspend fun listAttachments(
taskId: String,
board: String,
): NetworkResult<AttachmentListResponse> =
withContext(ioDispatcher) { safeApiCall { apiProvider().listAttachments(taskId, board) } }
override suspend fun deleteAttachment(
attachmentId: Long,
board: String,
): NetworkResult<DeleteAttachmentResponse> =
withContext(ioDispatcher) { safeApiCall { apiProvider().deleteAttachment(attachmentId, board) } }
override suspend fun downloadAttachment( override suspend fun downloadAttachment(
attachmentId: Long, attachmentId: Long,
board: String?, board: String?,
@@ -372,6 +441,48 @@ class KanbanRepositoryImpl(
safeApiCall { apiProvider().downloadAttachment(attachmentId = attachmentId, board = board) } safeApiCall { apiProvider().downloadAttachment(attachmentId = attachmentId, board = board) }
} }
override suspend fun getActiveWorkers(board: String): NetworkResult<ActiveWorkersResponse> =
withContext(ioDispatcher) { safeApiCall { apiProvider().getActiveWorkers(board) } }
override suspend fun getRun(
runId: Long,
board: String,
): NetworkResult<KanbanRunResponse> =
withContext(ioDispatcher) { safeApiCall { apiProvider().getRun(runId, board) } }
override suspend fun inspectRun(
runId: Long,
board: String,
): NetworkResult<KanbanRunInspection> =
withContext(ioDispatcher) { safeApiCall { apiProvider().inspectRun(runId, board) } }
override suspend fun terminateRun(
runId: Long,
board: String,
): NetworkResult<TerminateRunResponse> =
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<SpecifyTaskResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().specifyTask(taskId, board, SpecifyTaskBody(author = "mobile")) }
}
override suspend fun createTaskLink(
board: String,
parentId: String,
childId: String,
): NetworkResult<TaskLinkResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().createTaskLink(board, TaskLinkBody(parentId, childId)) }
}
override suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse> = override suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse> =
withContext(ioDispatcher) { withContext(ioDispatcher) {
safeApiCall { apiProvider().getKanbanProfiles() } safeApiCall { apiProvider().getKanbanProfiles() }
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -119,10 +120,22 @@ fun KanbanScreen(
var selectedAssignee by remember { mutableStateOf<String?>(null) } var selectedAssignee by remember { mutableStateOf<String?>(null) }
var selectedTenant by remember { mutableStateOf<String?>(null) } var selectedTenant by remember { mutableStateOf<String?>(null) }
var showFilterSheet by remember { mutableStateOf(false) } var showFilterSheet by remember { mutableStateOf(false) }
var showWorkersDialog by remember { mutableStateOf(false) }
val filteredTasks = val filteredTasks =
remember(query, state.tasks, selectedAssignee, selectedTenant) { remember(
state.tasks.filter { task -> query,
state.tasks,
selectedAssignee,
selectedTenant,
state.selectedWorkflowTemplateId,
state.selectedCurrentStepKey,
) {
filterKanbanTasks(
state.tasks,
state.selectedWorkflowTemplateId,
state.selectedCurrentStepKey,
).filter { task ->
val matchesQuery = val matchesQuery =
query.isBlank() || query.isBlank() ||
task.title.contains(query, ignoreCase = true) || task.title.contains(query, ignoreCase = true) ||
@@ -331,6 +344,14 @@ fun KanbanScreen(
}, },
) )
if (state.selectedBoard != null) { if (state.selectedBoard != null) {
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_active_workers)) },
onClick = {
showBoardMenu = false
showWorkersDialog = true
viewModel.loadActiveWorkers()
},
)
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_board_settings)) }, text = { Text(stringResource(R.string.kanban_board_settings)) },
onClick = { onClick = {
@@ -917,10 +938,23 @@ fun KanbanScreen(
tenants = tenants, tenants = tenants,
selectedAssignee = selectedAssignee, selectedAssignee = selectedAssignee,
selectedTenant = selectedTenant, 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, includeArchived = state.includeArchived,
groupRunning = state.groupRunning, groupRunning = state.groupRunning,
onSelectAssignee = { selectedAssignee = it }, onSelectAssignee = { selectedAssignee = it },
onSelectTenant = { selectedTenant = it }, onSelectTenant = { selectedTenant = it },
onSelectWorkflowFilters = viewModel::setWorkflowFilters,
onToggleIncludeArchived = viewModel::setIncludeArchived, onToggleIncludeArchived = viewModel::setIncludeArchived,
onToggleGroupRunning = viewModel::setGroupRunning, onToggleGroupRunning = viewModel::setGroupRunning,
onClearFilters = { onClearFilters = {
@@ -928,6 +962,7 @@ fun KanbanScreen(
selectedTenant = null selectedTenant = null
viewModel.setIncludeArchived(false) viewModel.setIncludeArchived(false)
viewModel.setGroupRunning(false) viewModel.setGroupRunning(false)
viewModel.setWorkflowFilters(null, null)
}, },
onDismiss = { showFilterSheet = false }, 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) { if (showDeleteBoardDialog && state.selectedBoard != null) {
AlertDialog( AlertDialog(
onDismissRequest = { showDeleteBoardDialog = false }, onDismissRequest = { showDeleteBoardDialog = false },
@@ -31,6 +31,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Description 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.Edit
import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.RestartAlt 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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily 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.KanbanTaskDetailKey
import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R 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.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanTaskFull import com.m57.hermescontrol.data.model.KanbanTaskFull
import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.TaskEstimate import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.data.model.TaskLinks import com.m57.hermescontrol.data.model.TaskLinks
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.MarkdownText import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.common.ErrorState 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.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.kanban.components.KanbanModelOverrideEditor 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -110,6 +122,7 @@ fun KanbanTaskScreen(
stringResource(R.string.kanban_tab_files), stringResource(R.string.kanban_tab_files),
) )
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
var runToTerminate by remember { mutableStateOf<Long?>(null) }
LaunchedEffect(boardSlug, taskId) { LaunchedEffect(boardSlug, taskId) {
viewModel.loadTask(boardSlug, taskId) viewModel.loadTask(boardSlug, taskId)
@@ -208,6 +221,10 @@ fun KanbanTaskScreen(
onReclaim = { onReclaim = {
viewModel.reclaim(boardSlug, taskId) 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( TaskRunsTab(
runs = detail.runs, runs = detail.runs,
workerLog = state.workerLog, 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( TaskFilesTab(
attachments = detail.attachments ?: emptyList(), attachments = detail.attachments ?: emptyList(),
isUploading = state.isUploadingAttachment, isUploading = state.isUploadingAttachment,
onUpload = { filename, mimeType, bytes -> isDeleting = state.isDeletingAttachment,
viewModel.uploadAttachment(boardSlug, taskId, filename, mimeType, bytes) 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 @Composable
@@ -293,11 +377,16 @@ private fun TaskOverviewTab(
onNavigateToTask: (String) -> Unit, onNavigateToTask: (String) -> Unit,
onSaveDescription: (String) -> Unit, onSaveDescription: (String) -> Unit,
onReclaim: () -> Unit, onReclaim: () -> Unit,
isSpecifying: Boolean,
isLinking: Boolean,
onSpecify: () -> Unit,
onAddParent: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var isEditingDescription by remember { mutableStateOf(false) } var isEditingDescription by remember { mutableStateOf(false) }
var descriptionDraft by remember(task.body) { mutableStateOf(task.body ?: "") } var descriptionDraft by remember(task.body) { mutableStateOf(task.body ?: "") }
var assigneeMenuExpanded by remember { mutableStateOf(false) } var assigneeMenuExpanded by remember { mutableStateOf(false) }
var parentDraft by remember { mutableStateOf("") }
val statusColors = LocalHermesStatusColors.current val statusColors = LocalHermesStatusColors.current
val currentModelOverride = val currentModelOverride =
@@ -430,6 +519,8 @@ private fun TaskOverviewTab(
task.tenant?.let { MetaRow(label = "Tenant", value = it) } task.tenant?.let { MetaRow(label = "Tenant", value = it) }
task.workspaceKind?.let { MetaRow(label = "Workspace", value = "$it ${task.workspacePath ?: ""}") } task.workspaceKind?.let { MetaRow(label = "Workspace", value = "$it ${task.workspacePath ?: ""}") }
task.workerPid?.let { MetaRow(label = "Worker PID", value = it.toString()) } 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)) Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider() HorizontalDivider()
@@ -446,7 +537,7 @@ private fun TaskOverviewTab(
} }
// Dependencies Section // Dependencies Section
if (links.parents.isNotEmpty() || links.children.isNotEmpty()) { run {
Card( Card(
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
modifier = modifier =
@@ -461,6 +552,22 @@ private fun TaskOverviewTab(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary, 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()) { if (links.parents.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
Text( Text(
@@ -506,6 +613,13 @@ private fun TaskOverviewTab(
} }
// Effort Estimation Section // 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( Card(
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
modifier = modifier =
@@ -778,8 +892,15 @@ private fun formatRunDuration(durationSeconds: Long): String =
private fun TaskRunsTab( private fun TaskRunsTab(
runs: List<com.m57.hermescontrol.data.model.KanbanRun>, runs: List<com.m57.hermescontrol.data.model.KanbanRun>,
workerLog: com.m57.hermescontrol.data.model.WorkerLog?, workerLog: com.m57.hermescontrol.data.model.WorkerLog?,
runStateType: String?,
runStateName: String?,
onFilter: (String?, String?) -> Unit,
onInspect: (Long) -> Unit,
modifier: Modifier = Modifier, 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( LazyColumn(
modifier = modifier.fillMaxSize().padding(16.dp), modifier = modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -790,6 +911,34 @@ private fun TaskRunsTab(
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold, 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()) { if (runs.isEmpty()) {
@@ -858,6 +1007,9 @@ private fun TaskRunsTab(
style = MaterialTheme.typography.bodySmall, 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( private fun TaskFilesTab(
attachments: List<com.m57.hermescontrol.data.model.KanbanAttachment>, attachments: List<com.m57.hermescontrol.data.model.KanbanAttachment>,
isUploading: Boolean, isUploading: Boolean,
onUpload: (filename: String, mimeType: String, bytes: ByteArray) -> Unit, isDeleting: Boolean,
board: String,
taskId: String,
viewModel: KanbanTaskViewModel,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val launcher = val configuration = LocalConfiguration.current
rememberLauncherForActivityResult( val resources = remember(context, configuration) { context.createConfigurationContext(configuration).resources }
contract = ActivityResultContracts.GetContent(), val scope = rememberCoroutineScope()
) { uri: Uri? -> var attachmentToDelete by remember { mutableStateOf<KanbanAttachment?>(null) }
if (uri != null) { var attachmentToDownload by remember { mutableStateOf<KanbanAttachment?>(null) }
var filename = "attachment" val downloadLauncher =
context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) val selected = attachmentToDownload
if (nameIndex != -1 && cursor.moveToFirst()) { attachmentToDownload = null
filename = cursor.getString(nameIndex) ?: "attachment" 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)) { Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
Row( Row(
@@ -1096,7 +1324,7 @@ private fun TaskFilesTab(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
Button( Button(
onClick = { launcher.launch("*/*") }, onClick = { launcher.launch(arrayOf("*/*")) },
enabled = !isUploading, enabled = !isUploading,
) { ) {
if (isUploading) { if (isUploading) {
@@ -1136,22 +1364,55 @@ private fun TaskFilesTab(
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = att.filename.ifBlank { "File #${att.id}" }, att.filename.ifBlank {
"File #${att.id}"
},
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
att.size?.let { att.size?.let {
Text( Text(
text = "${it / 1024} KB", "${it / 1024} KB",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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)) }
},
)
}
} }
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.KanbanProfile 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.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.model.PinnedModel
@@ -25,9 +27,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.ResponseBody
data class KanbanTaskUiState( data class KanbanTaskUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -47,6 +48,15 @@ data class KanbanTaskUiState(
val isReassigning: Boolean = false, val isReassigning: Boolean = false,
val isUpdatingModel: Boolean = false, val isUpdatingModel: Boolean = false,
val isUploadingAttachment: 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( class KanbanTaskViewModel(
@@ -61,6 +71,7 @@ class KanbanTaskViewModel(
private var eventsClient: KanbanEventsClient? = null private var eventsClient: KanbanEventsClient? = null
private var eventsBoard: String? = null private var eventsBoard: String? = null
private var activeTaskId: String? = null private var activeTaskId: String? = null
private var activeBoard: String? = null
private var currentLoadGen: Int = 0 private var currentLoadGen: Int = 0
private var reloadJob: Job? = null private var reloadJob: Job? = null
private var logPollJob: Job? = null private var logPollJob: Job? = null
@@ -74,7 +85,18 @@ class KanbanTaskViewModel(
reloadJob?.cancel() reloadJob?.cancel()
stopLogPolling() stopLogPolling()
detailPollJob?.cancel() detailPollJob?.cancel()
if (activeBoard != board || activeTaskId != taskId) {
_uiState.update {
it.copy(
runStateType = null,
runStateName = null,
selectedRun = null,
runInspection = null,
)
}
}
activeTaskId = taskId activeTaskId = taskId
activeBoard = board
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = true, isLoading = true,
@@ -90,7 +112,15 @@ class KanbanTaskViewModel(
startDetailPolling(board, taskId) startDetailPolling(board, taskId)
viewModelScope.launch { 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 -> { is NetworkResult.Success -> {
if (gen != currentLoadGen) return@launch if (gen != currentLoadGen) return@launch
val taskData = result.data val taskData = result.data
@@ -254,11 +284,13 @@ class KanbanTaskViewModel(
taskId: String, taskId: String,
) { ) {
val gen = currentLoadGen val gen = currentLoadGen
val result = repository.getTask(taskId = taskId, board = board) val result = repository.getTask(taskId, board, _uiState.value.runStateType, _uiState.value.runStateName)
if (gen != currentLoadGen || taskId != activeTaskId) return if (gen != currentLoadGen || taskId != activeTaskId || board != activeBoard) return
if (result is NetworkResult.Success) { if (result is NetworkResult.Success) {
val taskData = result.data 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 (taskData.task.status.equals("running", ignoreCase = true)) {
if (logPollJob == null) startLogPolling(board, taskId) if (logPollJob == null) startLogPolling(board, taskId)
} else { } 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( fun uploadAttachment(
board: String, board: String,
taskId: String, taskId: String,
@@ -548,7 +568,10 @@ class KanbanTaskViewModel(
toastMessage = "Attachment uploaded", toastMessage = "Attachment uploaded",
) )
} }
loadTask(board, taskId) if (activeTaskId == taskId && activeBoard == board) {
loadTaskSilently(board, taskId)
refreshAttachments(board, taskId)
}
} }
is NetworkResult.Failure -> { 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<ResponseBody> = 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() { public override fun onCleared() {
stopLogPolling() stopLogPolling()
detailPollJob?.cancel() detailPollJob?.cancel()
@@ -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<String>,
val blockingParentStatuses: List<String>,
)
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 })
}
}
@@ -12,6 +12,7 @@ import com.m57.hermescontrol.data.model.CreateBoardBody
import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.ExportBoardBody import com.m57.hermescontrol.data.model.ExportBoardBody
import com.m57.hermescontrol.data.model.ImportBoardBody 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.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanColumn 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.TaskEstimate
import com.m57.hermescontrol.data.model.UpdateTaskBody import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.remote.ApiClient 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.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.repository.KanbanRepository import com.m57.hermescontrol.data.repository.KanbanRepository
@@ -64,8 +66,22 @@ data class KanbanUiState(
val columnCollapseOverrides: Map<String, Boolean> = emptyMap(), val columnCollapseOverrides: Map<String, Boolean> = emptyMap(),
val errorMessage: String? = null, val errorMessage: String? = null,
val toastMessage: String? = null, val toastMessage: String? = null,
val selectedWorkflowTemplateId: String? = null,
val selectedCurrentStepKey: String? = null,
val activeWorkers: List<KanbanActiveWorker> = emptyList(),
val isLoadingWorkers: Boolean = false,
) )
fun filterKanbanTasks(
tasks: List<KanbanTask>,
workflowTemplateId: String?,
currentStepKey: String?,
): List<KanbanTask> =
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. * Task actions mirroring the desktop kanban's transition-gated buttons.
* Only moves the backend PATCH route accepts are offered: never `running` * Only moves the backend PATCH route accepts are offered: never `running`
@@ -250,7 +266,15 @@ class KanbanViewModel(
preferences.setSelectedBoard(endpoint, board.id) preferences.setSelectedBoard(endpoint, board.id)
val gen = ++currentLoadGen 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 { viewModelScope.launch {
loadBoardIntoState(board, gen) loadBoardIntoState(board, gen)
connectEvents(board) connectEvents(board)
@@ -272,6 +296,54 @@ class KanbanViewModel(
_uiState.update { it.copy(groupRunning = group) } _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( fun toggleColumnCollapse(
columnName: String, columnName: String,
boardHasWork: Boolean, boardHasWork: Boolean,
@@ -860,10 +932,22 @@ class KanbanViewModel(
operatingTaskIds = state.operatingTaskIds - task.id, operatingTaskIds = state.operatingTaskIds - task.id,
) )
} }
reloadBoardSilently()
} }
is NetworkResult.Failure -> { 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( repository.getBoard(
board = board.id, board = board.id,
includeArchived = _uiState.value.includeArchived, includeArchived = _uiState.value.includeArchived,
workflowTemplateId = _uiState.value.selectedWorkflowTemplateId,
currentStepKey = _uiState.value.selectedCurrentStepKey,
) )
if (gen != currentLoadGen) return@launch if (gen != currentLoadGen) return@launch
if (result is NetworkResult.Success) { if (result is NetworkResult.Success) {
@@ -1010,6 +1096,8 @@ class KanbanViewModel(
repository.getBoard( repository.getBoard(
board = board.id, board = board.id,
includeArchived = _uiState.value.includeArchived, includeArchived = _uiState.value.includeArchived,
workflowTemplateId = _uiState.value.selectedWorkflowTemplateId,
currentStepKey = _uiState.value.selectedCurrentStepKey,
) )
applyBoardResult(board, result, gen) applyBoardResult(board, result, gen)
} }
@@ -34,10 +34,15 @@ fun KanbanFilterSheet(
tenants: List<String>, tenants: List<String>,
selectedAssignee: String?, selectedAssignee: String?,
selectedTenant: String?, selectedTenant: String?,
workflowTemplateIds: List<String>,
currentStepKeys: List<String>,
selectedWorkflowTemplateId: String?,
selectedCurrentStepKey: String?,
includeArchived: Boolean, includeArchived: Boolean,
groupRunning: Boolean, groupRunning: Boolean,
onSelectAssignee: (String?) -> Unit, onSelectAssignee: (String?) -> Unit,
onSelectTenant: (String?) -> Unit, onSelectTenant: (String?) -> Unit,
onSelectWorkflowFilters: (String?, String?) -> Unit,
onToggleIncludeArchived: (Boolean) -> Unit, onToggleIncludeArchived: (Boolean) -> Unit,
onToggleGroupRunning: (Boolean) -> Unit, onToggleGroupRunning: (Boolean) -> Unit,
onClearFilters: () -> Unit, onClearFilters: () -> Unit,
@@ -105,6 +110,29 @@ fun KanbanFilterSheet(
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) 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 // Assignee Filter
Text( Text(
text = stringResource(R.string.kanban_assignee), 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)
}
}
+27
View File
@@ -1495,6 +1495,33 @@
<string name="session_integrations_close">إغلاق</string> <string name="session_integrations_close">إغلاق</string>
<string name="session_integrations_menu_item">تكاملات الجلسة</string> <string name="session_integrations_menu_item">تكاملات الجلسة</string>
<!-- Kanban task detail -->
<string name="kanban_specify_task">تحديد المهمة</string>
<string name="kanban_specifying">جارٍ التحديد…</string>
<string name="kanban_specify_success">تم تحديد المهمة بنجاح</string>
<string name="kanban_specify_failed">تعذر تحديد المهمة: %1$s</string>
<string name="kanban_add_parent">إضافة مهمة أصل</string>
<string name="kanban_parent_id">معرّف المهمة الأصل</string>
<string name="kanban_link_gated">لا يمكن إنشاء الرابط قبل تحديد المهمة</string>
<string name="kanban_link_created">تم إنشاء رابط المهمة</string>
<string name="kanban_run_details">تفاصيل التشغيل</string>
<string name="kanban_inspect_run">فحص التشغيل</string>
<string name="kanban_terminate_run">إنهاء التشغيل</string>
<string name="kanban_terminate_run_confirm">هل تريد إنهاء هذا التشغيل؟</string>
<string name="kanban_run_terminated">تم إنهاء التشغيل</string>
<string name="kanban_run_inactive">التشغيل غير نشط</string>
<string name="kanban_run_inspection_unavailable">فحص التشغيل غير متاح</string>
<string name="kanban_run_state_filter">حالة التشغيل</string>
<string name="kanban_all_runs">كل عمليات التشغيل</string>
<string name="kanban_delete_attachment">حذف المرفق</string>
<string name="kanban_delete_attachment_confirm">هل تريد حذف هذا المرفق؟</string>
<string name="kanban_download_attachment">تنزيل المرفق</string>
<string name="kanban_attachment_saved">تم حفظ المرفق</string>
<string name="kanban_attachment_deleted">تم حذف المرفق</string>
<string name="kanban_attachment_error">خطأ في المرفق: %1$s</string>
<string name="kanban_active_workers">العاملون النشطون</string>
<string name="kanban_no_active_workers">لا يوجد عاملون نشطون</string>
<!-- Media Player --> <!-- Media Player -->
<string name="media_player_play">تشغيل</string> <string name="media_player_play">تشغيل</string>
<string name="media_player_pause">إيقاف مؤقت</string> <string name="media_player_pause">إيقاف مؤقت</string>
+27
View File
@@ -1212,4 +1212,31 @@
<string name="connection_setup_required">%1$s은(는) 필수입니다</string> <string name="connection_setup_required">%1$s은(는) 필수입니다</string>
<string name="connection_setup_request_failed">설정 응답을 보낼 수 없습니다. 다시 시도하세요.</string> <string name="connection_setup_request_failed">설정 응답을 보낼 수 없습니다. 다시 시도하세요.</string>
<string name="connection_setup_pending">Hermes를 기다리는 중…</string> <string name="connection_setup_pending">Hermes를 기다리는 중…</string>
<!-- Kanban task detail -->
<string name="kanban_specify_task">작업 지정</string>
<string name="kanban_specifying">지정 중…</string>
<string name="kanban_specify_success">작업이 지정되었습니다</string>
<string name="kanban_specify_failed">작업 지정 실패: %1$s</string>
<string name="kanban_add_parent">상위 작업 추가</string>
<string name="kanban_parent_id">상위 작업 ID</string>
<string name="kanban_link_gated">작업을 지정하기 전에는 연결할 수 없습니다</string>
<string name="kanban_link_created">작업 링크가 생성되었습니다</string>
<string name="kanban_run_details">실행 상세 정보</string>
<string name="kanban_inspect_run">실행 검사</string>
<string name="kanban_terminate_run">실행 종료</string>
<string name="kanban_terminate_run_confirm">이 실행을 종료하시겠습니까?</string>
<string name="kanban_run_terminated">실행이 종료되었습니다</string>
<string name="kanban_run_inactive">실행이 비활성 상태입니다</string>
<string name="kanban_run_inspection_unavailable">실행 검사를 사용할 수 없습니다</string>
<string name="kanban_run_state_filter">실행 상태</string>
<string name="kanban_all_runs">모든 실행</string>
<string name="kanban_delete_attachment">첨부 파일 삭제</string>
<string name="kanban_delete_attachment_confirm">이 첨부 파일을 삭제하시겠습니까?</string>
<string name="kanban_download_attachment">첨부 파일 다운로드</string>
<string name="kanban_attachment_saved">첨부 파일이 저장되었습니다</string>
<string name="kanban_attachment_deleted">첨부 파일이 삭제되었습니다</string>
<string name="kanban_attachment_error">첨부 파일 오류: %1$s</string>
<string name="kanban_active_workers">활성 작업자</string>
<string name="kanban_no_active_workers">활성 작업자가 없습니다</string>
</resources> </resources>
+27
View File
@@ -1469,6 +1469,33 @@
<string name="media_player_play_video">播放视频</string> <string name="media_player_play_video">播放视频</string>
<string name="chat_session_preparing">正在准备会话…</string> <string name="chat_session_preparing">正在准备会话…</string>
<string name="chat_session_not_ready">会话未就绪,请重试以继续</string> <string name="chat_session_not_ready">会话未就绪,请重试以继续</string>
<!-- Kanban task detail -->
<string name="kanban_specify_task">指定任务</string>
<string name="kanban_specifying">正在指定…</string>
<string name="kanban_specify_success">任务指定成功</string>
<string name="kanban_specify_failed">指定任务失败:%1$s</string>
<string name="kanban_add_parent">添加父任务</string>
<string name="kanban_parent_id">父任务 ID</string>
<string name="kanban_link_gated">指定任务前无法创建链接</string>
<string name="kanban_link_created">任务链接已创建</string>
<string name="kanban_run_details">运行详情</string>
<string name="kanban_inspect_run">检查运行</string>
<string name="kanban_terminate_run">终止运行</string>
<string name="kanban_terminate_run_confirm">确定要终止此运行吗?</string>
<string name="kanban_run_terminated">运行已终止</string>
<string name="kanban_run_inactive">运行未处于活动状态</string>
<string name="kanban_run_inspection_unavailable">无法检查运行</string>
<string name="kanban_run_state_filter">运行状态</string>
<string name="kanban_all_runs">所有运行</string>
<string name="kanban_delete_attachment">删除附件</string>
<string name="kanban_delete_attachment_confirm">确定要删除此附件吗?</string>
<string name="kanban_download_attachment">下载附件</string>
<string name="kanban_attachment_saved">附件已保存</string>
<string name="kanban_attachment_deleted">附件已删除</string>
<string name="kanban_attachment_error">附件错误:%1$s</string>
<string name="kanban_active_workers">活跃工作进程</string>
<string name="kanban_no_active_workers">没有活跃工作进程</string>
<string name="connection_setup_target_title">设置%1$s</string> <string name="connection_setup_target_title">设置%1$s</string>
<string name="connection_setup_progress">第%1$d项,共%2$d项</string> <string name="connection_setup_progress">第%1$d项,共%2$d项</string>
<string name="connection_setup_authorized_no_tools">已授权,但工具不可用</string> <string name="connection_setup_authorized_no_tools">已授权,但工具不可用</string>
+27
View File
@@ -1710,4 +1710,31 @@
<string name="connection_setup_required">%1$s is required</string> <string name="connection_setup_required">%1$s is required</string>
<string name="connection_setup_request_failed">Couldn’t send the setup response. Try again.</string> <string name="connection_setup_request_failed">Couldn’t send the setup response. Try again.</string>
<string name="connection_setup_pending">Waiting for Hermes…</string> <string name="connection_setup_pending">Waiting for Hermes…</string>
<!-- Kanban task detail -->
<string name="kanban_specify_task">Specify task</string>
<string name="kanban_specifying">Specifying…</string>
<string name="kanban_specify_success">Task specified successfully</string>
<string name="kanban_specify_failed">Failed to specify task: %1$s</string>
<string name="kanban_add_parent">Add parent</string>
<string name="kanban_parent_id">Parent task ID</string>
<string name="kanban_link_gated">Linking is unavailable until the task is specified</string>
<string name="kanban_link_created">Task link created</string>
<string name="kanban_run_details">Run details</string>
<string name="kanban_inspect_run">Inspect run</string>
<string name="kanban_terminate_run">Terminate run</string>
<string name="kanban_terminate_run_confirm">Terminate this run?</string>
<string name="kanban_run_terminated">Run terminated</string>
<string name="kanban_run_inactive">Run is inactive</string>
<string name="kanban_run_inspection_unavailable">Run inspection is unavailable</string>
<string name="kanban_run_state_filter">Run state</string>
<string name="kanban_all_runs">All runs</string>
<string name="kanban_delete_attachment">Delete attachment</string>
<string name="kanban_delete_attachment_confirm">Delete this attachment?</string>
<string name="kanban_download_attachment">Download attachment</string>
<string name="kanban_attachment_saved">Attachment saved</string>
<string name="kanban_attachment_deleted">Attachment deleted</string>
<string name="kanban_attachment_error">Attachment error: %1$s</string>
<string name="kanban_active_workers">Active workers</string>
<string name="kanban_no_active_workers">No active workers</string>
</resources> </resources>
@@ -1256,6 +1256,17 @@ class E2eIntegrationTest {
com.m57.hermescontrol.data.model com.m57.hermescontrol.data.model
.UpdateTaskResponse(task = task.copy(status = "ready")), .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) viewModel.moveTask(viewModel.uiState.value.tasks[0], KanbanTaskAction.READY)
advanceUntilIdle() advanceUntilIdle()
assertEquals( assertEquals(
@@ -314,4 +314,57 @@ class KanbanSerializationTest {
assertTrue(unassignJson.contains("\"assignee\":\"\"")) assertTrue(unassignJson.contains("\"assignee\":\"\""))
assertTrue(unassignJson.contains("\"reclaim_first\":true")) assertTrue(unassignJson.contains("\"reclaim_first\":true"))
} }
@Test
fun testRunAndWorkerWireShapesRetainScopeAndOptionalInspectionState() {
val run =
json
.decodeFromString<KanbanRunResponse>(
"""{"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<ActiveWorkersResponse>(
"""{"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<KanbanRunInspection>(
"""{"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<KanbanTask>(
"""{"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<KanbanTaskDetailResponse>(
"""{"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\""))
}
} }
@@ -7,6 +7,9 @@ import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
import com.m57.hermescontrol.data.model.ReassignTaskBody import com.m57.hermescontrol.data.model.ReassignTaskBody
import com.m57.hermescontrol.data.model.ReclaimTaskBody import com.m57.hermescontrol.data.model.ReclaimTaskBody
import com.m57.hermescontrol.data.model.RenameBoardBody 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 com.m57.hermescontrol.data.model.UpdateTaskBody
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@@ -308,4 +311,155 @@ class KanbanApiServiceTest {
assertEquals("POST", nudgeReq.method) assertEquals("POST", nudgeReq.method)
assertEquals("/api/plugins/kanban/dispatch?board=dev", nudgeReq.path) 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)
}
} }
@@ -2,9 +2,13 @@ package com.m57.hermescontrol.data.repository
import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateTaskResponse 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.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanTask 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.UpdateTaskBody
import com.m57.hermescontrol.data.model.UpdateTaskResponse import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.remote.KanbanApiService import com.m57.hermescontrol.data.remote.KanbanApiService
@@ -107,4 +111,28 @@ class KanbanRepositoryTest {
val result = repository.getBoard("missing") val result = repository.getBoard("missing")
assertTrue(result is NetworkResult.Failure) 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" })
}
}
} }
@@ -161,6 +161,16 @@ class KanbanMutationTest {
// Complete network call // Complete network call
deferred.complete(NetworkResult.Success(UpdateTaskResponse(task = task.copy(status = "ready")))) 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() testDispatcher.scheduler.advanceUntilIdle()
assertFalse( assertFalse(
@@ -1,11 +1,21 @@
package com.m57.hermescontrol.ui.kanban 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.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.KanbanTaskFull import com.m57.hermescontrol.data.model.KanbanTaskFull
import com.m57.hermescontrol.data.model.ReassignTaskResponse import com.m57.hermescontrol.data.model.ReassignTaskResponse
import com.m57.hermescontrol.data.model.ReclaimTaskResponse 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.UpdateTaskResponse
import com.m57.hermescontrol.data.model.WorkerLog 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.remote.NetworkResult
import com.m57.hermescontrol.data.repository.KanbanRepository import com.m57.hermescontrol.data.repository.KanbanRepository
import com.m57.hermescontrol.data.ws.KanbanEventsClient 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.resetMain
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
@@ -197,7 +209,11 @@ class KanbanTaskViewModelTest {
) )
val vm = createViewModel() 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() testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockRepository.uploadAttachment("t_1", "dev", any()) } coVerify { mockRepository.uploadAttachment("t_1", "dev", any()) }
@@ -321,4 +337,160 @@ class KanbanTaskViewModelTest {
coVerify(exactly = 2) { mockRepository.getTask("t_1", "dev") } coVerify(exactly = 2) { mockRepository.getTask("t_1", "dev") }
vm.onCleared() 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") }
}
} }
@@ -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"))
}
}
@@ -223,6 +223,19 @@ class KanbanViewModelTest {
val vm = createViewModel() val vm = createViewModel()
vm.loadBoards() vm.loadBoards()
settle() 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()) coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.success(UpdateTaskResponse())
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY) vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
settle() 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 @Test
fun `moveTask complete sends the completion summary`() { fun `moveTask complete sends the completion summary`() {
val vm = createViewModel() val vm = createViewModel()