From 6691cbd8161ad48340928a77d740ac0b9449bee8 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 10 Sep 2026 23:13:29 +0300 Subject: [PATCH 1/4] fix: show progress for workspace actions Workspace actions could appear idle while blocking operations were still running. In particular, the Coder CLI start command can wait for external authentication, while its detached coroutine returned immediately and separated the command from the action's progress and error handling. Workspace actions now publish indeterminate progress for as long as their CLI or REST work is running. The blocking start command remains within the existing action lifecycle while executing on the IO dispatcher, which keeps progress and error handling active and allows the previous description and workspace state to be restored when appropriate; tests cover both execution paths. --- .../coder/toolbox/CoderRemoteEnvironment.kt | 76 +++++++++---- .../toolbox/CoderRemoteEnvironmentTest.kt | 107 ++++++++++++++++-- 2 files changed, 151 insertions(+), 32 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 6df71fc6..c440928c 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import java.io.File import java.nio.file.Path @@ -101,6 +102,16 @@ class CoderRemoteEnvironment( internal fun currentSessionId(): SessionId? = agent?.let { SessionIdRegistry.findSession(workspace.name, it.name) } + private suspend fun withProgress(message: String, action: suspend () -> T): T { + val previousDescription = description.value + description.value = EnvironmentDescription.Progress(context.i18n.ptrl(message), indeterminate = true) + return try { + action() + } finally { + description.value = previousDescription + } + } + private fun refreshAvailableActions() { val actions = mutableListOf() context.logger.debug("Refreshing available actions for workspace $id with status: $environmentStatus") @@ -139,25 +150,36 @@ class CoderRemoteEnvironment( if (environmentStatus.canStart()) { if (workspace.outdated) { actions.add(Action(context, "Update and start") { - context.logger.debug("Updating and starting $id...") - val build = client.updateWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) - workspaceRefreshTrigger.trySend(true) + withProgress("Updating and starting workspace…") { + context.logger.debug("Updating and starting $id...") + val build = client.updateWorkspace(workspace) + update(workspace.copy(latestBuild = build), agent) + workspaceRefreshTrigger.trySend(true) + } }) } else { actions.add(Action(context, "Start") { - context.logger.debug("Starting $id... ") - context.cs - .launch(CoroutineName("Start Workspace Action CLI Runner") + Dispatchers.IO) { - cli.startWorkspace(WorkspaceAddress.from(workspace)) + withProgress("Starting workspace…") { + context.logger.debug("Starting $id... ") + val previousStatus = environmentStatus + // The CLI can take a while before Coder reports a new workspace state. Show + // the pending state immediately and remove the Start action in the meantime. + updateStatus(WorkspaceAndAgentStatus.Queued(workspace)) + refreshAvailableActions() + var commandSucceeded = false + try { + withContext(Dispatchers.IO) { + cli.startWorkspace(WorkspaceAddress.from(workspace)) + } + commandSucceeded = true workspaceRefreshTrigger.trySend(true) + } finally { + if (!commandSucceeded) { + updateStatus(previousStatus) + refreshAvailableActions() + } } - // cli takes 15 seconds to move the workspace in queueing/starting state - // while the user won't see anything happening in TBX after start is clicked - // During those 15 seconds we work around by forcing a `Queuing` state - updateStatus(WorkspaceAndAgentStatus.Queued(workspace)) - // force refresh of the actions list (Start should no longer be available) - refreshAvailableActions() + } }) } } @@ -165,19 +187,23 @@ class CoderRemoteEnvironment( if (workspace.outdated) { actions.add( Action(context, "Update and restart") { - context.logger.debug(currentSessionId(), "Updating and re-starting $id...") - val build = client.updateWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) - workspaceRefreshTrigger.trySend(true) + withProgress("Updating and restarting workspace…") { + context.logger.debug(currentSessionId(), "Updating and re-starting $id...") + val build = client.updateWorkspace(workspace) + update(workspace.copy(latestBuild = build), agent) + workspaceRefreshTrigger.trySend(true) + } }.withCurrentSessionId(::currentSessionId) ) } actions.add( Action(context, "Stop") { - tryStopSshConnection() - context.logger.debug(currentSessionId(), "Stopping $id...") - val build = client.stopWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) + withProgress("Stopping workspace…") { + tryStopSshConnection() + context.logger.debug(currentSessionId(), "Stopping $id...") + val build = client.stopWorkspace(workspace) + update(workspace.copy(latestBuild = build), agent) + } }.withCurrentSessionId(::currentSessionId) ) } @@ -199,8 +225,10 @@ class CoderRemoteEnvironment( context.i18n.ptrl("Cancel") ) if (confirmation == workspace.name) { - context.logger.debug(currentSessionId(), "Deleting $id...") - deleteWorkspace() + withProgress("Deleting workspace…") { + context.logger.debug(currentSessionId(), "Deleting $id...") + deleteWorkspace() + } } }.withCurrentSessionId(::currentSessionId) ) diff --git a/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt b/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt index 9e186fac..00642438 100644 --- a/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt +++ b/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt @@ -12,28 +12,38 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.coder.toolbox.session.SessionId import com.coder.toolbox.session.SessionIdRegistry import com.coder.toolbox.store.CoderSettingsStore +import com.coder.toolbox.views.Action import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.environments.SshEnvironmentContentsView +import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentDescription import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.ui.ToolboxUi import io.mockk.Called import io.mockk.clearMocks +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue class CoderRemoteEnvironmentTest { @@ -309,6 +319,57 @@ class CoderRemoteEnvironmentTest { } } + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `start action shows progress until the CLI command finishes`() = runTest { + val fixture = fixture(this, workspaceStatus = WorkspaceStatus.STOPPED) + val commandStarted = CountDownLatch(1) + val finishCommand = CountDownLatch(1) + every { fixture.cli.startWorkspace(any()) } answers { + commandStarted.countDown() + check(finishCommand.await(5, TimeUnit.SECONDS)) + "" + } + + fixture.action("Start").run() + runCurrent() + + assertTrue(commandStarted.await(5, TimeUnit.SECONDS)) + val progress = assertIs(fixture.environment.description.value) + assertTrue(progress.indeterminate) + assertSame(fixture.localizedStrings.getValue("Starting workspace…"), progress.description) + + finishCommand.countDown() + advanceUntilIdle() + verify(exactly = 1) { fixture.cli.startWorkspace(any()) } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `REST workspace action shows progress until the request finishes`() = runTest { + val fixture = fixture(this, outdated = true) + val initialDescription = fixture.environment.description.value + val requestStarted = CompletableDeferred() + val finishRequest = CompletableDeferred() + coEvery { fixture.client.updateWorkspace(any()) } coAnswers { + requestStarted.complete(Unit) + finishRequest.await() + fixture.workspace.latestBuild + } + + fixture.action("Update and restart").run() + runCurrent() + + assertTrue(requestStarted.isCompleted) + val progress = assertIs(fixture.environment.description.value) + assertTrue(progress.indeterminate) + assertSame(fixture.localizedStrings.getValue("Updating and restarting workspace…"), progress.description) + + finishRequest.complete(Unit) + advanceUntilIdle() + assertSame(initialDescription, fixture.environment.description.value) + } + @Test fun `disposing an environment without a session is a no-op`() = runTest { val fixture = fixture(backgroundScope) @@ -321,19 +382,34 @@ class CoderRemoteEnvironmentTest { verify { fixture.logger wasNot Called } } - private fun fixture(scope: CoroutineScope, autoConnect: Boolean = false): Fixture { + private fun fixture( + scope: CoroutineScope, + autoConnect: Boolean = false, + workspaceStatus: WorkspaceStatus = WorkspaceStatus.RUNNING, + outdated: Boolean = false, + ): Fixture { val suffix = UUID.randomUUID().toString().take(8) val workspaceName = "workspace-$suffix" val agentName = "agent-$suffix" - val workspace = DataGen.workspace( - name = workspaceName, - agents = mapOf(agentName to UUID.randomUUID().toString()), - ) + val workspace = + DataGen.workspace( + name = workspaceName, + agents = mapOf(agentName to UUID.randomUUID().toString()), + ).let { + it.copy( + latestBuild = it.latestBuild.copy(status = workspaceStatus), + outdated = outdated, + ) + } val agent = requireNotNull(workspace.latestBuild.resources.single().agents).single() val context = mockk(relaxed = true) val logger = mockk(relaxed = true) val settingsStore = mockk(relaxed = true) val i18n = mockk(relaxed = true) + val localizedStrings = mutableMapOf() + every { i18n.ptrl(any()) } answers { + localizedStrings.getOrPut(firstArg()) { mockk(relaxed = true) } + } val coderLogger = CoderLogger(logger, mockk(relaxed = true), scope, i18n) every { context.cs } returns scope every { context.logger } returns coderLogger @@ -342,13 +418,15 @@ class CoderRemoteEnvironmentTest { every { context.envStateColorPalette } returns mockk(relaxed = true) every { settingsStore.shouldAutoConnect(any()) } returns autoConnect + val client = mockk(relaxed = true) + val cli = mockk(relaxed = true) val environment = CoderRemoteEnvironment( context = context, - client = mockk(relaxed = true), - cli = mockk(relaxed = true), + client = client, + cli = cli, workspaceRefreshTrigger = Channel(Channel.CONFLATED), workspace = workspace, - agent = agent, + agent = agent.takeIf { workspaceStatus == WorkspaceStatus.RUNNING }, ) return Fixture( environment, @@ -358,6 +436,9 @@ class CoderRemoteEnvironmentTest { agent, workspaceName, agentName, + client, + cli, + localizedStrings, ) } @@ -382,7 +463,17 @@ class CoderRemoteEnvironmentTest { val agent: WorkspaceAgent, val workspaceName: String, val agentName: String, + val client: CoderRestClient, + val cli: CoderCLIManager, + val localizedStrings: Map, ) { + fun action(label: String): Action { + val localizedLabel = localizedStrings.getValue(label) + return environment.actionsList.value + .filterIsInstance() + .single { it.label === localizedLabel } + } + fun currentSessionId(): SessionId? = SessionIdRegistry.findSession(workspaceName, agentName) fun removeSession() { From 9dfbac0be16e185cf6347d5168d143ffff0b8c0f Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 14 Sep 2026 21:29:04 +0300 Subject: [PATCH 2/4] feat: show live workspace start progress Workspace starts can spend time waiting for provisioning or external authentication, while Toolbox previously displayed only a static message. Stream Coder CLI output into the environment progress so users can see what the start command is doing without changing the complete output retained for logs and failures. --- CHANGELOG.md | 1 + .../coder/toolbox/CoderRemoteEnvironment.kt | 17 ++++++- .../com/coder/toolbox/cli/CoderCLIManager.kt | 22 +++++++-- .../toolbox/CoderRemoteEnvironmentTest.kt | 10 ++-- .../coder/toolbox/cli/CoderCLIManagerTest.kt | 46 +++++++++++++++++++ 5 files changed, 88 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2962f020..76c48d80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changed - upgraded the Toolbox plugin API, dropping support for Toolbox versions older than 3.7.2 +- display live workspace start progress reported by the Coder CLI ## 0.9.4 - 2026-08-26 diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index c440928c..fde1b442 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -48,6 +48,9 @@ import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds private val POLL_INTERVAL = 5.seconds +private val ANSI_ESCAPE_SEQUENCE = Regex("\u001B\\[[0-?]*[ -/]*[@-~]") + +private fun String.stripAnsiEscapeSequences(): String = replace(ANSI_ESCAPE_SEQUENCE, "") private fun environmentId(workspace: Workspace, agent: WorkspaceAgent?): String = agent?.let { "${workspace.name}.${it.name}" } ?: workspace.name @@ -112,6 +115,15 @@ class CoderRemoteEnvironment( } } + private fun showCliProgress(cliOutput: String) { + val progressMessage = cliOutput.stripAnsiEscapeSequences().trim() + if (progressMessage.isEmpty()) return + description.value = EnvironmentDescription.Progress( + context.i18n.pnotr(progressMessage), + indeterminate = true, + ) + } + private fun refreshAvailableActions() { val actions = mutableListOf() context.logger.debug("Refreshing available actions for workspace $id with status: $environmentStatus") @@ -169,7 +181,10 @@ class CoderRemoteEnvironment( var commandSucceeded = false try { withContext(Dispatchers.IO) { - cli.startWorkspace(WorkspaceAddress.from(workspace)) + cli.startWorkspace( + WorkspaceAddress.from(workspace), + showTextProgress = ::showCliProgress, + ) } commandSucceeded = true workspaceRefreshTrigger.trySend(true) diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index 9dca7450..dae90ae6 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -274,7 +274,11 @@ class CoderCLIManager( /** * Start a workspace. Throws if the command execution fails. */ - internal fun startWorkspace(wsAddress: WorkspaceAddress, feats: Features = features): String { + internal fun startWorkspace( + wsAddress: WorkspaceAddress, + feats: Features = features, + showTextProgress: (String) -> Unit = {}, + ): String { val args = mutableListOf( "--global-config", coderConfigPath.toString(), @@ -288,7 +292,7 @@ class CoderCLIManager( args.add("--") args.add(wsAddress.ownerAndWsName) - return exec(*args.toTypedArray()) + return exec(*args.toTypedArray(), showTextProgress = showTextProgress) } /** @@ -571,12 +575,22 @@ class CoderCLIManager( return matches } - private fun exec(vararg args: String): String { - val stdout = + private fun exec( + vararg args: String, + showTextProgress: ((String) -> Unit)? = null, + ): String { + val processExecutor = ProcessExecutor() .command(localBinaryPath.toString(), *args) .environment("CODER_HEADER_COMMAND", context.settingsStore.headerCommand) .exitValues(0) + + showTextProgress?.let { reportProgress -> + processExecutor.redirectOutput(reportProgress::invoke) + } + + val stdout = + processExecutor .readOutput(true) .execute() .outputUTF8() diff --git a/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt b/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt index 00642438..7ec2deb0 100644 --- a/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt +++ b/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt @@ -325,7 +325,8 @@ class CoderRemoteEnvironmentTest { val fixture = fixture(this, workspaceStatus = WorkspaceStatus.STOPPED) val commandStarted = CountDownLatch(1) val finishCommand = CountDownLatch(1) - every { fixture.cli.startWorkspace(any()) } answers { + every { fixture.cli.startWorkspace(any(), any(), any()) } answers { + thirdArg<(String) -> Unit>()("\u001B[92mProvisioning workspace...\u001B[0m") commandStarted.countDown() check(finishCommand.await(5, TimeUnit.SECONDS)) "" @@ -337,11 +338,11 @@ class CoderRemoteEnvironmentTest { assertTrue(commandStarted.await(5, TimeUnit.SECONDS)) val progress = assertIs(fixture.environment.description.value) assertTrue(progress.indeterminate) - assertSame(fixture.localizedStrings.getValue("Starting workspace…"), progress.description) + assertSame(fixture.localizedStrings.getValue("Provisioning workspace..."), progress.description) finishCommand.countDown() advanceUntilIdle() - verify(exactly = 1) { fixture.cli.startWorkspace(any()) } + verify(exactly = 1) { fixture.cli.startWorkspace(any(), any(), any()) } } @Test @@ -410,6 +411,9 @@ class CoderRemoteEnvironmentTest { every { i18n.ptrl(any()) } answers { localizedStrings.getOrPut(firstArg()) { mockk(relaxed = true) } } + every { i18n.pnotr(any()) } answers { + localizedStrings.getOrPut(firstArg()) { mockk(relaxed = true) } + } val coderLogger = CoderLogger(logger, mockk(relaxed = true), scope, i18n) every { context.cs } returns scope every { context.logger } returns coderLogger diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index fbbaf6a1..8f3d3d72 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -817,6 +817,52 @@ internal class CoderCLIManagerTest { verify(exactly = 0) { underlyingLogger.info("client_session_id=$sessionId $message") } } + @Test + fun `start workspace reports CLI output as text progress`() { + val testDirectory = tmpdir.resolve("start-progress-${UUID.randomUUID()}") + val binaryPath = if (getOS() == OS.WINDOWS) { + testDirectory.resolve("coder.bat") + } else { + testDirectory.resolve("coder") + } + binaryPath.parent.toFile().mkdirs() + binaryPath.toFile().writeText( + mkbin( + listOf( + echo("Queued"), + echo("Waiting for Git authentication..."), + ).joinToString(System.lineSeparator()) + ) + ) + if (getOS() != OS.WINDOWS) { + binaryPath.toFile().setExecutable(true) + } + val settings = CoderSettingsStore( + pluginTestSettingsStore( + BINARY_DESTINATION to binaryPath.toString(), + ENABLE_DOWNLOADS to "false", + ), + Environment(), + context.logger, + ) + val ccm = CoderCLIManager( + context.copy(settingsStore = settings), + URI("https://test.coder.invalid").toURL(), + ) + val workspace = workspace("start-progress") + val progressMessages = mutableListOf() + + val output = ccm.startWorkspace( + WorkspaceAddress.from(workspace), + Features(), + progressMessages::add, + ) + + assertEquals(listOf("Queued", "Waiting for Git authentication..."), progressMessages) + assertContains(output, "Queued") + assertContains(output, "Waiting for Git authentication...") + } + @Test fun testMalformedConfig() { val tests = From 55bf7d1ff7645fe3d440797f04eb85b1c1dffb13 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 14 Sep 2026 21:50:33 +0300 Subject: [PATCH 3/4] Show workspace build progress during actions Workspace update, update-and-restart, and stop requests can return before their builds finish, leaving Toolbox without useful progress while it waits for another workspace refresh. Keep the environment description aligned with pending, starting, and stopping build states reported by the poller. This preserves progress across environment replacement and restores the normal description when the build finishes. --- CHANGELOG.md | 3 +- .../coder/toolbox/CoderRemoteEnvironment.kt | 26 +++++- .../toolbox/CoderRemoteEnvironmentTest.kt | 86 ++++++++++++++++++- 3 files changed, 107 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76c48d80..80085828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,8 @@ ### Changed - upgraded the Toolbox plugin API, dropping support for Toolbox versions older than 3.7.2 -- display live workspace start progress reported by the Coder CLI +- display live workspace start output from the Coder CLI and use polled build states to show progress while updating, + restarting, or stopping workspaces ## 0.9.4 - 2026-08-26 diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index fde1b442..3a411a36 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -10,6 +10,7 @@ import com.coder.toolbox.sdk.ex.APIResponseException import com.coder.toolbox.sdk.v2.models.NetworkMetrics import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent +import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.coder.toolbox.session.SessionId import com.coder.toolbox.session.SessionIdRegistry import com.coder.toolbox.util.OS @@ -62,6 +63,22 @@ private fun OS?.displayName(): String = when (this) { null -> "unknown-OS" } +private fun WorkspaceAndAgentStatus.toEnvironmentDescription( + context: CoderToolboxContext, +): EnvironmentDescription { + val buildStatus = workspace.latestBuild.status + return when (buildStatus) { + WorkspaceStatus.PENDING, + WorkspaceStatus.STARTING, + WorkspaceStatus.STOPPING -> EnvironmentDescription.Progress( + context.i18n.pnotr("Building ${workspace.name} (${buildStatus.name.lowercase()})…"), + indeterminate = true, + ) + + else -> EnvironmentDescription.General(context.i18n.pnotr(workspace.templateDisplayName)) + } +} + /** * Represents a workspace, or a workspace and agent combination when an agent is available. * @@ -84,7 +101,7 @@ class CoderRemoteEnvironment( override val state: MutableStateFlow = MutableStateFlow(environmentStatus.toRemoteEnvironmentState(context)) override val description: MutableStateFlow = - MutableStateFlow(EnvironmentDescription.General(context.i18n.pnotr(workspace.templateDisplayName))) + MutableStateFlow(environmentStatus.toEnvironmentDescription(context)) override val additionalEnvironmentInformation: MutableMap = mutableMapOf() override val actionsList: MutableStateFlow> = MutableStateFlow(emptyList()) @@ -106,12 +123,11 @@ class CoderRemoteEnvironment( agent?.let { SessionIdRegistry.findSession(workspace.name, it.name) } private suspend fun withProgress(message: String, action: suspend () -> T): T { - val previousDescription = description.value description.value = EnvironmentDescription.Progress(context.i18n.ptrl(message), indeterminate = true) return try { action() } finally { - description.value = previousDescription + description.value = environmentStatus.toEnvironmentDescription(context) } } @@ -218,6 +234,7 @@ class CoderRemoteEnvironment( context.logger.debug(currentSessionId(), "Stopping $id...") val build = client.stopWorkspace(workspace) update(workspace.copy(latestBuild = build), agent) + workspaceRefreshTrigger.trySend(true) } }.withCurrentSessionId(::currentSessionId) ) @@ -423,6 +440,9 @@ class CoderRemoteEnvironment( state.update { environmentStatus.toRemoteEnvironmentState(context) } + description.update { + environmentStatus.toEnvironmentDescription(context) + } val message = "Overall status for workspace $id changed from ${previousEnvironmentStatus.label} " + "to ${environmentStatus.label}. " + diff --git a/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt b/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt index 7ec2deb0..f27e25bb 100644 --- a/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt +++ b/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt @@ -347,15 +347,14 @@ class CoderRemoteEnvironmentTest { @Test @OptIn(ExperimentalCoroutinesApi::class) - fun `REST workspace action shows progress until the request finishes`() = runTest { + fun `update and restart shows request progress followed by build progress`() = runTest { val fixture = fixture(this, outdated = true) - val initialDescription = fixture.environment.description.value val requestStarted = CompletableDeferred() val finishRequest = CompletableDeferred() coEvery { fixture.client.updateWorkspace(any()) } coAnswers { requestStarted.complete(Unit) finishRequest.await() - fixture.workspace.latestBuild + fixture.workspace.latestBuild.copy(status = WorkspaceStatus.STARTING) } fixture.action("Update and restart").run() @@ -368,7 +367,86 @@ class CoderRemoteEnvironmentTest { finishRequest.complete(Unit) advanceUntilIdle() - assertSame(initialDescription, fixture.environment.description.value) + val buildProgress = assertIs(fixture.environment.description.value) + assertTrue(buildProgress.indeterminate) + assertSame( + fixture.localizedStrings.getValue("Building ${fixture.workspaceName} (starting)…"), + buildProgress.description, + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `update and start keeps showing progress after the request finishes`() = runTest { + val fixture = fixture(this, workspaceStatus = WorkspaceStatus.STOPPED, outdated = true) + coEvery { fixture.client.updateWorkspace(any()) } returns + fixture.workspace.latestBuild.copy(status = WorkspaceStatus.PENDING) + + fixture.action("Update and start").run() + advanceUntilIdle() + + val progress = assertIs(fixture.environment.description.value) + assertTrue(progress.indeterminate) + assertSame( + fixture.localizedStrings.getValue("Building ${fixture.workspaceName} (pending)…"), + progress.description, + ) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `stop keeps showing progress after the request finishes`() = runTest { + val fixture = fixture(this) + coEvery { fixture.client.stopWorkspace(any()) } returns + fixture.workspace.latestBuild.copy(status = WorkspaceStatus.STOPPING) + + fixture.action("Stop").run() + advanceUntilIdle() + + val progress = assertIs(fixture.environment.description.value) + assertTrue(progress.indeterminate) + assertSame( + fixture.localizedStrings.getValue("Building ${fixture.workspaceName} (stopping)…"), + progress.description, + ) + } + + @Test + fun `polled build statuses update progress until the build finishes`() = runTest { + val fixture = fixture(backgroundScope, workspaceStatus = WorkspaceStatus.PENDING) + + val queued = assertIs(fixture.environment.description.value) + assertSame( + fixture.localizedStrings.getValue("Building ${fixture.workspaceName} (pending)…"), + queued.description, + ) + + val startingWorkspace = fixture.workspace.copy( + latestBuild = fixture.workspace.latestBuild.copy(status = WorkspaceStatus.STARTING), + ) + fixture.environment.update(startingWorkspace, null) + val starting = assertIs(fixture.environment.description.value) + assertSame( + fixture.localizedStrings.getValue("Building ${fixture.workspaceName} (starting)…"), + starting.description, + ) + + val stoppingWorkspace = fixture.workspace.copy( + latestBuild = fixture.workspace.latestBuild.copy(status = WorkspaceStatus.STOPPING), + ) + fixture.environment.update(stoppingWorkspace, null) + val stopping = assertIs(fixture.environment.description.value) + assertSame( + fixture.localizedStrings.getValue("Building ${fixture.workspaceName} (stopping)…"), + stopping.description, + ) + + val stoppedWorkspace = fixture.workspace.copy( + latestBuild = fixture.workspace.latestBuild.copy(status = WorkspaceStatus.STOPPED), + ) + fixture.environment.update(stoppedWorkspace, null) + val finished = assertIs(fixture.environment.description.value) + assertSame(fixture.localizedStrings.getValue(fixture.workspace.templateDisplayName), finished.description) } @Test From 35ed086b7e92a1bfa4e94224e2e21fdadb9744fb Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 15 Sep 2026 22:26:56 +0300 Subject: [PATCH 4/4] Show provisioner output while workspace builds run Workspace actions can continue provisioning after their initial request returns, leaving Toolbox with only a generic build status while it waits for the workspace to become ready. Use each poll update to display the latest provisioner output for active builds. Progress lookup failures keep the SSH session correlation when one exists and remain ordinary logs when no session is available. --- CHANGELOG.md | 2 +- .../coder/toolbox/CoderRemoteEnvironment.kt | 52 ++++++++--- .../coder/toolbox/diagnostics/CoderLogger.kt | 4 + .../com/coder/toolbox/sdk/CoderRestClient.kt | 20 +++++ .../coder/toolbox/sdk/v2/CoderV2RestFacade.kt | 7 ++ .../sdk/v2/models/ProvisionerJobLog.kt | 15 ++++ .../toolbox/CoderRemoteEnvironmentTest.kt | 90 ++++++++++++++++++- .../coder/toolbox/CoderRemoteProviderTest.kt | 1 + .../toolbox/diagnostics/CoderLoggerTest.kt | 4 + .../coder/toolbox/sdk/CoderRestClientTest.kt | 36 ++++++++ 10 files changed, 215 insertions(+), 16 deletions(-) create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/v2/models/ProvisionerJobLog.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 80085828..0ef1e85d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Changed - upgraded the Toolbox plugin API, dropping support for Toolbox versions older than 3.7.2 -- display live workspace start output from the Coder CLI and use polled build states to show progress while updating, +- display live workspace start output from the Coder CLI and poll build status and provisioner output while updating, restarting, or stopping workspaces ## 0.9.4 - 2026-08-26 diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 3a411a36..6d8a5d6e 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -45,11 +45,17 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import java.io.File import java.nio.file.Path +import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds private val POLL_INTERVAL = 5.seconds private val ANSI_ESCAPE_SEQUENCE = Regex("\u001B\\[[0-?]*[ -/]*[@-~]") +private val IN_PROGRESS_BUILD_STATUSES = setOf( + WorkspaceStatus.PENDING, + WorkspaceStatus.STARTING, + WorkspaceStatus.STOPPING, +) private fun String.stripAnsiEscapeSequences(): String = replace(ANSI_ESCAPE_SEQUENCE, "") @@ -67,15 +73,13 @@ private fun WorkspaceAndAgentStatus.toEnvironmentDescription( context: CoderToolboxContext, ): EnvironmentDescription { val buildStatus = workspace.latestBuild.status - return when (buildStatus) { - WorkspaceStatus.PENDING, - WorkspaceStatus.STARTING, - WorkspaceStatus.STOPPING -> EnvironmentDescription.Progress( + return if (buildStatus in IN_PROGRESS_BUILD_STATUSES) { + EnvironmentDescription.Progress( context.i18n.pnotr("Building ${workspace.name} (${buildStatus.name.lowercase()})…"), indeterminate = true, ) - - else -> EnvironmentDescription.General(context.i18n.pnotr(workspace.templateDisplayName)) + } else { + EnvironmentDescription.General(context.i18n.pnotr(workspace.templateDisplayName)) } } @@ -131,8 +135,8 @@ class CoderRemoteEnvironment( } } - private fun showCliProgress(cliOutput: String) { - val progressMessage = cliOutput.stripAnsiEscapeSequences().trim() + private fun showProgress(output: String) { + val progressMessage = output.stripAnsiEscapeSequences().trim() if (progressMessage.isEmpty()) return description.value = EnvironmentDescription.Progress( context.i18n.pnotr(progressMessage), @@ -181,7 +185,7 @@ class CoderRemoteEnvironment( withProgress("Updating and starting workspace…") { context.logger.debug("Updating and starting $id...") val build = client.updateWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) + applyWorkspaceSnapshot(workspace.copy(latestBuild = build), agent) workspaceRefreshTrigger.trySend(true) } }) @@ -199,7 +203,7 @@ class CoderRemoteEnvironment( withContext(Dispatchers.IO) { cli.startWorkspace( WorkspaceAddress.from(workspace), - showTextProgress = ::showCliProgress, + showTextProgress = ::showProgress, ) } commandSucceeded = true @@ -221,7 +225,7 @@ class CoderRemoteEnvironment( withProgress("Updating and restarting workspace…") { context.logger.debug(currentSessionId(), "Updating and re-starting $id...") val build = client.updateWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) + applyWorkspaceSnapshot(workspace.copy(latestBuild = build), agent) workspaceRefreshTrigger.trySend(true) } }.withCurrentSessionId(::currentSessionId) @@ -233,7 +237,7 @@ class CoderRemoteEnvironment( tryStopSshConnection() context.logger.debug(currentSessionId(), "Stopping $id...") val build = client.stopWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) + applyWorkspaceSnapshot(workspace.copy(latestBuild = build), agent) workspaceRefreshTrigger.trySend(true) } }.withCurrentSessionId(::currentSessionId) @@ -406,7 +410,12 @@ class CoderRemoteEnvironment( /** * Update the workspace/agent status to the listeners, if it has changed. */ - fun update(newWorkspace: Workspace, newAgent: WorkspaceAgent?) { + suspend fun update(newWorkspace: Workspace, newAgent: WorkspaceAgent?) { + applyWorkspaceSnapshot(newWorkspace, newAgent) + updateBuildProgress(newWorkspace) + } + + private fun applyWorkspaceSnapshot(newWorkspace: Workspace, newAgent: WorkspaceAgent?) { if (workspace.latestBuild == newWorkspace.latestBuild) { return } @@ -423,6 +432,23 @@ class CoderRemoteEnvironment( refreshAvailableActions() } + private suspend fun updateBuildProgress(newWorkspace: Workspace) { + val build = newWorkspace.latestBuild + if (build.status !in IN_PROGRESS_BUILD_STATUSES) return + val latestLog = try { + client.workspaceBuildLogs(build.id).lastOrNull() + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + context.logger.warn( + currentSessionId(), + ex, + "Failed to retrieve progress for workspace build ${build.id}", + ) + return + } + latestLog?.output?.let(::showProgress) + } private fun updateStatus( newState: WorkspaceAndAgentStatus, diff --git a/src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt b/src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt index d918dd4c..b219954b 100644 --- a/src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt +++ b/src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt @@ -59,6 +59,10 @@ class CoderLogger( delegate.warn(withSessionId(sessionId, message)) } + inline fun warn(sessionId: SessionId?, exception: Throwable, message: String) { + delegate.warn(exception, withSessionId(sessionId, message)) + } + inline fun warn(sessionIds: Set, exception: Throwable, message: String) { sessionIds.onceOrForEach { delegate.warn(exception, withSessionId(it, message)) } } diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index eeb25038..dc84879d 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -18,6 +18,7 @@ import com.coder.toolbox.sdk.v2.models.BuildInfo import com.coder.toolbox.sdk.v2.models.CoderIdentifierPolicy import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest import com.coder.toolbox.sdk.v2.models.InvalidCoderIdentifierException +import com.coder.toolbox.sdk.v2.models.ProvisionerJobLog import com.coder.toolbox.sdk.v2.models.Template import com.coder.toolbox.sdk.v2.models.User import com.coder.toolbox.sdk.v2.models.Workspace @@ -317,6 +318,25 @@ open class CoderRestClient( }.withoutUnsafeAgents() } + /** Retrieves the provisioner logs for [workspaceBuildID]. */ + suspend fun workspaceBuildLogs(workspaceBuildID: UUID): List { + val logsResponse = callWithRetry { + retroRestClient.workspaceBuildLogs(workspaceBuildID) + } + if (!logsResponse.isSuccessful) { + throw APIResponseException( + "retrieve logs for workspace build $workspaceBuildID", + url, + logsResponse.code(), + logsResponse.parseErrorBody(moshi), + ) + } + + return requireNotNull(logsResponse.body()) { + "Successful response returned null body for workspace build logs" + } + } + /** * @throws [APIResponseException] if issues are encountered during deletion */ diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt index 414f805e..a7d96e13 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt @@ -3,6 +3,7 @@ package com.coder.toolbox.sdk.v2 import com.coder.toolbox.sdk.v2.models.Appearance import com.coder.toolbox.sdk.v2.models.BuildInfo import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest +import com.coder.toolbox.sdk.v2.models.ProvisionerJobLog import com.coder.toolbox.sdk.v2.models.Template import com.coder.toolbox.sdk.v2.models.User import com.coder.toolbox.sdk.v2.models.Workspace @@ -57,6 +58,12 @@ interface CoderV2RestFacade { @Body createWorkspaceBuildRequest: CreateWorkspaceBuildRequest, ): Response + /** Retrieves the provisioner logs for a workspace build. */ + @GET("api/v2/workspacebuilds/{workspaceBuildID}/logs") + suspend fun workspaceBuildLogs( + @Path("workspaceBuildID") workspaceBuildID: UUID, + ): Response> + /** * Retrieves all templates the authenticated user can access. */ diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/ProvisionerJobLog.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/ProvisionerJobLog.kt new file mode 100644 index 00000000..46f94ab2 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/ProvisionerJobLog.kt @@ -0,0 +1,15 @@ +package com.coder.toolbox.sdk.v2.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.time.Instant + +@JsonClass(generateAdapter = true) +data class ProvisionerJobLog( + @property:Json(name = "id") val id: Long, + @property:Json(name = "created_at") val createdAt: Instant, + @property:Json(name = "log_source") val source: String, + @property:Json(name = "log_level") val level: String, + @property:Json(name = "stage") val stage: String, + @property:Json(name = "output") val output: String, +) \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt b/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt index f27e25bb..72d476c2 100644 --- a/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt +++ b/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt @@ -4,6 +4,7 @@ import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.diagnostics.CoderLogger import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.DataGen +import com.coder.toolbox.sdk.v2.models.ProvisionerJobLog import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.sdk.v2.models.WorkspaceAgentLifecycleState @@ -23,6 +24,7 @@ import com.jetbrains.toolbox.api.ui.ToolboxUi import io.mockk.Called import io.mockk.clearMocks import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -34,6 +36,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import java.time.Instant import java.util.UUID import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -380,7 +383,7 @@ class CoderRemoteEnvironmentTest { fun `update and start keeps showing progress after the request finishes`() = runTest { val fixture = fixture(this, workspaceStatus = WorkspaceStatus.STOPPED, outdated = true) coEvery { fixture.client.updateWorkspace(any()) } returns - fixture.workspace.latestBuild.copy(status = WorkspaceStatus.PENDING) + fixture.workspace.latestBuild.copy(status = WorkspaceStatus.PENDING) fixture.action("Update and start").run() advanceUntilIdle() @@ -398,7 +401,7 @@ class CoderRemoteEnvironmentTest { fun `stop keeps showing progress after the request finishes`() = runTest { val fixture = fixture(this) coEvery { fixture.client.stopWorkspace(any()) } returns - fixture.workspace.latestBuild.copy(status = WorkspaceStatus.STOPPING) + fixture.workspace.latestBuild.copy(status = WorkspaceStatus.STOPPING) fixture.action("Stop").run() advanceUntilIdle() @@ -449,6 +452,80 @@ class CoderRemoteEnvironmentTest { assertSame(fixture.localizedStrings.getValue(fixture.workspace.templateDisplayName), finished.description) } + @Test + fun `workspace poll does not request logs for a completed build`() = runTest { + val fixture = fixture(backgroundScope) + + fixture.environment.update(fixture.workspace, fixture.agent) + + coVerify(exactly = 0) { fixture.client.workspaceBuildLogs(any()) } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `workspace poll shows the latest log from an active build`() = runTest { + val fixture = fixture(this, workspaceStatus = WorkspaceStatus.PENDING) + val build = fixture.workspace.latestBuild + coEvery { fixture.client.workspaceBuildLogs(build.id) } returnsMany listOf( + listOf( + buildLog(1, "Preparing workspace"), + buildLog(2, "\u001B[92mApplying workspace resources\u001B[0m"), + ), + listOf( + buildLog(1, "Preparing workspace"), + buildLog(2, "Applying workspace resources"), + buildLog(3, "Starting workspace applications"), + ), + ) + + val workspaceSnapshot = fixture.workspace.copy(latestBuild = build) + + fixture.environment.update(workspaceSnapshot, null) + val firstProgress = assertIs(fixture.environment.description.value) + assertSame( + fixture.localizedStrings.getValue("Applying workspace resources"), + firstProgress.description, + ) + + fixture.environment.update(workspaceSnapshot, null) + val nextProgress = assertIs(fixture.environment.description.value) + assertSame( + fixture.localizedStrings.getValue("Starting workspace applications"), + nextProgress.description, + ) + + fixture.environment.update( + workspaceSnapshot.copy(latestBuild = build.copy(status = WorkspaceStatus.RUNNING)), + fixture.agent, + ) + assertIs(fixture.environment.description.value) + coVerify(exactly = 2) { fixture.client.workspaceBuildLogs(build.id) } + } + + @Test + fun `build progress failure includes the current session id`() = runTest { + val fixture = fixture(backgroundScope) + + try { + fixture.environment.beforeConnection() + val sessionId = assertNotNull(fixture.currentSessionId()) + val build = fixture.workspace.latestBuild.copy(status = WorkspaceStatus.STOPPING) + val failure = IllegalStateException("logs unavailable") + coEvery { fixture.client.workspaceBuildLogs(build.id) } throws failure + + fixture.environment.update(fixture.workspace.copy(latestBuild = build), fixture.agent) + + verify(exactly = 1) { + fixture.logger.warn( + failure, + "client_session_id=$sessionId Failed to retrieve progress for workspace build ${build.id}", + ) + } + } finally { + fixture.removeSession() + } + } + @Test fun `disposing an environment without a session is a no-op`() = runTest { val fixture = fixture(backgroundScope) @@ -537,6 +614,15 @@ class CoderRemoteEnvironmentTest { private fun hasSessionId(message: String, sessionId: SessionId): Boolean = message.startsWith("client_session_id=$sessionId ") + private fun buildLog(id: Long, output: String) = ProvisionerJobLog( + id = id, + createdAt = Instant.EPOCH, + source = "provisioner", + level = "info", + stage = "Building", + output = output, + ) + private data class Fixture( val environment: CoderRemoteEnvironment, val logger: Logger, diff --git a/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt b/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt index 717ea565..57d14851 100644 --- a/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt +++ b/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt @@ -717,6 +717,7 @@ class CoderRemoteProviderTest { resources: List ): Workspace { val latestBuild = mockk { + every { this@mockk.id } returns UUID.randomUUID() every { this@mockk.status } returns status every { this@mockk.resources } returns resources } diff --git a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerTest.kt b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerTest.kt index 4b0a626a..9f8b33e1 100644 --- a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerTest.kt @@ -38,6 +38,7 @@ class CoderLoggerTest { logger.error(null, exception, "exception") logger.warn(null, "warning") + logger.warn(null, exception, "warning exception") logger.debug(null, "debug") logger.info(null, "info") logger.info(message = "named info") @@ -45,6 +46,7 @@ class CoderLoggerTest { verify(exactly = 1) { delegate.error(exception, "exception") } verify(exactly = 1) { delegate.warn("warning") } + verify(exactly = 1) { delegate.warn(exception, "warning exception") } verify(exactly = 1) { delegate.debug("debug") } verify(exactly = 1) { delegate.info("info") } verify(exactly = 1) { delegate.info("named info") } @@ -57,11 +59,13 @@ class CoderLoggerTest { logger.error(sessionId, exception, "exception") logger.warn(sessionId, "warning") + logger.warn(sessionId, exception, "warning exception") logger.debug(sessionId, "debug") logger.info(sessionId, "info") verify(exactly = 1) { delegate.error(exception, "$prefix exception") } verify(exactly = 1) { delegate.warn("$prefix warning") } + verify(exactly = 1) { delegate.warn(exception, "$prefix warning exception") } verify(exactly = 1) { delegate.debug("$prefix debug") } verify(exactly = 1) { delegate.info("$prefix info") } } diff --git a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index 7ca23f7f..d1ebda5c 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -8,6 +8,7 @@ import com.coder.toolbox.sdk.v2.models.Appearance import com.coder.toolbox.sdk.v2.models.BuildInfo import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest import com.coder.toolbox.sdk.v2.models.InvalidCoderIdentifierException +import com.coder.toolbox.sdk.v2.models.ProvisionerJobLog import com.coder.toolbox.sdk.v2.models.Response import com.coder.toolbox.sdk.v2.models.Template import com.coder.toolbox.sdk.v2.models.User @@ -58,6 +59,7 @@ import java.net.SocketAddress import java.net.URI import java.net.URL import java.nio.file.Path +import java.time.Instant import java.util.UUID import javax.net.ssl.SSLHandshakeException import javax.net.ssl.SSLPeerUnverifiedException @@ -491,6 +493,40 @@ class CoderRestClientTest { srv.stop(0) } + @Test + fun `workspace build logs request returns entries`() { + val buildID = UUID.randomUUID() + val logs = listOf( + ProvisionerJobLog( + id = 43, + createdAt = Instant.parse("2026-09-14T12:00:00Z"), + source = "provisioner", + level = "info", + stage = "Planning", + output = "Planning workspace resources", + ), + ) + val type = Types.newParameterizedType(List::class.java, ProvisionerJobLog::class.java) + val body = moshi.adapter>(type).toJson(logs).toByteArray() + val queries = mutableListOf() + val (srv, url) = mockServer() + val client = CoderRestClient(context, URL(url), "token") + srv.createContext( + "/api/v2/workspacebuilds/$buildID/logs", + BaseHttpHandler("GET") { exchange -> + queries.add(exchange.requestURI.rawQuery) + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.size.toLong()) + exchange.responseBody.write(body) + }, + ) + + val returnedLogs = runBlocking { client.workspaceBuildLogs(buildID) } + + assertEquals(logs, returnedLogs) + assertEquals(listOf(null), queries) + srv.stop(0) + } + @Test fun `filtered workspaces request sanitizes identifiers and sends the query`() { val response = WorkspacesResponse(