Skip to content
Open
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
107 changes: 56 additions & 51 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import java.io.File
import java.nio.file.Path
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds

Expand All @@ -55,37 +54,39 @@ class CoderRemoteEnvironment(
private var workspace: Workspace,
private var agent: WorkspaceAgent,
) : RemoteProviderEnvironment("${workspace.name}.${agent.name}"), BeforeConnectionHook, AfterDisconnectHook {
private var wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent)
private var environmentStatus = WorkspaceAndAgentStatus.from(workspace, agent)

override var name: String = "${workspace.name}.${agent.name}"
private var isConnected: MutableStateFlow<Boolean> = MutableStateFlow(false)
override val connectionRequest: MutableStateFlow<Boolean> = MutableStateFlow(false)

override val state: MutableStateFlow<RemoteEnvironmentState> =
MutableStateFlow(wsRawStatus.toRemoteEnvironmentState(context))
MutableStateFlow(environmentStatus.toRemoteEnvironmentState(context))
override val description: MutableStateFlow<EnvironmentDescription> =
MutableStateFlow(EnvironmentDescription.General(context.i18n.pnotr(workspace.templateDisplayName)))
override val additionalEnvironmentInformation: MutableMap<LocalizableString, String> = mutableMapOf()
override val actionsList: MutableStateFlow<List<ActionDescription>> = MutableStateFlow(getAvailableActions())
override val actionsList: MutableStateFlow<List<ActionDescription>> = MutableStateFlow(emptyList())

private val networkMetricsMarshaller = Moshi.Builder().build().adapter(NetworkMetrics::class.java)
private val proxyCommandHandle = SshCommandProcessHandle(context)
private var pollJob: Job? = null
private val startIsInProgress = AtomicBoolean(false)

init {
if (context.settingsStore.shouldAutoConnect(id)) {
context.logger.info("resuming SSH connection to $id — last session was still active.")
startSshConnection()
}
refreshAvailableActions()
}

fun asPairOfWorkspaceAndAgent(): Pair<Workspace, WorkspaceAgent> = Pair(workspace, agent)

private fun getAvailableActions(): List<ActionDescription> {
private fun refreshAvailableActions() {
val actions = mutableListOf<ActionDescription>()
if (wsRawStatus.canStop()) {
context.logger.debug("Refreshing available actions for workspace $id with status: $environmentStatus")
if (environmentStatus.canStop()) {
actions.add(Action(context, "Open web terminal") {
context.logger.debug("Launching web terminal for $id...")
context.desktop.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) {
context.ui.showErrorInfoPopup(it)
}
Expand All @@ -97,8 +98,9 @@ class CoderRemoteEnvironment(
val urlTemplate = context.settingsStore.workspaceViewUrl
?: client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString()
val url = urlTemplate
.replace("\$workspaceOwner", "${workspace.ownerName}")
.replace("\$workspaceOwner", workspace.ownerName)
.replace("\$workspaceName", workspace.name)
context.logger.debug("Opening the dashboard for $id...")
context.desktop.browse(
url
) {
Expand All @@ -108,59 +110,47 @@ class CoderRemoteEnvironment(
)

actions.add(Action(context, "View template") {
context.logger.debug("Opening the template for $id...")
context.desktop.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) {
context.ui.showErrorInfoPopup(it)
}
}
)
})

if (wsRawStatus.canStart()) {
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)
}
)
})
} else {
actions.add(Action(context, "Start") {
try {
// needed in order to make sure Queuing is not overridden by the
// general polling loop with the `Stopped` state
startIsInProgress.set(true)
val startJob = context.cs
.launch(CoroutineName("Start Workspace Action CLI Runner") + Dispatchers.IO) {
cli.startWorkspace(workspace.ownerName, workspace.name)
}
// 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
while (startJob.isActive && client.workspace(workspace.id).latestBuild.status.isNotStarted()) {
state.update {
WorkspaceAndAgentStatus.QUEUED.toRemoteEnvironmentState(context)
}
delay(1.seconds)
context.logger.debug("Starting $id... ")
context.cs
.launch(CoroutineName("Start Workspace Action CLI Runner") + Dispatchers.IO) {
cli.startWorkspace(workspace.ownerName, workspace.name)
}
startIsInProgress.set(false)
// retrieve the status again and update the status
update(client.workspace(workspace.id), agent)
} finally {
startIsInProgress.set(false)
}
}
)
// 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)
// force refresh of the actions list (Start should no longer be available)
refreshAvailableActions()
})
}
}
if (wsRawStatus.canStop()) {
if (environmentStatus.canStop()) {
if (workspace.outdated) {
actions.add(Action(context, "Update and restart") {
context.logger.debug("Updating and re-starting $id...")
val build = client.updateWorkspace(workspace)
update(workspace.copy(latestBuild = build), agent)
}
)
}
actions.add(Action(context, "Stop") {
tryStopSshConnection()

context.logger.debug("Stoping $id...")
val build = client.stopWorkspace(workspace)
update(workspace.copy(latestBuild = build), agent)
}
Expand All @@ -170,12 +160,14 @@ class CoderRemoteEnvironment(
actions.add(Action(context, "Delete workspace", highlightInRed = true) {
context.cs.launch(CoroutineName("Delete Workspace Action")) {
var dialogText =
if (wsRawStatus.canStop()) "This will close the workspace and remove all its information, including files, unsaved changes, history, and usage data."
if (environmentStatus.canStop()) "This will close the workspace and remove all its information, including files, unsaved changes, history, and usage data."
else "This will remove all information from the workspace, including files, unsaved changes, history, and usage data."
dialogText += "\n\nType \"${workspace.name}\" below to confirm:"

val confirmation = context.ui.showTextInputPopup(
if (wsRawStatus.canStop()) context.i18n.ptrl("Delete running workspace?") else context.i18n.ptrl("Delete workspace?"),
if (environmentStatus.canStop()) context.i18n.ptrl("Delete running workspace?") else context.i18n.ptrl(
"Delete workspace?"
),
context.i18n.pnotr(dialogText),
context.i18n.ptrl("Workspace name"),
TextType.General,
Expand All @@ -185,10 +177,14 @@ class CoderRemoteEnvironment(
if (confirmation != workspace.name) {
return@launch
}
context.logger.debug("Deleting $id...")
deleteWorkspace()
}
})
return actions

actionsList.update {
actions
}
}

private suspend fun tryStopSshConnection() {
Expand Down Expand Up @@ -264,23 +260,32 @@ class CoderRemoteEnvironment(
* Update the workspace/agent status to the listeners, if it has changed.
*/
fun update(workspace: Workspace, agent: WorkspaceAgent) {
if (startIsInProgress.get()) {
context.logger.info("Skipping update for $id - workspace start is in progress")
if (WorkspaceAndAgentStatus.from(this.workspace, this.agent) == WorkspaceAndAgentStatus.from(
workspace,
agent
)
) {
Comment on lines +263 to +267
Copy link
Member

@code-asher code-asher Nov 21, 2025

Choose a reason for hiding this comment

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

I think checking the status is subtly different from checking if there is a new build, because imagine this scenario:

  1. Latest build is failed.
  2. The poll runs, so we currently have failed stored as the status.
  3. User starts workspace, we set to queued.
  4. Build fails again (a brand new build, but it is also failing).
  5. Poll runs again, checks failed against the last status, which is also failed, so it leaves the status as queued, even though the workspace is actually failed currently, so really we do want to update the state to failed.

So we could miss updates from one build to another build when both builds end up with the same status.

return
}
this.workspace = workspace
this.agent = agent
wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent)
// workspace&agent status can be different from "environment status"
// which is forced to queued state when a workspace is scheduled to start
updateStatus(WorkspaceAndAgentStatus.from(workspace, agent))

// we have to regenerate the action list in order to force a redraw
// because the actions don't have a state flow on the enabled property
actionsList.update {
getAvailableActions()
}
refreshAvailableActions()
}

private fun updateStatus(status: WorkspaceAndAgentStatus) {
environmentStatus = status
context.cs.launch(CoroutineName("Workspace Status Updater")) {
state.update {
wsRawStatus.toRemoteEnvironmentState(context)
environmentStatus.toRemoteEnvironmentState(context)
}
}
context.logger.debug("Overall status for workspace $id is $environmentStatus. Workspace status: ${workspace.latestBuild.status}, agent status: ${agent.status}, agent lifecycle state: ${agent.lifecycleState}, login before ready: ${agent.loginBeforeReady}")
}

/**
Expand Down Expand Up @@ -310,7 +315,7 @@ class CoderRemoteEnvironment(
* Returns true if the SSH connection was scheduled to start, false otherwise.
*/
fun startSshConnection(): Boolean {
if (wsRawStatus.ready() && !isConnected.value) {
if (environmentStatus.ready() && !isConnected.value) {
context.cs.launch(CoroutineName("SSH Connection Trigger")) {
connectionRequest.update {
true
Expand All @@ -336,7 +341,7 @@ class CoderRemoteEnvironment(
withTimeout(5.minutes) {
var workspaceStillExists = true
while (context.cs.isActive && workspaceStillExists) {
if (wsRawStatus == WorkspaceAndAgentStatus.DELETING || wsRawStatus == WorkspaceAndAgentStatus.DELETED) {
if (environmentStatus == WorkspaceAndAgentStatus.DELETING || environmentStatus == WorkspaceAndAgentStatus.DELETED) {
workspaceStillExists = false
context.envPageManager.showPluginEnvironmentsPage()
} else {
Expand Down
Loading