diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Kanban.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Kanban.kt deleted file mode 100644 index 592c4b50..00000000 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Kanban.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.m57.hermescontrol.data.model -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class KanbanBoard( - @SerialName("slug") val id: String, - val name: String, - val description: String? = null, -) - -@Serializable -data class KanbanTask( - val id: String, - val title: String, - @SerialName("body") val description: String? = null, - val status: String, - @SerialName("assignee") val assignedTo: String? = null, -) - -@Serializable -data class CreateTaskBody( - val title: String, - val body: String? = null, -) - -@Serializable -data class KanbanColumn( - val name: String, - val tasks: List, -) - -@Serializable -data class KanbanBoardResponse( - val columns: List, - val assignees: List? = null, - val tenants: List? = null, -) - -@Serializable -data class KanbanBoardsResponse( - val boards: List, - val current: String? = null, -) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanActivityModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanActivityModels.kt new file mode 100644 index 00000000..0b6a8974 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanActivityModels.kt @@ -0,0 +1,61 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class KanbanComment( + val id: Long, + val author: String, + val body: String, + val createdAt: Long, +) + +@Serializable +data class KanbanDetailEvent( + val id: Long, + val kind: String, + val payload: JsonElement? = null, + val createdAt: Long, +) + +@Serializable +data class KanbanRun( + val id: Long, + val profile: String? = null, + val status: String, + val outcome: String? = null, + val summary: String? = null, + val error: String? = null, + val metadata: JsonElement? = null, + val workerPid: Int? = null, + val startedAt: Long? = null, + val endedAt: Long? = null, +) + +@Serializable +data class WorkerLog( + val taskId: String, + val path: String? = null, + val exists: Boolean = false, + val sizeBytes: Long = 0L, + val content: String = "", + val truncated: Boolean = false, +) + +@Serializable +data class KanbanAttachment( + val id: Long, + val taskId: String? = null, + val filename: String, + val contentType: String? = null, + val size: Long? = null, + val uploadedBy: String? = null, + val storedPath: String? = null, + val createdAt: Long? = null, +) + +@Serializable +data class AttachmentUploadResponse( + val attachment: KanbanAttachment? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanBoardModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanBoardModels.kt new file mode 100644 index 00000000..22276588 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanBoardModels.kt @@ -0,0 +1,94 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class KanbanBoard( + @SerialName("slug") val id: String, + val name: String? = null, + val description: String? = null, + val isCurrent: Boolean = false, + val total: Int? = null, + val counts: Map = emptyMap(), + val defaultWorkdir: String? = null, + val defaultWorkspaceKind: String? = null, + val projectId: String? = null, + val projectName: String? = null, + val createdAt: Long? = null, + val archived: Boolean = false, +) { + val slug: String get() = id + val displayName: String get() = name?.takeIf { it.isNotBlank() } ?: id +} + +@Serializable +data class KanbanColumn( + val name: String, + val tasks: List = emptyList(), +) + +@Serializable +data class KanbanBoardResponse( + val columns: List = emptyList(), + val assignees: List? = null, + val tenants: List? = null, + val latestEventId: Long = 0L, + val now: Long = 0L, +) + +@Serializable +data class KanbanBoardsResponse( + val boards: List = emptyList(), + val current: String? = null, +) + +@Serializable +data class KanbanProject( + val id: String, + val slug: String, + val name: String, + val primaryPath: String? = null, + val icon: String? = null, + val color: String? = null, +) + +@Serializable +data class KanbanProjectsResponse( + val projects: List = emptyList(), +) + +@Serializable +data class CreateBoardBody( + val slug: String, + val name: String? = null, + val description: String? = null, + val projectId: String? = null, + val switch: Boolean = false, +) + +@Serializable +data class CreateBoardResponse( + val board: KanbanBoard? = null, + val current: String? = null, +) + +@Serializable +data class RenameBoardBody( + val name: String? = null, + val description: String? = null, + val defaultWorkdir: String? = null, + val projectId: String? = null, +) + +@Serializable +data class RenameBoardResponse( + val board: KanbanBoard? = null, +) + +@Serializable +data class DeleteBoardResponse( + val result: JsonElement? = null, + val current: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanSettingsModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanSettingsModels.kt new file mode 100644 index 00000000..e53f2914 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanSettingsModels.kt @@ -0,0 +1,70 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class KanbanProfile( + val name: String, + val isDefault: Boolean = false, + val description: String = "", + val descriptionAuto: Boolean = false, + val model: String? = null, + val provider: String? = null, + val skillCount: Int = 0, +) + +@Serializable +data class KanbanProfilesResponse( + val profiles: List = emptyList(), +) + +@Serializable +data class OrchestrationSettings( + val orchestratorProfile: String = "", + val defaultAssignee: String = "", + val autoDecompose: Boolean = true, + val autoPromoteChildren: Boolean = true, + val resolvedOrchestratorProfile: String = "", + val resolvedDefaultAssignee: String = "", + val activeProfile: String = "default", +) + +@Serializable +data class OrchestrationSettingsUpdate( + val orchestratorProfile: String? = null, + val defaultAssignee: String? = null, + val autoDecompose: Boolean? = null, + val autoPromoteChildren: Boolean? = null, +) + +@Serializable +data class AutoDescribeResponse( + val ok: Boolean = false, + val profile: String? = null, + val reason: String? = null, + val description: String? = null, +) + +@Serializable +data class BoardExportResult( + val board: String, + val archive: String, + val size: Long = 0L, +) + +@Serializable +data class BoardImportResult( + val board: String, + val name: String = "", + val renamed: Boolean = false, + val requestedBoard: String = "", + val counts: Map = emptyMap(), + val warnings: List = emptyList(), + val current: String? = null, +) + +@Serializable +data class DispatchResult( + val spawned: List = emptyList(), +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt new file mode 100644 index 00000000..b93d3c27 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt @@ -0,0 +1,255 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class TaskLinkCounts( + val parents: Int = 0, + val children: Int = 0, +) + +@Serializable +data class TaskProgress( + val done: Int = 0, + val total: Int = 0, +) + +@Serializable +data class TaskWarnings( + val count: Int = 0, + val highestSeverity: String? = null, +) + +@Serializable +data class TaskDiagnosticAction( + val kind: String, + val label: String, + val payload: Map = emptyMap(), + val suggested: Boolean = false, +) + +@Serializable +data class TaskDiagnostic( + val kind: String, + val severity: String, + val title: String, + val detail: String, + val actions: List = emptyList(), + val count: Int = 1, + val lastSeenAt: Long = 0L, + val data: Map = emptyMap(), +) + +@Serializable +data class TaskLinks( + val parents: List = emptyList(), + val children: List = emptyList(), +) + +@Serializable +data class ChildTaskResult( + val id: String, + val title: String, + val status: String, + val latestSummary: String? = null, + val result: String? = null, +) + +@Serializable +data class KanbanTask( + val id: String, + val title: String, + val body: String? = null, + val status: String, + val assignee: String? = null, + val priority: Int? = null, + val tenant: String? = null, + val createdAt: Long? = null, + val latestSummary: String? = null, + val commentCount: Int = 0, + val linkCounts: TaskLinkCounts? = null, + val progress: TaskProgress? = null, + val warnings: TaskWarnings? = null, + val startedAt: Long? = null, + val workerPid: Int? = null, + val lastHeartbeatAt: Long? = null, +) { + val description: String? get() = body + val assignedTo: String? get() = assignee + + constructor( + id: String, + title: String, + description: String? = null, + status: String, + assignedTo: String? = null, + ) : this( + id = id, + title = title, + body = description, + status = status, + assignee = assignedTo, + ) +} + +@Serializable +data class KanbanTaskFull( + val id: String, + val title: String, + val body: String? = null, + val status: String, + val assignee: String? = null, + val priority: Int? = null, + val tenant: String? = null, + val createdAt: Long? = null, + val latestSummary: String? = null, + val commentCount: Int = 0, + val linkCounts: TaskLinkCounts? = null, + val progress: TaskProgress? = null, + val warnings: TaskWarnings? = null, + val startedAt: Long? = null, + val workerPid: Int? = null, + val lastHeartbeatAt: Long? = null, + val result: String? = null, + val createdBy: String? = null, + val modelOverride: String? = null, + val providerOverride: String? = null, + val reasoningEffort: String? = null, + val completedAt: Long? = null, + val lastFailureError: String? = null, + val workspaceKind: String? = null, + val workspacePath: String? = null, + val branchName: String? = null, + val consecutiveFailures: Int = 0, + val diagnostics: List = emptyList(), +) { + val description: String? get() = body + val assignedTo: String? get() = assignee +} + +@Serializable +data class KanbanTaskDetailResponse( + val task: KanbanTaskFull, + val comments: List = emptyList(), + val events: List = emptyList(), + val attachments: List? = null, + val links: TaskLinks = TaskLinks(), + val childResults: List = emptyList(), + val runs: List = emptyList(), +) + +@Serializable +data class CreateTaskBody( + val title: String, + val body: String? = null, + val assignee: String? = null, + val tenant: String? = null, + val priority: Int = 0, + val workspaceKind: String? = null, + val workspacePath: String? = null, + val parents: List = emptyList(), + val triage: Boolean = false, + val maxRuntimeSeconds: Int? = null, + val skills: List? = null, + val goalMode: Boolean = false, + val goalMaxTurns: Int? = null, + val modelOverride: String? = null, + val providerOverride: String? = null, + val reasoningEffort: String? = null, + val projectId: String? = null, +) + +@Serializable +data class CreateTaskResponse( + val task: KanbanTask? = null, + val warning: String? = null, +) + +@Serializable +data class UpdateTaskBody( + val status: String? = null, + val assignee: String? = null, + val priority: Int? = null, + val title: String? = null, + val body: String? = null, + val result: String? = null, + val blockReason: String? = null, + val summary: String? = null, + val metadata: Map? = null, + val modelOverride: String? = null, + val providerOverride: String? = null, + val clearModelOverride: Boolean = false, + val reasoningEffort: String? = null, + val clearReasoningEffort: Boolean = false, +) + +@Serializable +data class UpdateTaskResponse( + val task: KanbanTask? = null, +) + +@Serializable +data class BulkTasksBody( + val ids: List, + val status: String? = null, + val assignee: String? = null, + val priority: Int? = null, + val archive: Boolean = false, + val result: String? = null, + val summary: String? = null, + val metadata: Map? = null, + val reclaimFirst: Boolean = false, + val modelOverride: String? = null, + val providerOverride: String? = null, + val clearModelOverride: Boolean = false, + val reasoningEffort: String? = null, + val clearReasoningEffort: Boolean = false, +) + +@Serializable +data class BulkTaskResult( + val id: String, + val ok: Boolean, + val error: String? = null, +) + +@Serializable +data class BulkTasksResponse( + val results: List = emptyList(), +) + +@Serializable +data class ReassignTaskBody( + val profile: String? = null, + val reclaimFirst: Boolean = true, + val reason: String? = null, +) + +@Serializable +data class ReassignTaskResponse( + val ok: Boolean = true, + val taskId: String, + val assignee: String? = null, +) + +@Serializable +data class ReclaimTaskBody( + val reason: String? = null, +) + +@Serializable +data class ReclaimTaskResponse( + val ok: Boolean = true, + val taskId: String, +) + +@Serializable +data class TaskEstimate( + val ok: Boolean, + val reason: String? = null, + val estTokens: Int? = null, + val complexity: String? = null, + val rationale: String? = null, + val model: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt index 94d32674..04ad2a34 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt @@ -22,6 +22,9 @@ object ApiClient { @Volatile private var service: HermesApiService? = null + @Volatile + private var kanbanService: KanbanApiService? = null + /** The current [HermesApiService] instance. Lazily created on first access. */ val hermesApi: HermesApiService get() { @@ -30,11 +33,20 @@ object ApiClient { } } + /** The current [KanbanApiService] instance. Lazily created on first access. */ + val kanbanApi: KanbanApiService + get() { + return kanbanService ?: synchronized(this) { + kanbanService ?: buildRetrofit().create(KanbanApiService::class.java).also { kanbanService = it } + } + } + /** Force-rebuild the Retrofit client (e.g. after settings change). */ fun rebuild() { synchronized(this) { retrofit = null service = null + kanbanService = null } } @@ -79,7 +91,15 @@ object ApiClient { // ── Internal ───────────────────────────────────────────────────────── - private fun buildService(): HermesApiService { + private fun buildService(): HermesApiService = + buildRetrofit().create(HermesApiService::class.java).also { + service = it + } + + private fun buildRetrofit(): Retrofit { + val cached = retrofit + if (cached != null) return cached + val logging = HttpLoggingInterceptor().apply { level = @@ -129,15 +149,12 @@ object ApiClient { .authenticator(TokenRefreshAuthenticator) .build() - val rf = - Retrofit - .Builder() - .baseUrl(AuthManager.endpointForBuild().baseUrl) - .client(okHttp) - .addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType())) - .build() - .also { retrofit = it } - - return rf.create(HermesApiService::class.java) + return Retrofit + .Builder() + .baseUrl(AuthManager.endpointForBuild().baseUrl) + .client(okHttp) + .addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType())) + .build() + .also { retrofit = it } } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt new file mode 100644 index 00000000..ca1a09ea --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt @@ -0,0 +1,195 @@ +package com.m57.hermescontrol.data.remote + +import com.m57.hermescontrol.data.model.AttachmentUploadResponse +import com.m57.hermescontrol.data.model.AutoDescribeResponse +import com.m57.hermescontrol.data.model.BulkTasksBody +import com.m57.hermescontrol.data.model.BulkTasksResponse +import com.m57.hermescontrol.data.model.CreateBoardBody +import com.m57.hermescontrol.data.model.CreateBoardResponse +import com.m57.hermescontrol.data.model.CreateTaskBody +import com.m57.hermescontrol.data.model.CreateTaskResponse +import com.m57.hermescontrol.data.model.DeleteBoardResponse +import com.m57.hermescontrol.data.model.DispatchResult +import com.m57.hermescontrol.data.model.KanbanBoardResponse +import com.m57.hermescontrol.data.model.KanbanBoardsResponse +import com.m57.hermescontrol.data.model.KanbanProfilesResponse +import com.m57.hermescontrol.data.model.KanbanProjectsResponse +import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse +import com.m57.hermescontrol.data.model.OrchestrationSettings +import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate +import com.m57.hermescontrol.data.model.ReassignTaskBody +import com.m57.hermescontrol.data.model.ReassignTaskResponse +import com.m57.hermescontrol.data.model.ReclaimTaskBody +import com.m57.hermescontrol.data.model.ReclaimTaskResponse +import com.m57.hermescontrol.data.model.RenameBoardBody +import com.m57.hermescontrol.data.model.RenameBoardResponse +import com.m57.hermescontrol.data.model.TaskEstimate +import com.m57.hermescontrol.data.model.UpdateTaskBody +import com.m57.hermescontrol.data.model.UpdateTaskResponse +import com.m57.hermescontrol.data.model.WorkerLog +import okhttp3.MultipartBody +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.Multipart +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query +import retrofit2.http.Streaming + +interface KanbanApiService { + @GET("api/plugins/kanban/boards") + suspend fun getBoards( + @Query("include_archived") includeArchived: Boolean = false, + ): Response + + @GET("api/plugins/kanban/board") + suspend fun getBoard( + @Query("board") board: String? = null, + @Query("include_archived") includeArchived: Boolean = false, + @Query("tenant") tenant: String? = null, + ): Response + + @POST("api/plugins/kanban/boards") + suspend fun createBoard( + @Body body: CreateBoardBody, + ): Response + + @PATCH("api/plugins/kanban/boards/{slug}") + suspend fun updateBoard( + @Path("slug") slug: String, + @Body body: RenameBoardBody, + ): Response + + @DELETE("api/plugins/kanban/boards/{slug}") + suspend fun deleteBoard( + @Path("slug") slug: String, + @Query("delete") delete: Boolean = false, + ): Response + + @POST("api/plugins/kanban/boards/{slug}/switch") + suspend fun switchBoard( + @Path("slug") slug: String, + ): Response + + @GET("api/plugins/kanban/tasks/{id}") + suspend fun getTask( + @Path("id") taskId: String, + @Query("board") board: String? = null, + ): Response + + @POST("api/plugins/kanban/tasks") + suspend fun createTask( + @Query("board") board: String? = null, + @Body body: CreateTaskBody, + ): Response + + @PATCH("api/plugins/kanban/tasks/{id}") + suspend fun updateTask( + @Path("id") taskId: String, + @Query("board") board: String? = null, + @Body body: UpdateTaskBody, + ): Response + + @DELETE("api/plugins/kanban/tasks/{id}") + suspend fun deleteTask( + @Path("id") taskId: String, + @Query("board") board: String? = null, + ): Response + + @POST("api/plugins/kanban/tasks/bulk") + suspend fun bulkTasks( + @Query("board") board: String? = null, + @Body body: BulkTasksBody, + ): Response + + @POST("api/plugins/kanban/tasks/{id}/comments") + suspend fun addComment( + @Path("id") taskId: String, + @Query("board") board: String? = null, + @Body body: Map, + ): Response + + @POST("api/plugins/kanban/tasks/{id}/reassign") + suspend fun reassignTask( + @Path("id") taskId: String, + @Query("board") board: String? = null, + @Body body: ReassignTaskBody, + ): Response + + @POST("api/plugins/kanban/tasks/{id}/reclaim") + suspend fun reclaimTask( + @Path("id") taskId: String, + @Query("board") board: String? = null, + @Body body: ReclaimTaskBody = ReclaimTaskBody(), + ): Response + + @GET("api/plugins/kanban/tasks/{id}/log") + suspend fun getTaskLog( + @Path("id") taskId: String, + @Query("tail") tail: Int = 16384, + @Query("board") board: String? = null, + ): Response + + @POST("api/plugins/kanban/tasks/{id}/estimate") + suspend fun estimateTask( + @Path("id") taskId: String, + @Query("board") board: String? = null, + ): Response + + @POST("api/plugins/kanban/estimate") + suspend fun estimateNew( + @Body body: Map, + ): Response + + @Multipart + @POST("api/plugins/kanban/tasks/{id}/attachments") + suspend fun uploadAttachment( + @Path("id") taskId: String, + @Query("board") board: String? = null, + @Part file: MultipartBody.Part, + ): Response + + @Streaming + @GET("api/plugins/kanban/attachments/{id}") + suspend fun downloadAttachment( + @Path("id") attachmentId: Long, + @Query("board") board: String? = null, + ): Response + + @GET("api/plugins/kanban/profiles") + suspend fun getProfiles(): Response + + @PATCH("api/plugins/kanban/profiles/{name}") + suspend fun updateProfileDescription( + @Path("name") name: String, + @Body body: Map, + ): Response + + @POST("api/plugins/kanban/profiles/{name}/describe-auto") + suspend fun autoDescribeProfile( + @Path("name") name: String, + @Body body: Map = mapOf("overwrite" to true), + ): Response + + @GET("api/plugins/kanban/orchestration") + suspend fun getOrchestration(): Response + + @PUT("api/plugins/kanban/orchestration") + suspend fun updateOrchestration( + @Body body: OrchestrationSettingsUpdate, + ): Response + + @GET("api/plugins/kanban/projects") + suspend fun getProjects(): Response + + @POST("api/plugins/kanban/dispatch") + suspend fun nudgeDispatcher( + @Query("board") board: String? = null, + ): Response +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt index cfb5b7b1..f0476e22 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt @@ -142,7 +142,7 @@ fun KanbanScreen( Tab( selected = board == state.selectedBoard, onClick = { viewModel.selectBoard(board) }, - text = { Text(board.name) }, + text = { Text(board.displayName) }, ) } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/KanbanSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/KanbanSerializationTest.kt new file mode 100644 index 00000000..cefde4ed --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/model/KanbanSerializationTest.kt @@ -0,0 +1,292 @@ +package com.m57.hermescontrol.data.model + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.encodeToString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class KanbanSerializationTest { + private val json = OkHttpProvider.json + + @Test + fun testBoardResponseSerialization() { + val raw = + """ + { + "columns": [ + { + "name": "todo", + "tasks": [ + { + "id": "t_101", + "title": "Build mobile Kanban", + "body": "Full desktop parity", + "status": "todo", + "assignee": "researcher", + "priority": 1, + "tenant": "mobile", + "created_at": 1718000000, + "latest_summary": "Initial draft ready", + "comment_count": 3, + "link_counts": { "parents": 1, "children": 2 }, + "progress": { "done": 1, "total": 2 }, + "warnings": { "count": 1, "highest_severity": "warning" }, + "started_at": 1718000100, + "worker_pid": 12345, + "last_heartbeat_at": 1718000200 + } + ] + } + ], + "assignees": ["researcher", "reviewer"], + "tenants": ["mobile"], + "latest_event_id": 42, + "now": 1718000500 + } + """.trimIndent() + + val decoded = json.decodeFromString(raw) + assertEquals(1, decoded.columns.size) + assertEquals("todo", decoded.columns[0].name) + val task = decoded.columns[0].tasks[0] + assertEquals("t_101", task.id) + assertEquals("Build mobile Kanban", task.title) + assertEquals("Full desktop parity", task.body) + assertEquals("Full desktop parity", task.description) + assertEquals("researcher", task.assignee) + assertEquals("researcher", task.assignedTo) + assertEquals(1, task.priority) + assertEquals(3, task.commentCount) + assertEquals(1, task.linkCounts?.parents) + assertEquals(2, task.linkCounts?.children) + assertEquals(1, task.progress?.done) + assertEquals(2, task.progress?.total) + assertEquals(1, task.warnings?.count) + assertEquals("warning", task.warnings?.highestSeverity) + assertEquals(12345, task.workerPid) + assertEquals(42L, decoded.latestEventId) + assertEquals(1718000500L, decoded.now) + } + + @Test + fun testTaskDetailResponseSerialization() { + val raw = + """ + { + "task": { + "id": "t_202", + "title": "Fix memory leak", + "body": "Detailed description", + "status": "running", + "assignee": "debugger", + "result": "Resolved leak", + "created_by": "m57", + "model_override": "gpt-4o", + "provider_override": "openai", + "reasoning_effort": "high", + "completed_at": 1718000900, + "last_failure_error": null, + "workspace_kind": "worktree", + "workspace_path": "/tmp/wt-1", + "branch_name": "feat/leak-fix", + "consecutive_failures": 0, + "diagnostics": [ + { + "kind": "stale_heartbeat", + "severity": "warning", + "title": "Worker heartbeat missing", + "detail": "Last ping was 3 minutes ago", + "count": 1, + "last_seen_at": 1718000850, + "actions": [ + { "kind": "reclaim", "label": "Reclaim task", "suggested": true } + ] + } + ] + }, + "comments": [ + { + "id": 1, + "author": "mobile", + "body": "Steering comment", + "created_at": 1718000800 + } + ], + "events": [ + { + "id": 10, + "kind": "status", + "payload": { "status": "running" }, + "created_at": 1718000700 + } + ], + "attachments": [ + { + "id": 5, + "task_id": "t_202", + "filename": "leak.png", + "content_type": "image/png", + "size": 1024, + "uploaded_by": "mobile", + "stored_path": "/files/leak.png", + "created_at": 1718000750 + } + ], + "links": { + "parents": ["t_100"], + "children": ["t_300"] + }, + "child_results": [ + { + "id": "t_300", + "title": "Child task", + "status": "done", + "latest_summary": "Finished child", + "result": "OK" + } + ], + "runs": [ + { + "id": 7, + "profile": "debugger", + "status": "done", + "outcome": "completed", + "summary": "Completed attempt 1", + "worker_pid": 9876, + "started_at": 1718000500, + "ended_at": 1718000900 + } + ] + } + """.trimIndent() + + val decoded = json.decodeFromString(raw) + assertEquals("t_202", decoded.task.id) + assertEquals("gpt-4o", decoded.task.modelOverride) + assertEquals("high", decoded.task.reasoningEffort) + assertEquals(1, decoded.task.diagnostics.size) + assertEquals("stale_heartbeat", decoded.task.diagnostics[0].kind) + assertTrue( + decoded.task.diagnostics[0] + .actions[0] + .suggested, + ) + assertEquals(1, decoded.comments.size) + assertEquals("Steering comment", decoded.comments[0].body) + assertEquals(1, decoded.events.size) + assertEquals("status", decoded.events[0].kind) + assertEquals(1, decoded.attachments?.size) + assertEquals("leak.png", decoded.attachments?.get(0)?.filename) + assertEquals(listOf("t_100"), decoded.links.parents) + assertEquals(listOf("t_300"), decoded.links.children) + assertEquals(1, decoded.childResults.size) + assertEquals(1, decoded.runs.size) + assertEquals(7L, decoded.runs[0].id) + assertEquals("completed", decoded.runs[0].outcome) + } + + @Test + fun testCreateAndUpdatePayloadSerialization() { + val createBody = + CreateTaskBody( + title = "New Task", + body = "Task description", + assignee = "default", + priority = 2, + parents = listOf("t_001"), + triage = true, + modelOverride = "claude-3-5-sonnet", + reasoningEffort = "none", + ) + val createJson = json.encodeToString(createBody) + assertTrue(createJson.contains("\"title\":\"New Task\"")) + assertTrue(createJson.contains("\"triage\":true")) + assertTrue(createJson.contains("\"parents\":[\"t_001\"]")) + assertTrue(createJson.contains("\"reasoning_effort\":\"none\"")) + + val updateBody = + UpdateTaskBody( + status = "blocked", + blockReason = "Waiting for credentials", + clearModelOverride = true, + clearReasoningEffort = true, + ) + val updateJson = json.encodeToString(updateBody) + assertTrue(updateJson.contains("\"status\":\"blocked\"")) + assertTrue(updateJson.contains("\"block_reason\":\"Waiting for credentials\"")) + assertTrue(updateJson.contains("\"clear_model_override\":true")) + assertTrue(updateJson.contains("\"clear_reasoning_effort\":true")) + } + + @Test + fun testBulkAndSettingsSerialization() { + val bulk = + BulkTasksBody( + ids = listOf("t_1", "t_2"), + status = "ready", + reclaimFirst = true, + ) + val bulkJson = json.encodeToString(bulk) + assertTrue(bulkJson.contains("\"ids\":[\"t_1\",\"t_2\"]")) + assertTrue(bulkJson.contains("\"reclaim_first\":true")) + + val orchestrationRaw = + """ + { + "orchestrator_profile": "lead", + "default_assignee": "coder", + "auto_decompose": true, + "auto_promote_children": false, + "resolved_orchestrator_profile": "lead", + "resolved_default_assignee": "coder", + "active_profile": "default" + } + """.trimIndent() + val orchestration = json.decodeFromString(orchestrationRaw) + assertEquals("lead", orchestration.orchestratorProfile) + assertEquals("coder", orchestration.defaultAssignee) + assertTrue(orchestration.autoDecompose) + assertFalse(orchestration.autoPromoteChildren) + } + + @Test + fun testWorkerLogAndEstimateSerialization() { + val logRaw = + """ + { + "task_id": "t_999", + "path": "/logs/worker.log", + "exists": true, + "size_bytes": 4096, + "content": "Running tests...\nAll passed!", + "truncated": false + } + """.trimIndent() + val log = json.decodeFromString(logRaw) + assertEquals("t_999", log.taskId) + assertTrue(log.exists) + assertEquals(4096L, log.sizeBytes) + assertEquals("Running tests...\nAll passed!", log.content) + assertFalse(log.truncated) + + val estRaw = + """ + { + "ok": true, + "est_tokens": 15000, + "complexity": "M", + "rationale": "Multi-file changes needed", + "model": "auxiliary" + } + """.trimIndent() + val estimate = json.decodeFromString(estRaw) + assertTrue(estimate.ok) + assertEquals(15000, estimate.estTokens) + assertEquals("M", estimate.complexity) + assertEquals("Multi-file changes needed", estimate.rationale) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/KanbanApiServiceTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/KanbanApiServiceTest.kt new file mode 100644 index 00000000..22f62c43 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/KanbanApiServiceTest.kt @@ -0,0 +1,311 @@ +package com.m57.hermescontrol.data.remote + +import com.m57.hermescontrol.data.model.BulkTasksBody +import com.m57.hermescontrol.data.model.CreateBoardBody +import com.m57.hermescontrol.data.model.CreateTaskBody +import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate +import com.m57.hermescontrol.data.model.ReassignTaskBody +import com.m57.hermescontrol.data.model.ReclaimTaskBody +import com.m57.hermescontrol.data.model.RenameBoardBody +import com.m57.hermescontrol.data.model.UpdateTaskBody +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory + +class KanbanApiServiceTest { + private lateinit var mockServer: MockWebServer + private lateinit var api: KanbanApiService + + @Before + fun setUp() { + mockServer = MockWebServer() + mockServer.start() + + api = + Retrofit + .Builder() + .baseUrl(mockServer.url("/")) + .addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType())) + .build() + .create(KanbanApiService::class.java) + } + + @After + fun tearDown() { + mockServer.shutdown() + } + + @Test + fun testGetBoards() = + runBlocking { + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """ + { + "boards": [ + { "slug": "default", "name": "Default Board", "total": 5, "is_current": true } + ], + "current": "default" + } + """.trimIndent(), + ), + ) + + val response = api.getBoards(includeArchived = true) + assertTrue(response.isSuccessful) + val body = response.body() + assertNotNull(body) + assertEquals(1, body?.boards?.size) + assertEquals("default", body?.boards?.get(0)?.id) + assertEquals("Default Board", body?.boards?.get(0)?.name) + assertEquals("default", body?.current) + + val request = mockServer.takeRequest() + assertEquals("GET", request.method) + assertEquals("/api/plugins/kanban/boards?include_archived=true", request.path) + } + + @Test + fun testGetBoardScoped() = + runBlocking { + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """ + { + "columns": [ + { "name": "ready", "tasks": [] } + ], + "assignees": ["lead"], + "tenants": [], + "latest_event_id": 99, + "now": 1718000000 + } + """.trimIndent(), + ), + ) + + val response = api.getBoard(board = "dev", includeArchived = false, tenant = "team-a") + assertTrue(response.isSuccessful) + val body = response.body() + assertNotNull(body) + assertEquals(1, body?.columns?.size) + assertEquals("ready", body?.columns?.get(0)?.name) + + val request = mockServer.takeRequest() + assertEquals("GET", request.method) + assertTrue(request.path?.contains("board=dev") == true) + assertTrue(request.path?.contains("tenant=team-a") == true) + } + + @Test + fun testCreateAndPatchTask() = + runBlocking { + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """ + { + "task": { + "id": "t_new", + "title": "Implement feature", + "status": "triage" + }, + "warning": "Dispatcher not running" + } + """.trimIndent(), + ), + ) + + val createRes = + api.createTask( + board = "dev", + body = + CreateTaskBody( + title = "Implement feature", + triage = true, + ), + ) + assertTrue(createRes.isSuccessful) + assertEquals("t_new", createRes.body()?.task?.id) + assertEquals("Dispatcher not running", createRes.body()?.warning) + + val createReq = mockServer.takeRequest() + assertEquals("POST", createReq.method) + assertEquals("/api/plugins/kanban/tasks?board=dev", createReq.path) + assertTrue(createReq.body.readUtf8().contains("\"triage\":true")) + + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """ + { + "task": { + "id": "t_new", + "title": "Implement feature", + "status": "ready" + } + } + """.trimIndent(), + ), + ) + + val patchRes = + api.updateTask( + taskId = "t_new", + board = "dev", + body = + UpdateTaskBody( + status = "ready", + ), + ) + assertTrue(patchRes.isSuccessful) + assertEquals("ready", patchRes.body()?.task?.status) + + val patchReq = mockServer.takeRequest() + assertEquals("PATCH", patchReq.method) + assertEquals("/api/plugins/kanban/tasks/t_new?board=dev", patchReq.path) + } + + @Test + fun testBulkTasks() = + runBlocking { + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """ + { + "results": [ + { "id": "t_1", "ok": true }, + { "id": "t_2", "ok": false, "error": "transition refused" } + ] + } + """.trimIndent(), + ), + ) + + val res = + api.bulkTasks( + board = "dev", + body = + BulkTasksBody( + ids = listOf("t_1", "t_2"), + status = "done", + ), + ) + assertTrue(res.isSuccessful) + val results = res.body()?.results + assertEquals(2, results?.size) + assertTrue(results?.get(0)?.ok == true) + assertEquals("transition refused", results?.get(1)?.error) + + val req = mockServer.takeRequest() + assertEquals("POST", req.method) + assertEquals("/api/plugins/kanban/tasks/bulk?board=dev", req.path) + } + + @Test + fun testReassignAndReclaim() = + runBlocking { + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{ "ok": true, "task_id": "t_1", "assignee": "reviewer" }"""), + ) + + val reassignRes = + api.reassignTask( + taskId = "t_1", + board = "dev", + body = ReassignTaskBody(profile = "reviewer", reclaimFirst = true), + ) + assertTrue(reassignRes.isSuccessful) + assertEquals("reviewer", reassignRes.body()?.assignee) + + val reassignReq = mockServer.takeRequest() + assertEquals("POST", reassignReq.method) + assertEquals("/api/plugins/kanban/tasks/t_1/reassign?board=dev", reassignReq.path) + + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{ "ok": true, "task_id": "t_1" }"""), + ) + + val reclaimRes = + api.reclaimTask( + taskId = "t_1", + board = "dev", + body = ReclaimTaskBody(reason = "Worker unresponsive"), + ) + assertTrue(reclaimRes.isSuccessful) + + val reclaimReq = mockServer.takeRequest() + assertEquals("POST", reclaimReq.method) + assertEquals("/api/plugins/kanban/tasks/t_1/reclaim?board=dev", reclaimReq.path) + } + + @Test + fun testOrchestrationAndDispatchNudge() = + runBlocking { + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody( + """ + { + "orchestrator_profile": "boss", + "default_assignee": "worker", + "auto_decompose": true, + "auto_promote_children": true, + "resolved_orchestrator_profile": "boss", + "resolved_default_assignee": "worker", + "active_profile": "default" + } + """.trimIndent(), + ), + ) + + val updateRes = + api.updateOrchestration( + OrchestrationSettingsUpdate( + orchestratorProfile = "boss", + defaultAssignee = "worker", + ), + ) + assertTrue(updateRes.isSuccessful) + assertEquals("boss", updateRes.body()?.orchestratorProfile) + + val putReq = mockServer.takeRequest() + assertEquals("PUT", putReq.method) + assertEquals("/api/plugins/kanban/orchestration", putReq.path) + + mockServer.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{ "spawned": [] }"""), + ) + + val nudgeRes = api.nudgeDispatcher(board = "dev") + assertTrue(nudgeRes.isSuccessful) + + val nudgeReq = mockServer.takeRequest() + assertEquals("POST", nudgeReq.method) + assertEquals("/api/plugins/kanban/dispatch?board=dev", nudgeReq.path) + } +}