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
97 changes: 97 additions & 0 deletions backend/app/api/endpoints/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,3 +438,100 @@ async def execute_device_command(
result.get("duration"),
)
return DeviceCommandResponse(**result)


# ==================== Device Session Endpoints ====================

DEFAULT_DEVICE_SESSION_PATH = "/home/ubuntu/.wegent-executor/workspace"


class DeviceSessionResponse(BaseModel):
"""Response model for device session creation."""

session_id: str = Field(..., description="Unique session identifier")
device_id: str = Field(..., description="Target device ID")
type: str = Field(..., description="Session type (terminal or code_server)")
path: str = Field(..., description="Working directory path")
url: str = Field(default="", description="Browser-accessible session URL")


@router.post("/{device_id}/terminal", response_model=DeviceSessionResponse)
async def start_device_terminal(
device_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(security.get_current_user),
):
Comment on lines +458 to +463

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add return type hints and async tracing decorators on the new endpoints.

Both new async handlers are missing explicit return annotations and endpoint-level tracing decorators.

As per coding guidelines, use type hints in Python code and apply @trace_async() to entire async functions.

Also applies to: 499-504

🧰 Tools
🪛 Ruff (0.15.14)

[warning] 461-461: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)


[warning] 462-462: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/api/endpoints/devices.py` around lines 458 - 463, Add explicit
return type annotations and the tracing decorator to the new async endpoint
handlers: annotate start_device_terminal with -> DeviceSessionResponse and
prepend the function with `@trace_async`(), and do the same for the other async
handler defined around lines 499-504 (apply `@trace_async`() and add its correct
response model return type). Ensure you import trace_async if needed and place
the decorator directly above each async def (start_device_terminal and the other
handler name) so tracing wraps the entire coroutine.

"""Start a terminal session on a device.

Sends a Socket.IO RPC to the online device executor to start a ttyd
terminal session. Requires the device to be online.
"""
from app.services.device.session_service import (
DeviceSessionError,
local_device_session_service,
)

try:
result = await local_device_session_service.start_session(
db=db,
user_id=current_user.id,
device_id=device_id,
project_id=0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Extract project_id=0 into a named constant.

The literal 0 is repeated in both session-start calls; define a module constant for clarity and contract intent.

♻️ Proposed refactor
 DEFAULT_DEVICE_SESSION_PATH = "/home/ubuntu/.wegent-executor/workspace"
+DEFAULT_DEVICE_PROJECT_ID = 0
...
-            project_id=0,
+            project_id=DEFAULT_DEVICE_PROJECT_ID,
...
-            project_id=0,
+            project_id=DEFAULT_DEVICE_PROJECT_ID,

As per coding guidelines, extract magic numbers to named constants in Python code.

Also applies to: 520-520

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/api/endpoints/devices.py` at line 479, Replace the magic literal
project_id=0 in the two session-start calls with a named module-level constant
(e.g. SYSTEM_PROJECT_ID or DEFAULT_PROJECT_ID); declare this constant at the top
of the module and use it in place of the literal in both locations (the two
occurrences of project_id=0 in devices.py) so callers like the session-start
invocations reference the constant (e.g., DEFAULT_PROJECT_ID) instead of the
hard-coded 0.

session_type="terminal",
path=DEFAULT_DEVICE_SESSION_PATH,
create_if_missing=True,
)
except DeviceSessionError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
Comment on lines +485 to +488

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Preserve exception causality when mapping session errors to HTTP errors.

Use exception chaining so traceback clearly shows the original DeviceSessionError.

🛠️ Proposed fix
     except DeviceSessionError as exc:
-        raise HTTPException(
+        raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,
             detail=str(exc),
-        )
+        ) from exc
...
     except DeviceSessionError as exc:
-        raise HTTPException(
+        raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,
             detail=str(exc),
-        )
+        ) from exc

Also applies to: 526-529

🧰 Tools
🪛 Ruff (0.15.14)

[warning] 485-488: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/api/endpoints/devices.py` around lines 485 - 488, When mapping a
caught DeviceSessionError to an HTTPException in devices.py (the blocks that
currently do raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc))), preserve exception causality by using exception chaining:
raise the HTTPException with the same status_code and detail but append "from
exc" (e.g., raise HTTPException(..., detail=str(exc)) from exc). Apply this
change to both locations handling DeviceSessionError (the block around the
existing raise at lines ~485-488 and the similar block at ~526-529) so the
original DeviceSessionError appears in tracebacks.


return DeviceSessionResponse(
session_id=result.get("session_id", ""),
device_id=result.get("device_id", device_id),
type="terminal",
path=result.get("path", DEFAULT_DEVICE_SESSION_PATH),
url=result.get("url", ""),
)


@router.post("/{device_id}/code-server", response_model=DeviceSessionResponse)
async def start_device_code_server(
device_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(security.get_current_user),
):
"""Start a code-server (IDE) session on a device.

Sends a Socket.IO RPC to the online device executor to start a
code-server session. Requires the device to be online.
"""
from app.services.device.session_service import (
DeviceSessionError,
local_device_session_service,
)

try:
result = await local_device_session_service.start_session(
db=db,
user_id=current_user.id,
device_id=device_id,
project_id=0,
session_type="code_server",
path=DEFAULT_DEVICE_SESSION_PATH,
create_if_missing=True,
)
except DeviceSessionError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)

return DeviceSessionResponse(
session_id=result.get("session_id", ""),
device_id=result.get("device_id", device_id),
type="code_server",
path=result.get("path", DEFAULT_DEVICE_SESSION_PATH),
url=result.get("url", ""),
)
30 changes: 28 additions & 2 deletions docs/en/user-guide/ai-devices/local-device-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,31 @@ Simply change the device selection before sending each message.
Access your devices through:

1. **Device Selector**: Quick access in chat interface
2. **API**: `GET /devices` for programmatic access
2. **Settings Page**: Go to **Settings** → **Connections** to view connectable devices
3. **API**: `GET /devices` for programmatic access

### Managing Cloud Devices

The **Settings** → **Connections** page lists Claude Code cloud devices that the current account can connect to. It only shows devices with `device_type=cloud` and `bind_shell=claudecode`, and displays online status, executor version, CPU, memory, and disk usage.

When no cloud device exists, click **Add** to create one. After the create request returns, the page keeps a "cloud device creating" notice visible. Initialization usually takes 2-3 minutes, and the device appears in the list automatically when it comes online.

Online cloud devices can open interactive sessions directly:

| Action | Backend API | Description |
|--------|-------------|-------------|
| **Terminal** | `POST /api/devices/{device_id}/terminal` | Starts ttyd in the default working directory `/home/ubuntu/.wegent-executor/workspace` |
| **IDE** | `POST /api/devices/{device_id}/code-server` | Opens a code-server session |

The returned URL includes a short-lived session token and is exposed through the device-side session gateway. Terminal and IDE buttons are disabled while the device is offline.

The more menu contains lower-frequency management actions:

| Action | Description |
|--------|-------------|
| **Rename** | Click the device name or edit icon; the list refreshes after saving |
| **Restart Device** | Requires confirmation; the device briefly goes offline and active connections may be interrupted |
| **Delete Device** | Requires confirmation; the cloud resources are released |

### Device Information

Expand All @@ -244,6 +268,8 @@ Each device shows:
|-------|-------------|
| **Name** | Device hostname (e.g., "Darwin - MacBook-Pro.local") |
| **Status** | Online/Offline indicator |
| **Version** | Executor version, when available |
| **Resource Usage** | CPU, memory, and disk usage, when reported by the device |
| **Slots** | Concurrent task capacity (X/5) |
| **Default** | Star indicator if set as default |

Expand All @@ -255,7 +281,7 @@ Each device shows:
| **Remove Default** | Click star again on current default |
| **Delete Device** | Click delete icon |

> **Note**: Deleting a device only removes the registration. If the device reconnects, it will automatically re-register.
> **Note**: Deleting a local device only removes the registration. If the device reconnects, it will automatically re-register. Deleting a cloud device from the Connections settings page releases the corresponding cloud resources.

### Offline Device Handling

Expand Down
30 changes: 28 additions & 2 deletions docs/zh/user-guide/ai-devices/local-device-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,31 @@ EXECUTOR_MODE=local wegent-executor
通过以下方式访问您的设备:

1. **设备选择器**:聊天界面中快速访问
2. **API**:`GET /devices` 用于程序化访问
2. **设置页**:进入 **设置** → **连接** 查看可连接设备
3. **API**:`GET /devices` 用于程序化访问

### 管理云设备

**设置** → **连接** 页面会列出当前账号可连接的 Claude Code 云设备。页面仅展示 `device_type=cloud` 且 `bind_shell=claudecode` 的设备,并显示在线状态、executor 版本、CPU、内存和磁盘使用率。

当没有云设备时,点击 **添加** 可以创建一台新的云设备。创建请求返回后,页面会保留“云设备创建中”的提示;初始化通常需要 2-3 分钟,设备上线后会自动出现在列表中。

在线云设备支持直接打开交互式会话:

| 操作 | 后端接口 | 说明 |
|------|----------|------|
| **终端** | `POST /api/devices/{device_id}/terminal` | 在默认工作目录 `/home/ubuntu/.wegent-executor/workspace` 启动 ttyd |
| **IDE** | `POST /api/devices/{device_id}/code-server` | 打开 code-server 会话 |

返回的访问地址带有短期 session token,并通过设备侧 session gateway 暴露。设备离线时,终端和 IDE 按钮不可用。

更多菜单提供低频管理操作:

| 操作 | 说明 |
|------|------|
| **重命名** | 点击设备名称或编辑图标,保存后会刷新列表 |
| **重启设备** | 需要二次确认;设备会短暂离线,进行中的连接可能中断 |
| **删除设备** | 需要二次确认;云资源会被释放 |

### 设备信息

Expand All @@ -246,6 +270,8 @@ EXECUTOR_MODE=local wegent-executor
|------|------|
| **名称** | 设备主机名(如 "Darwin - MacBook-Pro.local") |
| **状态** | 在线/离线指示器 |
| **版本** | executor 版本(如适用) |
| **资源使用率** | CPU、内存、磁盘使用率(如设备上报) |
| **槽位** | 并发任务容量(X/5) |
| **默认** | 如果设为默认则显示星号 |

Expand All @@ -257,7 +283,7 @@ EXECUTOR_MODE=local wegent-executor
| **取消默认** | 再次点击当前默认设备的星号 |
| **删除设备** | 点击删除图标 |

> **注意**:删除设备只是移除注册记录。如果设备重新连接,它会自动重新注册。
> **注意**:本地设备的删除只是移除注册记录。如果设备重新连接,它会自动重新注册。云设备在连接设置页删除时会释放对应云资源。

### 离线设备处理

Expand Down
20 changes: 20 additions & 0 deletions wework/src/api/devices.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, test, vi } from 'vitest'
import { createDeviceApi } from './devices'
import type { HttpClient } from './http'

describe('createDeviceApi', () => {
test('uses cloud device endpoints for restart and delete actions', async () => {
const client = {
delete: vi.fn().mockResolvedValue({ message: 'deleted' }),
post: vi.fn().mockResolvedValue({ message: 'restart sent' }),
} as unknown as HttpClient

const api = createDeviceApi(client)

await api.restartCloudDevice('device/1')
await api.deleteCloudDevice('device/1')

expect(client.post).toHaveBeenCalledWith('/cloud-devices/device%2F1/restart')
expect(client.delete).toHaveBeenCalledWith('/cloud-devices/device%2F1')
})
})
61 changes: 51 additions & 10 deletions wework/src/api/devices.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import type { DeviceCommandResponse, DeviceInfo } from '@/types/api'
import type { DeviceCommandResponse } from '@/types/api'
import type {
CloudDeviceResponse,
DeviceInfo,
DeviceListResponse,
DeviceSessionResponse,
} from '@/types/devices'
import type { HttpClient } from './http'

interface DeviceListResponse {
items: DeviceInfo[]
total: number
}

export function createDeviceApi(client: HttpClient) {
async function fetchDevices(): Promise<DeviceInfo[]> {
const response = await client.get<DeviceListResponse>('/devices')
return response.items
}

return {
async listDevices(): Promise<DeviceInfo[]> {
const response = await client.get<DeviceListResponse>('/devices')
return response.items
},
listDevices: fetchDevices,

getAllDevices: fetchDevices,

async listDirectories(deviceId: string, path: string): Promise<string[]> {
const response = await client.post<DeviceCommandResponse>(
`/devices/${encodeURIComponent(deviceId)}/commands`,
Expand All @@ -27,5 +33,40 @@ export function createDeviceApi(client: HttpClient) {
}
return Array.isArray(response.stdout) ? response.stdout : []
},

async startTerminal(deviceId: string): Promise<DeviceSessionResponse> {
return client.post<DeviceSessionResponse>(
`/devices/${encodeURIComponent(deviceId)}/terminal`,
)
},

async startCodeServer(deviceId: string): Promise<DeviceSessionResponse> {
return client.post<DeviceSessionResponse>(
`/devices/${encodeURIComponent(deviceId)}/code-server`,
)
},

async createCloudDevice(): Promise<CloudDeviceResponse> {
return client.post<CloudDeviceResponse>('/cloud-devices')
},

async restartCloudDevice(deviceId: string): Promise<{ message: string }> {
return client.post<{ message: string }>(
`/cloud-devices/${encodeURIComponent(deviceId)}/restart`,
)
},

async deleteCloudDevice(deviceId: string): Promise<{ message: string }> {
return client.delete<{ message: string }>(
`/cloud-devices/${encodeURIComponent(deviceId)}`,
)
},

async renameDevice(deviceId: string, alias: string): Promise<void> {
await client.put<{ message: string }>(
`/devices/${encodeURIComponent(deviceId)}/alias`,
{ alias },
)
},
}
}
47 changes: 41 additions & 6 deletions wework/src/components/layout/DesktopWorkbenchLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,50 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { createDeviceApi } from '@/api/devices'
import { DesktopWorkbenchLayout } from './DesktopWorkbenchLayout'

vi.mock('@/config/runtime', () => ({
getRuntimeConfig: () => ({ apiBaseUrl: '/api' }),
}))

vi.mock('@/api/http', () => ({
createHttpClient: vi.fn(() => ({})),
}))

vi.mock('@/api/devices', () => ({
createDeviceApi: vi.fn(),
}))

const createDeviceApiMock = vi.mocked(createDeviceApi)

describe('DesktopWorkbenchLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
createDeviceApiMock.mockReturnValue({
getAllDevices: vi.fn().mockResolvedValue([
{
id: 1,
device_id: '24a59054-4638-4744-983d-372706c30fcd',
name: 'yunpeng7-executor-372706c30fcd',
status: 'online',
is_default: false,
device_type: 'cloud',
bind_shell: 'claudecode',
executor_version: '1.712',
cpu_usage: 42,
memory_usage: 68,
disk_usage: 57,
},
]),
startTerminal: vi.fn(),
startCodeServer: vi.fn(),
createCloudDevice: vi.fn(),
renameDevice: vi.fn(),
restartCloudDevice: vi.fn(),
deleteCloudDevice: vi.fn(),
})
})

const baseProps = {
Expand Down Expand Up @@ -416,13 +454,10 @@ describe('DesktopWorkbenchLayout', () => {
expect(screen.queryByText('Linux-Device-481b616e8e0b')).not.toBeInTheDocument()
expect(screen.getByText('可连接的设备')).toBeInTheDocument()
expect(screen.queryByText('可连接这台设备的云设备')).not.toBeInTheDocument()
expect(screen.getByText('云设备')).toBeInTheDocument()
expect(await screen.findByText('云设备')).toBeInTheDocument()
expect(
screen.getByTestId('connection-device-icon-24a59054-4638-4744-983d-372706c30fcd'),
).toHaveClass('text-[#3c4043]')
expect(
screen.getByTestId('connection-device-icon-24a59054-4638-4744-983d-372706c30fcd'),
).not.toHaveClass('bg-[#f7f7f8]')
screen.getByTestId('connection-device-24a59054-4638-4744-983d-372706c30fcd'),
).toBeInTheDocument()
expect(screen.getByText('yunpeng7-executor-372706c30fcd')).toBeInTheDocument()
expect(screen.getByText('v1.712')).toBeInTheDocument()
expect(screen.getByText('在线')).toBeInTheDocument()
Expand Down
Loading
Loading