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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user