Skip to content
Draft
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
15 changes: 15 additions & 0 deletions apps/controller/src/BaseController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type TaskRun,
db,
taskRuns,
taskWorkspaceTransitions,
buildPendingEnvironmentSnapshotMatchForTaskRun,
readManagedDeploymentAccess,
recordTaskRunLifecycleEvent,
Expand Down Expand Up @@ -538,6 +539,20 @@ export abstract class BaseController {
machineId: taskRun.machineId ?? null,
},
});

await tx
.update(taskWorkspaceTransitions)
.set({
status: 'succeeded',
completedAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
eq(taskWorkspaceTransitions.targetRunId, taskRun.id),
eq(taskWorkspaceTransitions.status, 'target_queued'),
),
);
});

if (dequeueSkipped) {
Expand Down
6 changes: 5 additions & 1 deletion apps/controller/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,16 @@ export async function getNamedPortsForTaskRun(
let environmentConfig: EnvironmentConfig | undefined;

if (taskRun.payload.environmentId) {
const pinnedWorkspace = taskRun.resolvedWorkspaceSpec;
const environment = await db.query.environments.findFirst({
where: eq(environments.id, taskRun.payload.environmentId),
});

if (environment) {
environmentConfig = environment.config;
environmentConfig =
pinnedWorkspace?.environmentId === environment.id
? pinnedWorkspace.config
: environment.config;
const previewRuntimeReady = await isPreviewRuntimeReady();

namedPorts = getNamedPortsForEnvironment({
Expand Down
6 changes: 6 additions & 0 deletions apps/docs/environments.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ Add enough context for Roomote to start productively:
Environment changes apply to new tasks. Running tasks keep the workspace they
already started with.

An environment is a versioned run template, not a permanent identity for a
task. An active standard web task can move to another verified environment
with **More actions > Change workspace**. Roomote pins the selected template,
checks that current Git work is safely pushed, and continues the same task in
a fresh runtime rather than mutating the existing sandbox.

## Edit an environment

When you edit an existing environment, Roomote gives you three views of the
Expand Down
17 changes: 17 additions & 0 deletions apps/docs/tasks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,23 @@ Good follow-ups are specific:
- Start a new task when the work is a separate objective, should use a
different environment, or would make the current task thread too broad.

## Change the workspace for an active task

For an active standard task started from the web, open the task's **More
actions** menu and choose **Change workspace**. Select another verified
environment. Roomote keeps the same task and conversation, but starts a fresh
runtime and agent session in the selected environment.

Before shutting down the current runtime, Roomote checks every Git repository
in its workspace. The switch is blocked if a worktree has local changes, if a
branch has commits that have not been pushed, or if a branch has no upstream.
Commit and push the work, then try the switch again. This gate prevents the old
sandbox from being destroyed while it contains the only copy of a change.

Environment configuration is pinned when you request the switch. Later edits
to that environment apply to later tasks and switches, not to the successor run
already being created.

## Before you merge or ship

Before you merge changes, review the diff and the verification Roomote ran.
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,28 @@
import { memo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { Moon, MoreVertical, Trash2 } from '@/components/system';
import {
ArrowLeftRight,
Moon,
MoreVertical,
Trash2,
} from '@/components/system';
import { SideNavItem } from '@/components/layout/side-nav/SideNavItem';

import {
isExitedRunStatus,
isResumableTaskPayloadKind,
isTaskResumeCapableComputeProvider,
runningRunStatuses,
TaskPayloadKind,
} from '@roomote/types';

import { useUser } from '@/hooks/useUser';
import { useDeleteTasks } from '@/hooks/tasks';
import { useCancelTaskRun } from '@/hooks/task-runs';
import { useRequestTaskRunSleep } from '@/hooks/snapshots';
import { useAvailableEnvironments } from '@/hooks/environments';
import { useTRPCClient } from '@/trpc/client';

import {
Button,
Expand All @@ -29,6 +37,12 @@ import {
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/system';

import type { OverflowMenuProps } from './types';
Expand All @@ -44,6 +58,7 @@ function OverflowMenuBase({
const { user } = useUser();

const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showWorkspaceDialog, setShowWorkspaceDialog] = useState(false);

// Task deletion is deployment-wide: any member can delete any task.
const canShutdown = !!taskRun && !isExitedRunStatus(taskRun.status);
Expand All @@ -56,6 +71,11 @@ function OverflowMenuBase({
!taskRun.snapshotFailedAt &&
isResumableTaskPayloadKind(taskRun.payloadKind) &&
isTaskResumeCapableComputeProvider(taskRun.vendor);
const currentEnvironmentId = taskRun?.payload?.environmentId;
const canSwitchWorkspace =
!!taskRun &&
canShutdown &&
taskRun.payloadKind === TaskPayloadKind.StandardTask;

const deleteTasks = useDeleteTasks({
onSuccess: () => {
Expand Down Expand Up @@ -142,6 +162,15 @@ function OverflowMenuBase({
Sleep
</DropdownMenuItem>
) : null}
{canSwitchWorkspace ? (
<DropdownMenuItem
onClick={() => setShowWorkspaceDialog(true)}
className="flex cursor-pointer items-center gap-2"
>
<ArrowLeftRight className="size-4" />
Change workspace
</DropdownMenuItem>
) : null}
<DropdownMenuItem
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
Expand Down Expand Up @@ -183,8 +212,108 @@ function OverflowMenuBase({
</div>
</DialogContent>
</Dialog>
{canSwitchWorkspace && showWorkspaceDialog ? (
<WorkspaceSwitchDialog
taskId={taskId}
currentEnvironmentId={currentEnvironmentId}
open={showWorkspaceDialog}
onOpenChange={setShowWorkspaceDialog}
/>
) : null}
</>
);
}

export const OverflowMenu = memo(OverflowMenuBase);

function WorkspaceSwitchDialog({
taskId,
currentEnvironmentId,
open,
onOpenChange,
}: {
taskId: string;
currentEnvironmentId: string | undefined;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [targetEnvironmentId, setTargetEnvironmentId] = useState('');
const [isSwitching, setIsSwitching] = useState(false);
const environments = useAvailableEnvironments();
const trpcClient = useTRPCClient();
const switchTargets = (environments.data ?? []).filter(
(environment) => environment.id !== currentEnvironmentId,
);

const handleSwitch = async () => {
setIsSwitching(true);
try {
const result = await trpcClient.taskWorkspaceTransitions.request.mutate({
taskId,
targetEnvironmentId,
});
if (!result.success) {
toast.error(result.error);
return;
}
if (result.noop) {
toast.info('This task already uses that workspace.');
onOpenChange(false);
return;
}
toast.success('Workspace switch started.');
onOpenChange(false);
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Workspace switch failed.',
);
} finally {
setIsSwitching(false);
}
};

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>Change workspace</DialogTitle>
<DialogDescription>
Roomote will verify that all current work is committed and pushed,
shut down this runtime, then continue the same task in a fresh
runtime and session.
</DialogDescription>
</DialogHeader>
<Select
value={targetEnvironmentId}
onValueChange={setTargetEnvironmentId}
>
<SelectTrigger aria-label="Target workspace" className="w-full">
<SelectValue placeholder="Select a verified workspace" />
</SelectTrigger>
<SelectContent>
{switchTargets.map((environment) => (
<SelectItem key={environment.id} value={environment.id}>
{environment.name}
</SelectItem>
))}
</SelectContent>
</Select>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSwitching}
>
Cancel
</Button>
<Button
onClick={handleSwitch}
disabled={!targetEnvironmentId || isSwitching}
>
{isSwitching ? 'Checking workspace…' : 'Continue'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
8 changes: 7 additions & 1 deletion apps/web/src/trpc/commands/environments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,13 @@ export async function getEnvironmentsCommand(
const envs = await db
.select()
.from(environments)
.where(and(buildOwnershipFilter(), eq(environments.isEval, false)))
.where(
and(
buildOwnershipFilter(),
eq(environments.isEval, false),
eq(environments.isVerified, true),
),
)
.orderBy(desc(environments.updatedAt));

const snapshotsByEnvironment = await loadEnvironmentSnapshots(envs);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { WorkspaceGitManifest } from '@roomote/types';

export function getGitBlockReason(
manifest: WorkspaceGitManifest,
): string | null {
if (manifest.repositories.length === 0) {
return 'No Git repositories were found in the current workspace.';
}

const dirty = manifest.repositories.filter(
(repository) => repository.dirtyPaths.length > 0,
);
if (dirty.length > 0) {
return `Commit or discard local changes before switching (${dirty.map((repository) => repository.repository).join(', ')}).`;
}

const unpushed = manifest.repositories.filter(
(repository) => repository.upstream === null || repository.ahead > 0,
);
if (unpushed.length > 0) {
return `Push every current branch before switching (${unpushed.map((repository) => repository.repository).join(', ')}).`;
}

return null;
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading