diff --git a/CHANGELOG.md b/CHANGELOG.md index 2962f020..0ef1e85d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### 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 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 6df71fc6..6d8a5d6e 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 @@ -40,13 +41,23 @@ 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 +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, "") private fun environmentId(workspace: Workspace, agent: WorkspaceAgent?): String = agent?.let { "${workspace.name}.${it.name}" } ?: workspace.name @@ -58,6 +69,20 @@ private fun OS?.displayName(): String = when (this) { null -> "unknown-OS" } +private fun WorkspaceAndAgentStatus.toEnvironmentDescription( + context: CoderToolboxContext, +): EnvironmentDescription { + val buildStatus = workspace.latestBuild.status + 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)) + } +} + /** * Represents a workspace, or a workspace and agent combination when an agent is available. * @@ -80,7 +105,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()) @@ -101,6 +126,24 @@ class CoderRemoteEnvironment( internal fun currentSessionId(): SessionId? = agent?.let { SessionIdRegistry.findSession(workspace.name, it.name) } + private suspend fun withProgress(message: String, action: suspend () -> T): T { + description.value = EnvironmentDescription.Progress(context.i18n.ptrl(message), indeterminate = true) + return try { + action() + } finally { + description.value = environmentStatus.toEnvironmentDescription(context) + } + } + + private fun showProgress(output: String) { + val progressMessage = output.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") @@ -139,25 +182,39 @@ 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) + applyWorkspaceSnapshot(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), + showTextProgress = ::showProgress, + ) + } + 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 +222,24 @@ 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) + applyWorkspaceSnapshot(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) + applyWorkspaceSnapshot(workspace.copy(latestBuild = build), agent) + workspaceRefreshTrigger.trySend(true) + } }.withCurrentSessionId(::currentSessionId) ) } @@ -199,8 +261,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) ) @@ -346,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 } @@ -363,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, @@ -380,6 +466,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/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/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 9e186fac..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 @@ -12,28 +13,40 @@ 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.coVerify 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.time.Instant 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 +322,210 @@ 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(), any(), any()) } answers { + thirdArg<(String) -> Unit>()("\u001B[92mProvisioning workspace...\u001B[0m") + 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("Provisioning workspace..."), progress.description) + + finishCommand.countDown() + advanceUntilIdle() + verify(exactly = 1) { fixture.cli.startWorkspace(any(), any(), any()) } + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `update and restart shows request progress followed by build progress`() = runTest { + val fixture = fixture(this, outdated = true) + val requestStarted = CompletableDeferred() + val finishRequest = CompletableDeferred() + coEvery { fixture.client.updateWorkspace(any()) } coAnswers { + requestStarted.complete(Unit) + finishRequest.await() + fixture.workspace.latestBuild.copy(status = WorkspaceStatus.STARTING) + } + + 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() + 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 + 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) @@ -321,19 +538,37 @@ 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) } + } + 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 @@ -342,13 +577,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 +595,9 @@ class CoderRemoteEnvironmentTest { agent, workspaceName, agentName, + client, + cli, + localizedStrings, ) } @@ -374,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, @@ -382,7 +631,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() { 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/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 = 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(