From 42e0226211d03495d651fc4f49566e014b49a846 Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Wed, 22 Apr 2026 17:14:50 -0400 Subject: [PATCH 1/2] Add desktop queue sidecar Amp-Thread-ID: https://ampcode.com/threads/T-019db6e8-3b0c-7085-b3b0-fd1807ac2c4a Co-authored-by: Amp --- .gitignore | 4 + README.md | 26 + desktop-sidecar/README.md | 36 ++ desktop-sidecar/build.gradle.kts | 40 ++ desktop-sidecar/gradlew | 5 + desktop-sidecar/gradlew.bat | 3 + desktop-sidecar/settings.gradle.kts | 1 + .../com/block/agenttaskqueue/sidecar/Main.kt | 523 ++++++++++++++++++ .../agenttaskqueue/sidecar/QueueSnapshot.kt | 141 +++++ .../sidecar/TaskQueueDatabase.kt | 69 +++ 10 files changed, 848 insertions(+) create mode 100644 desktop-sidecar/README.md create mode 100644 desktop-sidecar/build.gradle.kts create mode 100755 desktop-sidecar/gradlew create mode 100644 desktop-sidecar/gradlew.bat create mode 100644 desktop-sidecar/settings.gradle.kts create mode 100644 desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/Main.kt create mode 100644 desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshot.kt create mode 100644 desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabase.kt diff --git a/.gitignore b/.gitignore index f1a306c..7c410b1 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,7 @@ thoughts/ # IntelliJ Plugin intellij-plugin/build/ intellij-plugin/.gradle/ + +# Desktop sidecar +desktop-sidecar/build/ +desktop-sidecar/.gradle/ diff --git a/README.md b/README.md index cb19463..f02b175 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,32 @@ With the queue: - **Zombie Protection**: Detects dead processes, kills orphans, clears stale locks - **Auto-Kill**: Tasks running > 120 minutes are terminated +## Desktop Sidecar + +The repo also includes a minimal Compose Multiplatform desktop app in [desktop-sidecar](desktop-sidecar/README.md) for watching the queue in real time. + +It reads the same local SQLite database as `tq` and the IntelliJ plugin, then shows: + +- running tasks +- waiting tasks +- exact queues grouped by their root scope (for example `gradle/build` and `gradle/emulator-5554` under `gradle`) + +Run it with: + +```bash +cd desktop-sidecar +./gradlew run +``` + +Or point it at a different queue data directory: + +```bash +cd desktop-sidecar +./gradlew run --args="--data-dir /path/to/agent-task-queue" +``` + +The sidecar defaults to `$TASK_QUEUE_DATA_DIR` or `/tmp/agent-task-queue`. It visualizes live occupancy from `queue.db`; configured `--queue-capacity` limits are process-local and are not stored in SQLite, so the UI shows live tasks and queue topology rather than persisted capacity settings. + ## Installation ```bash diff --git a/desktop-sidecar/README.md b/desktop-sidecar/README.md new file mode 100644 index 0000000..aeb420b --- /dev/null +++ b/desktop-sidecar/README.md @@ -0,0 +1,36 @@ +# Agent Task Queue Desktop Sidecar + +Minimal Compose Multiplatform desktop app for watching the local `agent-task-queue` database in real time. + +The sidecar reads the existing SQLite queue DB directly and shows: + +- running tasks +- waiting tasks +- exact queues grouped by root scope so hierarchical queue activity is easier to understand + +It is intentionally read-only. There is no new MCP protocol or server surface. + +## Run + +```bash +./gradlew run +``` + +By default the app reads `$TASK_QUEUE_DATA_DIR` or `/tmp/agent-task-queue`. + +Use a specific queue directory with: + +```bash +./gradlew run --args="--data-dir /path/to/agent-task-queue" +``` + +## Package + +```bash +./gradlew packageDistributionForCurrentOS +``` + +## Notes + +- `./gradlew` in this directory delegates to the checked-in Gradle wrapper under `../intellij-plugin/` so the sidecar stays lightweight. +- Queue capacities configured with `--queue-capacity` are process-local and are not persisted in `queue.db`, so the app visualizes live tasks and queue layout rather than stored capacity numbers. diff --git a/desktop-sidecar/build.gradle.kts b/desktop-sidecar/build.gradle.kts new file mode 100644 index 0000000..8664fc4 --- /dev/null +++ b/desktop-sidecar/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + kotlin("multiplatform") version "2.3.20" + kotlin("plugin.compose") version "2.3.20" + id("org.jetbrains.compose") version "1.10.3" +} + +repositories { + mavenCentral() + google() +} + +kotlin { + jvm("desktop") + jvmToolchain(21) + + sourceSets { + val desktopMain by getting { + dependencies { + implementation(compose.desktop.currentOs) + implementation(compose.foundation) + implementation(compose.material3) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + implementation("org.xerial:sqlite-jdbc:3.53.0.0") + } + } + } +} + +compose.desktop { + application { + mainClass = "com.block.agenttaskqueue.sidecar.MainKt" + + nativeDistributions { + targetFormats(org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg) + packageName = "AgentTaskQueueSidecar" + packageVersion = "1.0.0" + description = "Desktop sidecar for visualizing agent-task-queue state" + } + } +} diff --git a/desktop-sidecar/gradlew b/desktop-sidecar/gradlew new file mode 100755 index 0000000..d7b8389 --- /dev/null +++ b/desktop-sidecar/gradlew @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) +exec "$SCRIPT_DIR/../intellij-plugin/gradlew" -p "$SCRIPT_DIR" "$@" diff --git a/desktop-sidecar/gradlew.bat b/desktop-sidecar/gradlew.bat new file mode 100644 index 0000000..7bcdf78 --- /dev/null +++ b/desktop-sidecar/gradlew.bat @@ -0,0 +1,3 @@ +@echo off +set SCRIPT_DIR=%~dp0 +call "%SCRIPT_DIR%..\intellij-plugin\gradlew.bat" -p "%SCRIPT_DIR%" %* diff --git a/desktop-sidecar/settings.gradle.kts b/desktop-sidecar/settings.gradle.kts new file mode 100644 index 0000000..1590c91 --- /dev/null +++ b/desktop-sidecar/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "agent-task-queue-sidecar" diff --git a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/Main.kt b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/Main.kt new file mode 100644 index 0000000..01513dd --- /dev/null +++ b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/Main.kt @@ -0,0 +1,523 @@ +package com.block.agenttaskqueue.sidecar + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.system.exitProcess + +private const val ACTIVE_INTERVAL_MS = 1000L +private const val IDLE_INTERVAL_MS = 3000L + +private val DashboardColors = lightColorScheme( + primary = Color(0xFF305B78), + secondary = Color(0xFFB35C33), + tertiary = Color(0xFF46705C), + background = Color(0xFFF7F1E8), + surface = Color(0xFFFFFCF8), + surfaceVariant = Color(0xFFE9DFCf), + onBackground = Color(0xFF1F262D), + onSurface = Color(0xFF1F262D), + outline = Color(0xFF877F74), + error = Color(0xFF8D2C2C), +) + +fun main(args: Array) = application { + val dataDir = resolveDataDir(args) + + Window( + onCloseRequest = ::exitApplication, + title = "Agent Task Queue Sidecar", + state = rememberWindowState(width = 1320.dp, height = 900.dp), + ) { + MaterialTheme(colorScheme = DashboardColors) { + QueueDashboard(dataDir = dataDir) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun QueueDashboard(dataDir: Path) { + val refreshRequests = remember(dataDir) { Channel(Channel.CONFLATED) } + var snapshot by remember(dataDir) { + mutableStateOf(QueueSnapshot.empty(dataDir, statusMessage = "Loading queue state...")) + } + + LaunchedEffect(dataDir) { + while (true) { + snapshot = withContext(Dispatchers.IO) { TaskQueueDatabase.loadSnapshot(dataDir) } + val interval = if (snapshot.tasks.isNotEmpty()) ACTIVE_INTERVAL_MS else IDLE_INTERVAL_MS + withTimeoutOrNull(interval) { + refreshRequests.receive() + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Agent Task Queue", fontWeight = FontWeight.SemiBold) + Text( + text = snapshot.dataDir.toString(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + actions = { + Text( + text = "Updated ${formatRefreshTime(snapshot.refreshedAt)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + Spacer(Modifier.width(12.dp)) + Button(onClick = { refreshRequests.trySend(Unit) }) { + Text("Refresh") + } + Spacer(Modifier.width(16.dp)) + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + listOf( + MaterialTheme.colorScheme.background, + Color(0xFFF3ECE2), + ) + ) + ) + .verticalScroll(rememberScrollState()) + .padding(innerPadding) + .padding(horizontal = 24.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + SummaryRow(snapshot) + ScopeOverview(snapshot.scopeGroups) + + snapshot.errorMessage?.let { ErrorBanner(it) } + snapshot.statusMessage?.let { InfoBanner(it) } + + TaskSection( + title = "Running Now", + subtitle = "Tasks currently holding queue slots.", + tasks = snapshot.runningTasks, + emptyLabel = "No running tasks.", + ) + + TaskSection( + title = "Queued / Waiting", + subtitle = "Tasks blocked behind older work in their exact queue.", + tasks = snapshot.waitingTasks, + emptyLabel = "No waiting tasks.", + ) + + ScopeDetails(snapshot.scopeGroups) + + Text( + text = "Live view from queue.db. Queue capacities set with --queue-capacity are process-local and not persisted in SQLite.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), + ) + } + } +} + +@Composable +private fun SummaryRow(snapshot: QueueSnapshot) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + SummaryCard( + title = "Running", + value = snapshot.summary.running.toString(), + caption = "Active commands", + accent = Color(0xFFD06A3A), + modifier = Modifier.weight(1f), + ) + SummaryCard( + title = "Waiting", + value = snapshot.summary.waiting.toString(), + caption = "Queued tasks", + accent = Color(0xFF3D7EA6), + modifier = Modifier.weight(1f), + ) + SummaryCard( + title = "Exact Queues", + value = snapshot.queueLanes.size.toString(), + caption = "Distinct queue_name values", + accent = Color(0xFF5B8A67), + modifier = Modifier.weight(1f), + ) + SummaryCard( + title = "Root Scopes", + value = snapshot.scopeGroups.size.toString(), + caption = "Top-level queue groups", + accent = Color(0xFF8B5F8C), + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun SummaryCard( + title: String, + value: String, + caption: String, + accent: Color, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(999.dp)) + .background(accent.copy(alpha = 0.16f)) + .padding(horizontal = 10.dp, vertical = 5.dp), + ) { + Text(title, color = accent, style = MaterialTheme.typography.labelLarge) + } + Text(value, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text( + caption, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + } + } +} + +@Composable +private fun ScopeOverview(scopeGroups: List) { + if (scopeGroups.isEmpty()) { + return + } + + SectionCard(title = "Scope Activity", subtitle = "Each card rolls up descendant exact queues under a shared root scope.") { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + scopeGroups.forEach { scope -> + Card( + modifier = Modifier.widthIn(min = 220.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFFF7F0E4)), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(scope.scopeName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text( + text = "${scope.runningCount} running · ${scope.waitingCount} waiting", + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = "${scope.lanes.size} exact queues · ${scope.taskCount} total tasks", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.68f), + ) + } + } + } + } + } +} + +@Composable +private fun TaskSection( + title: String, + subtitle: String, + tasks: List, + emptyLabel: String, +) { + SectionCard(title = title, subtitle = subtitle) { + if (tasks.isEmpty()) { + Text(emptyLabel, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f)) + return@SectionCard + } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + tasks.forEach { task -> + TaskRow(task = task, showQueue = true) + } + } + } +} + +@Composable +private fun ScopeDetails(scopeGroups: List) { + SectionCard( + title = "Queues By Scope", + subtitle = "Exact queues stay FIFO; grouping them here makes hierarchical queue families easier to scan.", + ) { + if (scopeGroups.isEmpty()) { + Text("No active queues.", color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f)) + return@SectionCard + } + + Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { + scopeGroups.forEach { scope -> + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(scope.scopeName, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + scope.lanes.forEach { lane -> + QueueLaneCard(lane) + } + } + } + } + } +} + +@Composable +private fun QueueLaneCard(lane: QueueLane) { + Card(colors = CardDefaults.cardColors(containerColor = Color(0xFFFFFBF5))) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(lane.queueName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text( + text = "${lane.runningCount} running · ${lane.waitingCount} waiting · ${lane.tasks.size} task(s)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + } + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + lane.tasks.forEach { task -> + TaskRow(task = task, showQueue = false) + } + } + } + } +} + +@Composable +private fun TaskRow(task: QueueTask, showQueue: Boolean) { + val accent = if (task.status.equals("running", ignoreCase = true)) { + Color(0xFFD06A3A) + } else { + Color(0xFF3D7EA6) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 1.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, accent.copy(alpha = 0.18f), RoundedCornerShape(18.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + StatusBadge(task.status, accent) + Text("#${task.id}", fontWeight = FontWeight.Medium) + if (showQueue) { + Text( + task.queueName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + } + } + Text( + task.statusAge(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + } + + Text( + text = task.displayCommand, + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + val processLine = buildString { + task.pid?.let { append("server pid $it") } + task.childPid?.let { + if (isNotEmpty()) append(" · ") + append("child pid $it") + } + } + if (processLine.isNotEmpty()) { + Text( + processLine, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + } + } + } +} + +@Composable +private fun StatusBadge(text: String, accent: Color) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(999.dp)) + .background(accent.copy(alpha = 0.16f)) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) { + Text( + text = text.uppercase(), + color = accent, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +private fun SectionCard( + title: String, + subtitle: String, + content: @Composable ColumnScope.() -> Unit, +) { + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { + Column( + modifier = Modifier.fillMaxWidth().padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + content = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) + Text( + subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + } + content() + }, + ) + } +} + +@Composable +private fun ErrorBanner(message: String) { + Banner(message = message, background = Color(0xFFFBE4E3), foreground = MaterialTheme.colorScheme.error) +} + +@Composable +private fun InfoBanner(message: String) { + Banner(message = message, background = Color(0xFFEAF1F6), foreground = MaterialTheme.colorScheme.primary) +} + +@Composable +private fun Banner(message: String, background: Color, foreground: Color) { + Surface(shape = RoundedCornerShape(16.dp), color = background) { + Text( + text = message, + modifier = Modifier.fillMaxWidth().padding(14.dp), + color = foreground, + ) + } +} + +private fun resolveDataDir(args: Array): Path { + if (args.any { it == "-h" || it == "--help" }) { + println("Usage: ./gradlew run --args=\"[--data-dir PATH]\"") + exitProcess(0) + } + + var configuredPath: String? = null + var index = 0 + while (index < args.size) { + val arg = args[index] + when { + arg.startsWith("--data-dir=") -> configuredPath = arg.substringAfter("=") + arg == "--data-dir" && index + 1 < args.size -> { + configuredPath = args[index + 1] + index += 1 + } + } + index += 1 + } + + val fallback = System.getenv("TASK_QUEUE_DATA_DIR")?.takeIf { it.isNotBlank() } + ?: "/tmp/agent-task-queue" + return Paths.get(configuredPath ?: fallback) +} diff --git a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshot.kt b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshot.kt new file mode 100644 index 0000000..9413ca0 --- /dev/null +++ b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshot.kt @@ -0,0 +1,141 @@ +package com.block.agenttaskqueue.sidecar + +import java.nio.file.Path +import java.time.Duration +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset + +data class QueueTask( + val id: Int, + val queueName: String, + val status: String, + val command: String?, + val pid: Int?, + val childPid: Int?, + val createdAt: String?, + val updatedAt: String?, +) { + val displayCommand: String + get() = (command ?: "unknown").replace(Regex("^(\\w+=\\S+\\s+)+"), "") + + fun statusAge(now: Instant = Instant.now()): String { + val reference = when (status.lowercase()) { + "running" -> parseQueueInstant(updatedAt) ?: parseQueueInstant(createdAt) + else -> parseQueueInstant(createdAt) + } ?: return "time unknown" + + val prefix = if (status.equals("running", ignoreCase = true)) "running" else "queued" + return "$prefix ${relativeDuration(now, reference)}" + } +} + +data class QueueSummary( + val total: Int, + val running: Int, + val waiting: Int, +) { + companion object { + fun fromTasks(tasks: List): QueueSummary { + return QueueSummary( + total = tasks.size, + running = tasks.count { it.status.equals("running", ignoreCase = true) }, + waiting = tasks.count { it.status.equals("waiting", ignoreCase = true) }, + ) + } + } +} + +data class QueueLane( + val queueName: String, + val tasks: List, +) { + val runningCount: Int = tasks.count { it.status.equals("running", ignoreCase = true) } + val waitingCount: Int = tasks.count { it.status.equals("waiting", ignoreCase = true) } +} + +data class ScopeGroup( + val scopeName: String, + val lanes: List, +) { + val taskCount: Int = lanes.sumOf { it.tasks.size } + val runningCount: Int = lanes.sumOf { it.runningCount } + val waitingCount: Int = lanes.sumOf { it.waitingCount } +} + +data class QueueSnapshot( + val dataDir: Path, + val tasks: List, + val refreshedAt: Instant, + val statusMessage: String? = null, + val errorMessage: String? = null, +) { + val summary: QueueSummary = QueueSummary.fromTasks(tasks) + val runningTasks: List = tasks.filter { it.status.equals("running", ignoreCase = true) } + val waitingTasks: List = tasks.filter { it.status.equals("waiting", ignoreCase = true) } + val queueLanes: List = tasks + .groupBy { it.queueName } + .toSortedMap() + .map { (queueName, queuedTasks) -> QueueLane(queueName, queuedTasks.sortedBy { it.id }) } + val scopeGroups: List = queueLanes + .groupBy { rootScope(it.queueName) } + .toSortedMap() + .map { (scopeName, lanes) -> ScopeGroup(scopeName, lanes) } + + companion object { + fun empty( + dataDir: Path, + statusMessage: String? = null, + errorMessage: String? = null, + ): QueueSnapshot { + return QueueSnapshot( + dataDir = dataDir, + tasks = emptyList(), + refreshedAt = Instant.now(), + statusMessage = statusMessage, + errorMessage = errorMessage, + ) + } + + fun fromTasks( + dataDir: Path, + tasks: List, + statusMessage: String? = null, + ): QueueSnapshot { + return QueueSnapshot( + dataDir = dataDir, + tasks = tasks.sortedWith(compareBy({ it.queueName }, { it.id })), + refreshedAt = Instant.now(), + statusMessage = statusMessage, + ) + } + } +} + +fun parseQueueInstant(raw: String?): Instant? { + if (raw.isNullOrBlank()) { + return null + } + + val normalized = raw.replace(" ", "T") + return runCatching { + LocalDateTime.parse(normalized).toInstant(ZoneOffset.UTC) + }.getOrNull() +} + +fun formatRefreshTime(instant: Instant): String { + val localTime = instant.atZone(java.time.ZoneId.systemDefault()).toLocalTime() + return localTime.truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString() +} + +private fun rootScope(queueName: String): String = queueName.substringBefore('/') + +private fun relativeDuration(now: Instant, then: Instant): String { + val elapsed = Duration.between(then, now).seconds.coerceAtLeast(0) + return when { + elapsed < 60 -> "${elapsed}s" + elapsed < 3600 -> "${elapsed / 60}m" + elapsed < 86_400 -> "${elapsed / 3600}h ${elapsed % 3600 / 60}m" + else -> "${elapsed / 86_400}d ${elapsed % 86_400 / 3600}h" + } +} diff --git a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabase.kt b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabase.kt new file mode 100644 index 0000000..b5e475c --- /dev/null +++ b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/TaskQueueDatabase.kt @@ -0,0 +1,69 @@ +package com.block.agenttaskqueue.sidecar + +import java.nio.file.Path +import java.sql.DriverManager +import java.sql.ResultSet + +object TaskQueueDatabase { + init { + Class.forName("org.sqlite.JDBC") + } + + fun loadSnapshot(dataDir: Path): QueueSnapshot { + val dbPath = dataDir.resolve("queue.db") + if (!dbPath.toFile().exists()) { + return QueueSnapshot.empty( + dataDir = dataDir, + statusMessage = "Waiting for queue database at $dbPath", + ) + } + + return runCatching { + DriverManager.getConnection("jdbc:sqlite:$dbPath").use { connection -> + connection.createStatement().use { statement -> + statement.execute("PRAGMA journal_mode=WAL") + statement.execute("PRAGMA busy_timeout=5000") + } + + connection.createStatement().use { statement -> + statement.executeQuery("SELECT * FROM queue ORDER BY queue_name, id").use { rs -> + val tasks = mutableListOf() + while (rs.next()) { + tasks += QueueTask( + id = rs.getInt("id"), + queueName = rs.getString("queue_name"), + status = rs.getString("status"), + command = rs.getString("command"), + pid = rs.getNullableInt("pid"), + childPid = rs.getNullableInt("child_pid"), + createdAt = rs.getString("created_at"), + updatedAt = rs.getString("updated_at"), + ) + } + + QueueSnapshot.fromTasks( + dataDir = dataDir, + tasks = tasks, + statusMessage = if (tasks.isEmpty()) "Queue is empty" else null, + ) + } + } + } + }.getOrElse { error -> + QueueSnapshot.empty( + dataDir = dataDir, + errorMessage = error.message ?: "Failed to read $dbPath", + ) + } + } +} + +private fun ResultSet.getNullableInt(columnName: String): Int? { + val value = getObject(columnName) ?: return null + return when (value) { + is Int -> value + is Long -> value.toInt() + is Number -> value.toInt() + else -> value.toString().toIntOrNull() + } +} From 0467fdeb59423277ef1e06d7227edf12ee95dfbb Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Thu, 23 Apr 2026 09:36:42 -0400 Subject: [PATCH 2/2] Fix sidecar running timestamp parsing Amp-Thread-ID: https://ampcode.com/threads/T-019dba87-ec91-706d-90f1-d2252ebdbea5 Co-authored-by: Amp --- desktop-sidecar/build.gradle.kts | 6 +++ .../agenttaskqueue/sidecar/QueueSnapshot.kt | 7 +-- .../sidecar/QueueSnapshotTest.kt | 54 +++++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshotTest.kt diff --git a/desktop-sidecar/build.gradle.kts b/desktop-sidecar/build.gradle.kts index 8664fc4..6a52402 100644 --- a/desktop-sidecar/build.gradle.kts +++ b/desktop-sidecar/build.gradle.kts @@ -23,6 +23,12 @@ kotlin { implementation("org.xerial:sqlite-jdbc:3.53.0.0") } } + + val desktopTest by getting { + dependencies { + implementation(kotlin("test")) + } + } } } diff --git a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshot.kt b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshot.kt index 9413ca0..75c9faf 100644 --- a/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshot.kt +++ b/desktop-sidecar/src/desktopMain/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshot.kt @@ -4,6 +4,7 @@ import java.nio.file.Path import java.time.Duration import java.time.Instant import java.time.LocalDateTime +import java.time.ZoneId import java.time.ZoneOffset data class QueueTask( @@ -21,7 +22,7 @@ data class QueueTask( fun statusAge(now: Instant = Instant.now()): String { val reference = when (status.lowercase()) { - "running" -> parseQueueInstant(updatedAt) ?: parseQueueInstant(createdAt) + "running" -> parseQueueInstant(updatedAt, ZoneId.systemDefault()) ?: parseQueueInstant(createdAt) else -> parseQueueInstant(createdAt) } ?: return "time unknown" @@ -112,14 +113,14 @@ data class QueueSnapshot( } } -fun parseQueueInstant(raw: String?): Instant? { +fun parseQueueInstant(raw: String?, defaultZone: ZoneId = ZoneOffset.UTC): Instant? { if (raw.isNullOrBlank()) { return null } val normalized = raw.replace(" ", "T") return runCatching { - LocalDateTime.parse(normalized).toInstant(ZoneOffset.UTC) + LocalDateTime.parse(normalized).atZone(defaultZone).toInstant() }.getOrNull() } diff --git a/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshotTest.kt b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshotTest.kt new file mode 100644 index 0000000..66a62fa --- /dev/null +++ b/desktop-sidecar/src/desktopTest/kotlin/com/block/agenttaskqueue/sidecar/QueueSnapshotTest.kt @@ -0,0 +1,54 @@ +package com.block.agenttaskqueue.sidecar + +import java.time.Instant +import java.util.TimeZone +import kotlin.test.Test +import kotlin.test.assertEquals + +class QueueSnapshotTest { + @Test + fun runningTasksInterpretUpdatedAtInLocalTime() { + withDefaultTimeZone("America/Los_Angeles") { + val task = QueueTask( + id = 1, + queueName = "global", + status = "running", + command = null, + pid = null, + childPid = null, + createdAt = "2026-04-22 19:00:00", + updatedAt = "2026-04-22T12:00:00", + ) + + assertEquals("running 15m", task.statusAge(Instant.parse("2026-04-22T19:15:00Z"))) + } + } + + @Test + fun waitingTasksKeepCreatedAtOnUtcTimeline() { + withDefaultTimeZone("America/Los_Angeles") { + val task = QueueTask( + id = 1, + queueName = "global", + status = "waiting", + command = null, + pid = null, + childPid = null, + createdAt = "2026-04-22 19:00:00", + updatedAt = null, + ) + + assertEquals("queued 15m", task.statusAge(Instant.parse("2026-04-22T19:15:00Z"))) + } + } + + private fun withDefaultTimeZone(timeZoneId: String, block: () -> Unit) { + val original = TimeZone.getDefault() + try { + TimeZone.setDefault(TimeZone.getTimeZone(timeZoneId)) + block() + } finally { + TimeZone.setDefault(original) + } + } +}