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) },
)
}
}