feat(kanban): add typed models and KanbanApiService for desktop parity

This commit is contained in:
M57 committed 2026-09-14 22:12:30 +03:00
1 parent 7520008ae3
commit a486191d9d
10 files changed
+1307 -56

No files matched your search

@@ -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<KanbanTask>,
)
@Serializable
data class KanbanBoardResponse(
val columns: List<KanbanColumn>,
val assignees: List<String>? = null,
val tenants: List<String>? = null,
)
@Serializable
data class KanbanBoardsResponse(
val boards: List<KanbanBoard>,
val current: String? = null,
)
@@ -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,
)
@@ -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<String, Int> = 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<KanbanTask> = emptyList(),
)
@Serializable
data class KanbanBoardResponse(
val columns: List<KanbanColumn> = emptyList(),
val assignees: List<String>? = null,
val tenants: List<String>? = null,
val latestEventId: Long = 0L,
val now: Long = 0L,
)
@Serializable
data class KanbanBoardsResponse(
val boards: List<KanbanBoard> = 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<KanbanProject> = 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,
)
@@ -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<KanbanProfile> = 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<String, Int> = emptyMap(),
val warnings: List<String> = emptyList(),
val current: String? = null,
)
@Serializable
data class DispatchResult(
val spawned: List<JsonElement> = emptyList(),
)
@@ -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<String, JsonElement> = emptyMap(),
val suggested: Boolean = false,
)
@Serializable
data class TaskDiagnostic(
val kind: String,
val severity: String,
val title: String,
val detail: String,
val actions: List<TaskDiagnosticAction> = emptyList(),
val count: Int = 1,
val lastSeenAt: Long = 0L,
val data: Map<String, JsonElement> = emptyMap(),
)
@Serializable
data class TaskLinks(
val parents: List<String> = emptyList(),
val children: List<String> = 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<TaskDiagnostic> = emptyList(),
) {
val description: String? get() = body
val assignedTo: String? get() = assignee
}
@Serializable
data class KanbanTaskDetailResponse(
val task: KanbanTaskFull,
val comments: List<KanbanComment> = emptyList(),
val events: List<KanbanDetailEvent> = emptyList(),
val attachments: List<KanbanAttachment>? = null,
val links: TaskLinks = TaskLinks(),
val childResults: List<ChildTaskResult> = emptyList(),
val runs: List<KanbanRun> = 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<String> = emptyList(),
val triage: Boolean = false,
val maxRuntimeSeconds: Int? = null,
val skills: List<String>? = 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<String, JsonElement>? = 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<String>,
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<String, JsonElement>? = 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<BulkTaskResult> = 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,
)
@@ -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 }
}
}
@@ -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<KanbanBoardsResponse>
@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<KanbanBoardResponse>
@POST("api/plugins/kanban/boards")
suspend fun createBoard(
@Body body: CreateBoardBody,
): Response<CreateBoardResponse>
@PATCH("api/plugins/kanban/boards/{slug}")
suspend fun updateBoard(
@Path("slug") slug: String,
@Body body: RenameBoardBody,
): Response<RenameBoardResponse>
@DELETE("api/plugins/kanban/boards/{slug}")
suspend fun deleteBoard(
@Path("slug") slug: String,
@Query("delete") delete: Boolean = false,
): Response<DeleteBoardResponse>
@POST("api/plugins/kanban/boards/{slug}/switch")
suspend fun switchBoard(
@Path("slug") slug: String,
): Response<Unit>
@GET("api/plugins/kanban/tasks/{id}")
suspend fun getTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
): Response<KanbanTaskDetailResponse>
@POST("api/plugins/kanban/tasks")
suspend fun createTask(
@Query("board") board: String? = null,
@Body body: CreateTaskBody,
): Response<CreateTaskResponse>
@PATCH("api/plugins/kanban/tasks/{id}")
suspend fun updateTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
@Body body: UpdateTaskBody,
): Response<UpdateTaskResponse>
@DELETE("api/plugins/kanban/tasks/{id}")
suspend fun deleteTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
): Response<Unit>
@POST("api/plugins/kanban/tasks/bulk")
suspend fun bulkTasks(
@Query("board") board: String? = null,
@Body body: BulkTasksBody,
): Response<BulkTasksResponse>
@POST("api/plugins/kanban/tasks/{id}/comments")
suspend fun addComment(
@Path("id") taskId: String,
@Query("board") board: String? = null,
@Body body: Map<String, String>,
): Response<Unit>
@POST("api/plugins/kanban/tasks/{id}/reassign")
suspend fun reassignTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
@Body body: ReassignTaskBody,
): Response<ReassignTaskResponse>
@POST("api/plugins/kanban/tasks/{id}/reclaim")
suspend fun reclaimTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
@Body body: ReclaimTaskBody = ReclaimTaskBody(),
): Response<ReclaimTaskResponse>
@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<WorkerLog>
@POST("api/plugins/kanban/tasks/{id}/estimate")
suspend fun estimateTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
): Response<TaskEstimate>
@POST("api/plugins/kanban/estimate")
suspend fun estimateNew(
@Body body: Map<String, String>,
): Response<TaskEstimate>
@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<AttachmentUploadResponse>
@Streaming
@GET("api/plugins/kanban/attachments/{id}")
suspend fun downloadAttachment(
@Path("id") attachmentId: Long,
@Query("board") board: String? = null,
): Response<ResponseBody>
@GET("api/plugins/kanban/profiles")
suspend fun getProfiles(): Response<KanbanProfilesResponse>
@PATCH("api/plugins/kanban/profiles/{name}")
suspend fun updateProfileDescription(
@Path("name") name: String,
@Body body: Map<String, String>,
): Response<Unit>
@POST("api/plugins/kanban/profiles/{name}/describe-auto")
suspend fun autoDescribeProfile(
@Path("name") name: String,
@Body body: Map<String, Boolean> = mapOf("overwrite" to true),
): Response<AutoDescribeResponse>
@GET("api/plugins/kanban/orchestration")
suspend fun getOrchestration(): Response<OrchestrationSettings>
@PUT("api/plugins/kanban/orchestration")
suspend fun updateOrchestration(
@Body body: OrchestrationSettingsUpdate,
): Response<OrchestrationSettings>
@GET("api/plugins/kanban/projects")
suspend fun getProjects(): Response<KanbanProjectsResponse>
@POST("api/plugins/kanban/dispatch")
suspend fun nudgeDispatcher(
@Query("board") board: String? = null,
): Response<DispatchResult>
}
@@ -142,7 +142,7 @@ fun KanbanScreen(
Tab(
selected = board == state.selectedBoard,
onClick = { viewModel.selectBoard(board) },
text = { Text(board.name) },
text = { Text(board.displayName) },
)
}
}
@@ -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<KanbanBoardResponse>(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<KanbanTaskDetailResponse>(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<OrchestrationSettings>(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<WorkerLog>(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<TaskEstimate>(estRaw)
assertTrue(estimate.ok)
assertEquals(15000, estimate.estTokens)
assertEquals("M", estimate.complexity)
assertEquals("Multi-file changes needed", estimate.rationale)
}
}
@@ -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)
}
}