Skip to content

Commit cee4a4e

Browse files
authored
fix: show progress for workspace actions (#361)
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. This was especially confusing when a workspace start paused for external authentication or when an update, restart, or stop continued after the initial request had completed. Toolbox now keeps the environment description in sync with the active workspace action. Workspace starts display live output from the Coder CLI, while other ongoing builds use the status and latest provisioner output retrieved during workspace polling. Once the build finishes, the normal environment description is restored. Failures while retrieving progress are recorded without interrupting workspace monitoring. They retain the SSH session correlation when a session is available and remain ordinary logs when the workspace has no active session. - resolves https://linear.app/codercom/issue/JET-1
1 parent f877f8d commit cee4a4e

12 files changed

Lines changed: 535 additions & 38 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
### Changed
66

77
- upgraded the Toolbox plugin API, dropping support for Toolbox versions older than 3.7.2
8+
- display live workspace start output from the Coder CLI and poll build status and provisioner output while updating,
9+
restarting, or stopping workspaces
810

911
## 0.9.4 - 2026-08-26
1012

‎src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt‎

Lines changed: 115 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.coder.toolbox.sdk.ex.APIResponseException
1010
import com.coder.toolbox.sdk.v2.models.NetworkMetrics
1111
import com.coder.toolbox.sdk.v2.models.Workspace
1212
import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
13+
import com.coder.toolbox.sdk.v2.models.WorkspaceStatus
1314
import com.coder.toolbox.session.SessionId
1415
import com.coder.toolbox.session.SessionIdRegistry
1516
import com.coder.toolbox.util.OS
@@ -40,13 +41,23 @@ import kotlinx.coroutines.flow.StateFlow
4041
import kotlinx.coroutines.flow.update
4142
import kotlinx.coroutines.isActive
4243
import kotlinx.coroutines.launch
44+
import kotlinx.coroutines.withContext
4345
import kotlinx.coroutines.withTimeout
4446
import java.io.File
4547
import java.nio.file.Path
48+
import kotlin.coroutines.cancellation.CancellationException
4649
import kotlin.time.Duration.Companion.minutes
4750
import kotlin.time.Duration.Companion.seconds
4851

4952
private val POLL_INTERVAL = 5.seconds
53+
private val ANSI_ESCAPE_SEQUENCE = Regex("\u001B\\[[0-?]*[ -/]*[@-~]")
54+
private val IN_PROGRESS_BUILD_STATUSES = setOf(
55+
WorkspaceStatus.PENDING,
56+
WorkspaceStatus.STARTING,
57+
WorkspaceStatus.STOPPING,
58+
)
59+
60+
private fun String.stripAnsiEscapeSequences(): String = replace(ANSI_ESCAPE_SEQUENCE, "")
5061

5162
private fun environmentId(workspace: Workspace, agent: WorkspaceAgent?): String =
5263
agent?.let { "${workspace.name}.${it.name}" } ?: workspace.name
@@ -58,6 +69,20 @@ private fun OS?.displayName(): String = when (this) {
5869
null -> "unknown-OS"
5970
}
6071

72+
private fun WorkspaceAndAgentStatus.toEnvironmentDescription(
73+
context: CoderToolboxContext,
74+
): EnvironmentDescription {
75+
val buildStatus = workspace.latestBuild.status
76+
return if (buildStatus in IN_PROGRESS_BUILD_STATUSES) {
77+
EnvironmentDescription.Progress(
78+
context.i18n.pnotr("Building ${workspace.name} (${buildStatus.name.lowercase()})…"),
79+
indeterminate = true,
80+
)
81+
} else {
82+
EnvironmentDescription.General(context.i18n.pnotr(workspace.templateDisplayName))
83+
}
84+
}
85+
6186
/**
6287
* Represents a workspace, or a workspace and agent combination when an agent is available.
6388
*
@@ -80,7 +105,7 @@ class CoderRemoteEnvironment(
80105
override val state: MutableStateFlow<RemoteEnvironmentState> =
81106
MutableStateFlow(environmentStatus.toRemoteEnvironmentState(context))
82107
override val description: MutableStateFlow<EnvironmentDescription> =
83-
MutableStateFlow(EnvironmentDescription.General(context.i18n.pnotr(workspace.templateDisplayName)))
108+
MutableStateFlow(environmentStatus.toEnvironmentDescription(context))
84109
override val additionalEnvironmentInformation: MutableMap<LocalizableString, String> = mutableMapOf()
85110
override val actionsList: MutableStateFlow<List<ActionDescription>> = MutableStateFlow(emptyList())
86111

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

129+
private suspend fun <T> withProgress(message: String, action: suspend () -> T): T {
130+
description.value = EnvironmentDescription.Progress(context.i18n.ptrl(message), indeterminate = true)
131+
return try {
132+
action()
133+
} finally {
134+
description.value = environmentStatus.toEnvironmentDescription(context)
135+
}
136+
}
137+
138+
private fun showProgress(output: String) {
139+
val progressMessage = output.stripAnsiEscapeSequences().trim()
140+
if (progressMessage.isEmpty()) return
141+
description.value = EnvironmentDescription.Progress(
142+
context.i18n.pnotr(progressMessage),
143+
indeterminate = true,
144+
)
145+
}
146+
104147
private fun refreshAvailableActions() {
105148
val actions = mutableListOf<ActionDescription>()
106149
context.logger.debug("Refreshing available actions for workspace $id with status: $environmentStatus")
@@ -139,45 +182,64 @@ class CoderRemoteEnvironment(
139182
if (environmentStatus.canStart()) {
140183
if (workspace.outdated) {
141184
actions.add(Action(context, "Update and start") {
142-
context.logger.debug("Updating and starting $id...")
143-
val build = client.updateWorkspace(workspace)
144-
update(workspace.copy(latestBuild = build), agent)
145-
workspaceRefreshTrigger.trySend(true)
185+
withProgress("Updating and starting workspace…") {
186+
context.logger.debug("Updating and starting $id...")
187+
val build = client.updateWorkspace(workspace)
188+
applyWorkspaceSnapshot(workspace.copy(latestBuild = build), agent)
189+
workspaceRefreshTrigger.trySend(true)
190+
}
146191
})
147192
} else {
148193
actions.add(Action(context, "Start") {
149-
context.logger.debug("Starting $id... ")
150-
context.cs
151-
.launch(CoroutineName("Start Workspace Action CLI Runner") + Dispatchers.IO) {
152-
cli.startWorkspace(WorkspaceAddress.from(workspace))
194+
withProgress("Starting workspace…") {
195+
context.logger.debug("Starting $id... ")
196+
val previousStatus = environmentStatus
197+
// The CLI can take a while before Coder reports a new workspace state. Show
198+
// the pending state immediately and remove the Start action in the meantime.
199+
updateStatus(WorkspaceAndAgentStatus.Queued(workspace))
200+
refreshAvailableActions()
201+
var commandSucceeded = false
202+
try {
203+
withContext(Dispatchers.IO) {
204+
cli.startWorkspace(
205+
WorkspaceAddress.from(workspace),
206+
showTextProgress = ::showProgress,
207+
)
208+
}
209+
commandSucceeded = true
153210
workspaceRefreshTrigger.trySend(true)
211+
} finally {
212+
if (!commandSucceeded) {
213+
updateStatus(previousStatus)
214+
refreshAvailableActions()
215+
}
154216
}
155-
// cli takes 15 seconds to move the workspace in queueing/starting state
156-
// while the user won't see anything happening in TBX after start is clicked
157-
// During those 15 seconds we work around by forcing a `Queuing` state
158-
updateStatus(WorkspaceAndAgentStatus.Queued(workspace))
159-
// force refresh of the actions list (Start should no longer be available)
160-
refreshAvailableActions()
217+
}
161218
})
162219
}
163220
}
164221
if (environmentStatus.canStop()) {
165222
if (workspace.outdated) {
166223
actions.add(
167224
Action(context, "Update and restart") {
168-
context.logger.debug(currentSessionId(), "Updating and re-starting $id...")
169-
val build = client.updateWorkspace(workspace)
170-
update(workspace.copy(latestBuild = build), agent)
171-
workspaceRefreshTrigger.trySend(true)
225+
withProgress("Updating and restarting workspace…") {
226+
context.logger.debug(currentSessionId(), "Updating and re-starting $id...")
227+
val build = client.updateWorkspace(workspace)
228+
applyWorkspaceSnapshot(workspace.copy(latestBuild = build), agent)
229+
workspaceRefreshTrigger.trySend(true)
230+
}
172231
}.withCurrentSessionId(::currentSessionId)
173232
)
174233
}
175234
actions.add(
176235
Action(context, "Stop") {
177-
tryStopSshConnection()
178-
context.logger.debug(currentSessionId(), "Stopping $id...")
179-
val build = client.stopWorkspace(workspace)
180-
update(workspace.copy(latestBuild = build), agent)
236+
withProgress("Stopping workspace…") {
237+
tryStopSshConnection()
238+
context.logger.debug(currentSessionId(), "Stopping $id...")
239+
val build = client.stopWorkspace(workspace)
240+
applyWorkspaceSnapshot(workspace.copy(latestBuild = build), agent)
241+
workspaceRefreshTrigger.trySend(true)
242+
}
181243
}.withCurrentSessionId(::currentSessionId)
182244
)
183245
}
@@ -199,8 +261,10 @@ class CoderRemoteEnvironment(
199261
context.i18n.ptrl("Cancel")
200262
)
201263
if (confirmation == workspace.name) {
202-
context.logger.debug(currentSessionId(), "Deleting $id...")
203-
deleteWorkspace()
264+
withProgress("Deleting workspace…") {
265+
context.logger.debug(currentSessionId(), "Deleting $id...")
266+
deleteWorkspace()
267+
}
204268
}
205269
}.withCurrentSessionId(::currentSessionId)
206270
)
@@ -346,7 +410,12 @@ class CoderRemoteEnvironment(
346410
/**
347411
* Update the workspace/agent status to the listeners, if it has changed.
348412
*/
349-
fun update(newWorkspace: Workspace, newAgent: WorkspaceAgent?) {
413+
suspend fun update(newWorkspace: Workspace, newAgent: WorkspaceAgent?) {
414+
applyWorkspaceSnapshot(newWorkspace, newAgent)
415+
updateBuildProgress(newWorkspace)
416+
}
417+
418+
private fun applyWorkspaceSnapshot(newWorkspace: Workspace, newAgent: WorkspaceAgent?) {
350419
if (workspace.latestBuild == newWorkspace.latestBuild) {
351420
return
352421
}
@@ -363,6 +432,23 @@ class CoderRemoteEnvironment(
363432
refreshAvailableActions()
364433
}
365434

435+
private suspend fun updateBuildProgress(newWorkspace: Workspace) {
436+
val build = newWorkspace.latestBuild
437+
if (build.status !in IN_PROGRESS_BUILD_STATUSES) return
438+
val latestLog = try {
439+
client.workspaceBuildLogs(build.id).lastOrNull()
440+
} catch (ex: CancellationException) {
441+
throw ex
442+
} catch (ex: Exception) {
443+
context.logger.warn(
444+
currentSessionId(),
445+
ex,
446+
"Failed to retrieve progress for workspace build ${build.id}",
447+
)
448+
return
449+
}
450+
latestLog?.output?.let(::showProgress)
451+
}
366452

367453
private fun updateStatus(
368454
newState: WorkspaceAndAgentStatus,
@@ -380,6 +466,9 @@ class CoderRemoteEnvironment(
380466
state.update {
381467
environmentStatus.toRemoteEnvironmentState(context)
382468
}
469+
description.update {
470+
environmentStatus.toEnvironmentDescription(context)
471+
}
383472
val message =
384473
"Overall status for workspace $id changed from ${previousEnvironmentStatus.label} " +
385474
"to ${environmentStatus.label}. " +

‎src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,11 @@ class CoderCLIManager(
274274
/**
275275
* Start a workspace. Throws if the command execution fails.
276276
*/
277-
internal fun startWorkspace(wsAddress: WorkspaceAddress, feats: Features = features): String {
277+
internal fun startWorkspace(
278+
wsAddress: WorkspaceAddress,
279+
feats: Features = features,
280+
showTextProgress: (String) -> Unit = {},
281+
): String {
278282
val args = mutableListOf(
279283
"--global-config",
280284
coderConfigPath.toString(),
@@ -288,7 +292,7 @@ class CoderCLIManager(
288292
args.add("--")
289293
args.add(wsAddress.ownerAndWsName)
290294

291-
return exec(*args.toTypedArray())
295+
return exec(*args.toTypedArray(), showTextProgress = showTextProgress)
292296
}
293297

294298
/**
@@ -571,12 +575,22 @@ class CoderCLIManager(
571575
return matches
572576
}
573577

574-
private fun exec(vararg args: String): String {
575-
val stdout =
578+
private fun exec(
579+
vararg args: String,
580+
showTextProgress: ((String) -> Unit)? = null,
581+
): String {
582+
val processExecutor =
576583
ProcessExecutor()
577584
.command(localBinaryPath.toString(), *args)
578585
.environment("CODER_HEADER_COMMAND", context.settingsStore.headerCommand)
579586
.exitValues(0)
587+
588+
showTextProgress?.let { reportProgress ->
589+
processExecutor.redirectOutput(reportProgress::invoke)
590+
}
591+
592+
val stdout =
593+
processExecutor
580594
.readOutput(true)
581595
.execute()
582596
.outputUTF8()

‎src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ class CoderLogger(
5959
delegate.warn(withSessionId(sessionId, message))
6060
}
6161

62+
inline fun warn(sessionId: SessionId?, exception: Throwable, message: String) {
63+
delegate.warn(exception, withSessionId(sessionId, message))
64+
}
65+
6266
inline fun warn(sessionIds: Set<SessionId>, exception: Throwable, message: String) {
6367
sessionIds.onceOrForEach { delegate.warn(exception, withSessionId(it, message)) }
6468
}

‎src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import com.coder.toolbox.sdk.v2.models.BuildInfo
1818
import com.coder.toolbox.sdk.v2.models.CoderIdentifierPolicy
1919
import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest
2020
import com.coder.toolbox.sdk.v2.models.InvalidCoderIdentifierException
21+
import com.coder.toolbox.sdk.v2.models.ProvisionerJobLog
2122
import com.coder.toolbox.sdk.v2.models.Template
2223
import com.coder.toolbox.sdk.v2.models.User
2324
import com.coder.toolbox.sdk.v2.models.Workspace
@@ -317,6 +318,25 @@ open class CoderRestClient(
317318
}.withoutUnsafeAgents()
318319
}
319320

321+
/** Retrieves the provisioner logs for [workspaceBuildID]. */
322+
suspend fun workspaceBuildLogs(workspaceBuildID: UUID): List<ProvisionerJobLog> {
323+
val logsResponse = callWithRetry {
324+
retroRestClient.workspaceBuildLogs(workspaceBuildID)
325+
}
326+
if (!logsResponse.isSuccessful) {
327+
throw APIResponseException(
328+
"retrieve logs for workspace build $workspaceBuildID",
329+
url,
330+
logsResponse.code(),
331+
logsResponse.parseErrorBody(moshi),
332+
)
333+
}
334+
335+
return requireNotNull(logsResponse.body()) {
336+
"Successful response returned null body for workspace build logs"
337+
}
338+
}
339+
320340
/**
321341
* @throws [APIResponseException] if issues are encountered during deletion
322342
*/

‎src/main/kotlin/com/coder/toolbox/sdk/v2/CoderV2RestFacade.kt‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.coder.toolbox.sdk.v2
33
import com.coder.toolbox.sdk.v2.models.Appearance
44
import com.coder.toolbox.sdk.v2.models.BuildInfo
55
import com.coder.toolbox.sdk.v2.models.CreateWorkspaceBuildRequest
6+
import com.coder.toolbox.sdk.v2.models.ProvisionerJobLog
67
import com.coder.toolbox.sdk.v2.models.Template
78
import com.coder.toolbox.sdk.v2.models.User
89
import com.coder.toolbox.sdk.v2.models.Workspace
@@ -57,6 +58,12 @@ interface CoderV2RestFacade {
5758
@Body createWorkspaceBuildRequest: CreateWorkspaceBuildRequest,
5859
): Response<WorkspaceBuild>
5960

61+
/** Retrieves the provisioner logs for a workspace build. */
62+
@GET("api/v2/workspacebuilds/{workspaceBuildID}/logs")
63+
suspend fun workspaceBuildLogs(
64+
@Path("workspaceBuildID") workspaceBuildID: UUID,
65+
): Response<List<ProvisionerJobLog>>
66+
6067
/**
6168
* Retrieves all templates the authenticated user can access.
6269
*/
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.coder.toolbox.sdk.v2.models
2+
3+
import com.squareup.moshi.Json
4+
import com.squareup.moshi.JsonClass
5+
import java.time.Instant
6+
7+
@JsonClass(generateAdapter = true)
8+
data class ProvisionerJobLog(
9+
@property:Json(name = "id") val id: Long,
10+
@property:Json(name = "created_at") val createdAt: Instant,
11+
@property:Json(name = "log_source") val source: String,
12+
@property:Json(name = "log_level") val level: String,
13+
@property:Json(name = "stage") val stage: String,
14+
@property:Json(name = "output") val output: String,
15+
)

0 commit comments

Comments
 (0)