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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

- application name can now be displayed as the main title page instead of the URL

### Changed

- workspaces are now started with the help of the CLI

## 0.7.2 - 2025-11-03

### Changed
Expand Down
33 changes: 30 additions & 3 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import com.jetbrains.toolbox.api.ui.actions.ActionDescription
import com.jetbrains.toolbox.api.ui.components.TextType
import com.squareup.moshi.Moshi
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
Expand All @@ -36,6 +37,7 @@ 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 Down Expand Up @@ -69,6 +71,7 @@ class CoderRemoteEnvironment(
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)) {
Expand Down Expand Up @@ -120,9 +123,29 @@ class CoderRemoteEnvironment(
)
} else {
actions.add(Action(context, "Start") {
val build = client.startWorkspace(workspace)
update(workspace.copy(latestBuild = build), agent)

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)
}
Comment on lines +134 to +142
Copy link
Member

Choose a reason for hiding this comment

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

Thinking out loud, maybe it would be possible this could incorrectly set the state to queued? Like suppose the workspace goes to canceling, then we could possibly update to queued mistakenly, depending on the timing.

Probably not a huge deal, it would eventually correct itself and probably be brief at worst, but one thought is we could check to see if the latest build has changed yet which would indicate a new build finally actually started. Also occurs to me we probably only need to update to queued once.

// ...
// During those 15 seconds ...
state.update { WorkspaceAndAgentStatus.QUEUED.toRemoteEnvironmentState(context) }
// wait for the build to actually start since there is a dry run first
while (startJob.isActive && client.workspace(workspace.id).latestBuild == lastBuild) {
  delay(1.seconds)
}

Or, maybe a more general fix would be for the updater itself to only update the status if the build changed otherwise preserve the current state so we could set the state once manually and have it persist.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The problem with that is every 5 seconds we run the general polling loop. For 15 seconds the workspace has the same status (in most cases it is stopped). We'd have to stop the general polling loop otherwise the workspaces for which we dispatched the start command will go from Stopped to Queued and then back to Stopped at the next general poll.
But if we pause the general poll loop we miss updates on the rest of workspaces. I'm still pondering on the best approach...

Copy link
Member

Choose a reason for hiding this comment

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

This is about my general fix idea right? I was thinking the build check would prevent going back to stopped. So something like:

  1. General poll runs, gets stopped build and sets status to stopped.
  2. User starts workspace and we set the status to queued.
  3. General poll runs, gets the same stopped build again, skips setting the status though because it is the same build it saw previously.
  4. General poll runs twice more, preserves the current status each time.
  5. Workspace finally actually starts after 15 seconds which creates a new started build.
  6. General poll runs, gets started build, updates status to started because it is a new build.

But idk if this is any better than having the startIsInProgress gate, mostly just a thought. I suppose it would let us do things like also immediately set a workspace to stopping when stopping a workspace rather than wait for the request round trip to update the status, which could be nice.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think the existing implementation works in almost exactly what you proposed except the part about gets the same stopped build again, skips setting the status though because it is the same build it saw previously. which does things a bit differently. Instead of the general poll "remembering" the previous poll state it is actually forbidden to change the status of a workspace we know should be starting soon. A second polling loop only for the workspace that should be starting has the right to move the status from "queued" to "starting", after which the secondary poll loop dies and the primary poll loop will get back the full rights.

Now that I'm explaining it, it sounds like a bit of over engineering (two polling loops) though.

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.

Yup yup, similar but only for this specific starting flow, whereas my proposal works for any state change that might take a while, plus it only needs the one loop.

startIsInProgress.set(false)
// retrieve the status again and update the status
update(client.workspace(workspace.id), agent)
} finally {
startIsInProgress.set(false)
}
}
)
}
Expand Down Expand Up @@ -241,6 +264,10 @@ 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")
return
}
this.workspace = workspace
this.agent = agent
wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent)
Expand Down
23 changes: 22 additions & 1 deletion src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ data class Features(
val disableAutostart: Boolean = false,
val reportWorkspaceUsage: Boolean = false,
val wildcardSsh: Boolean = false,
val buildReason: Boolean = false,
)

/**
Expand Down Expand Up @@ -304,6 +305,25 @@ class CoderCLIManager(
)
}

/**
* Start a workspace. Throws if the command execution fails.
*/
fun startWorkspace(workspaceOwner: String, workspaceName: String, feats: Features = features): String {
val args = mutableListOf(
"--global-config",
coderConfigPath.toString(),
"start",
"--yes",
"$workspaceOwner/$workspaceName"
)

if (feats.buildReason) {
args.addAll(listOf("--reason", "jetbrains_connection"))
}

return exec(*args.toTypedArray())
}

/**
* Configure SSH to use this binary.
*
Expand Down Expand Up @@ -569,7 +589,8 @@ class CoderCLIManager(
Features(
disableAutostart = version >= SemVer(2, 5, 0),
reportWorkspaceUsage = version >= SemVer(2, 13, 0),
version >= SemVer(2, 19, 0),
wildcardSsh = version >= SemVer(2, 19, 0),
buildReason = version >= SemVer(2, 25, 0),
)
}
}
Expand Down
1 change: 1 addition & 0 deletions src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ open class CoderRestClient(
/**
* @throws [APIResponseException].
*/
@Deprecated(message = "This operation needs to be delegated to the CLI")
suspend fun startWorkspace(workspace: Workspace): WorkspaceBuild {
val buildRequest = CreateWorkspaceBuildRequest(
null,
Expand Down
49 changes: 35 additions & 14 deletions src/main/kotlin/com/coder/toolbox/sdk/v2/models/WorkspaceBuild.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,41 @@ import java.util.UUID
*/
@JsonClass(generateAdapter = true)
data class WorkspaceBuild(
@Json(name = "template_version_id") val templateVersionID: UUID,
@Json(name = "resources") val resources: List<WorkspaceResource>,
@Json(name = "status") val status: WorkspaceStatus,
@property:Json(name = "template_version_id") val templateVersionID: UUID,
@property:Json(name = "resources") val resources: List<WorkspaceResource>,
@property:Json(name = "status") val status: WorkspaceStatus,
)

enum class WorkspaceStatus {
@Json(name = "pending") PENDING,
@Json(name = "starting") STARTING,
@Json(name = "running") RUNNING,
@Json(name = "stopping") STOPPING,
@Json(name = "stopped") STOPPED,
@Json(name = "failed") FAILED,
@Json(name = "canceling") CANCELING,
@Json(name = "canceled") CANCELED,
@Json(name = "deleting") DELETING,
@Json(name = "deleted") DELETED,
}
@Json(name = "pending")
PENDING,

@Json(name = "starting")
STARTING,

@Json(name = "running")
RUNNING,

@Json(name = "stopping")
STOPPING,

@Json(name = "stopped")
STOPPED,

@Json(name = "failed")
FAILED,

@Json(name = "canceling")
CANCELING,

@Json(name = "canceled")
CANCELED,

@Json(name = "deleting")
DELETING,

@Json(name = "deleted")
DELETED;

fun isNotStarted(): Boolean = this != STARTING && this != RUNNING
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ open class CoderProtocolHandler(
}
reInitialize(restClient, cli)
context.envPageManager.showPluginEnvironmentsPage()
if (!prepareWorkspace(workspace, restClient, workspaceName, deploymentURL)) return
if (!prepareWorkspace(workspace, restClient, cli, workspaceName, deploymentURL)) return
// we resolve the agent after the workspace is started otherwise we can get misleading
// errors like: no agent available while workspace is starting or stopping
// we also need to retrieve the workspace again to have the latest resources (ex: agent)
Expand Down Expand Up @@ -180,6 +180,7 @@ open class CoderProtocolHandler(
private suspend fun prepareWorkspace(
workspace: Workspace,
restClient: CoderRestClient,
cli: CoderCLIManager,
workspaceName: String,
deploymentURL: String
): Boolean {
Expand Down Expand Up @@ -207,7 +208,7 @@ open class CoderProtocolHandler(
if (workspace.outdated) {
restClient.updateWorkspace(workspace)
} else {
restClient.startWorkspace(workspace)
cli.startWorkspace(workspace.ownerName, workspace.name)
}
} catch (e: Exception) {
context.logAndShowError(
Expand Down
20 changes: 18 additions & 2 deletions src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -976,8 +976,24 @@ internal class CoderCLIManagerTest {
val tests =
listOf(
Pair("2.5.0", Features(true)),
Pair("2.13.0", Features(true, true)),
Pair("4.9.0", Features(true, true, true)),
Pair("2.13.0", Features(disableAutostart = true, reportWorkspaceUsage = true)),
Pair(
"2.25.0",
Features(
disableAutostart = true,
reportWorkspaceUsage = true,
wildcardSsh = true,
buildReason = true
)
),
Pair(
"4.9.0", Features(
disableAutostart = true,
reportWorkspaceUsage = true,
wildcardSsh = true,
buildReason = true
)
),
Pair("2.4.9", Features(false)),
Pair("1.0.1", Features(false)),
)
Expand Down
Loading