feat(kanban): add typed models and KanbanApiService for desktop parity
This commit is contained in:
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
|
@Volatile
|
||||||
private var service: HermesApiService? = null
|
private var service: HermesApiService? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var kanbanService: KanbanApiService? = null
|
||||||
|
|
||||||
/** The current [HermesApiService] instance. Lazily created on first access. */
|
/** The current [HermesApiService] instance. Lazily created on first access. */
|
||||||
val hermesApi: HermesApiService
|
val hermesApi: HermesApiService
|
||||||
get() {
|
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). */
|
/** Force-rebuild the Retrofit client (e.g. after settings change). */
|
||||||
fun rebuild() {
|
fun rebuild() {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
retrofit = null
|
retrofit = null
|
||||||
service = null
|
service = null
|
||||||
|
kanbanService = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +91,15 @@ object ApiClient {
|
|||||||
|
|
||||||
// ── Internal ─────────────────────────────────────────────────────────
|
// ── 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 =
|
val logging =
|
||||||
HttpLoggingInterceptor().apply {
|
HttpLoggingInterceptor().apply {
|
||||||
level =
|
level =
|
||||||
@@ -129,15 +149,12 @@ object ApiClient {
|
|||||||
.authenticator(TokenRefreshAuthenticator)
|
.authenticator(TokenRefreshAuthenticator)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val rf =
|
return Retrofit
|
||||||
Retrofit
|
.Builder()
|
||||||
.Builder()
|
.baseUrl(AuthManager.endpointForBuild().baseUrl)
|
||||||
.baseUrl(AuthManager.endpointForBuild().baseUrl)
|
.client(okHttp)
|
||||||
.client(okHttp)
|
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
|
||||||
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
|
.build()
|
||||||
.build()
|
.also { retrofit = it }
|
||||||
.also { retrofit = it }
|
|
||||||
|
|
||||||
return rf.create(HermesApiService::class.java)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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(
|
Tab(
|
||||||
selected = board == state.selectedBoard,
|
selected = board == state.selectedBoard,
|
||||||
onClick = { viewModel.selectBoard(board) },
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user