Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
141 changes: 115 additions & 26 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
*
Expand All @@ -80,7 +105,7 @@ class CoderRemoteEnvironment(
override val state: MutableStateFlow<RemoteEnvironmentState> =
MutableStateFlow(environmentStatus.toRemoteEnvironmentState(context))
override val description: MutableStateFlow<EnvironmentDescription> =
MutableStateFlow(EnvironmentDescription.General(context.i18n.pnotr(workspace.templateDisplayName)))
MutableStateFlow(environmentStatus.toEnvironmentDescription(context))
override val additionalEnvironmentInformation: MutableMap<LocalizableString, String> = mutableMapOf()
override val actionsList: MutableStateFlow<List<ActionDescription>> = MutableStateFlow(emptyList())

Expand All @@ -101,6 +126,24 @@ class CoderRemoteEnvironment(
internal fun currentSessionId(): SessionId? =
agent?.let { SessionIdRegistry.findSession(workspace.name, it.name) }

private suspend fun <T> 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<ActionDescription>()
context.logger.debug("Refreshing available actions for workspace $id with status: $environmentStatus")
Expand Down Expand Up @@ -139,45 +182,64 @@ 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()
}
})
}
}
if (environmentStatus.canStop()) {
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)
)
}
Expand All @@ -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)
)
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should add follow=true to the build log request? That turns it into a web socket and lets us watch the logs.

Otherwise we may be sitting there without more logs unless/until update runs again.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would keep this PR as-is and handle WebSocket following separately, as it is a more complex solution.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense!

}

private fun applyWorkspaceSnapshot(newWorkspace: Workspace, newAgent: WorkspaceAgent?) {
if (workspace.latestBuild == newWorkspace.latestBuild) {
return
}
Expand All @@ -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,
Expand All @@ -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}. " +
Expand Down
22 changes: 18 additions & 4 deletions src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -288,7 +292,7 @@ class CoderCLIManager(
args.add("--")
args.add(wsAddress.ownerAndWsName)

return exec(*args.toTypedArray())
return exec(*args.toTypedArray(), showTextProgress = showTextProgress)
}

/**
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably not something we have to actually worry about, but do you know if this buffers by newline or anything like that? Thinking it could be weird (although unlikely) if half a sentence appears, then is replaced by the next half, just depending on how the output happens to be buffered.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're using zt-exec for now (though I have a PR in review of OAuth2 support that gets rid of the library because it had some drawbacks and I need to make a note about your question to be researched there as well) and zt-exec is line-buffered and it accumulates bytes until it encounters \n or \r.

}

val stdout =
processExecutor
.readOutput(true)
.execute()
.outputUTF8()
Expand Down
4 changes: 4 additions & 0 deletions src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<SessionId>, exception: Throwable, message: String) {
sessionIds.onceOrForEach { delegate.warn(exception, withSessionId(it, message)) }
}
Expand Down
20 changes: 20 additions & 0 deletions src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -317,6 +318,25 @@ open class CoderRestClient(
}.withoutUnsafeAgents()
}

/** Retrieves the provisioner logs for [workspaceBuildID]. */
suspend fun workspaceBuildLogs(workspaceBuildID: UUID): List<ProvisionerJobLog> {
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
*/
Expand Down
7 changes: 7 additions & 0 deletions src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,6 +58,12 @@ interface CoderV2RestFacade {
@Body createWorkspaceBuildRequest: CreateWorkspaceBuildRequest,
): Response<WorkspaceBuild>

/** Retrieves the provisioner logs for a workspace build. */
@GET("api/v2/workspacebuilds/{workspaceBuildID}/logs")
suspend fun workspaceBuildLogs(
@Path("workspaceBuildID") workspaceBuildID: UUID,
): Response<List<ProvisionerJobLog>>

/**
* Retrieves all templates the authenticated user can access.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading