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
10 changes: 5 additions & 5 deletions cmd/internal/agentworkspace/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/devsy-org/devsy/pkg/agent/tunnel"
"github.com/devsy-org/devsy/pkg/agent/tunnelserver"
"github.com/devsy-org/devsy/pkg/client/clientimplementation"
"github.com/devsy-org/devsy/pkg/clierr"
"github.com/devsy-org/devsy/pkg/command"
"github.com/devsy-org/devsy/pkg/config"
"github.com/devsy-org/devsy/pkg/credentials"
Expand Down Expand Up @@ -151,11 +152,10 @@ func (cmd *UpCmd) up(
) error {
result, err := cmd.devsyUp(ctx, workspaceInfo)
if err != nil {
// Forward the structured error back to the host through the tunnel
// BEFORE returning so the CLI can surface the actual cause (e.g.
// host requirements not met) instead of the generic "did not
// receive a result back from agent" fallback.
errResult := &config2.Result{Error: err.Error()}
errResult := &config2.Result{
Error: err.Error(),
RecoveryAvailable: errors.Is(err, clierr.ErrBuildFailedRecoverable),
}
if sendErr := cmd.sendResult(ctx, errResult, tunnelClient); sendErr != nil {
log.Errorf("failed to forward up error %q to host: %v", err, sendErr)
}
Expand Down
3 changes: 3 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ func exitCodeForError(err error, machineMode bool) int {
if errors.Is(err, workspace.ErrWorkspaceNotFound) {
return exitcode.Retryable
}
if cliErr.Code == clierr.CodeBuildFailedRecoverable {
return exitcode.BuildFailedRecoverable
}
return exitcode.Failure
}

Expand Down
30 changes: 27 additions & 3 deletions cmd/workspace/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
cliflags "github.com/devsy-org/devsy/pkg/flags"
"github.com/devsy-org/devsy/pkg/flags/names"
"github.com/devsy-org/devsy/pkg/output"
"github.com/devsy-org/devsy/pkg/provider"
workspace2 "github.com/devsy-org/devsy/pkg/workspace"
"github.com/spf13/cobra"
)
Expand All @@ -24,7 +25,8 @@ type StatusCmd struct {
*flags.GlobalFlags
client2.StatusOptions

Timeout string
Timeout string
Recovery bool
}

// NewStatusCmd creates a new command.
Expand Down Expand Up @@ -55,6 +57,9 @@ func NewStatusCmd(globalFlags *flags.GlobalFlags) *cobra.Command {
"If enabled shows the workspace container status as well"),
cliflags.String(&cmd.Timeout, names.Timeout, "30s",
"The timeout to wait until the status can be retrieved"),
cliflags.Bool(&cmd.Recovery, names.Recovery, false,
"Include whether the running container is a recovery container "+
"(JSON output only)"),
)
return statusCmd
}
Expand Down Expand Up @@ -90,12 +95,14 @@ func (cmd *StatusCmd) Run(
case output.ModePlain:
_, _ = fmt.Fprintln(os.Stdout, string(instanceStatus))
case output.ModeJSON:
out, err := json.Marshal(&client2.WorkspaceStatus{
status := client2.WorkspaceStatus{
ID: client.Workspace(),
Context: client.Context(),
Provider: client.Provider(),
State: string(instanceStatus),
})
Recovery: cmd.resolveRecovery(client, instanceStatus),
}
out, err := json.Marshal(&status)
if err != nil {
return err
}
Expand All @@ -106,6 +113,23 @@ func (cmd *StatusCmd) Run(
return nil
}

// resolveRecovery reports whether the running container was built in recovery
// mode, looked up from the persisted workspace result. It is opt-in (--recovery)
// so the frequent status poll pays no extra disk read.
func (cmd *StatusCmd) resolveRecovery(
c client2.BaseWorkspaceClient,
status client2.Status,
) bool {
if !cmd.Recovery || status != client2.StatusRunning {
return false
}
result, err := provider.LoadWorkspaceResult(c.Context(), c.Workspace())
if err != nil || result == nil {
return false
}
return result.RecoveryContainer
}

func (cmd *StatusCmd) execute(ctx context.Context, args []string) error {
if _, err := clientimplementation.DecodeOptionsFromEnv(
config.EnvFlagsStatus, &cmd.StatusOptions,
Expand Down
5 changes: 4 additions & 1 deletion cmd/workspace/up/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,15 +330,18 @@ func reportErr(err error, emitJSON bool, out io.Writer) error {
func emitUpResult(wctx *workspaceContext, ideURL string, out io.Writer) {
containerID := config2.GetContainerID(wctx.result)
var warnings []string
recovery := false
if wctx.result != nil {
warnings = wctx.result.HostWarnings
recovery = wctx.result.RecoveryContainer
}
_ = config2.WriteResultJSON(out, config2.ResultEnvelope{
ContainerID: containerID,
RemoteUser: wctx.user,
RemoteWorkspaceFolder: wctx.workdir,
URL: ideURL,
Warnings: warnings,
Recovery: recovery,
})
}

Expand Down Expand Up @@ -472,7 +475,7 @@ func (cmd *UpCmd) executeDevsyUp(
func validateUpResult(result *config2.Result, err error) error {
if resultErr := result.Err(); resultErr != nil {
if err != nil {
return fmt.Errorf("start workspace: %s: %w", resultErr, err)
return fmt.Errorf("start workspace: %w: %w", resultErr, err)
}
return fmt.Errorf("start workspace: %w", resultErr)
}
Expand Down
3 changes: 3 additions & 0 deletions cmd/workspace/up/up_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ func (cmd *UpCmd) registerBuildFlags(upCmd *cobra.Command) {
"instead of writing it"),
flags.Bool(&cmd.NoLockfile, names.NoLockfile, false,
"Disable devcontainer-lock.json generation and verification"),
flags.Bool(&cmd.Recovery, names.Recovery, false,
"If the dev container build fails, launch a recovery container with features and "+
"lifecycle commands disabled so you can repair devcontainer.json and rebuild"),
)
flags.RegisterDevContainerModifierFlags(upCmd.Flags(), flags.DevContainerModifierFlags{
Image: &cmd.DevContainerImage,
Expand Down
19 changes: 13 additions & 6 deletions desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,16 +240,18 @@ export function registerIpcHandlers(deps: IpcDependencies): {

ipcMain.handle(
"workspace_status",
async (_event, args: { workspaceId: string }) => {
return cli.runRaw([
async (_event, args: { workspaceId: string; recovery?: boolean }) => {
const cliArgs = [
"workspace",
"status",
args.workspaceId,
"--result-format",
"json",
"--timeout",
"5s",
])
]
if (args.recovery) cliArgs.push("--recovery")
return cli.runRaw(cliArgs)
},
)

Expand Down Expand Up @@ -704,6 +706,7 @@ export function registerIpcHandlers(deps: IpcDependencies): {
devcontainer?: string
prebuildRepository?: string
platform?: string
recovery?: boolean
},
) => {
trackEvent("workspace_create", {
Expand All @@ -723,6 +726,7 @@ export function registerIpcHandlers(deps: IpcDependencies): {
if (args.prebuildRepository)
cliArgs.push("--prebuild-repo", args.prebuildRepository)
if (args.platform) cliArgs.push("--platform", args.platform)
if (args.recovery) cliArgs.push("--recovery")

const wsId = args.workspaceId ?? args.source
const cmdId = crypto.randomUUID()
Expand Down Expand Up @@ -758,13 +762,14 @@ export function registerIpcHandlers(deps: IpcDependencies): {

if (!sink.line(formatted)) return logStore.onDrain(logPath)
},
(code) => {
(code, cliError) => {
if (tunnelProcesses.get(wsId) === child) {
tunnelProcesses.delete(wsId)
}
if (signalledDone) return
void sink.done(
formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"),
code === 0 ? undefined : { level: "error", cliError },
)
},
wsId,
Expand Down Expand Up @@ -884,9 +889,10 @@ export function registerIpcHandlers(deps: IpcDependencies): {
(line) => {
if (!sink.line(formatLogLine(line))) return logStore.onDrain(logPath)
},
(code) => {
(code, cliError) => {
void sink.done(
formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"),
code === 0 ? undefined : { level: "error", cliError },
)
},
args.workspaceId,
Expand Down Expand Up @@ -922,9 +928,10 @@ export function registerIpcHandlers(deps: IpcDependencies): {
(line) => {
if (!sink.line(formatLogLine(line))) return logStore.onDrain(logPath)
},
(code) => {
(code, cliError) => {
void sink.done(
formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"),
code === 0 ? undefined : { level: "error", cliError },
)
},
args.workspaceId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,7 @@ import { goto } from "$lib/router.js"
import { Button } from "$lib/components/ui/button/index.js"
import { badgeVariants } from "$lib/components/ui/badge/index.js"
import ConfirmDialog from "$lib/components/layout/ConfirmDialog.svelte"
import {
workspaceUp,
workspaceStop,
workspaceDelete,
} from "$lib/ipc/commands.js"
import { workspaceStop, workspaceDelete } from "$lib/ipc/commands.js"
import { toasts } from "$lib/stores/toasts.js"
import { extractErrorMessage } from "$lib/utils/error.js"
import type { Workspace } from "$lib/types/index.js"
Expand Down Expand Up @@ -38,17 +34,9 @@ function handleOpen(e: Event) {
goto(`/workspaces/${workspace.id}?action=open-ide`)
}

async function handleStart(e: Event) {
function handleStart(e: Event) {
e.stopPropagation()
acting = true
try {
await workspaceUp({ source: workspace.id })
toasts.success(`Starting ${workspace.id}...`)
} catch (err) {
toasts.error(`Failed to start: ${extractErrorMessage(err)}`)
} finally {
acting = false
}
goto(`/workspaces/${workspace.id}?action=start`)
}

async function handleStop(e: Event) {
Expand Down Expand Up @@ -127,9 +115,7 @@ async function handleDelete() {
Open
</Button>
{:else if isStopped}
<Button size="sm" onclick={handleStart} disabled={acting}>
{acting ? "Starting..." : "Start"}
</Button>
<Button size="sm" onclick={handleStart}>Start</Button>
{/if}
{#if isRunning || isBusy}
<Button variant="outline" size="sm" onclick={handleStop} disabled={acting}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
GitBranch,
FolderOpen,
Container,
LifeBuoy,
} from "@lucide/svelte"
import { Button } from "$lib/components/ui/button/index.js"
import * as Command from "$lib/components/ui/command/index.js"
Expand Down Expand Up @@ -48,7 +49,11 @@ import { isImageCompatible } from "$lib/stores/imageCatalog.js"
import { loadLocalOptions } from "$lib/stores/settings.js"
import { toasts } from "$lib/stores/toasts.js"
import { extractErrorMessage } from "$lib/utils/error.js"
import { isCommandSuccess, stripAnsi } from "$lib/utils/log-parser.js"
import {
isRecoverableBuildFailure,
isCommandSuccess,
stripAnsi,
} from "$lib/utils/log-parser.js"
import type { UnlistenFn } from "$lib/ipc/types.js"

type Step = "provider" | "source" | "ide" | "review" | "launch"
Expand Down Expand Up @@ -218,6 +223,9 @@ let commandId = $state<string | null>(null)
let outputLines = $state<string[]>([])
let launchRunning = $state(false)
let launchError = $state("")
let launchBuildFailed = $state(false)
let launchIsRecovery = $state(false)
let lastAttemptedId = $state("")
let launchSuccess = $state(false)
let launchedWorkspaceId = $state<string | null>(null)
let confirmCancelOpen = $state(false)
Expand Down Expand Up @@ -465,14 +473,29 @@ function handleProgress(progress: CommandProgress, wsId: string | undefined) {
} else {
launchError = "Workspace creation failed. Check output for details."
toasts.error(launchError)
if (isRecoverableBuildFailure(progress.cliError)) {
const pref = loadLocalOptions().onBuildFailure
if (pref === "auto-recovery" && !launchIsRecovery) {
void handleLaunch(true)
} else if (pref !== "nothing") {
launchBuildFailed = true
}
}
}
}
}

async function handleLaunch(isRetry = false) {
if (resolvedIdInvalid || (nameConflict && !isRetry)) return
async function handleLaunch(recovery = false) {
// Retrying/recovering the id we already attempted is allowed; only a
// genuinely new conflicting id is blocked.
if (resolvedIdInvalid || (nameConflict && resolvedId !== lastAttemptedId)) {
return
}
lastAttemptedId = resolvedId
launchIsRecovery = recovery
launchRunning = true
launchError = ""
launchBuildFailed = false
launchSuccess = false
outputLines = []
pendingLines = []
Expand Down Expand Up @@ -515,6 +538,7 @@ async function handleLaunch(isRetry = false) {
? emulationTarget
: undefined,
debug: loadLocalOptions().debugFlag,
recovery,
})

commandId = cmdId
Expand Down Expand Up @@ -1243,7 +1267,13 @@ function selectTemplate(t: { name: string; source: string }) {
</Button>
{:else if launchError}
<Button variant="outline" onclick={() => (open = false)}>Close</Button>
<Button onclick={() => handleLaunch(true)}>Retry</Button>
{#if launchBuildFailed && !launchIsRecovery}
<Button variant="secondary" onclick={() => handleLaunch(true)}>
<LifeBuoy class="h-4 w-4" />
Reopen in Recovery Container
</Button>
{/if}
<Button onclick={() => handleLaunch()}>Retry</Button>
{/if}
</div>
</div>
Expand Down
19 changes: 19 additions & 0 deletions desktop/src/renderer/src/lib/ipc/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ describe("IPC commands", () => {
})
})

it("workspaceUp forwards recovery", async () => {
mockInvoke.mockResolvedValue("cmd-recovery")
await workspaceUp({ source: "my-repo", recovery: true })
expect(mockInvoke).toHaveBeenCalledWith("workspace_up", {
source: "my-repo",
recovery: true,
})
})

it("workspaceStop passes workspaceId", async () => {
await workspaceStop("ws-1")
expect(mockInvoke).toHaveBeenCalledWith("workspace_stop", {
Expand All @@ -94,10 +103,20 @@ describe("IPC commands", () => {
const result = await workspaceStatus("ws-1")
expect(mockInvoke).toHaveBeenCalledWith("workspace_status", {
workspaceId: "ws-1",
recovery: false,
})
expect(result).toBe('{"state":"Running"}')
})

it("workspaceStatus requests recovery when asked", async () => {
mockInvoke.mockResolvedValue('{"state":"Running","recovery":true}')
await workspaceStatus("ws-1", true)
expect(mockInvoke).toHaveBeenCalledWith("workspace_status", {
workspaceId: "ws-1",
recovery: true,
})
})

it("workspaceUp forwards devcontainer and prebuildRepository", async () => {
mockInvoke.mockResolvedValue("cmd-id")
await workspaceUp({
Expand Down
Loading
Loading