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