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(
assignees = emptyList(),
tenants = emptyList(),
workflowTemplateIds = emptyList(),
currentStepKeys = emptyList(),
selectedAssignee = null,
selectedTenant = null,
selectedWorkflowTemplateId = null,
selectedCurrentStepKey = null,
includeArchived = false,
groupRunning = false,
onSelectAssignee = {},
onSelectTenant = {},
onSelectWorkflowFilters = { _, _ -> },
onToggleIncludeArchived = { toggled = it },
onToggleGroupRunning = {},
onClearFilters = {},
@@ -22,8 +22,14 @@ data class KanbanDetailEvent(
@Serializable
data class KanbanRun(
val id: Long,
val taskId: String? = null,
val profile: String? = null,
val stepKey: String? = null,
val status: String,
val claimLock: String? = null,
val claimExpires: Long? = null,
val maxRuntimeSeconds: Int? = null,
val lastHeartbeatAt: Long? = null,
val outcome: String? = null,
val summary: String? = null,
val error: String? = null,
@@ -59,3 +65,71 @@ data class KanbanAttachment(
data class AttachmentUploadResponse(
val attachment: KanbanAttachment? = null,
)
@Serializable
data class AttachmentListResponse(
val attachments: List<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(),
)
@Serializable
data class TaskLinkBody(
val parentId: String,
val childId: String,
)
@Serializable
data class TaskLinkResponse(
val ok: Boolean,
val gated: Boolean = false,
)
@Serializable
data class ChildTaskResult(
val id: String,
@@ -75,6 +87,9 @@ data class KanbanTask(
val startedAt: Long? = null,
val workerPid: Int? = null,
val lastHeartbeatAt: Long? = null,
val currentRunId: Long? = null,
val workflowTemplateId: String? = null,
val currentStepKey: String? = null,
) {
val description: String? get() = body
val assignedTo: String? get() = assignee
@@ -112,6 +127,10 @@ data class KanbanTaskFull(
val startedAt: Long? = null,
val workerPid: Int? = null,
val lastHeartbeatAt: Long? = null,
val currentRunId: Long? = null,
val workflowTemplateId: String? = null,
val currentStepKey: String? = null,
val maxRuntimeSeconds: Int? = null,
val result: String? = null,
val createdBy: String? = null,
val modelOverride: String? = null,
@@ -245,6 +264,19 @@ data class ReclaimTaskResponse(
val taskId: String,
)
@Serializable
data class SpecifyTaskBody(
val author: String? = null,
)
@Serializable
data class SpecifyTaskResponse(
val ok: Boolean,
val taskId: String,
val reason: String? = null,
val newTitle: String? = null,
)
@Serializable
data class TaskEstimate(
val ok: Boolean,
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.model.ActiveWorkersResponse
import com.m57.hermescontrol.data.model.AttachmentListResponse
import com.m57.hermescontrol.data.model.AttachmentUploadResponse
import com.m57.hermescontrol.data.model.AutoDescribeResponse
import com.m57.hermescontrol.data.model.BoardExportResult
@@ -10,6 +12,7 @@ import com.m57.hermescontrol.data.model.CreateBoardBody
import com.m57.hermescontrol.data.model.CreateBoardResponse
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateTaskResponse
import com.m57.hermescontrol.data.model.DeleteAttachmentResponse
import com.m57.hermescontrol.data.model.DeleteBoardResponse
import com.m57.hermescontrol.data.model.DispatchResult
import com.m57.hermescontrol.data.model.ExportBoardBody
@@ -18,6 +21,8 @@ import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanProfilesResponse
import com.m57.hermescontrol.data.model.KanbanProjectsResponse
import com.m57.hermescontrol.data.model.KanbanRunInspection
import com.m57.hermescontrol.data.model.KanbanRunResponse
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.OrchestrationSettings
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
@@ -27,7 +32,13 @@ import com.m57.hermescontrol.data.model.ReclaimTaskBody
import com.m57.hermescontrol.data.model.ReclaimTaskResponse
import com.m57.hermescontrol.data.model.RenameBoardBody
import com.m57.hermescontrol.data.model.RenameBoardResponse
import com.m57.hermescontrol.data.model.SpecifyTaskBody
import com.m57.hermescontrol.data.model.SpecifyTaskResponse
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.data.model.TaskLinkBody
import com.m57.hermescontrol.data.model.TaskLinkResponse
import com.m57.hermescontrol.data.model.TerminateRunBody
import com.m57.hermescontrol.data.model.TerminateRunResponse
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.model.WorkerLog
@@ -57,6 +68,8 @@ interface KanbanApiService {
@Query("board") board: String? = null,
@Query("include_archived") includeArchived: Boolean = false,
@Query("tenant") tenant: String? = null,
@Query("workflow_template_id") workflowTemplateId: String? = null,
@Query("current_step_key") currentStepKey: String? = null,
): Response<KanbanBoardResponse>
@POST("api/plugins/kanban/boards")
@@ -96,6 +109,8 @@ interface KanbanApiService {
suspend fun getTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
@Query("run_state_type") runStateType: String? = null,
@Query("run_state_name") runStateName: String? = null,
): Response<KanbanTaskDetailResponse>
@POST("api/plugins/kanban/tasks")
@@ -170,6 +185,12 @@ interface KanbanApiService {
@Part file: MultipartBody.Part,
): Response<AttachmentUploadResponse>
@GET("api/plugins/kanban/tasks/{id}/attachments")
suspend fun listAttachments(
@Path("id") taskId: String,
@Query("board") board: String? = null,
): Response<AttachmentListResponse>
@Streaming
@GET("api/plugins/kanban/attachments/{id}")
suspend fun downloadAttachment(
@@ -177,6 +198,49 @@ interface KanbanApiService {
@Query("board") board: String? = null,
): 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")
suspend fun getKanbanProfiles(): Response<KanbanProfilesResponse>
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.repository
import com.m57.hermescontrol.data.model.ActiveWorkersResponse
import com.m57.hermescontrol.data.model.AttachmentListResponse
import com.m57.hermescontrol.data.model.AttachmentUploadResponse
import com.m57.hermescontrol.data.model.AutoDescribeResponse
import com.m57.hermescontrol.data.model.BoardExportResult
@@ -10,6 +12,7 @@ import com.m57.hermescontrol.data.model.CreateBoardBody
import com.m57.hermescontrol.data.model.CreateBoardResponse
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateTaskResponse
import com.m57.hermescontrol.data.model.DeleteAttachmentResponse
import com.m57.hermescontrol.data.model.DeleteBoardResponse
import com.m57.hermescontrol.data.model.DispatchResult
import com.m57.hermescontrol.data.model.ExportBoardBody
@@ -18,6 +21,8 @@ import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanProfilesResponse
import com.m57.hermescontrol.data.model.KanbanProjectsResponse
import com.m57.hermescontrol.data.model.KanbanRunInspection
import com.m57.hermescontrol.data.model.KanbanRunResponse
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.OrchestrationSettings
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
@@ -27,7 +32,13 @@ import com.m57.hermescontrol.data.model.ReclaimTaskBody
import com.m57.hermescontrol.data.model.ReclaimTaskResponse
import com.m57.hermescontrol.data.model.RenameBoardBody
import com.m57.hermescontrol.data.model.RenameBoardResponse
import com.m57.hermescontrol.data.model.SpecifyTaskBody
import com.m57.hermescontrol.data.model.SpecifyTaskResponse
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.data.model.TaskLinkBody
import com.m57.hermescontrol.data.model.TaskLinkResponse
import com.m57.hermescontrol.data.model.TerminateRunBody
import com.m57.hermescontrol.data.model.TerminateRunResponse
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.model.WorkerLog
@@ -48,6 +59,8 @@ interface KanbanRepository {
board: String,
includeArchived: Boolean = false,
tenant: String? = null,
workflowTemplateId: String? = null,
currentStepKey: String? = null,
): NetworkResult<KanbanBoardResponse>
suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse>
@@ -72,6 +85,8 @@ interface KanbanRepository {
suspend fun getTask(
taskId: String,
board: String?,
runStateType: String? = null,
runStateName: String? = null,
): NetworkResult<KanbanTaskDetailResponse>
suspend fun createTask(
@@ -136,11 +151,49 @@ interface KanbanRepository {
file: MultipartBody.Part,
): NetworkResult<AttachmentUploadResponse>
suspend fun listAttachments(
taskId: String,
board: String,
): NetworkResult<AttachmentListResponse>
suspend fun deleteAttachment(
attachmentId: Long,
board: String,
): NetworkResult<DeleteAttachmentResponse>
suspend fun downloadAttachment(
attachmentId: Long,
board: String?,
): 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 updateProfileDescription(
@@ -173,9 +226,11 @@ class KanbanRepositoryImpl(
board: String,
includeArchived: Boolean,
tenant: String?,
workflowTemplateId: String?,
currentStepKey: String?,
): NetworkResult<KanbanBoardResponse> =
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> =
@@ -215,9 +270,11 @@ class KanbanRepositoryImpl(
override suspend fun getTask(
taskId: String,
board: String?,
runStateType: String?,
runStateName: String?,
): NetworkResult<KanbanTaskDetailResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getTask(taskId = taskId, board = board) }
safeApiCall { apiProvider().getTask(taskId, board, runStateType, runStateName) }
}
override suspend fun createTask(
@@ -364,6 +421,18 @@ class KanbanRepositoryImpl(
safeApiCall { apiProvider().uploadAttachment(taskId = taskId, board = board, file = file) }
}
override suspend fun listAttachments(
taskId: String,
board: String,
): NetworkResult<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(
attachmentId: Long,
board: String?,
@@ -372,6 +441,48 @@ class KanbanRepositoryImpl(
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> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getKanbanProfiles() }
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -119,10 +120,22 @@ fun KanbanScreen(
var selectedAssignee by remember { mutableStateOf<String?>(null) }
var selectedTenant by remember { mutableStateOf<String?>(null) }
var showFilterSheet by remember { mutableStateOf(false) }
var showWorkersDialog by remember { mutableStateOf(false) }
val filteredTasks =
remember(query, state.tasks, selectedAssignee, selectedTenant) {
state.tasks.filter { task ->
remember(
query,
state.tasks,
selectedAssignee,
selectedTenant,
state.selectedWorkflowTemplateId,
state.selectedCurrentStepKey,
) {
filterKanbanTasks(
state.tasks,
state.selectedWorkflowTemplateId,
state.selectedCurrentStepKey,
).filter { task ->
val matchesQuery =
query.isBlank() ||
task.title.contains(query, ignoreCase = true) ||
@@ -331,6 +344,14 @@ fun KanbanScreen(
},
)
if (state.selectedBoard != null) {
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_active_workers)) },
onClick = {
showBoardMenu = false
showWorkersDialog = true
viewModel.loadActiveWorkers()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_board_settings)) },
onClick = {
@@ -917,10 +938,23 @@ fun KanbanScreen(
tenants = tenants,
selectedAssignee = selectedAssignee,
selectedTenant = selectedTenant,
workflowTemplateIds =
state.tasks
.mapNotNull { it.workflowTemplateId }
.distinct()
.sorted(),
currentStepKeys =
state.tasks
.mapNotNull { it.currentStepKey }
.distinct()
.sorted(),
selectedWorkflowTemplateId = state.selectedWorkflowTemplateId,
selectedCurrentStepKey = state.selectedCurrentStepKey,
includeArchived = state.includeArchived,
groupRunning = state.groupRunning,
onSelectAssignee = { selectedAssignee = it },
onSelectTenant = { selectedTenant = it },
onSelectWorkflowFilters = viewModel::setWorkflowFilters,
onToggleIncludeArchived = viewModel::setIncludeArchived,
onToggleGroupRunning = viewModel::setGroupRunning,
onClearFilters = {
@@ -928,6 +962,7 @@ fun KanbanScreen(
selectedTenant = null
viewModel.setIncludeArchived(false)
viewModel.setGroupRunning(false)
viewModel.setWorkflowFilters(null, null)
},
onDismiss = { showFilterSheet = false },
)
@@ -1291,6 +1326,46 @@ fun KanbanScreen(
)
}
if (showWorkersDialog) {
AlertDialog(
onDismissRequest = { showWorkersDialog = false },
title = { Text(stringResource(R.string.kanban_active_workers)) },
text = {
if (state.isLoadingWorkers) {
CircularProgressIndicator()
} else if (state.activeWorkers.isEmpty()) {
Text(stringResource(R.string.kanban_no_active_workers))
} else {
LazyColumn(modifier = Modifier.heightIn(max = 320.dp)) {
items(state.activeWorkers, key = { it.runId }) { worker ->
Text(
"${worker.taskTitle} · ${worker.profile ?: worker.taskAssignee.orEmpty()}",
modifier =
Modifier
.fillMaxWidth()
.clickable {
val board = state.selectedBoard ?: return@clickable
showWorkersDialog = false
NavigationController.navigateTo(
KanbanTaskDetailKey(
boardSlug = board.id,
taskId = worker.taskId,
),
)
}.padding(vertical = 8.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = { showWorkersDialog = false }) {
Text(stringResource(R.string.action_close))
}
},
)
}
if (showDeleteBoardDialog && state.selectedBoard != null) {
AlertDialog(
onDismissRequest = { showDeleteBoardDialog = false },
@@ -31,6 +31,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.RestartAlt
@@ -62,9 +63,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
@@ -75,12 +78,14 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.KanbanTaskDetailKey
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.KanbanAttachment
import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanTaskFull
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.data.model.TaskLinks
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.common.ErrorState
@@ -89,6 +94,13 @@ import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.kanban.components.KanbanModelOverrideEditor
import com.m57.hermescontrol.util.StreamingUriRequestBody
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -110,6 +122,7 @@ fun KanbanTaskScreen(
stringResource(R.string.kanban_tab_files),
)
var showDeleteDialog by remember { mutableStateOf(false) }
var runToTerminate by remember { mutableStateOf<Long?>(null) }
LaunchedEffect(boardSlug, taskId) {
viewModel.loadTask(boardSlug, taskId)
@@ -208,6 +221,10 @@ fun KanbanTaskScreen(
onReclaim = {
viewModel.reclaim(boardSlug, taskId)
},
isSpecifying = state.isSpecifying,
isLinking = state.isLinking,
onSpecify = { viewModel.specifyTask(boardSlug, taskId) },
onAddParent = { viewModel.createParentLink(boardSlug, taskId, it) },
)
}
@@ -229,6 +246,10 @@ fun KanbanTaskScreen(
TaskRunsTab(
runs = detail.runs,
workerLog = state.workerLog,
runStateType = state.runStateType,
runStateName = state.runStateName,
onFilter = { type, name -> viewModel.setRunFilter(boardSlug, taskId, type, name) },
onInspect = { viewModel.inspectRun(boardSlug, taskId, it) },
)
}
@@ -242,9 +263,10 @@ fun KanbanTaskScreen(
TaskFilesTab(
attachments = detail.attachments ?: emptyList(),
isUploading = state.isUploadingAttachment,
onUpload = { filename, mimeType, bytes ->
viewModel.uploadAttachment(boardSlug, taskId, filename, mimeType, bytes)
},
isDeleting = state.isDeletingAttachment,
board = boardSlug,
taskId = taskId,
viewModel = viewModel,
)
}
}
@@ -276,6 +298,68 @@ fun KanbanTaskScreen(
},
)
}
val selectedRun = state.selectedRun
if (selectedRun != null) {
AlertDialog(
onDismissRequest = viewModel::clearRunInspection,
title = { Text(stringResource(R.string.kanban_run_details)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
MetaRow(stringResource(R.string.kanban_run_number, selectedRun.id), selectedRun.status)
selectedRun.profile?.let { MetaRow(stringResource(R.string.kanban_assignee), it) }
selectedRun.stepKey?.let { MetaRow("Step", it) }
selectedRun.workerPid?.let { MetaRow("PID", it.toString()) }
selectedRun.summary?.let { Text(it) }
selectedRun.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
state.runInspection?.let { inspection ->
Text(
if (inspection.alive) {
inspection.status ?: selectedRun.status
} else {
inspection.reason ?: stringResource(R.string.kanban_run_inspection_unavailable)
},
)
inspection.cpuPercent?.let { MetaRow("CPU", "$it%") }
inspection.memoryRssBytes?.let { MetaRow("Memory", "${it / 1024} KiB") }
inspection.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
}
},
confirmButton = {
if (selectedRun.endedAt == null && selectedRun.status == "running" &&
state.detail?.task?.status == "running"
) {
TextButton(onClick = { runToTerminate = selectedRun.id }, enabled = !state.isTerminatingRun) {
Text(stringResource(R.string.kanban_terminate_run))
}
}
},
dismissButton = {
TextButton(onClick = viewModel::clearRunInspection) { Text(stringResource(R.string.action_cancel)) }
},
)
}
if (runToTerminate != null) {
AlertDialog(
onDismissRequest = { runToTerminate = null },
title = { Text(stringResource(R.string.kanban_terminate_run)) },
text = { Text(stringResource(R.string.kanban_terminate_run_confirm)) },
confirmButton = {
Button(
onClick = {
runToTerminate?.let { viewModel.terminateRun(boardSlug, taskId, it) }
runToTerminate = null
},
enabled = !state.isTerminatingRun,
) { Text(stringResource(R.string.kanban_terminate_run)) }
},
dismissButton = {
TextButton(onClick = { runToTerminate = null }) { Text(stringResource(R.string.action_cancel)) }
},
)
}
}
@Composable
@@ -293,11 +377,16 @@ private fun TaskOverviewTab(
onNavigateToTask: (String) -> Unit,
onSaveDescription: (String) -> Unit,
onReclaim: () -> Unit,
isSpecifying: Boolean,
isLinking: Boolean,
onSpecify: () -> Unit,
onAddParent: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var isEditingDescription by remember { mutableStateOf(false) }
var descriptionDraft by remember(task.body) { mutableStateOf(task.body ?: "") }
var assigneeMenuExpanded by remember { mutableStateOf(false) }
var parentDraft by remember { mutableStateOf("") }
val statusColors = LocalHermesStatusColors.current
val currentModelOverride =
@@ -430,6 +519,8 @@ private fun TaskOverviewTab(
task.tenant?.let { MetaRow(label = "Tenant", value = it) }
task.workspaceKind?.let { MetaRow(label = "Workspace", value = "$it ${task.workspacePath ?: ""}") }
task.workerPid?.let { MetaRow(label = "Worker PID", value = it.toString()) }
task.workflowTemplateId?.let { MetaRow(label = "Workflow", value = it) }
task.currentStepKey?.let { MetaRow(label = "Step", value = it) }
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider()
@@ -446,7 +537,7 @@ private fun TaskOverviewTab(
}
// Dependencies Section
if (links.parents.isNotEmpty() || links.children.isNotEmpty()) {
run {
Card(
shape = RoundedCornerShape(8.dp),
modifier =
@@ -461,6 +552,22 @@ private fun TaskOverviewTab(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = parentDraft,
onValueChange = { parentDraft = it },
label = { Text(stringResource(R.string.kanban_parent_id)) },
modifier = Modifier.weight(1f),
singleLine = true,
)
TextButton(
onClick = {
onAddParent(parentDraft)
parentDraft = ""
},
enabled = parentDraft.isNotBlank() && !isLinking,
) { Text(stringResource(R.string.kanban_add_parent)) }
}
if (links.parents.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
Text(
@@ -506,6 +613,13 @@ private fun TaskOverviewTab(
}
// Effort Estimation Section
if (task.status == "triage") {
OutlinedButton(
onClick = onSpecify,
enabled = !isSpecifying,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) { Text(stringResource(R.string.kanban_specify_task)) }
}
Card(
shape = RoundedCornerShape(8.dp),
modifier =
@@ -778,8 +892,15 @@ private fun formatRunDuration(durationSeconds: Long): String =
private fun TaskRunsTab(
runs: List<com.m57.hermescontrol.data.model.KanbanRun>,
workerLog: com.m57.hermescontrol.data.model.WorkerLog?,
runStateType: String?,
runStateName: String?,
onFilter: (String?, String?) -> Unit,
onInspect: (Long) -> Unit,
modifier: Modifier = Modifier,
) {
var filterType by remember(runStateType) { mutableStateOf(runStateType ?: "status") }
var filterName by remember(runStateName) { mutableStateOf(runStateName ?: "") }
var filterMenuExpanded by remember { mutableStateOf(false) }
LazyColumn(
modifier = modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -790,6 +911,34 @@ private fun TaskRunsTab(
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Box {
TextButton(onClick = { filterMenuExpanded = true }) {
Text(if (filterType == "status") "Status" else "Outcome")
}
DropdownMenu(expanded = filterMenuExpanded, onDismissRequest = { filterMenuExpanded = false }) {
listOf("status", "outcome").forEach { type ->
DropdownMenuItem(text = { Text(type) }, onClick = {
filterType = type
filterMenuExpanded = false
})
}
}
}
OutlinedTextField(
value = filterName,
onValueChange = { filterName = it },
modifier = Modifier.weight(1f),
label = { Text(stringResource(R.string.kanban_run_state_filter)) },
singleLine = true,
)
TextButton(onClick = { onFilter(filterType, filterName.trim()) }, enabled = filterName.isNotBlank()) {
Text(stringResource(R.string.kanban_filter))
}
}
if (runStateName != null) {
TextButton(onClick = { onFilter(null, null) }) { Text(stringResource(R.string.kanban_all_runs)) }
}
}
if (runs.isEmpty()) {
@@ -858,6 +1007,9 @@ private fun TaskRunsTab(
style = MaterialTheme.typography.bodySmall,
)
}
OutlinedButton(onClick = { onInspect(run.id) }) {
Text(stringResource(R.string.kanban_inspect_run))
}
}
}
}
@@ -1058,31 +1210,107 @@ private fun MetaRow(
private fun TaskFilesTab(
attachments: List<com.m57.hermescontrol.data.model.KanbanAttachment>,
isUploading: Boolean,
onUpload: (filename: String, mimeType: String, bytes: ByteArray) -> Unit,
isDeleting: Boolean,
board: String,
taskId: String,
viewModel: KanbanTaskViewModel,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
) { uri: Uri? ->
if (uri != null) {
var filename = "attachment"
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1 && cursor.moveToFirst()) {
filename = cursor.getString(nameIndex) ?: "attachment"
val configuration = LocalConfiguration.current
val resources = remember(context, configuration) { context.createConfigurationContext(configuration).resources }
val scope = rememberCoroutineScope()
var attachmentToDelete by remember { mutableStateOf<KanbanAttachment?>(null) }
var attachmentToDownload by remember { mutableStateOf<KanbanAttachment?>(null) }
val downloadLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
val selected = attachmentToDownload
attachmentToDownload = null
if (uri != null && selected != null) {
scope.launch {
when (val result = viewModel.downloadAttachment(board, selected.id)) {
is NetworkResult.Success -> {
try {
withContext(Dispatchers.IO) {
result.data.use { body ->
context.contentResolver.openOutputStream(uri)?.use { output ->
body.byteStream().use { input -> input.copyTo(output) }
} ?: error("Could not open the selected destination")
}
}
viewModel.showToast(resources.getString(R.string.kanban_attachment_saved))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
viewModel.showToast(
resources.getString(R.string.kanban_attachment_error, e.message ?: "Unknown error"),
)
}
}
is NetworkResult.Failure -> {
viewModel.showToast(result.error.message)
}
}
}
val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream"
val bytes =
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: byteArrayOf()
if (bytes.isNotEmpty()) {
onUpload(filename, mimeType, bytes)
}
}
}
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
if (uri != null) {
scope.launch {
try {
val part =
withContext(Dispatchers.IO) {
var filename = "attachment"
var contentLength = -1L
context.contentResolver
.query(
uri,
arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
null,
null,
null,
)?.use { c ->
if (c.moveToFirst()) {
c.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }?.let {
filename = c.getString(it) ?: filename
}
c
.getColumnIndex(
OpenableColumns.SIZE,
).takeIf { it >= 0 && !c.isNull(it) }
?.let {
contentLength = c.getLong(it)
}
}
}
val mime = context.contentResolver.getType(uri) ?: "application/octet-stream"
val body =
StreamingUriRequestBody(
context.contentResolver,
uri,
mime.toMediaTypeOrNull(),
contentLength,
)
MultipartBody.Part.createFormData("file", filename, body)
}
viewModel.uploadAttachment(board, taskId, part)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
viewModel.showToast(
resources.getString(
R.string.kanban_attachment_error,
e.message ?: "Unknown error",
),
)
}
}
}
}
LaunchedEffect(board, taskId) { viewModel.refreshAttachments(board, taskId) }
Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
Row(
@@ -1096,7 +1324,7 @@ private fun TaskFilesTab(
fontWeight = FontWeight.Bold,
)
Button(
onClick = { launcher.launch("*/*") },
onClick = { launcher.launch(arrayOf("*/*")) },
enabled = !isUploading,
) {
if (isUploading) {
@@ -1136,22 +1364,55 @@ private fun TaskFilesTab(
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = att.filename.ifBlank { "File #${att.id}" },
att.filename.ifBlank {
"File #${att.id}"
},
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
att.size?.let {
Text(
text = "${it / 1024} KB",
"${it / 1024} KB",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
IconButton(onClick = {
attachmentToDownload = att
downloadLauncher.launch(att.filename)
}) {
Icon(
Icons.Filled.Download,
contentDescription = stringResource(R.string.kanban_download_attachment),
)
}
IconButton(onClick = { attachmentToDelete = att }, enabled = !isDeleting) {
Icon(
Icons.Filled.Delete,
contentDescription = stringResource(R.string.kanban_delete_attachment),
)
}
}
}
}
}
}
}
attachmentToDelete?.let { attachment ->
AlertDialog(
onDismissRequest = { attachmentToDelete = null },
title = { Text(stringResource(R.string.kanban_delete_attachment)) },
text = { Text(stringResource(R.string.kanban_delete_attachment_confirm)) },
confirmButton = {
TextButton(onClick = {
attachmentToDelete = null
viewModel.deleteAttachment(board, taskId, attachment.id)
}) { Text(stringResource(R.string.kanban_delete_attachment)) }
},
dismissButton = {
TextButton(onClick = { attachmentToDelete = null }) { Text(stringResource(R.string.action_cancel)) }
},
)
}
}
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanRun
import com.m57.hermescontrol.data.model.KanbanRunInspection
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
@@ -25,9 +27,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.ResponseBody
data class KanbanTaskUiState(
val isLoading: Boolean = false,
@@ -47,6 +48,15 @@ data class KanbanTaskUiState(
val isReassigning: Boolean = false,
val isUpdatingModel: Boolean = false,
val isUploadingAttachment: Boolean = false,
val isDeletingAttachment: Boolean = false,
val isSpecifying: Boolean = false,
val isLinking: Boolean = false,
val isInspectingRun: Boolean = false,
val isTerminatingRun: Boolean = false,
val selectedRun: KanbanRun? = null,
val runInspection: KanbanRunInspection? = null,
val runStateType: String? = null,
val runStateName: String? = null,
)
class KanbanTaskViewModel(
@@ -61,6 +71,7 @@ class KanbanTaskViewModel(
private var eventsClient: KanbanEventsClient? = null
private var eventsBoard: String? = null
private var activeTaskId: String? = null
private var activeBoard: String? = null
private var currentLoadGen: Int = 0
private var reloadJob: Job? = null
private var logPollJob: Job? = null
@@ -74,7 +85,18 @@ class KanbanTaskViewModel(
reloadJob?.cancel()
stopLogPolling()
detailPollJob?.cancel()
if (activeBoard != board || activeTaskId != taskId) {
_uiState.update {
it.copy(
runStateType = null,
runStateName = null,
selectedRun = null,
runInspection = null,
)
}
}
activeTaskId = taskId
activeBoard = board
_uiState.update {
it.copy(
isLoading = true,
@@ -90,7 +112,15 @@ class KanbanTaskViewModel(
startDetailPolling(board, taskId)
viewModelScope.launch {
when (val result = repository.getTask(taskId = taskId, board = board)) {
when (
val result =
repository.getTask(
taskId,
board,
_uiState.value.runStateType,
_uiState.value.runStateName,
)
) {
is NetworkResult.Success -> {
if (gen != currentLoadGen) return@launch
val taskData = result.data
@@ -254,11 +284,13 @@ class KanbanTaskViewModel(
taskId: String,
) {
val gen = currentLoadGen
val result = repository.getTask(taskId = taskId, board = board)
if (gen != currentLoadGen || taskId != activeTaskId) return
val result = repository.getTask(taskId, board, _uiState.value.runStateType, _uiState.value.runStateName)
if (gen != currentLoadGen || taskId != activeTaskId || board != activeBoard) return
if (result is NetworkResult.Success) {
val taskData = result.data
_uiState.update { it.copy(detail = taskData) }
_uiState.update { state ->
state.copy(detail = taskData.copy(attachments = taskData.attachments ?: state.detail?.attachments))
}
if (taskData.task.status.equals("running", ignoreCase = true)) {
if (logPollJob == null) startLogPolling(board, taskId)
} else {
@@ -521,18 +553,6 @@ class KanbanTaskViewModel(
}
}
fun uploadAttachment(
board: String,
taskId: String,
filename: String,
mimeType: String,
bytes: ByteArray,
) {
val requestBody = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("file", filename, requestBody)
uploadAttachment(board, taskId, part)
}
fun uploadAttachment(
board: String,
taskId: String,
@@ -548,7 +568,10 @@ class KanbanTaskViewModel(
toastMessage = "Attachment uploaded",
)
}
loadTask(board, taskId)
if (activeTaskId == taskId && activeBoard == board) {
loadTaskSilently(board, taskId)
refreshAttachments(board, taskId)
}
}
is NetworkResult.Failure -> {
@@ -563,6 +586,217 @@ class KanbanTaskViewModel(
}
}
fun refreshAttachments(
board: String,
taskId: String,
) {
viewModelScope.launch {
when (val result = repository.listAttachments(taskId, board)) {
is NetworkResult.Success -> {
_uiState.update { state ->
if (activeTaskId != taskId || activeBoard != board) {
state
} else {
state.copy(detail = state.detail?.copy(attachments = result.data.attachments))
}
}
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = result.error.message) }
}
}
}
}
fun deleteAttachment(
board: String,
taskId: String,
attachmentId: Long,
) {
if (_uiState.value.isDeletingAttachment) return
_uiState.update { it.copy(isDeletingAttachment = true) }
viewModelScope.launch {
when (val result = repository.deleteAttachment(attachmentId, board)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isDeletingAttachment = false,
toastMessage = if (result.data.ok) "Attachment deleted" else "Attachment was not deleted",
)
}
if (result.data.ok) refreshAttachments(board, taskId)
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(isDeletingAttachment = false, toastMessage = result.error.message)
}
}
}
}
}
suspend fun downloadAttachment(
board: String,
attachmentId: Long,
): NetworkResult<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() {
stopLogPolling()
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.ExportBoardBody
import com.m57.hermescontrol.data.model.ImportBoardBody
import com.m57.hermescontrol.data.model.KanbanActiveWorker
import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanColumn
@@ -26,6 +27,7 @@ import com.m57.hermescontrol.data.model.RenameBoardBody
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.repository.KanbanRepository
@@ -64,8 +66,22 @@ data class KanbanUiState(
val columnCollapseOverrides: Map<String, Boolean> = emptyMap(),
val errorMessage: 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.
* Only moves the backend PATCH route accepts are offered: never `running`
@@ -250,7 +266,15 @@ class KanbanViewModel(
preferences.setSelectedBoard(endpoint, board.id)
val gen = ++currentLoadGen
_uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) }
_uiState.update {
it.copy(
selectedBoard = board,
isLoading = true,
errorMessage = null,
activeWorkers = emptyList(),
isLoadingWorkers = false,
)
}
viewModelScope.launch {
loadBoardIntoState(board, gen)
connectEvents(board)
@@ -272,6 +296,54 @@ class KanbanViewModel(
_uiState.update { it.copy(groupRunning = group) }
}
fun setWorkflowFilters(
workflowTemplateId: String?,
currentStepKey: String?,
) {
_uiState.update {
it.copy(
selectedWorkflowTemplateId = workflowTemplateId,
selectedCurrentStepKey = currentStepKey,
)
}
_uiState.value.selectedBoard?.let { board ->
val gen = ++currentLoadGen
viewModelScope.launch { loadBoardIntoState(board, gen) }
}
}
fun loadActiveWorkers() {
val board = _uiState.value.selectedBoard ?: return
_uiState.update { it.copy(isLoadingWorkers = true) }
viewModelScope.launch {
when (val result = repository.getActiveWorkers(board.id)) {
is NetworkResult.Success -> {
_uiState.update {
if (it.selectedBoard?.id == board.id) {
it.copy(activeWorkers = result.data.workers, isLoadingWorkers = false)
} else {
it
}
}
}
is NetworkResult.Failure -> {
_uiState.update {
if (it.selectedBoard?.id == board.id) {
it.copy(
isLoadingWorkers = false,
activeWorkers = emptyList(),
toastMessage = "Failed to load active workers: ${result.error.message}",
)
} else {
it
}
}
}
}
}
}
fun toggleColumnCollapse(
columnName: String,
boardHasWork: Boolean,
@@ -860,10 +932,22 @@ class KanbanViewModel(
operatingTaskIds = state.operatingTaskIds - task.id,
)
}
reloadBoardSilently()
}
is NetworkResult.Failure -> {
revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}")
val refusal =
(result.error as? NetworkError.Http)
?.takeIf { it.code == 409 }
?.let { kanbanTransitionRefusal(it.message) }
val message =
if (refusal != null) {
"Move blocked by parents ${refusal.blockingParentIds.joinToString()} " +
"(${refusal.blockingParentStatuses.joinToString()}): ${result.error.message}"
} else {
"Move failed: ${result.error.message}"
}
revertTaskMove(task.id, originalStatus, message)
}
}
}
@@ -945,6 +1029,8 @@ class KanbanViewModel(
repository.getBoard(
board = board.id,
includeArchived = _uiState.value.includeArchived,
workflowTemplateId = _uiState.value.selectedWorkflowTemplateId,
currentStepKey = _uiState.value.selectedCurrentStepKey,
)
if (gen != currentLoadGen) return@launch
if (result is NetworkResult.Success) {
@@ -1010,6 +1096,8 @@ class KanbanViewModel(
repository.getBoard(
board = board.id,
includeArchived = _uiState.value.includeArchived,
workflowTemplateId = _uiState.value.selectedWorkflowTemplateId,
currentStepKey = _uiState.value.selectedCurrentStepKey,
)
applyBoardResult(board, result, gen)
}
@@ -34,10 +34,15 @@ fun KanbanFilterSheet(
tenants: List<String>,
selectedAssignee: String?,
selectedTenant: String?,
workflowTemplateIds: List<String>,
currentStepKeys: List<String>,
selectedWorkflowTemplateId: String?,
selectedCurrentStepKey: String?,
includeArchived: Boolean,
groupRunning: Boolean,
onSelectAssignee: (String?) -> Unit,
onSelectTenant: (String?) -> Unit,
onSelectWorkflowFilters: (String?, String?) -> Unit,
onToggleIncludeArchived: (Boolean) -> Unit,
onToggleGroupRunning: (Boolean) -> Unit,
onClearFilters: () -> Unit,
@@ -105,6 +110,29 @@ fun KanbanFilterSheet(
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
"Workflow template",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
FilterChoiceRow("All templates", selectedWorkflowTemplateId == null) {
onSelectWorkflowFilters(null, selectedCurrentStepKey)
}
workflowTemplateIds.forEach { value ->
FilterChoiceRow(value, selectedWorkflowTemplateId == value) {
onSelectWorkflowFilters(value, selectedCurrentStepKey)
}
}
Text("Current step", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
FilterChoiceRow("All steps", selectedCurrentStepKey == null) {
onSelectWorkflowFilters(selectedWorkflowTemplateId, null)
}
currentStepKeys.forEach { value ->
FilterChoiceRow(value, selectedCurrentStepKey == value) {
onSelectWorkflowFilters(selectedWorkflowTemplateId, value)
}
}
// Assignee Filter
Text(
text = stringResource(R.string.kanban_assignee),
@@ -192,3 +220,18 @@ fun KanbanFilterSheet(
}
}
}
@Composable
private fun FilterChoiceRow(
label: String,
selected: Boolean,
onClick: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp),
) {
RadioButton(selected = selected, onClick = onClick)
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}
+27
View File
@@ -1495,6 +1495,33 @@
<string name="session_integrations_close">إغلاق</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 -->
<string name="media_player_play">تشغيل</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_request_failed">설정 응답을 보낼 수 없습니다. 다시 시도하세요.</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>
+27
View File
@@ -1469,6 +1469,33 @@
<string name="media_player_play_video">播放视频</string>
<string name="chat_session_preparing">正在准备会话…</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_progress">第%1$d项,共%2$d项</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_request_failed">Couldn’t send the setup response. Try again.</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>
@@ -1256,6 +1256,17 @@ class E2eIntegrationTest {
com.m57.hermescontrol.data.model
.UpdateTaskResponse(task = task.copy(status = "ready")),
)
coEvery { mockApiService.getBoard("board-1", any(), any()) } returns
Response.success(
KanbanBoardResponse(
listOf(
KanbanColumn("todo", emptyList()),
KanbanColumn("ready", listOf(task.copy(status = "ready"))),
),
null,
null,
),
)
viewModel.moveTask(viewModel.uiState.value.tasks[0], KanbanTaskAction.READY)
advanceUntilIdle()
assertEquals(
@@ -314,4 +314,57 @@ class KanbanSerializationTest {
assertTrue(unassignJson.contains("\"assignee\":\"\""))
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.ReclaimTaskBody
import com.m57.hermescontrol.data.model.RenameBoardBody
import com.m57.hermescontrol.data.model.SpecifyTaskBody
import com.m57.hermescontrol.data.model.TaskLinkBody
import com.m57.hermescontrol.data.model.TerminateRunBody
import com.m57.hermescontrol.data.model.UpdateTaskBody
import kotlinx.coroutines.runBlocking
import okhttp3.MediaType.Companion.toMediaType
@@ -308,4 +311,155 @@ class KanbanApiServiceTest {
assertEquals("POST", nudgeReq.method)
assertEquals("/api/plugins/kanban/dispatch?board=dev", nudgeReq.path)
}
@Test
fun testBoardAndTaskFiltersAreExplicitlyScoped() =
runBlocking {
mockServer.enqueue(MockResponse().setResponseCode(200).setBody("""{ "columns": [] }"""))
api.getBoard(
board = "dev",
includeArchived = false,
tenant = null,
workflowTemplateId = "release",
currentStepKey = "verify",
)
val boardRequest = mockServer.takeRequest()
assertEquals("GET", boardRequest.method)
assertTrue(boardRequest.path?.contains("board=dev") == true)
assertTrue(boardRequest.path?.contains("workflow_template_id=release") == true)
assertTrue(boardRequest.path?.contains("current_step_key=verify") == true)
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "task": { "id": "t_1", "title": "Task", "status": "done" } }"""),
)
api.getTask(
taskId = "t_1",
board = "dev",
runStateType = "outcome",
runStateName = "completed",
)
val taskRequest = mockServer.takeRequest()
assertEquals("GET", taskRequest.method)
assertTrue(taskRequest.path?.contains("board=dev") == true)
assertTrue(taskRequest.path?.contains("run_state_type=outcome") == true)
assertTrue(taskRequest.path?.contains("run_state_name=completed") == true)
}
@Test
fun testAttachmentListAndDeleteAreBoardScoped() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "attachments": [{ "id": 9, "filename": "proof.txt" }] }"""),
)
val listed = api.listAttachments(taskId = "t_1", board = "dev")
assertEquals(
"proof.txt",
listed
.body()
?.attachments
?.single()
?.filename,
)
assertEquals("/api/plugins/kanban/tasks/t_1/attachments?board=dev", mockServer.takeRequest().path)
mockServer.enqueue(MockResponse().setResponseCode(200).setBody("""{ "ok": true, "id": 9 }"""))
val deleted = api.deleteAttachment(attachmentId = 9L, board = "dev")
assertTrue(deleted.body()?.ok == true)
val request = mockServer.takeRequest()
assertEquals("DELETE", request.method)
assertEquals("/api/plugins/kanban/attachments/9?board=dev", request.path)
}
@Test
fun testWorkerRunAndSpecificationOperationsAreBoardScoped() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""{ "workers": [{ "run_id": 7, "task_id": "t_1", "task_title": "Task", "task_status": "running", "worker_pid": 42 }], "count": 1, "checked_at": 123 }""",
),
)
val workers = api.getActiveWorkers(board = "dev")
assertEquals(
7L,
workers
.body()
?.workers
?.single()
?.runId,
)
assertEquals("/api/plugins/kanban/workers/active?board=dev", mockServer.takeRequest().path)
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "run": { "id": 7, "task_id": "t_1", "status": "running", "started_at": 100 } }"""),
)
val run = api.getRun(runId = 7L, board = "dev")
assertEquals("t_1", run.body()?.run?.taskId)
assertEquals("/api/plugins/kanban/runs/7?board=dev", mockServer.takeRequest().path)
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "run_id": 7, "alive": true, "pid": 42, "cpu_percent": 1.5 }"""),
)
val inspection = api.inspectRun(runId = 7L, board = "dev")
assertTrue(inspection.body()?.alive == true)
assertEquals("/api/plugins/kanban/runs/7/inspect?board=dev", mockServer.takeRequest().path)
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "ok": true, "run_id": 7, "task_id": "t_1" }"""),
)
val terminated =
api.terminateRun(
runId = 7L,
board = "dev",
body = TerminateRunBody(reason = "Operator requested stop"),
)
assertTrue(terminated.body()?.ok == true)
val terminateRequest = mockServer.takeRequest()
assertEquals("POST", terminateRequest.method)
assertEquals("/api/plugins/kanban/runs/7/terminate?board=dev", terminateRequest.path)
assertTrue(terminateRequest.body.readUtf8().contains("Operator requested stop"))
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "ok": true, "task_id": "t_1", "new_title": "Specified task" }"""),
)
val specified = api.specifyTask("t_1", "dev", SpecifyTaskBody(author = "mobile"))
assertEquals("Specified task", specified.body()?.newTitle)
assertEquals("/api/plugins/kanban/tasks/t_1/specify?board=dev", mockServer.takeRequest().path)
}
@Test
fun testTaskLinkConsumesGatingResult() =
runBlocking {
mockServer.enqueue(MockResponse().setResponseCode(200).setBody("""{ "ok": true, "gated": true }"""))
val response =
api.createTaskLink(
board = "dev",
body = TaskLinkBody(parentId = "parent", childId = "child"),
)
assertTrue(response.body()?.ok == true)
assertTrue(response.body()?.gated == true)
val request = mockServer.takeRequest()
assertEquals("POST", request.method)
assertEquals("/api/plugins/kanban/links?board=dev", request.path)
}
}
@@ -2,9 +2,13 @@ package com.m57.hermescontrol.data.repository
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateTaskResponse
import com.m57.hermescontrol.data.model.DeleteAttachmentResponse
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.model.SpecifyTaskResponse
import com.m57.hermescontrol.data.model.TaskLinkResponse
import com.m57.hermescontrol.data.model.TerminateRunResponse
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.remote.KanbanApiService
@@ -107,4 +111,28 @@ class KanbanRepositoryTest {
val result = repository.getBoard("missing")
assertTrue(result is NetworkResult.Failure)
}
@Test
fun testNewTaskOperationsCarryBoardAndTypedResults() =
runTest(testDispatcher) {
coEvery { mockApi.deleteAttachment(9L, "ops") } returns
Response.success(DeleteAttachmentResponse(ok = true, id = 9L))
coEvery { mockApi.terminateRun(7L, "ops", any()) } returns
Response.success(TerminateRunResponse(ok = true, runId = 7L, taskId = "t_1"))
coEvery { mockApi.specifyTask("t_1", "ops", any()) } returns
Response.success(SpecifyTaskResponse(ok = false, taskId = "t_1", reason = "Not configured"))
coEvery { mockApi.createTaskLink("ops", any()) } returns
Response.success(TaskLinkResponse(ok = true, gated = true))
assertTrue((repository.deleteAttachment(9L, "ops") as NetworkResult.Success).data.ok)
assertEquals("t_1", (repository.terminateRun(7L, "ops") as NetworkResult.Success).data.taskId)
assertEquals("Not configured", (repository.specifyTask("t_1", "ops") as NetworkResult.Success).data.reason)
assertTrue((repository.createTaskLink("ops", "parent", "t_1") as NetworkResult.Success).data.gated)
coVerify { mockApi.deleteAttachment(9L, "ops") }
coVerify { mockApi.terminateRun(7L, "ops", any()) }
coVerify { mockApi.specifyTask("t_1", "ops", any()) }
coVerify {
mockApi.createTaskLink("ops", match { it.parentId == "parent" && it.childId == "t_1" })
}
}
}
@@ -161,6 +161,16 @@ class KanbanMutationTest {
// Complete network call
deferred.complete(NetworkResult.Success(UpdateTaskResponse(task = task.copy(status = "ready"))))
coEvery { mockRepository.getBoard("dev") } returns
NetworkResult.Success(
KanbanBoardResponse(
columns =
listOf(
KanbanColumn("todo", emptyList()),
KanbanColumn("ready", listOf(task.copy(status = "ready"))),
),
),
)
testDispatcher.scheduler.advanceUntilIdle()
assertFalse(
@@ -1,11 +1,21 @@
package com.m57.hermescontrol.ui.kanban
import com.m57.hermescontrol.data.model.AttachmentListResponse
import com.m57.hermescontrol.data.model.DeleteAttachmentResponse
import com.m57.hermescontrol.data.model.KanbanAttachment
import com.m57.hermescontrol.data.model.KanbanRun
import com.m57.hermescontrol.data.model.KanbanRunInspection
import com.m57.hermescontrol.data.model.KanbanRunResponse
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.KanbanTaskFull
import com.m57.hermescontrol.data.model.ReassignTaskResponse
import com.m57.hermescontrol.data.model.ReclaimTaskResponse
import com.m57.hermescontrol.data.model.SpecifyTaskResponse
import com.m57.hermescontrol.data.model.TaskLinkResponse
import com.m57.hermescontrol.data.model.TerminateRunResponse
import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.model.WorkerLog
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.repository.KanbanRepository
import com.m57.hermescontrol.data.ws.KanbanEventsClient
@@ -19,6 +29,8 @@ import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@@ -197,7 +209,11 @@ class KanbanTaskViewModelTest {
)
val vm = createViewModel()
vm.uploadAttachment("dev", "t_1", "test.png", "image/png", byteArrayOf(1, 2, 3))
vm.uploadAttachment(
"dev",
"t_1",
MultipartBody.Part.createFormData("file", "test.png", byteArrayOf(1, 2, 3).toRequestBody()),
)
testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockRepository.uploadAttachment("t_1", "dev", any()) }
@@ -321,4 +337,160 @@ class KanbanTaskViewModelTest {
coVerify(exactly = 2) { mockRepository.getTask("t_1", "dev") }
vm.onCleared()
}
@Test
fun testTerminateRunRefreshesRunAndExactTask() =
runTest(testDispatcher) {
val running = KanbanTaskFull(id = "t_1", title = "Task", status = "running")
val ready = running.copy(status = "ready")
coEvery { mockRepository.getTask("t_1", "dev", any(), any()) } returnsMany
listOf(
NetworkResult.Success(KanbanTaskDetailResponse(task = running)),
NetworkResult.Success(KanbanTaskDetailResponse(task = ready)),
)
coEvery { mockRepository.terminateRun(7L, "dev") } returns
NetworkResult.Success(TerminateRunResponse(ok = true, runId = 7L, taskId = "t_1"))
coEvery { mockRepository.getRun(7L, "dev") } returns
NetworkResult.Success(KanbanRunResponse(KanbanRun(id = 7L, status = "reclaimed")))
val vm = createViewModel()
vm.loadTask("dev", "t_1")
testDispatcher.scheduler.runCurrent()
vm.terminateRun("dev", "t_1", 7L)
testDispatcher.scheduler.runCurrent()
assertEquals(
"ready",
vm.uiState.value.detail
?.task
?.status,
)
assertEquals(
"reclaimed",
vm.uiState.value.selectedRun
?.status,
)
coVerify(exactly = 2) { mockRepository.getTask("t_1", "dev", any(), any()) }
coVerify { mockRepository.getRun(7L, "dev") }
vm.onCleared()
}
@Test
fun testRunInspectionUnavailableIsRepresentedHonestly() =
runTest(testDispatcher) {
coEvery { mockRepository.getRun(7L, "dev") } returns
NetworkResult.Success(KanbanRunResponse(KanbanRun(id = 7L, status = "running")))
coEvery { mockRepository.inspectRun(7L, "dev") } returns
NetworkResult.Success(KanbanRunInspection(runId = 7L, alive = false, reason = "psutil not available"))
val vm = createViewModel()
vm.inspectRun("dev", "t_1", 7L)
testDispatcher.scheduler.runCurrent()
assertEquals(
"psutil not available",
vm.uiState.value.runInspection
?.reason,
)
assertEquals(
7L,
vm.uiState.value.selectedRun
?.id,
)
}
@Test
fun testSpecifyFailureUsesTypedReasonWithoutRefreshingTask() =
runTest(testDispatcher) {
coEvery { mockRepository.specifyTask("t_1", "dev") } returns
NetworkResult.Success(SpecifyTaskResponse(ok = false, taskId = "t_1", reason = "Not configured"))
val vm = createViewModel()
vm.specifyTask("dev", "t_1")
testDispatcher.scheduler.runCurrent()
assertTrue(
vm.uiState.value.toastMessage
?.contains("Not configured") == true,
)
coVerify(exactly = 0) { mockRepository.getTask("t_1", "dev", any(), any()) }
}
@Test
fun testLinkGatingOutcomeIsVisibleAndReloadsTask() =
runTest(testDispatcher) {
coEvery { mockRepository.createTaskLink("dev", "parent", "t_1") } returns
NetworkResult.Success(TaskLinkResponse(ok = true, gated = true))
coEvery { mockRepository.getTask("t_1", "dev", any(), any()) } returns
NetworkResult.Success(
KanbanTaskDetailResponse(task = KanbanTaskFull(id = "t_1", title = "Child", status = "todo")),
)
val vm = createViewModel()
vm.createParentLink("dev", "t_1", "parent")
testDispatcher.scheduler.runCurrent()
assertTrue(
vm.uiState.value.toastMessage
?.contains("gated") == true,
)
coVerify { mockRepository.getTask("t_1", "dev", any(), any()) }
}
@Test
fun testDeleteAttachmentReloadsExactTaskAttachmentList() =
runTest(testDispatcher) {
val task = KanbanTaskFull(id = "t_1", title = "Task", status = "todo")
coEvery { mockRepository.getTask("t_1", "dev", any(), any()) } returns
NetworkResult.Success(
KanbanTaskDetailResponse(task = task, attachments = listOf(KanbanAttachment(9L, filename = "old"))),
)
coEvery { mockRepository.deleteAttachment(9L, "dev") } returns
NetworkResult.Success(DeleteAttachmentResponse(ok = true, id = 9L))
coEvery { mockRepository.listAttachments("t_1", "dev") } returns
NetworkResult.Success(AttachmentListResponse(emptyList()))
val vm = createViewModel()
vm.loadTask("dev", "t_1")
testDispatcher.scheduler.runCurrent()
vm.deleteAttachment("dev", "t_1", 9L)
testDispatcher.scheduler.runCurrent()
assertTrue(
vm.uiState.value.detail
?.attachments
?.isEmpty() == true,
)
coVerify { mockRepository.listAttachments("t_1", "dev") }
}
@Test
fun testRunFilterPassesPairedBackendParameters() =
runTest(testDispatcher) {
val detail = KanbanTaskDetailResponse(task = KanbanTaskFull("t_1", "Task", status = "done"))
coEvery { mockRepository.getTask("t_1", "dev", any(), any()) } returns NetworkResult.Success(detail)
val vm = createViewModel()
vm.loadTask("dev", "t_1")
testDispatcher.scheduler.runCurrent()
vm.setRunFilter("dev", "t_1", "status", "reclaimed")
testDispatcher.scheduler.runCurrent()
coVerify { mockRepository.getTask("t_1", "dev", "status", "reclaimed") }
assertEquals("reclaimed", vm.uiState.value.runStateName)
}
@Test
fun testTerminateRefusalPreservesTaskAndReportsReason() =
runTest(testDispatcher) {
coEvery { mockRepository.terminateRun(7L, "dev") } returns
NetworkResult.Failure(NetworkError.Http(409, "run 7 already ended"))
val vm = createViewModel()
vm.terminateRun("dev", "t_1", 7L)
testDispatcher.scheduler.runCurrent()
assertEquals("run 7 already ended", vm.uiState.value.toastMessage)
assertTrue(!vm.uiState.value.isTerminatingRun)
coVerify(exactly = 0) { mockRepository.getRun(7L, "dev") }
}
}
@@ -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()
vm.loadBoards()
settle()
coEvery { mockApi.getBoard(board = "work", any(), any()) } returns
Response.success(
KanbanBoardResponse(
columns =
listOf(
KanbanColumn(name = "todo", tasks = emptyList()),
KanbanColumn(
name = "ready",
tasks = listOf(KanbanTask(id = "t1", title = "Task 1", status = "ready")),
),
),
),
)
coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.success(UpdateTaskResponse())
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
settle()
@@ -255,6 +268,132 @@ class KanbanViewModelTest {
)
}
@Test
fun `moveTask dependency conflict surfaces blocking parent ids`() {
val vm = createViewModel()
vm.loadBoards()
settle()
coEvery { mockApi.updateTask(any(), any(), any()) } returns
Response.error(
409,
"""{"detail":"Cannot move to 'ready': blocked by parent(s) not done — 'Build' (t_parent, status=running)"}"""
.toResponseBody(),
)
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
settle()
assertEquals(
"todo",
vm.uiState.value.tasks
.single()
.status,
)
assertTrue(
vm.uiState.value.toastMessage
.orEmpty()
.contains("t_parent"),
)
}
@Test
fun `workflow filters retain only matching tasks`() {
val tasks =
listOf(
KanbanTask("a", "A", status = "todo", workflowTemplateId = "wf", currentStepKey = "build"),
KanbanTask("b", "B", status = "todo", workflowTemplateId = "other", currentStepKey = "test"),
)
assertEquals(listOf("a"), filterKanbanTasks(tasks, "wf", "build").map { it.id })
assertEquals(listOf("a"), filterKanbanTasks(tasks, "wf", null).map { it.id })
}
@Test
fun `active workers are loaded for selected board`() {
val vm = createViewModel()
vm.loadBoards()
settle()
coEvery { mockApi.getActiveWorkers("work") } returns
Response.success(
com.m57.hermescontrol.data.model.ActiveWorkersResponse(
workers =
listOf(
com.m57.hermescontrol.data.model.KanbanActiveWorker(
runId = 1,
taskId = "t1",
taskTitle = "Task 1",
taskStatus = "running",
workerPid = 42,
),
),
checkedAt = 1,
),
)
vm.loadActiveWorkers()
settle()
assertEquals(1, vm.uiState.value.activeWorkers.size)
assertEquals(
"t1",
vm.uiState.value.activeWorkers
.single()
.taskId,
)
vm.selectBoard(KanbanBoard(id = "ops", name = "Ops"))
settle()
assertTrue(
vm.uiState.value.activeWorkers
.isEmpty(),
)
}
@Test
fun `successful move refreshes board to reconcile done ordering`() {
coEvery { mockApi.getBoard(board = "work", any(), any()) } returns
Response.success(
KanbanBoardResponse(
columns =
listOf(
KanbanColumn(
name = "todo",
tasks = listOf(KanbanTask(id = "t1", title = "Task 1", status = "todo")),
),
KanbanColumn(
name = "done",
tasks = listOf(KanbanTask(id = "older", title = "Older", status = "done")),
),
),
),
)
val vm = createViewModel()
vm.loadBoards()
settle()
coEvery { mockApi.getBoard(board = "work", any(), any()) } returns
Response.success(
KanbanBoardResponse(
columns =
listOf(
KanbanColumn(name = "todo", tasks = emptyList()),
KanbanColumn(
name = "done",
tasks =
listOf(
KanbanTask(id = "t1", title = "Task 1", status = "done"),
KanbanTask(id = "older", title = "Older", status = "done"),
),
),
),
),
)
coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.success(UpdateTaskResponse())
vm.moveTask(KanbanTask("t1", "Task 1", status = "todo"), KanbanTaskAction.COMPLETE, "done")
settle()
coVerify(atLeast = 2) { mockApi.getBoard(board = "work", any(), any()) }
assertEquals(
listOf("t1", "older"),
vm.uiState.value.columns
.single { it.name == "done" }
.tasks
.map { it.id },
)
}
@Test
fun `moveTask complete sends the completion summary`() {
val vm = createViewModel()