From 6b216ebb7484f7e60fff015d01219fdc9218468d Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 25 Jun 2026 14:16:34 +0800 Subject: [PATCH 01/17] docs(wework): add local-first app runtime design --- ...026-06-25-wework-local-first-app-design.md | 481 ++++++++++++++++++ 1 file changed, 481 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-25-wework-local-first-app-design.md diff --git a/docs/superpowers/specs/2026-06-25-wework-local-first-app-design.md b/docs/superpowers/specs/2026-06-25-wework-local-first-app-design.md new file mode 100644 index 0000000000..e0a451f37e --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-wework-local-first-app-design.md @@ -0,0 +1,481 @@ +--- +sidebar_position: 1 +--- + +# WeWork Local-First App Runtime + +## Context + +WeWork is currently a Tauri/Vite React app that assumes a Backend API is +available. `AuthProvider` requires Backend authentication before rendering the +workbench, and `WorkbenchProvider` creates API services from +`getRuntimeConfig().apiBaseUrl`. Runtime LocalTask work is already device-owned: +Backend lists local work by fanning out `runtime:rpc` calls to online local +executors, and executor-local state remains on the user's device. + +The new product direction is stronger than the existing standalone Docker mode: +packaged WeWork should be a real local desktop app. Its main coding workflow +must not depend on Backend being started. Backend remains useful after an +optional login for cloud capabilities such as model synchronization, cloud +projects, remote devices, and web control of the user's local computer. + +The existing Wegent frontend must not change. Web users should continue to reach +local computers through Backend and the existing local executor WebSocket +channel. + +## Goals + +- Let packaged desktop WeWork open and run local tasks without Backend. +- Make the local app path the primary WeWork runtime path, not a fallback after a + failed Backend request. +- Keep Backend connection optional for login, model synchronization, cloud + project operations, remote/cloud devices, and web control of local devices. +- Let one executor connect directly to the WeWork app and also connect to + Backend at the same time. +- Preserve the existing Backend-controlled path for the Wegent frontend. +- Reuse the existing executor runtime-work RPC and device command semantics + wherever possible. +- Keep local task state on the device and avoid creating central `TaskResource` + or `Subtask` rows for local runtime work. +- Make the first implementation independently verifiable without a running + Backend. + +## Non-Goals + +- Do not change the Wegent frontend. +- Do not require users to run the full Backend stack for local WeWork tasks. +- Do not embed MySQL, Redis, or the Backend service inside the desktop app. +- Do not make Backend the owner of local runtime task state. +- Do not implement full cloud model/project sync in the first local execution + pass; design the seam so sync can be added without changing local execution. +- Do not expose a public executor HTTP API from the user's machine. App and + executor communication stays on loopback. +- Do not remove the current Backend device WebSocket protocol. + +## Recommended Architecture + +Packaged WeWork owns a local control plane. The control plane is a small +loopback service started and supervised by the Tauri app. It exposes a +Backend-compatible subset of APIs to WeWork and a `/local-executor` Socket.IO +namespace to local executors. + +```mermaid +flowchart LR + subgraph "User Desktop" + WW["WeWork Tauri App"] + LG["Local App Gateway"] + EX["Wegent Executor"] + RT["Codex / Claude Code"] + FS["Local Files"] + end + + subgraph "Optional Cloud" + BE["Wegent Backend"] + WEB["Wegent Frontend"] + end + + WW -->|HTTP + app events on loopback| LG + EX <-->|Socket.IO /local-executor| LG + EX --> RT + RT --> FS + + WW -. optional login/sync .-> BE + EX -. optional second Socket.IO channel .-> BE + WEB -. existing Backend path .-> BE + BE -. existing /local-executor routing .-> EX +``` + +The local app gateway should be implemented in Python/FastAPI/Socket.IO and +shipped as part of the desktop app's bundled local runtime helper. The helper +can be the executor binary running a gateway mode, or a sibling binary built +from the same executor package. The desktop app must not require a separately +installed helper for the default local path, although it may reuse or upgrade a +compatible installed executor when one already exists. This avoids +reimplementing Socket.IO server behavior in Rust and matches the existing +executor dependencies: the executor package already includes FastAPI, uvicorn, +and python-socketio. + +Tauri remains responsible for process ownership: + +- choose local gateway port and token +- start or find the local gateway +- start or find the local executor +- pass local connection settings to the React app +- surface gateway or executor startup errors in WeWork settings + +## Runtime Modes + +### Local-First Mode + +Local-first mode is the default for packaged desktop WeWork. + +- `AuthProvider` creates a local user/session and does not redirect to + `/login`. +- `WorkbenchProvider` receives local services through the existing `services` + injection boundary. +- Runtime tasks, device commands, model defaults, skills, and workspace + metadata come from the local app gateway. +- Backend being unavailable does not block workbench bootstrap or task send. + +### Cloud-Connected Mode + +Cloud-connected mode is an optional capability layered on top of local-first +mode. + +- A user can sign in to Backend from settings or login UI. +- Backend services can synchronize model definitions, cloud projects, remote + device metadata, and other account-scoped data into local app state. +- Local task execution still routes through the local gateway unless the user + explicitly selects a cloud/remote device. +- If Backend disconnects, local tasks continue and sync status degrades to + offline. + +### Web-Control Mode + +Web-control mode is the existing Backend path used by the Wegent frontend. + +- The executor keeps an optional Backend channel. +- Backend still owns web auth, device online state, command RPC, and + runtime-work fan-out for web users. +- No Wegent frontend change is required. + +## Local App Gateway Responsibilities + +The gateway provides the minimal Backend-like surface that WeWork needs for +local coding workflows. + +### Local Session + +The gateway returns a local user: + +```json +{ + "id": 0, + "user_name": "local", + "email": "local@wework.local", + "preferences": {} +} +``` + +The local session is not a Backend account. Cloud login stores separate cloud +credentials and sync metadata. + +### Default Team + +The gateway returns one local default Team for workbench execution: + +```json +{ + "id": 0, + "name": "local-wework", + "displayName": "Local WeWork", + "is_active": true, + "default_for_modes": ["wework"], + "recommended_mode": "code" +} +``` + +The executor does not need a central Team row for runtime LocalTasks; the Team +object only satisfies WeWork's UI and send payload contract. + +### Models + +The gateway returns local model definitions from local runtime configuration, +Codex config, Claude Code config, and optional cloud sync cache. Local models +must be enough for `useWorkbenchModels` to infer the runtime family and build a +`RuntimeTaskCreateRequest`. + +Cloud-synced models can be merged into the local list, but local execution must +not depend on a successful sync. + +### Devices + +The gateway maintains an in-memory and lightweight persisted registry of +executors connected to the app. A local executor connected to the gateway is +reported as an online `local` device. The device shape should match the current +WeWork expectations, including: + +- `device_id` +- `name` +- `status` +- `device_type` +- `bind_shell` +- `executor_version` +- `capabilities` +- `slot_used` +- `slot_max` +- `runtime_transfer_host` + +### Runtime Work + +The gateway exposes the same local runtime work API shape used by WeWork: + +- list runtime work +- search local work +- open transcript +- create task +- send message +- cancel task +- archive, unarchive, rename, and delete archived conversations +- open, rename, and remove runtime workspaces +- fork runtime tasks where both source and target devices are available + +The gateway should call the target executor through the same +`runtime:rpc` event and method names currently used by Backend: + +- `runtime.tasks.list` +- `runtime.tasks.search` +- `runtime.tasks.transcript` +- `runtime.tasks.create` +- `runtime.tasks.send` +- `runtime.tasks.cancel` +- `runtime.workspaces.open` +- `runtime.workspaces.rename` +- `runtime.workspaces.remove` + +The response shapes should stay compatible with `wework/src/types/api.ts`. + +### Device Commands + +The gateway exposes local equivalents of: + +```text +POST /devices/{device_id}/commands +``` + +It resolves command keys through a command registry equivalent to Backend's +`DEFAULT_LOCAL_DEVICE_COMMANDS` and sends `device:execute_command` to the +executor. WeWork should not gain a raw shell execution API. + +### Streaming + +Runtime Responses events emitted by the executor should be forwarded to the +WeWork renderer over the same logical event names currently handled by the +workbench stream layer: + +- `response.created` +- `response.in_progress` +- `response.output_text.delta` +- `response.completed` +- `response.incomplete` +- `error` + +The first implementation can keep the existing Socket.IO client abstraction by +providing a local Socket.IO namespace that behaves like the current chat stream +for runtime events. + +## Executor Multi-Channel Model + +The executor should separate device runtime logic from connection ownership. +Instead of a single implicit Backend connection, it should support named +channels: + +```json +{ + "channels": [ + { + "name": "app", + "url": "http://127.0.0.1:", + "auth_token": "", + "enabled": true + }, + { + "name": "backend", + "url": "https://backend.example.com", + "auth_token": "wg-...", + "enabled": true + } + ] +} +``` + +For compatibility, existing `connection.backend_url` and +`connection.auth_token` remain supported and map to a single `backend` channel. + +Each channel has independent: + +- WebSocket client +- registration state +- heartbeat loop +- runtime RPC request handling +- command RPC request handling +- streaming event emitter +- reconnect policy + +The same runtime task can be created through the app channel or Backend channel, +but an individual request is owned by the channel that dispatched it. Streaming +events should return to the same channel to avoid duplicate UI updates. Native +Codex watcher notifications should also include the channel context that caused +the send when possible; passive native updates can be broadcast to connected +channels only after terminal messages are deduplicated by `deviceId + +localTaskId`. + +## WeWork Service Boundary + +WeWork should keep `WorkbenchProvider` as the main UI state owner. The service +boundary becomes explicit: + +```text +createDefaultServices() + packaged local-first app -> createLocalAppServices() + browser/dev/backend mode -> createBackendServices() +``` + +`createLocalAppServices()` provides the same service contracts used by +`WorkbenchProvider`: + +- `teamApi` +- `modelApi` +- `skillApi` +- `deviceApi` +- `runtimeWorkApi` +- `userApi` +- `socketClient` +- `chatStream` + +APIs that are cloud-only should return a clear "cloud connection required" +error in local-first mode instead of trying to contact Backend implicitly. + +The runtime config should distinguish app runtime mode from API URL: + +```ts +type RuntimeMode = 'local-first' | 'backend' +``` + +Packaged Tauri defaults to `local-first`. Browser dev mode can keep the current +Backend default. + +## Cloud Sync Boundary + +Cloud sync is an optional layer with explicit state: + +- disconnected +- signing in +- connected +- syncing +- sync failed + +Cloud data should be cached locally and merged into local services only after a +successful sync. Local models and local tasks remain usable if cloud sync is not +configured or fails. + +Cloud project operations must not mutate local runtime task identity. LocalTask +identity remains `deviceId + localTaskId`; workspace path remains context for +tools and grouping. + +## Process And Packaging + +Desktop packages should include enough local runtime components to run without +Backend: + +- WeWork frontend assets +- Tauri native shell +- bundled local app gateway helper +- bundled local executor helper +- optional reuse of a compatible managed installed executor + +On startup: + +1. Tauri chooses a loopback gateway port and creates an ephemeral token. +2. Tauri starts the local app gateway if it is not already healthy. +3. Tauri starts or locates the local executor. +4. Tauri configures the executor `app` channel with the gateway URL and token. +5. React receives local gateway API/socket config through runtime config. +6. WeWork bootstraps from local services. + +If the executor cannot start, WeWork should still open and show a connection +state that lets the user retry setup. It should not redirect to Backend login. + +## Security + +- The local gateway listens only on loopback. +- The gateway uses a per-launch token generated by Tauri. +- The token is passed to the renderer only through runtime config and to the + executor through process environment or channel config. +- Device commands remain key-based and registry-controlled. +- Raw shell command execution is not exposed to the renderer. +- Cloud credentials are stored separately from local session state. +- Logs must redact local gateway tokens and cloud tokens. + +## Error Handling + +- Backend unavailable: local workbench remains usable; cloud sync shows + disconnected. +- Local gateway unavailable: packaged app shows a local runtime startup error + and retry action. +- Executor unavailable: workbench opens, device list shows no online local + executor, and sending is blocked with a local executor setup message. +- Executor connected to Backend but not app: web control still works; app-local + work remains unavailable until the app channel connects. +- App connected but Backend disconnected: local tasks continue; web control and + cloud sync are unavailable. +- Same executor connected to both channels: each channel reports independent + online state and routes only its own requests. + +## First Implementation Scope + +The first implementation should produce working software that proves the local +main path: + +- Packaged desktop WeWork opens without Backend. +- WeWork renders the workbench with a local user and local default Team. +- The app starts or discovers the local gateway. +- The app starts or discovers a local executor. +- The executor registers to the app channel. +- WeWork lists local runtime work through the local gateway. +- WeWork can create a new local runtime task without Backend. +- WeWork can continue an existing local runtime task without Backend. +- Runtime Responses events stream back to the current WeWork window. +- Basic device commands used by workspace, git, diff, and file views work + through the local gateway. +- Existing Backend path remains available for the Wegent frontend and for + optional executor Backend channel registration. + +Cloud login, cloud model sync, and cloud project operations should be present as +clear extension points or settings surface, but they are not required for local +task execution to pass. + +## Testing + +Unit tests: + +- Runtime config selects `local-first` for packaged Tauri and Backend mode for + browser/dev defaults. +- AuthProvider creates a local user in local-first mode without calling Backend. +- Workbench default services select local services in local-first mode. +- Local service adapters return the expected user, Team, model, device, and + runtime-work shapes. +- Executor channel config maps legacy `connection` to a `backend` channel. +- Executor can build independent `app` and `backend` channel clients. + +Integration tests: + +- Local gateway accepts executor registration and heartbeat. +- Local gateway dispatches `runtime:rpc` to a connected executor and returns the + response. +- Local gateway dispatches `device:execute_command` through the command registry. +- Runtime Responses events emitted by executor are forwarded to WeWork's local + stream client. + +Manual verification: + +- Start packaged WeWork with Backend stopped. +- Verify the workbench opens without redirecting to `/login`. +- Verify a local executor appears online. +- Send a new local task and observe streaming assistant output. +- Refresh the app and reopen the LocalTask transcript. +- Start Backend separately, connect the executor backend channel, and verify + web control still works without changing the Wegent frontend. + +## Documentation Updates + +After implementation, update Chinese docs first, then English: + +- `docs/zh/developer-guide/wework-local-first-app.md` +- `docs/en/developer-guide/wework-local-first-app.md` +- `docs/zh/developer-guide/local-device-architecture.md` +- `docs/en/developer-guide/local-device-architecture.md` +- `executor/docs/LOCAL_MODE.md` + +The docs must explain that local WeWork task execution does not require Backend, +while Backend login adds optional cloud capabilities. From ca6c9222c73bbc1735fd61097673ca00e1242c47 Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 25 Jun 2026 14:23:14 +0800 Subject: [PATCH 02/17] docs(wework): revise local runtime direct IPC design --- ...026-06-25-wework-local-first-app-design.md | 321 +++++++++++------- 1 file changed, 198 insertions(+), 123 deletions(-) diff --git a/docs/superpowers/specs/2026-06-25-wework-local-first-app-design.md b/docs/superpowers/specs/2026-06-25-wework-local-first-app-design.md index e0a451f37e..b3e31ce744 100644 --- a/docs/superpowers/specs/2026-06-25-wework-local-first-app-design.md +++ b/docs/superpowers/specs/2026-06-25-wework-local-first-app-design.md @@ -13,19 +13,23 @@ workbench, and `WorkbenchProvider` creates API services from Backend lists local work by fanning out `runtime:rpc` calls to online local executors, and executor-local state remains on the user's device. -The new product direction is stronger than the existing standalone Docker mode: -packaged WeWork should be a real local desktop app. Its main coding workflow -must not depend on Backend being started. Backend remains useful after an -optional login for cloud capabilities such as model synchronization, cloud -projects, remote devices, and web control of the user's local computer. - -The existing Wegent frontend must not change. Web users should continue to reach -local computers through Backend and the existing local executor WebSocket -channel. +The product direction is stronger than standalone Docker mode: packaged WeWork +should behave like a real local desktop app. Its main coding workflow must run +without Backend, and the local app must not start an additional local gateway, +HTTP service, FastAPI service, or Socket.IO server. The local runtime consists +of the WeWork desktop app UI and one executor process managed by the app. + +Backend remains useful after an optional login for cloud capabilities such as +model synchronization, cloud projects, remote devices, and web control of the +user's local computer. The existing Wegent frontend must not change. Web users +should continue to reach local computers through Backend and the existing local +executor WebSocket channel. ## Goals - Let packaged desktop WeWork open and run local tasks without Backend. +- Keep the local runtime to two processes: the WeWork app and an executor child + or managed sidecar. - Make the local app path the primary WeWork runtime path, not a fallback after a failed Backend request. - Keep Backend connection optional for login, model synchronization, cloud @@ -44,26 +48,29 @@ channel. - Do not change the Wegent frontend. - Do not require users to run the full Backend stack for local WeWork tasks. +- Do not start a local app gateway, local HTTP API, FastAPI helper, or app-owned + Socket.IO server for the desktop local path. - Do not embed MySQL, Redis, or the Backend service inside the desktop app. - Do not make Backend the owner of local runtime task state. - Do not implement full cloud model/project sync in the first local execution pass; design the seam so sync can be added without changing local execution. -- Do not expose a public executor HTTP API from the user's machine. App and - executor communication stays on loopback. +- Do not expose a public executor API from the user's machine. +- Do not expose raw shell execution to the renderer. - Do not remove the current Backend device WebSocket protocol. ## Recommended Architecture -Packaged WeWork owns a local control plane. The control plane is a small -loopback service started and supervised by the Tauri app. It exposes a -Backend-compatible subset of APIs to WeWork and a `/local-executor` Socket.IO -namespace to local executors. +Packaged WeWork owns a direct local control plane. Tauri supervises an executor +sidecar or managed child process and talks to it over newline-delimited JSON-RPC +on stdin/stdout. React does not manage processes directly. The renderer uses +local service adapters that call Tauri commands and listen for Tauri events. ```mermaid flowchart LR subgraph "User Desktop" - WW["WeWork Tauri App"] - LG["Local App Gateway"] + UI["WeWork React UI"] + TAURI["Tauri Rust Shell"] + IPC["stdin/stdout JSON-RPC"] EX["Wegent Executor"] RT["Codex / Claude Code"] FS["Local Files"] @@ -74,34 +81,33 @@ flowchart LR WEB["Wegent Frontend"] end - WW -->|HTTP + app events on loopback| LG - EX <-->|Socket.IO /local-executor| LG + UI -->|"Tauri invoke + events"| TAURI + TAURI <-->|"child process stdio"| IPC + IPC <-->|"newline JSON messages"| EX EX --> RT RT --> FS - WW -. optional login/sync .-> BE - EX -. optional second Socket.IO channel .-> BE + UI -. optional login/sync .-> BE + EX -. optional Socket.IO backend channel .-> BE WEB -. existing Backend path .-> BE BE -. existing /local-executor routing .-> EX ``` -The local app gateway should be implemented in Python/FastAPI/Socket.IO and -shipped as part of the desktop app's bundled local runtime helper. The helper -can be the executor binary running a gateway mode, or a sibling binary built -from the same executor package. The desktop app must not require a separately -installed helper for the default local path, although it may reuse or upgrade a -compatible installed executor when one already exists. This avoids -reimplementing Socket.IO server behavior in Rust and matches the existing -executor dependencies: the executor package already includes FastAPI, uvicorn, -and python-socketio. - Tauri remains responsible for process ownership: -- choose local gateway port and token -- start or find the local gateway -- start or find the local executor -- pass local connection settings to the React app -- surface gateway or executor startup errors in WeWork settings +- locate the bundled or managed executor binary +- start the executor with local app IPC enabled +- pass optional Backend channel settings to the executor when cloud/web control + is configured +- parse executor stdout lines into JSON responses and events +- write JSON requests to executor stdin +- expose request commands and event subscriptions to React +- restart or reconnect the child process only after explicit local runtime retry +- surface executor startup errors in WeWork settings and workbench state + +The executor remains responsible for runtime work, device commands, tool +execution, and optional Backend registration. No additional local process sits +between the app and executor. ## Runtime Modes @@ -114,7 +120,7 @@ Local-first mode is the default for packaged desktop WeWork. - `WorkbenchProvider` receives local services through the existing `services` injection boundary. - Runtime tasks, device commands, model defaults, skills, and workspace - metadata come from the local app gateway. + metadata come from local service adapters backed by Tauri executor IPC. - Backend being unavailable does not block workbench bootstrap or task send. ### Cloud-Connected Mode @@ -125,8 +131,8 @@ mode. - A user can sign in to Backend from settings or login UI. - Backend services can synchronize model definitions, cloud projects, remote device metadata, and other account-scoped data into local app state. -- Local task execution still routes through the local gateway unless the user - explicitly selects a cloud/remote device. +- Local task execution still routes through direct app-to-executor IPC unless + the user explicitly selects a cloud/remote device. - If Backend disconnects, local tasks continue and sync status degrades to offline. @@ -134,19 +140,94 @@ mode. Web-control mode is the existing Backend path used by the Wegent frontend. -- The executor keeps an optional Backend channel. +- The executor keeps an optional Backend Socket.IO channel. - Backend still owns web auth, device online state, command RPC, and runtime-work fan-out for web users. - No Wegent frontend change is required. -## Local App Gateway Responsibilities +## Direct App IPC Protocol + +The app-to-executor protocol is newline-delimited JSON over the executor +process's stdin/stdout. Each line is one complete JSON object encoded as UTF-8. +The protocol has three message classes. + +### Request + +```json +{ + "type": "request", + "id": "local-req-0001", + "method": "runtime.tasks.create", + "params": { + "device_id": "local-device", + "runtime_family": "codex", + "message": "Implement the selected change", + "workspace": { + "path": "/Users/example/project" + } + } +} +``` + +### Response + +```json +{ + "type": "response", + "id": "local-req-0001", + "ok": true, + "result": { + "task": { + "deviceId": "local-device", + "localTaskId": "task_123" + } + } +} +``` -The gateway provides the minimal Backend-like surface that WeWork needs for -local coding workflows. +Errors use the same envelope and include a stable code and human-readable +message: + +```json +{ + "type": "response", + "id": "local-req-0001", + "ok": false, + "error": { + "code": "executor_not_ready", + "message": "The local executor is still starting." + } +} +``` + +### Event + +```json +{ + "type": "event", + "event": "response.output_text.delta", + "payload": { + "deviceId": "local-device", + "localTaskId": "task_123", + "delta": "Done" + } +} +``` + +The Tauri bridge keeps an in-memory map from request id to pending request, +enforces request timeouts, and emits executor events to the renderer as +`local-executor:event`. Requests that outlive the child process fail with +`executor_disconnected`. + +## Local Service Responsibilities + +The local service layer provides the minimal Backend-like surface that WeWork +needs for local coding workflows. These services live in the WeWork frontend and +call Tauri commands rather than Backend HTTP APIs. ### Local Session -The gateway returns a local user: +Local-first mode returns a local user: ```json { @@ -162,7 +243,7 @@ credentials and sync metadata. ### Default Team -The gateway returns one local default Team for workbench execution: +Local-first mode returns one local default Team for workbench execution: ```json { @@ -180,7 +261,7 @@ object only satisfies WeWork's UI and send payload contract. ### Models -The gateway returns local model definitions from local runtime configuration, +The local service returns model definitions from local runtime configuration, Codex config, Claude Code config, and optional cloud sync cache. Local models must be enough for `useWorkbenchModels` to infer the runtime family and build a `RuntimeTaskCreateRequest`. @@ -190,10 +271,9 @@ not depend on a successful sync. ### Devices -The gateway maintains an in-memory and lightweight persisted registry of -executors connected to the app. A local executor connected to the gateway is -reported as an online `local` device. The device shape should match the current -WeWork expectations, including: +The Tauri bridge reports the managed executor as an online `local` device when +the child process is running and has completed IPC initialization. The device +shape should match the current WeWork expectations, including: - `device_id` - `name` @@ -208,7 +288,8 @@ WeWork expectations, including: ### Runtime Work -The gateway exposes the same local runtime work API shape used by WeWork: +The local runtime-work service exposes the same API shape used by +`WorkbenchProvider`: - list runtime work - search local work @@ -220,8 +301,8 @@ The gateway exposes the same local runtime work API shape used by WeWork: - open, rename, and remove runtime workspaces - fork runtime tasks where both source and target devices are available -The gateway should call the target executor through the same -`runtime:rpc` event and method names currently used by Backend: +The service calls the executor through the same method names currently used by +Backend's `runtime:rpc` path: - `runtime.tasks.list` - `runtime.tasks.search` @@ -233,25 +314,20 @@ The gateway should call the target executor through the same - `runtime.workspaces.rename` - `runtime.workspaces.remove` -The response shapes should stay compatible with `wework/src/types/api.ts`. +The response shapes stay compatible with `wework/src/types/api.ts`. ### Device Commands -The gateway exposes local equivalents of: - -```text -POST /devices/{device_id}/commands -``` - -It resolves command keys through a command registry equivalent to Backend's -`DEFAULT_LOCAL_DEVICE_COMMANDS` and sends `device:execute_command` to the -executor. WeWork should not gain a raw shell execution API. +The local device service exposes the same high-level command operations used by +WeWork today. It resolves command keys through a registry equivalent to +Backend's `DEFAULT_LOCAL_DEVICE_COMMANDS` and sends `device.execute_command` to +the executor over app IPC. The renderer never sends raw shell commands. ### Streaming -Runtime Responses events emitted by the executor should be forwarded to the -WeWork renderer over the same logical event names currently handled by the -workbench stream layer: +Runtime Responses events emitted by the executor are forwarded to the WeWork +renderer over the same logical event names currently handled by the workbench +stream layer: - `response.created` - `response.in_progress` @@ -260,25 +336,23 @@ workbench stream layer: - `response.incomplete` - `error` -The first implementation can keep the existing Socket.IO client abstraction by -providing a local Socket.IO namespace that behaves like the current chat stream -for runtime events. +The local stream client subscribes to Tauri's `local-executor:event`, filters by +`deviceId + localTaskId`, and feeds existing chat stream reducers. Backend +Socket.IO remains in use only for Backend mode and web-control mode. -## Executor Multi-Channel Model +## Executor Direct-App Mode -The executor should separate device runtime logic from connection ownership. -Instead of a single implicit Backend connection, it should support named -channels: +The executor should separate runtime logic from connection ownership. It gains a +direct app IPC channel in addition to its existing Backend WebSocket channel. ```json { + "app_ipc": { + "enabled": true, + "transport": "stdio", + "device_id": "local-device" + }, "channels": [ - { - "name": "app", - "url": "http://127.0.0.1:", - "auth_token": "", - "enabled": true - }, { "name": "backend", "url": "https://backend.example.com", @@ -292,23 +366,21 @@ channels: For compatibility, existing `connection.backend_url` and `connection.auth_token` remain supported and map to a single `backend` channel. -Each channel has independent: +Direct app IPC and Backend channel each have independent: -- WebSocket client -- registration state -- heartbeat loop +- registration or readiness state +- heartbeat or liveness reporting - runtime RPC request handling - command RPC request handling - streaming event emitter -- reconnect policy +- reconnect policy for Backend only The same runtime task can be created through the app channel or Backend channel, but an individual request is owned by the channel that dispatched it. Streaming -events should return to the same channel to avoid duplicate UI updates. Native -Codex watcher notifications should also include the channel context that caused -the send when possible; passive native updates can be broadcast to connected -channels only after terminal messages are deduplicated by `deviceId + -localTaskId`. +events return to the same channel to avoid duplicate UI updates. Native Codex +watcher notifications should include the channel context that caused the send +when possible; passive native updates can be broadcast to connected channels +only after terminal messages are deduplicated by `deviceId + localTaskId`. ## WeWork Service Boundary @@ -370,47 +442,48 @@ Backend: - WeWork frontend assets - Tauri native shell -- bundled local app gateway helper -- bundled local executor helper +- bundled executor sidecar or managed executor binary - optional reuse of a compatible managed installed executor On startup: -1. Tauri chooses a loopback gateway port and creates an ephemeral token. -2. Tauri starts the local app gateway if it is not already healthy. -3. Tauri starts or locates the local executor. -4. Tauri configures the executor `app` channel with the gateway URL and token. -5. React receives local gateway API/socket config through runtime config. -6. WeWork bootstraps from local services. +1. Tauri creates the local executor supervisor state. +2. Tauri starts or locates the executor with app IPC enabled. +3. Tauri passes optional Backend channel settings to the executor when the user + has configured cloud/web control. +4. React receives local runtime mode and local executor readiness through + runtime config and Tauri commands. +5. WeWork bootstraps from local services. If the executor cannot start, WeWork should still open and show a connection state that lets the user retry setup. It should not redirect to Backend login. ## Security -- The local gateway listens only on loopback. -- The gateway uses a per-launch token generated by Tauri. -- The token is passed to the renderer only through runtime config and to the - executor through process environment or channel config. +- App-to-executor communication stays inside the parent/child process boundary. +- The executor process is launched only from a configured bundled binary or a + verified managed executor path. - Device commands remain key-based and registry-controlled. - Raw shell command execution is not exposed to the renderer. - Cloud credentials are stored separately from local session state. -- Logs must redact local gateway tokens and cloud tokens. +- Logs must redact cloud tokens and any generated process credentials. +- Renderer access to local IPC is limited to explicit Tauri commands. ## Error Handling - Backend unavailable: local workbench remains usable; cloud sync shows disconnected. -- Local gateway unavailable: packaged app shows a local runtime startup error +- Executor startup failure: packaged app shows a local runtime startup error and retry action. -- Executor unavailable: workbench opens, device list shows no online local - executor, and sending is blocked with a local executor setup message. -- Executor connected to Backend but not app: web control still works; app-local - work remains unavailable until the app channel connects. -- App connected but Backend disconnected: local tasks continue; web control and - cloud sync are unavailable. -- Same executor connected to both channels: each channel reports independent - online state and routes only its own requests. +- Executor disconnected: pending local IPC requests fail with + `executor_disconnected`, device list shows no online local executor, and + sending is blocked with a local executor setup message. +- Executor connected to Backend but not app IPC: web control still works; app + local work remains unavailable until the app IPC channel is ready. +- App IPC connected but Backend disconnected: local tasks continue; web control + and cloud sync are unavailable. +- Same executor connected to both paths: each path reports independent online + state and routes only its own requests. ## First Implementation Scope @@ -419,15 +492,14 @@ main path: - Packaged desktop WeWork opens without Backend. - WeWork renders the workbench with a local user and local default Team. -- The app starts or discovers the local gateway. - The app starts or discovers a local executor. -- The executor registers to the app channel. -- WeWork lists local runtime work through the local gateway. +- The executor initializes direct app IPC over stdio. +- WeWork lists local runtime work through Tauri-backed local services. - WeWork can create a new local runtime task without Backend. - WeWork can continue an existing local runtime task without Backend. - Runtime Responses events stream back to the current WeWork window. - Basic device commands used by workspace, git, diff, and file views work - through the local gateway. + through direct app IPC. - Existing Backend path remains available for the Wegent frontend and for optional executor Backend channel registration. @@ -445,17 +517,20 @@ Unit tests: - Workbench default services select local services in local-first mode. - Local service adapters return the expected user, Team, model, device, and runtime-work shapes. +- Tauri local executor IPC bridge correlates responses by request id and emits + executor events to the renderer. - Executor channel config maps legacy `connection` to a `backend` channel. -- Executor can build independent `app` and `backend` channel clients. +- Executor can run direct app IPC and optional Backend channel independently. Integration tests: -- Local gateway accepts executor registration and heartbeat. -- Local gateway dispatches `runtime:rpc` to a connected executor and returns the - response. -- Local gateway dispatches `device:execute_command` through the command registry. -- Runtime Responses events emitted by executor are forwarded to WeWork's local - stream client. +- Executor app IPC accepts `runtime.tasks.list` and returns the runtime-work + response shape. +- Executor app IPC accepts `runtime.tasks.create` and emits streaming response + events for the created task. +- Executor app IPC accepts `device.execute_command` through the command + registry. +- Tauri local services can bootstrap Workbench with Backend stopped. Manual verification: @@ -464,7 +539,7 @@ Manual verification: - Verify a local executor appears online. - Send a new local task and observe streaming assistant output. - Refresh the app and reopen the LocalTask transcript. -- Start Backend separately, connect the executor backend channel, and verify +- Start Backend separately, connect the executor Backend channel, and verify web control still works without changing the Wegent frontend. ## Documentation Updates From cbb0caed9451c402ea963f61089707c43fcc6ed6 Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 25 Jun 2026 14:30:50 +0800 Subject: [PATCH 03/17] docs(wework): add local-first app implementation plan --- .../2026-06-25-wework-local-first-app.md | 2247 +++++++++++++++++ 1 file changed, 2247 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-wework-local-first-app.md diff --git a/docs/superpowers/plans/2026-06-25-wework-local-first-app.md b/docs/superpowers/plans/2026-06-25-wework-local-first-app.md new file mode 100644 index 0000000000..9ca2a8163d --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-wework-local-first-app.md @@ -0,0 +1,2247 @@ +# WeWork Local-First App Runtime Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build packaged WeWork as a local-first desktop app that opens and runs local executor tasks without Backend, while keeping Backend login/sync/web-control optional and leaving the Wegent frontend unchanged. + +**Architecture:** Packaged WeWork defaults to local-first mode. React uses local service adapters; Tauri supervises a bundled or managed `wegent-executor` child process; app and executor communicate through newline-delimited JSON-RPC over child-process stdin/stdout. The executor keeps its existing optional Backend Socket.IO channel for web control, so direct app execution and Backend-controlled web execution are independent paths into the same runtime-work and command handlers. + +**Tech Stack:** WeWork Tauri v2, Vite, React 19, TypeScript, Vitest, Rust, `tauri-plugin-shell`, Python executor, PyInstaller executor binary, pytest, existing runtime-work RPC and local command handlers. + +--- + +## Source Of Truth + +The design spec is `docs/superpowers/specs/2026-06-25-wework-local-first-app-design.md`. + +Key constraints: + +- Do not add a local app gateway, local HTTP API, FastAPI helper, or app-owned Socket.IO server. +- Do not change `frontend/`. +- Do not require Backend for packaged WeWork local tasks. +- Keep Backend as an optional cloud/web-control capability. +- Keep local renderer access limited to explicit Tauri commands and events. +- Preserve the executor Backend WebSocket protocol. + +## File Structure + +Frontend and Tauri: + +- Modify: `wework/src/config/runtime.ts` + - Add `runtimeMode: 'local-first' | 'backend'`. + - Make packaged Tauri default to `local-first` through runtime config/env. +- Modify: `wework/src/config/runtime.test.ts` + - Cover runtime mode defaults and overrides. +- Modify: `wework/src/features/auth/AuthProvider.tsx` + - Create a local user in local-first mode and skip Backend redirects. +- Modify: `wework/src/features/auth/AuthProvider.test.tsx` + - Cover local-first user creation without auth API calls. +- Create: `wework/src/api/local/localSession.ts` + - Own local user constants and local session helpers. +- Create: `wework/src/tauri/localExecutor.ts` + - TypeScript wrapper around Tauri commands/events for executor IPC. +- Create: `wework/src/tauri/localExecutor.test.ts` + - Validate invoke payloads, event subscription cleanup, and error normalization. +- Create: `wework/src/api/local/localChatStream.ts` + - Adapter that maps Tauri executor events into `ChatStreamHandlers`. +- Create: `wework/src/api/local/localServices.ts` + - Build local `WorkbenchServices` from local constants plus Tauri executor client. +- Create: `wework/src/api/local/localServices.test.ts` + - Verify local user, Team, devices, runtime work, command, and stream shapes. +- Modify: `wework/src/features/workbench/WorkbenchProvider.tsx` + - Split `createBackendServices()` from `createLocalAppServices()`. + - Select services from `getRuntimeConfig().runtimeMode`. +- Modify: `wework/src/features/workbench/WorkbenchProvider.test.tsx` + - Cover local service selection and local bootstrap with Backend stopped. +- Create: `wework/src-tauri/src/local_executor.rs` + - Rust child-process supervisor, JSON line parser, pending request map, command handlers. +- Modify: `wework/src-tauri/src/lib.rs` + - Register shell plugin, manage local executor state, expose new commands. +- Modify: `wework/src-tauri/Cargo.toml` + - Add `tauri-plugin-shell`. +- Modify: `wework/src-tauri/tauri.conf.json` + - Add executor sidecar binary under `bundle.externalBin`. +- Modify: `wework/src-tauri/capabilities/default.json` + - Permit the configured sidecar and its fixed arguments. +- Create: `wework/scripts/prepare-local-executor-sidecar.sh` + - Copy the PyInstaller executor binary into Tauri's expected sidecar location. + +Executor: + +- Modify: `executor/config/device_config.py` + - Add `AppIpcConfig`. + - Add `ChannelConfig` list for optional Backend channel. + - Preserve legacy `connection` mapping to `backend`. +- Modify: `executor/config/config.py` + - Sync the selected Backend channel into current global config fields for legacy modules. +- Modify: `executor/tests/config/test_device_config_update.py` + - Cover app IPC config, channel parsing, env overrides, and legacy mapping. +- Create: `executor/modes/local/app_ipc.py` + - Newline JSON-RPC stdio server that dispatches runtime-work and command methods. +- Create: `executor/tests/test_local_app_ipc.py` + - Unit tests for request/response/event behavior. +- Modify: `executor/runtime_work/rpc_handler.py` + - Keep runtime logic transport-agnostic and support app-IPC event emission. +- Modify: `executor/modes/local/runner.py` + - Start app IPC when enabled and Backend WebSocket when configured. + - Keep task loop and runtime handlers shared. +- Modify: `executor/modes/local/websocket_client.py` + - Rename backend-specific assumptions internally where needed and accept explicit channel config. +- Modify: `executor/main.py` + - Add `--app-ipc` and `--no-backend` CLI flags used by Tauri sidecar startup. +- Modify: `executor/tests/test_local_websocket_client.py` + - Ensure missing Backend URL is allowed when app IPC is enabled and Backend channel is disabled. +- Modify: `executor/tests/test_local_command_handler.py` + - Cover the app-IPC command method payload. +- Modify: `executor/tests/runtime_work/test_runtime_work_runner_registration.py` + - Confirm app IPC can use existing runtime-work runner registration. +- Modify: `executor/scripts/build_local.py` + - Ensure the PyInstaller artifact accepts the new CLI flags. +- Modify: `executor/tests/scripts/test_build_local.py` + - Cover the sidecar-compatible artifact name and flags. + +Documentation: + +- Create: `docs/zh/developer-guide/wework-local-first-app.md` +- Create: `docs/en/developer-guide/wework-local-first-app.md` +- Modify: `docs/zh/developer-guide/local-device-command-rpc.md` +- Modify: `docs/en/developer-guide/local-device-command-rpc.md` +- Modify: `executor/docs/LOCAL_MODE.md` + +## Shared Contracts + +Use these contracts in every task. + +TypeScript runtime mode: + +```ts +export type RuntimeMode = 'local-first' | 'backend' +``` + +Local user: + +```ts +export const LOCAL_USER = { + id: 0, + user_name: 'local', + email: 'local@wework.local', + preferences: {}, +} satisfies User +``` + +Local default Team: + +```ts +export const LOCAL_WORKBENCH_TEAM = { + id: 0, + name: 'local-wework', + display_name: 'Local WeWork', + is_active: true, + default_for_modes: ['wework'], + recommended_mode: 'code', +} +``` + +Tauri command names: + +```ts +export const LOCAL_EXECUTOR_COMMANDS = { + ensure: 'local_executor_ensure_started', + status: 'local_executor_status', + request: 'local_executor_request', + restart: 'local_executor_restart', +} as const +``` + +Tauri event name: + +```ts +export const LOCAL_EXECUTOR_EVENT = 'local-executor:event' +``` + +JSON-RPC request: + +```json +{ + "type": "request", + "id": "local-req-0001", + "method": "runtime.tasks.list", + "params": {} +} +``` + +JSON-RPC success response: + +```json +{ + "type": "response", + "id": "local-req-0001", + "ok": true, + "result": {} +} +``` + +JSON-RPC error response: + +```json +{ + "type": "response", + "id": "local-req-0001", + "ok": false, + "error": { + "code": "executor_not_ready", + "message": "The local executor is still starting." + } +} +``` + +JSON-RPC event: + +```json +{ + "type": "event", + "event": "response.output_text.delta", + "payload": { + "deviceId": "local-device", + "localTaskId": "task_123", + "delta": "text" + } +} +``` + +## Tasks + +### Task 1: Runtime Mode Configuration + +**Files:** + +- Modify: `wework/src/config/runtime.ts` +- Modify: `wework/src/config/runtime.test.ts` + +- [ ] **Step 1: Write failing runtime mode tests** + +Add these tests to `wework/src/config/runtime.test.ts`: + +```ts +test('defaults to backend mode in browser development', () => { + expect(getRuntimeConfig().runtimeMode).toBe('backend') +}) + +test('uses local-first mode from runtime config override', () => { + window.__WEWORK_RUNTIME_CONFIG__ = { + runtimeMode: 'local-first', + } + + expect(getRuntimeConfig().runtimeMode).toBe('local-first') +}) + +test('uses local-first mode from build-time environment', () => { + vi.stubEnv('VITE_WEWORK_RUNTIME_MODE', 'local-first') + + expect(getRuntimeConfig().runtimeMode).toBe('local-first') +}) + +test('ignores invalid runtime modes', () => { + vi.stubEnv('VITE_WEWORK_RUNTIME_MODE', 'invalid-mode') + window.__WEWORK_RUNTIME_CONFIG__ = { + runtimeMode: 'invalid-runtime-mode' as never, + } + + expect(getRuntimeConfig().runtimeMode).toBe('backend') +}) +``` + +- [ ] **Step 2: Run the focused test and verify failure** + +Run: `pnpm --dir wework test src/config/runtime.test.ts` + +Expected: FAIL because `runtimeMode` is not present on `RuntimeConfig`. + +- [ ] **Step 3: Implement runtime mode** + +In `wework/src/config/runtime.ts`, add: + +```ts +export type RuntimeMode = 'local-first' | 'backend' +``` + +Add `runtimeMode` to `RuntimeConfig`: + +```ts +runtimeMode: RuntimeMode +``` + +Add this validator: + +```ts +function isValidRuntimeMode(value: string): value is RuntimeMode { + return value === 'local-first' || value === 'backend' +} +``` + +Add this resolver: + +```ts +function resolveRuntimeMode(overrides: RuntimeConfigOverrides): RuntimeMode { + const runtimeValue = runtimeString(overrides, 'runtimeMode') + if (runtimeValue && isValidRuntimeMode(runtimeValue)) { + return runtimeValue + } + + const envValue = import.meta.env.VITE_WEWORK_RUNTIME_MODE + if (envValue && isValidRuntimeMode(envValue)) { + return envValue + } + + return 'backend' +} +``` + +Add this field to the object returned by `getRuntimeConfig()`: + +```ts +runtimeMode: resolveRuntimeMode(overrides), +``` + +- [ ] **Step 4: Run the focused test and verify pass** + +Run: `pnpm --dir wework test src/config/runtime.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add wework/src/config/runtime.ts wework/src/config/runtime.test.ts +git commit -m "feat(wework): add runtime mode config" +``` + +### Task 2: Local Session And AuthProvider Local-First Branch + +**Files:** + +- Create: `wework/src/api/local/localSession.ts` +- Modify: `wework/src/features/auth/AuthProvider.tsx` +- Modify: `wework/src/features/auth/AuthProvider.test.tsx` + +- [ ] **Step 1: Write failing local auth test** + +Add imports to `wework/src/features/auth/AuthProvider.test.tsx`: + +```ts +import { LOCAL_USER } from '@/api/local/localSession' +``` + +Add this test: + +```ts +test('creates local user without redirect or backend calls in local-first mode', async () => { + window.__WEWORK_RUNTIME_CONFIG__ = { + runtimeMode: 'local-first', + } + const authApi = { + getCurrentUser: vi.fn(), + getCurrentUserWithoutAuthRedirect: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + loginWithOidcToken: vi.fn(), + setupAdminPassword: vi.fn(), + } + + render( + + + + ) + + await waitFor(() => + expect(screen.getByTestId('auth-probe')).toHaveTextContent(`${LOCAL_USER.user_name}:ready`) + ) + expect(window.location.pathname).toBe('/') + expect(authApi.getCurrentUser).not.toHaveBeenCalled() + expect(authApi.getCurrentUserWithoutAuthRedirect).not.toHaveBeenCalled() +}) +``` + +- [ ] **Step 2: Run the focused test and verify failure** + +Run: `pnpm --dir wework test src/features/auth/AuthProvider.test.tsx` + +Expected: FAIL because `@/api/local/localSession` does not exist. + +- [ ] **Step 3: Add local session helper** + +Create `wework/src/api/local/localSession.ts`: + +```ts +import type { User } from '@/types/api' + +export const LOCAL_USER = { + id: 0, + user_name: 'local', + email: 'local@wework.local', + preferences: {}, +} satisfies User + +export function getLocalUser(): User { + return LOCAL_USER +} +``` + +- [ ] **Step 4: Add AuthProvider local-first branch** + +In `wework/src/features/auth/AuthProvider.tsx`, import `getLocalUser`: + +```ts +import { getLocalUser } from '@/api/local/localSession' +``` + +Change `refresh` so the first branch is: + +```ts +const { runtimeMode } = getRuntimeConfig() +if (runtimeMode === 'local-first') { + setUser(getLocalUser()) + clearAdminPasswordSetupState() + setIsLoading(false) + return +} +``` + +Change `logout` so local-first returns to the local session: + +```ts +const logout = useCallback(() => { + const { runtimeMode } = getRuntimeConfig() + if (runtimeMode === 'local-first') { + removeToken() + setUser(getLocalUser()) + clearAdminPasswordSetupState() + return + } + + resolvedAuthApi.logout() + setUser(null) + clearAdminPasswordSetupState() + redirectToLogin() +}, [clearAdminPasswordSetupState, resolvedAuthApi]) +``` + +- [ ] **Step 5: Run the focused test and verify pass** + +Run: `pnpm --dir wework test src/features/auth/AuthProvider.test.tsx` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add wework/src/api/local/localSession.ts wework/src/features/auth/AuthProvider.tsx wework/src/features/auth/AuthProvider.test.tsx +git commit -m "feat(wework): bootstrap local auth session" +``` + +### Task 3: Tauri Local Executor TypeScript Client + +**Files:** + +- Create: `wework/src/tauri/localExecutor.ts` +- Create: `wework/src/tauri/localExecutor.test.ts` + +- [ ] **Step 1: Write failing client tests** + +Create `wework/src/tauri/localExecutor.test.ts`: + +```ts +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { + LOCAL_EXECUTOR_COMMANDS, + LOCAL_EXECUTOR_EVENT, + ensureLocalExecutorStarted, + requestLocalExecutor, + subscribeLocalExecutorEvents, +} from './localExecutor' + +const invokeMock = vi.fn() +const listenMock = vi.fn() + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), +})) + +vi.mock('@tauri-apps/api/event', () => ({ + listen: (...args: unknown[]) => listenMock(...args), +})) + +describe('local executor tauri client', () => { + beforeEach(() => { + invokeMock.mockReset() + listenMock.mockReset() + }) + + test('ensures local executor through tauri command', async () => { + invokeMock.mockResolvedValue({ running: true, deviceId: 'local-device' }) + + await expect(ensureLocalExecutorStarted()).resolves.toEqual({ + running: true, + deviceId: 'local-device', + }) + expect(invokeMock).toHaveBeenCalledWith(LOCAL_EXECUTOR_COMMANDS.ensure) + }) + + test('sends local executor request payload', async () => { + invokeMock.mockResolvedValue({ tasks: [] }) + + await expect(requestLocalExecutor('runtime.tasks.list', { includeArchived: false })).resolves.toEqual({ + tasks: [], + }) + expect(invokeMock).toHaveBeenCalledWith(LOCAL_EXECUTOR_COMMANDS.request, { + method: 'runtime.tasks.list', + params: { includeArchived: false }, + }) + }) + + test('subscribes to local executor events', async () => { + const unlisten = vi.fn() + listenMock.mockResolvedValue(unlisten) + const handler = vi.fn() + + const cleanup = await subscribeLocalExecutorEvents(handler) + const [, callback] = listenMock.mock.calls[0] + callback({ payload: { event: 'response.completed', payload: { localTaskId: 'task-1' } } }) + cleanup() + + expect(listenMock).toHaveBeenCalledWith(LOCAL_EXECUTOR_EVENT, expect.any(Function)) + expect(handler).toHaveBeenCalledWith({ + event: 'response.completed', + payload: { localTaskId: 'task-1' }, + }) + expect(unlisten).toHaveBeenCalled() + }) +}) +``` + +- [ ] **Step 2: Run the focused test and verify failure** + +Run: `pnpm --dir wework test src/tauri/localExecutor.test.ts` + +Expected: FAIL because `wework/src/tauri/localExecutor.ts` does not exist. + +- [ ] **Step 3: Implement TypeScript client** + +Create `wework/src/tauri/localExecutor.ts`: + +```ts +import { invoke } from '@tauri-apps/api/core' +import { listen } from '@tauri-apps/api/event' + +export const LOCAL_EXECUTOR_COMMANDS = { + ensure: 'local_executor_ensure_started', + status: 'local_executor_status', + request: 'local_executor_request', + restart: 'local_executor_restart', +} as const + +export const LOCAL_EXECUTOR_EVENT = 'local-executor:event' + +export interface LocalExecutorStatus { + running: boolean + ready?: boolean + deviceId?: string + error?: string +} + +export interface LocalExecutorEvent { + event: string + payload: Record +} + +export async function ensureLocalExecutorStarted(): Promise { + return invoke(LOCAL_EXECUTOR_COMMANDS.ensure) +} + +export async function getLocalExecutorStatus(): Promise { + return invoke(LOCAL_EXECUTOR_COMMANDS.status) +} + +export async function restartLocalExecutor(): Promise { + return invoke(LOCAL_EXECUTOR_COMMANDS.restart) +} + +export async function requestLocalExecutor( + method: string, + params: Record = {} +): Promise { + return invoke(LOCAL_EXECUTOR_COMMANDS.request, { method, params }) +} + +export async function subscribeLocalExecutorEvents( + handler: (event: LocalExecutorEvent) => void +): Promise<() => void> { + const unlisten = await listen(LOCAL_EXECUTOR_EVENT, event => { + handler(event.payload) + }) + + return unlisten +} +``` + +- [ ] **Step 4: Run the focused test and verify pass** + +Run: `pnpm --dir wework test src/tauri/localExecutor.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add wework/src/tauri/localExecutor.ts wework/src/tauri/localExecutor.test.ts +git commit -m "feat(wework): add local executor tauri client" +``` + +### Task 4: Local Chat Stream Adapter + +**Files:** + +- Create: `wework/src/api/local/localChatStream.ts` +- Create: `wework/src/api/local/localChatStream.test.ts` + +- [ ] **Step 1: Write failing stream adapter tests** + +Create `wework/src/api/local/localChatStream.test.ts`: + +```ts +import { beforeEach, describe, expect, test, vi } from 'vitest' +import type { LocalExecutorEvent } from '@/tauri/localExecutor' +import { createLocalChatStream } from './localChatStream' + +const subscribeMock = vi.fn() +const requestMock = vi.fn() + +describe('createLocalChatStream', () => { + beforeEach(() => { + subscribeMock.mockReset() + requestMock.mockReset() + }) + + test('routes response events to chat handlers', async () => { + let listener!: (event: LocalExecutorEvent) => void + subscribeMock.mockImplementation(async handler => { + listener = handler + return vi.fn() + }) + const stream = createLocalChatStream({ + subscribe: subscribeMock, + request: requestMock, + }) + const onChatChunk = vi.fn() + const onChatDone = vi.fn() + + const cleanup = stream.subscribe({ onChatChunk, onChatDone }) + await Promise.resolve() + listener({ + event: 'response.output_text.delta', + payload: { + device_id: 'local-device', + local_task_id: 'task-1', + data: { delta: 'hello' }, + }, + }) + listener({ + event: 'response.completed', + payload: { + device_id: 'local-device', + local_task_id: 'task-1', + data: { output_text: 'hello' }, + }, + }) + cleanup() + + expect(onChatChunk).toHaveBeenCalled() + expect(onChatDone).toHaveBeenCalled() + }) + + test('sends guidance through app ipc', async () => { + requestMock.mockResolvedValue({ success: true }) + const stream = createLocalChatStream({ + subscribe: subscribeMock, + request: requestMock, + }) + + await expect(stream.sendGuidance({ task_id: 1, subtask_id: 2, content: 'continue' })).resolves.toEqual({ + success: true, + }) + expect(requestMock).toHaveBeenCalledWith('runtime.tasks.guidance', { + task_id: 1, + subtask_id: 2, + content: 'continue', + }) + }) +}) +``` + +- [ ] **Step 2: Run the focused test and verify failure** + +Run: `pnpm --dir wework test src/api/local/localChatStream.test.ts` + +Expected: FAIL because `localChatStream.ts` does not exist. + +- [ ] **Step 3: Implement local chat stream adapter** + +Create `wework/src/api/local/localChatStream.ts`: + +```ts +import type { ChatStreamHandlers } from '@/stream/chatStream' +import type { ChatCancelAck, ChatCancelPayload, ChatGuideAck, ChatGuidePayload } from '@/types/api' +import type { LocalExecutorEvent } from '@/tauri/localExecutor' + +interface LocalChatStreamDeps { + subscribe: (handler: (event: LocalExecutorEvent) => void) => Promise<() => void> + request: (method: string, params: Record) => Promise +} + +function emitResponseEvent(handlers: ChatStreamHandlers, event: LocalExecutorEvent): void { + const payload = event.payload + if (event.event === 'response.output_text.delta') { + handlers.onChatChunk?.(payload as never) + return + } + if (event.event === 'response.completed') { + handlers.onChatDone?.(payload as never) + return + } + if (event.event === 'response.created' || event.event === 'response.in_progress') { + handlers.onChatStart?.(payload as never) + return + } + if (event.event === 'response.incomplete' || event.event === 'error') { + handlers.onChatError?.(payload as never) + } +} + +export function createLocalChatStream(deps: LocalChatStreamDeps) { + return { + sendGuidance(payload: ChatGuidePayload): Promise { + return deps.request('runtime.tasks.guidance', payload as unknown as Record) + }, + cancelStream(payload: ChatCancelPayload): Promise { + return deps.request('runtime.tasks.cancel', payload as unknown as Record) + }, + subscribe(handlers: ChatStreamHandlers): () => void { + let cleanup: (() => void) | null = null + let active = true + + void deps.subscribe(event => { + if (active) { + emitResponseEvent(handlers, event) + } + }).then(unlisten => { + if (active) { + cleanup = unlisten + } else { + unlisten() + } + }) + + return () => { + active = false + cleanup?.() + } + }, + } +} +``` + +- [ ] **Step 4: Run the focused test and verify pass** + +Run: `pnpm --dir wework test src/api/local/localChatStream.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add wework/src/api/local/localChatStream.ts wework/src/api/local/localChatStream.test.ts +git commit -m "feat(wework): stream local executor events" +``` + +### Task 5: Local Workbench Services + +**Files:** + +- Create: `wework/src/api/local/localServices.ts` +- Create: `wework/src/api/local/localServices.test.ts` +- Modify: `wework/src/features/workbench/WorkbenchProvider.tsx` +- Modify: `wework/src/features/workbench/WorkbenchProvider.test.tsx` + +- [ ] **Step 1: Write failing local services tests** + +Create `wework/src/api/local/localServices.test.ts`: + +```ts +import { describe, expect, test, vi } from 'vitest' +import { LOCAL_USER } from './localSession' +import { createLocalAppServices } from './localServices' + +describe('createLocalAppServices', () => { + test('returns local workbench bootstrap data without backend', async () => { + const request = vi.fn().mockResolvedValue({ projects: [], chats: [], totalLocalTasks: 0 }) + const ensure = vi.fn().mockResolvedValue({ running: true, ready: true, deviceId: 'local-device' }) + const services = createLocalAppServices({ + ensure, + request, + subscribe: vi.fn(), + }) + + await expect(services.userApi?.getCurrentUser()).resolves.toEqual(LOCAL_USER) + await expect(services.teamApi.getDefaultWorkbenchTeam()).resolves.toMatchObject({ + id: 0, + name: 'local-wework', + }) + await expect(services.deviceApi.listDevices()).resolves.toEqual([ + expect.objectContaining({ + device_id: 'local-device', + status: 'online', + device_type: 'local', + }), + ]) + await expect(services.runtimeWorkApi?.listRuntimeWork()).resolves.toEqual({ + projects: [], + chats: [], + totalLocalTasks: 0, + }) + }) + + test('routes runtime work create through local executor request', async () => { + const request = vi.fn().mockResolvedValue({ + accepted: true, + deviceId: 'local-device', + localTaskId: 'task-1', + }) + const services = createLocalAppServices({ + ensure: vi.fn().mockResolvedValue({ running: true, ready: true, deviceId: 'local-device' }), + request, + subscribe: vi.fn(), + }) + + await services.runtimeWorkApi?.createRuntimeTask({ + deviceId: 'local-device', + runtime: 'codex', + message: 'hello', + } as never) + + expect(request).toHaveBeenCalledWith('runtime.tasks.create', expect.any(Object)) + }) +}) +``` + +- [ ] **Step 2: Run the focused test and verify failure** + +Run: `pnpm --dir wework test src/api/local/localServices.test.ts` + +Expected: FAIL because `localServices.ts` does not exist. + +- [ ] **Step 3: Implement local services** + +Create `wework/src/api/local/localServices.ts`: + +```ts +import type { WorkbenchServices } from '@/features/workbench/WorkbenchProvider' +import type { DeviceInfo, UnifiedModel } from '@/types/api' +import { + ensureLocalExecutorStarted, + requestLocalExecutor, + subscribeLocalExecutorEvents, + type LocalExecutorEvent, + type LocalExecutorStatus, +} from '@/tauri/localExecutor' +import { createLocalChatStream } from './localChatStream' +import { LOCAL_USER } from './localSession' + +export const LOCAL_WORKBENCH_TEAM = { + id: 0, + name: 'local-wework', + display_name: 'Local WeWork', + is_active: true, + default_for_modes: ['wework'], + recommended_mode: 'code', +} + +const LOCAL_MODELS: UnifiedModel[] = [ + { + id: 'local-codex', + name: 'Local Codex', + provider: 'local', + model: 'codex', + runtime: 'codex', + enabled: true, + } as unknown as UnifiedModel, +] + +interface LocalServicesDeps { + ensure?: () => Promise + request?: (method: string, params?: Record) => Promise + subscribe?: (handler: (event: LocalExecutorEvent) => void) => Promise<() => void> +} + +function createLocalDevice(status: LocalExecutorStatus): DeviceInfo { + return { + id: 0, + device_id: status.deviceId || 'local-device', + name: 'Local Executor', + status: status.running && status.ready !== false ? 'online' : 'offline', + is_default: true, + device_type: 'local', + bind_shell: 'codex', + executor_version: '', + capabilities: ['runtime-work', 'device-commands'], + slot_used: 0, + slot_max: 5, + runtime_transfer_host: null, + } as unknown as DeviceInfo +} + +function cloudRequired(name: string) { + return async () => { + throw new Error(`${name} requires cloud connection`) + } +} + +export function createLocalAppServices(deps: LocalServicesDeps = {}): WorkbenchServices { + const ensure = deps.ensure ?? ensureLocalExecutorStarted + const request = deps.request ?? requestLocalExecutor + const subscribe = deps.subscribe ?? subscribeLocalExecutorEvents + + return { + teamApi: { + getDefaultWorkbenchTeam: async () => LOCAL_WORKBENCH_TEAM, + } as never, + modelApi: { + listModels: async () => ({ data: LOCAL_MODELS }), + } as never, + skillApi: { + listSkills: async () => [], + getTeamSkills: async () => ({ skills: [], preload_skills: [] }), + } as never, + projectApi: { + listProjects: async () => ({ items: [] }), + getProject: cloudRequired('getProject'), + createProject: cloudRequired('createProject'), + updateProject: cloudRequired('updateProject'), + deleteProject: cloudRequired('deleteProject'), + } as never, + taskApi: { + getTurnFileChangesDiff: cloudRequired('getTurnFileChangesDiff'), + revertTurnFileChanges: cloudRequired('revertTurnFileChanges'), + } as never, + deviceApi: { + listDevices: async () => [createLocalDevice(await ensure())], + getHomeDirectory: async deviceId => request('device.get_home_directory', { deviceId }), + getProjectWorkspaceRoot: async deviceId => request('device.get_project_workspace_root', { deviceId }), + listDirectories: async (deviceId, path) => request('device.list_directories', { deviceId, path }), + createDirectory: async (deviceId, path) => request('device.create_directory', { deviceId, path }), + executeCommand: async (deviceId, command, payload) => + request('device.execute_command', { deviceId, command, payload }), + upgradeDevice: cloudRequired('upgradeDevice'), + listSkills: async () => [], + } as never, + runtimeWorkApi: { + listRuntimeWork: async params => request('runtime.tasks.list', params as Record), + searchRuntimeWork: async params => request('runtime.tasks.search', params as Record), + getRuntimeTranscript: async params => request('runtime.tasks.transcript', params as Record), + createRuntimeTask: async params => request('runtime.tasks.create', params as Record), + sendRuntimeMessage: async params => request('runtime.tasks.send', params as Record), + cancelRuntimeTask: async params => request('runtime.tasks.cancel', params as Record), + archiveRuntimeTask: async params => request('runtime.tasks.archive', params as Record), + renameRuntimeTask: async params => request('runtime.tasks.rename', params as Record), + openRuntimeWorkspace: async params => request('runtime.workspaces.open', params as Record), + renameRuntimeWorkspace: async params => request('runtime.workspaces.rename', params as Record), + removeRuntimeWorkspace: async params => request('runtime.workspaces.remove', params as Record), + forkRuntimeTask: async params => request('runtime.tasks.import_fork', params as Record), + bindRuntimeTaskImSessions: cloudRequired('bindRuntimeTaskImSessions'), + getImNotificationSettings: async () => ({ + global: { enabled: false, sessionKey: null, session: null }, + runtimeTaskSubscriptions: [], + }), + updateGlobalImNotification: cloudRequired('updateGlobalImNotification'), + subscribeRuntimeTaskNotifications: cloudRequired('subscribeRuntimeTaskNotifications'), + unsubscribeRuntimeTaskNotifications: cloudRequired('unsubscribeRuntimeTaskNotifications'), + revertRuntimeFileChanges: async params => + request('runtime.tasks.revert_file_changes', params as Record), + } as never, + userApi: { + getCurrentUser: async () => LOCAL_USER, + } as never, + chatStream: createLocalChatStream({ subscribe, request }), + } +} +``` + +- [ ] **Step 4: Split Workbench service factories** + +In `wework/src/features/workbench/WorkbenchProvider.tsx`, import local services: + +```ts +import { createLocalAppServices } from '@/api/local/localServices' +``` + +Rename current `createDefaultServices()` to `createBackendServices()` and add: + +```ts +export function createDefaultServices(): WorkbenchServices { + const { runtimeMode } = getRuntimeConfig() + if (runtimeMode === 'local-first') { + return createLocalAppServices() + } + return createBackendServices() +} +``` + +- [ ] **Step 5: Add Workbench local selection test** + +Add this test to `wework/src/features/workbench/WorkbenchProvider.test.tsx`: + +```ts +test('selects local app services in local-first runtime mode', async () => { + window.__WEWORK_RUNTIME_CONFIG__ = { + runtimeMode: 'local-first', + } + + const services = createWorkbenchServices({ + teamApi: { + getDefaultWorkbenchTeam: vi.fn().mockResolvedValue({ id: 0, name: 'local-wework', is_active: true }), + }, + }) + + renderWorkbench(, services) + + await waitFor(() => expect(screen.getByTestId('boot-state')).toHaveTextContent('alice')) +}) +``` + +- [ ] **Step 6: Run focused tests** + +Run: + +```bash +pnpm --dir wework test src/api/local/localServices.test.ts src/features/workbench/WorkbenchProvider.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add wework/src/api/local/localServices.ts wework/src/api/local/localServices.test.ts wework/src/features/workbench/WorkbenchProvider.tsx wework/src/features/workbench/WorkbenchProvider.test.tsx +git commit -m "feat(wework): add local workbench services" +``` + +### Task 6: Tauri Rust Executor IPC Bridge + +**Files:** + +- Create: `wework/src-tauri/src/local_executor.rs` +- Modify: `wework/src-tauri/src/lib.rs` +- Modify: `wework/src-tauri/Cargo.toml` +- Modify: `wework/src-tauri/tauri.conf.json` +- Modify: `wework/src-tauri/capabilities/default.json` + +- [ ] **Step 1: Add Rust tests for JSON line parsing** + +Create `wework/src-tauri/src/local_executor.rs` with the test module first: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_success_response_line() { + let line = r#"{"type":"response","id":"req-1","ok":true,"result":{"value":1}}"#; + let message = parse_executor_line(line).expect("line should parse"); + + assert_eq!(message.id(), Some("req-1")); + assert!(matches!(message, ExecutorLine::Response(_))); + } + + #[test] + fn parses_event_line() { + let line = r#"{"type":"event","event":"response.completed","payload":{"localTaskId":"task-1"}}"#; + let message = parse_executor_line(line).expect("line should parse"); + + assert!(matches!(message, ExecutorLine::Event(_))); + } +} +``` + +- [ ] **Step 2: Run Rust tests and verify failure** + +Run: `cargo test --manifest-path wework/src-tauri/Cargo.toml local_executor` + +Expected: FAIL because `parse_executor_line` and `ExecutorLine` are missing. + +- [ ] **Step 3: Implement bridge types and parser** + +At the top of `wework/src-tauri/src/local_executor.rs`, add: + +```rust +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub enum ExecutorLine { + #[serde(rename = "response")] + Response(ExecutorResponse), + #[serde(rename = "event")] + Event(ExecutorEvent), +} + +impl ExecutorLine { + pub fn id(&self) -> Option<&str> { + match self { + ExecutorLine::Response(response) => Some(response.id.as_str()), + ExecutorLine::Event(_) => None, + } + } +} + +#[derive(Debug, Deserialize)] +pub struct ExecutorResponse { + pub id: String, + pub ok: bool, + pub result: Option, + pub error: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ExecutorEvent { + pub event: String, + pub payload: Value, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ExecutorError { + pub code: String, + pub message: String, +} + +pub fn parse_executor_line(line: &str) -> Result { + serde_json::from_str::(line).map_err(|error| error.to_string()) +} +``` + +- [ ] **Step 4: Add supervisor state and Tauri commands** + +In the same file, add these command signatures and implement them with a `LocalExecutorState` containing child handle, pending requests, and readiness fields: + +```rust +#[derive(Default)] +pub struct LocalExecutorState { + inner: std::sync::Mutex, +} + +#[derive(Default)] +struct LocalExecutorInner { + running: bool, + ready: bool, + device_id: Option, + error: Option, +} + +#[derive(Serialize)] +pub struct LocalExecutorStatus { + running: bool, + ready: bool, + #[serde(rename = "deviceId")] + device_id: Option, + error: Option, +} + +#[derive(Deserialize)] +pub struct LocalExecutorRequest { + method: String, + params: Value, +} + +pub fn status_from_state(state: &LocalExecutorState) -> Result { + let inner = state.inner.lock().map_err(|_| "local executor state lock failed".to_string())?; + Ok(LocalExecutorStatus { + running: inner.running, + ready: inner.ready, + device_id: inner.device_id.clone(), + error: inner.error.clone(), + }) +} + +#[tauri::command] +pub async fn local_executor_status( + state: tauri::State<'_, LocalExecutorState>, +) -> Result { + status_from_state(&state) +} + +#[tauri::command] +pub async fn local_executor_ensure_started( + app: tauri::AppHandle, + state: tauri::State<'_, LocalExecutorState>, +) -> Result { + start_executor_if_needed(app, &state).await?; + status_from_state(&state) +} + +#[tauri::command] +pub async fn local_executor_restart( + app: tauri::AppHandle, + state: tauri::State<'_, LocalExecutorState>, +) -> Result { + restart_executor(app, &state).await?; + status_from_state(&state) +} + +#[tauri::command] +pub async fn local_executor_request( + app: tauri::AppHandle, + state: tauri::State<'_, LocalExecutorState>, + method: String, + params: Value, +) -> Result { + start_executor_if_needed(app.clone(), &state).await?; + send_executor_request(app, &state, LocalExecutorRequest { method, params }).await +} +``` + +Implement `start_executor_if_needed`, `restart_executor`, and `send_executor_request` using `tauri_plugin_shell::ShellExt::sidecar("binaries/wegent-executor")`. The child process must be spawned with arguments: + +```rust +["--app-ipc", "--no-backend"] +``` + +The stdout loop must: + +- call `parse_executor_line` +- resolve pending request promises when `ExecutorLine::Response` +- emit `local-executor:event` with `ExecutorEvent` when `ExecutorLine::Event` +- set `running=false`, `ready=false`, and `error=Some(...)` when the child exits + +- [ ] **Step 5: Register plugin, state, and commands** + +In `wework/src-tauri/src/lib.rs`, add: + +```rust +mod local_executor; +``` + +Add plugin and state in `run()`: + +```rust +.plugin(tauri_plugin_shell::init()) +.manage(local_executor::LocalExecutorState::default()) +``` + +Add command registrations: + +```rust +local_executor::local_executor_status, +local_executor::local_executor_ensure_started, +local_executor::local_executor_restart, +local_executor::local_executor_request, +``` + +- [ ] **Step 6: Add Tauri shell dependency and sidecar config** + +In `wework/src-tauri/Cargo.toml`, add: + +```toml +tauri-plugin-shell = "2" +``` + +In `wework/src-tauri/tauri.conf.json`, add this under `bundle`: + +```json +"externalBin": ["binaries/wegent-executor"] +``` + +In `wework/src-tauri/capabilities/default.json`, add shell sidecar permissions that allow only `binaries/wegent-executor` with `--app-ipc` and `--no-backend`. + +- [ ] **Step 7: Run Rust checks** + +Run: + +```bash +cargo test --manifest-path wework/src-tauri/Cargo.toml local_executor +cargo check --manifest-path wework/src-tauri/Cargo.toml +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add wework/src-tauri/src/local_executor.rs wework/src-tauri/src/lib.rs wework/src-tauri/Cargo.toml wework/src-tauri/Cargo.lock wework/src-tauri/tauri.conf.json wework/src-tauri/capabilities/default.json +git commit -m "feat(wework): bridge local executor over app ipc" +``` + +### Task 7: Executor App IPC Configuration + +**Files:** + +- Modify: `executor/config/device_config.py` +- Modify: `executor/config/config.py` +- Modify: `executor/tests/config/test_device_config_update.py` + +- [ ] **Step 1: Write failing config tests** + +Add tests to `executor/tests/config/test_device_config_update.py`: + +```python +def test_device_config_reads_app_ipc_settings(): + from executor.config.device_config import DeviceConfig + + config = DeviceConfig.from_dict( + { + "app_ipc": { + "enabled": True, + "transport": "stdio", + "device_id": "local-device", + }, + "channels": [ + { + "name": "backend", + "url": "https://backend.example.com", + "auth_token": "token", + "enabled": True, + } + ], + } + ) + + assert config.app_ipc.enabled is True + assert config.app_ipc.transport == "stdio" + assert config.app_ipc.device_id == "local-device" + assert config.channels[0].name == "backend" + assert config.channels[0].url == "https://backend.example.com" + + +def test_legacy_connection_maps_to_backend_channel(): + from executor.config.device_config import DeviceConfig + + config = DeviceConfig.from_dict( + { + "connection": { + "backend_url": "https://backend.example.com", + "auth_token": "token", + } + } + ) + + backend_channel = config.enabled_backend_channel() + assert backend_channel is not None + assert backend_channel.name == "backend" + assert backend_channel.url == "https://backend.example.com" + assert backend_channel.auth_token == "token" +``` + +- [ ] **Step 2: Run focused tests and verify failure** + +Run: `cd executor && uv run pytest tests/config/test_device_config_update.py -q` + +Expected: FAIL because `app_ipc`, `channels`, and `enabled_backend_channel()` are missing. + +- [ ] **Step 3: Implement config dataclasses** + +In `executor/config/device_config.py`, add: + +```python +@dataclass +class AppIpcConfig: + """Direct app IPC configuration.""" + + enabled: bool = False + transport: str = "stdio" + device_id: str = "" + + def to_dict(self) -> Dict[str, Any]: + return { + "enabled": self.enabled, + "transport": self.transport, + "device_id": self.device_id, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "AppIpcConfig": + return cls( + enabled=bool(data.get("enabled", False)), + transport=data.get("transport", "stdio") or "stdio", + device_id=data.get("device_id", "") or "", + ) + + +@dataclass +class ChannelConfig: + """Optional remote control channel configuration.""" + + name: str = "backend" + url: str = "" + auth_token: str = "" + enabled: bool = True + + def to_dict(self) -> Dict[str, Any]: + return { + "name": self.name, + "url": self.url, + "auth_token": self.auth_token, + "enabled": self.enabled, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ChannelConfig": + return cls( + name=data.get("name", "backend") or "backend", + url=data.get("url", "") or data.get("backend_url", "") or "", + auth_token=data.get("auth_token", "") or "", + enabled=bool(data.get("enabled", True)), + ) +``` + +Add fields to `DeviceConfig`: + +```python +app_ipc: AppIpcConfig = field(default_factory=AppIpcConfig) +channels: List[ChannelConfig] = field(default_factory=list) +``` + +Add method: + +```python +def enabled_backend_channel(self) -> Optional[ChannelConfig]: + for channel in self.channels: + if channel.enabled and channel.name == "backend" and channel.url and channel.auth_token: + return channel + if self.connection.backend_url and self.connection.auth_token: + return ChannelConfig( + name="backend", + url=self.connection.backend_url, + auth_token=self.connection.auth_token, + enabled=True, + ) + return None +``` + +Update `to_dict()` and `from_dict()` to round-trip `app_ipc` and `channels`. + +- [ ] **Step 4: Add environment overrides** + +In the existing environment override function in `executor/config/device_config.py`, handle: + +```python +if os.getenv("WEGENT_APP_IPC_ENABLED"): + config.app_ipc.enabled = os.getenv("WEGENT_APP_IPC_ENABLED", "").lower() in { + "1", + "true", + "yes", + } +if os.getenv("WEGENT_APP_IPC_DEVICE_ID"): + config.app_ipc.device_id = os.getenv("WEGENT_APP_IPC_DEVICE_ID", "") +``` + +Keep existing `WEGENT_BACKEND_URL` and `WEGENT_AUTH_TOKEN` behavior by writing both `connection` and the `backend` channel. + +- [ ] **Step 5: Run focused tests** + +Run: `cd executor && uv run pytest tests/config/test_device_config_update.py -q` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add executor/config/device_config.py executor/config/config.py executor/tests/config/test_device_config_update.py +git commit -m "feat(executor): add app ipc config" +``` + +### Task 8: Executor Stdio App IPC Server + +**Files:** + +- Create: `executor/modes/local/app_ipc.py` +- Create: `executor/tests/test_local_app_ipc.py` +- Modify: `executor/modes/local/command_handler.py` + +- [ ] **Step 1: Write failing app IPC tests** + +Create `executor/tests/test_local_app_ipc.py`: + +```python +import asyncio + +import pytest + +from executor.modes.local.command_handler import CommandHandler +from executor.modes.local.app_ipc import AppIpcServer, JsonLineMemoryTransport + + +class RuntimeHandler: + async def handle_runtime_rpc(self, data): + return {"ok": True, "method": data["method"], "payload": data.get("payload", {})} + + +@pytest.mark.asyncio +async def test_app_ipc_dispatches_runtime_request(): + transport = JsonLineMemoryTransport() + server = AppIpcServer( + runtime_handler=RuntimeHandler(), + command_handler=CommandHandler(), + transport=transport, + ) + + await server.handle_line( + '{"type":"request","id":"req-1","method":"runtime.tasks.list","params":{"includeArchived":false}}' + ) + + assert transport.writes == [ + { + "type": "response", + "id": "req-1", + "ok": True, + "result": { + "ok": True, + "method": "runtime.tasks.list", + "payload": {"includeArchived": False}, + }, + } + ] + + +@pytest.mark.asyncio +async def test_app_ipc_dispatches_command_request(monkeypatch): + async def fake_execute(self, data): + return {"success": True, "stdout": "ok"} + + monkeypatch.setattr(CommandHandler, "handle_execute_command", fake_execute) + transport = JsonLineMemoryTransport() + server = AppIpcServer( + runtime_handler=RuntimeHandler(), + command_handler=CommandHandler(), + transport=transport, + ) + + await server.handle_line( + '{"type":"request","id":"req-2","method":"device.execute_command","params":{"command":"pwd"}}' + ) + + assert transport.writes[0]["ok"] is True + assert transport.writes[0]["result"]["stdout"] == "ok" + + +@pytest.mark.asyncio +async def test_app_ipc_emits_response_events(): + transport = JsonLineMemoryTransport() + server = AppIpcServer( + runtime_handler=RuntimeHandler(), + command_handler=CommandHandler(), + transport=transport, + ) + + await server.emit("response.completed", {"localTaskId": "task-1"}) + + assert transport.writes == [ + { + "type": "event", + "event": "response.completed", + "payload": {"localTaskId": "task-1"}, + } + ] +``` + +- [ ] **Step 2: Run focused tests and verify failure** + +Run: `cd executor && uv run pytest tests/test_local_app_ipc.py -q` + +Expected: FAIL because `executor.modes.local.app_ipc` does not exist. + +- [ ] **Step 3: Implement app IPC server** + +Create `executor/modes/local/app_ipc.py`: + +```python +"""Direct app IPC server for local executor mode.""" + +from __future__ import annotations + +import asyncio +import json +import sys +from dataclasses import dataclass, field +from typing import Any, Protocol + + +class JsonLineTransport(Protocol): + async def write(self, message: dict[str, Any]) -> None: + """Write one JSON message.""" + + +@dataclass +class JsonLineMemoryTransport: + writes: list[dict[str, Any]] = field(default_factory=list) + + async def write(self, message: dict[str, Any]) -> None: + self.writes.append(message) + + +class StdioJsonLineTransport: + async def write(self, message: dict[str, Any]) -> None: + line = json.dumps(message, ensure_ascii=False, separators=(",", ":")) + print(line, flush=True) + + +class AppIpcServer: + def __init__( + self, + runtime_handler: Any, + command_handler: Any, + transport: JsonLineTransport | None = None, + ) -> None: + self.runtime_handler = runtime_handler + self.command_handler = command_handler + self.transport = transport or StdioJsonLineTransport() + + async def handle_line(self, line: str) -> None: + try: + request = json.loads(line) + response = await self._dispatch_request(request) + except Exception as exc: + response = { + "type": "response", + "id": "unknown", + "ok": False, + "error": {"code": "bad_request", "message": str(exc)}, + } + await self.transport.write(response) + + async def emit(self, event: str, payload: dict[str, Any]) -> None: + await self.transport.write({"type": "event", "event": event, "payload": payload}) + + async def run_stdio(self) -> None: + loop = asyncio.get_running_loop() + while True: + line = await loop.run_in_executor(None, sys.stdin.readline) + if line == "": + return + await self.handle_line(line.strip()) + + async def _dispatch_request(self, request: dict[str, Any]) -> dict[str, Any]: + request_id = str(request.get("id") or "unknown") + method = request.get("method") + params = request.get("params") if isinstance(request.get("params"), dict) else {} + if not isinstance(method, str) or not method: + return self._error(request_id, "bad_request", "method is required") + + if method.startswith("runtime."): + result = await self.runtime_handler.handle_runtime_rpc( + {"method": method, "payload": params} + ) + return {"type": "response", "id": request_id, "ok": True, "result": result} + + if method == "device.execute_command": + result = await self.command_handler.handle_execute_command(params) + return {"type": "response", "id": request_id, "ok": True, "result": result} + + return self._error(request_id, "not_found", f"Unsupported app IPC method: {method}") + + def _error(self, request_id: str, code: str, message: str) -> dict[str, Any]: + return { + "type": "response", + "id": request_id, + "ok": False, + "error": {"code": code, "message": message}, + } +``` + +- [ ] **Step 4: Run focused tests** + +Run: `cd executor && uv run pytest tests/test_local_app_ipc.py -q` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add executor/modes/local/app_ipc.py executor/tests/test_local_app_ipc.py executor/modes/local/command_handler.py +git commit -m "feat(executor): add direct app ipc transport" +``` + +### Task 9: Executor Runner Starts App IPC And Optional Backend Channel + +**Files:** + +- Modify: `executor/main.py` +- Modify: `executor/modes/local/runner.py` +- Modify: `executor/modes/local/websocket_client.py` +- Modify: `executor/tests/test_local_websocket_client.py` +- Modify: `executor/tests/runtime_work/test_runtime_work_runner_registration.py` + +- [ ] **Step 1: Write failing runner tests** + +Add this test to `executor/tests/test_local_websocket_client.py`: + +```python +def test_websocket_client_allows_disabled_backend_channel(): + from executor.config.device_config import ChannelConfig, DeviceConfig + from executor.modes.local.websocket_client import WebSocketClient + + device_config = DeviceConfig( + device_id="local-device", + device_name="Local Device", + channels=[ChannelConfig(name="backend", url="", auth_token="", enabled=False)], + ) + + client = WebSocketClient(device_config=device_config, require_connection=False) + + assert client.backend_url == "" + assert client.auth_token == "" +``` + +Add this test to `executor/tests/runtime_work/test_runtime_work_runner_registration.py`: + +```python +def test_local_runner_builds_app_ipc_server_when_enabled(): + from executor.config.device_config import AppIpcConfig, DeviceConfig + from executor.modes.local.runner import LocalRunner + + runner = LocalRunner( + device_config=DeviceConfig( + device_id="local-device", + app_ipc=AppIpcConfig(enabled=True, transport="stdio"), + ) + ) + + server = runner.create_app_ipc_server() + + assert server is not None +``` + +- [ ] **Step 2: Run focused tests and verify failure** + +Run: + +```bash +cd executor && uv run pytest tests/test_local_websocket_client.py tests/runtime_work/test_runtime_work_runner_registration.py -q +``` + +Expected: FAIL because `require_connection` and `create_app_ipc_server()` are missing. + +- [ ] **Step 3: Add CLI flags** + +In `executor/main.py`, add parser flags: + +```python +parser.add_argument( + "--app-ipc", + action="store_true", + help="Enable direct app IPC over stdio", +) +parser.add_argument( + "--no-backend", + action="store_true", + help="Disable Backend WebSocket connection", +) +``` + +After loading `device_config`, apply: + +```python +if args.app_ipc: + device_config.app_ipc.enabled = True + device_config.app_ipc.transport = "stdio" +if args.no_backend: + device_config.channels = [ + channel for channel in device_config.channels if channel.name != "backend" + ] + device_config.connection.backend_url = "" + device_config.connection.auth_token = "" +``` + +- [ ] **Step 4: Allow WebSocketClient without Backend** + +In `executor/modes/local/websocket_client.py`, update constructor signature: + +```python +def __init__( + self, + device_config: Optional[DeviceConfig] = None, + require_connection: bool = True, +): +``` + +If `require_connection` is `False`, do not raise for missing Backend URL/token and leave `connect()` unused by the app IPC path. + +- [ ] **Step 5: Add app IPC server factory to LocalRunner** + +In `executor/modes/local/runner.py`, import: + +```python +from executor.modes.local.app_ipc import AppIpcServer +``` + +Add method: + +```python +def create_app_ipc_server(self) -> AppIpcServer: + return AppIpcServer( + runtime_handler=self.runtime_work_handler, + command_handler=self.command_handler, + ) +``` + +Add helper: + +```python +def should_start_backend_channel(self) -> bool: + if not self.device_config: + return bool(config.WEGENT_BACKEND_URL and config.WEGENT_AUTH_TOKEN) + return self.device_config.enabled_backend_channel() is not None +``` + +In `start()`, start app IPC when `device_config.app_ipc.enabled` is true: + +```python +app_ipc_task = None +if self.device_config and self.device_config.app_ipc.enabled: + app_ipc_task = asyncio.create_task(self.create_app_ipc_server().run_stdio()) +``` + +Skip Backend connect/register/heartbeat when `should_start_backend_channel()` is false. Keep task loop alive while app IPC is active: + +```python +if not self.should_start_backend_channel(): + if app_ipc_task is not None: + await app_ipc_task + return +``` + +- [ ] **Step 6: Run focused tests** + +Run: + +```bash +cd executor && uv run pytest tests/test_local_websocket_client.py tests/runtime_work/test_runtime_work_runner_registration.py tests/test_local_app_ipc.py -q +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add executor/main.py executor/modes/local/runner.py executor/modes/local/websocket_client.py executor/tests/test_local_websocket_client.py executor/tests/runtime_work/test_runtime_work_runner_registration.py +git commit -m "feat(executor): run app ipc without backend" +``` + +### Task 10: Sidecar Packaging Script + +**Files:** + +- Create: `wework/scripts/prepare-local-executor-sidecar.sh` +- Modify: `wework/package.json` +- Modify: `executor/scripts/build_local.py` +- Modify: `executor/tests/scripts/test_build_local.py` + +- [ ] **Step 1: Write failing build script test** + +Add this test to `executor/tests/scripts/test_build_local.py`: + +```python +def test_build_output_name_matches_tauri_sidecar_name(): + build_local = load_build_local_module() + + assert build_local.get_output_name("Darwin") == "wegent-executor" + assert build_local.get_output_name("Linux") == "wegent-executor" + assert build_local.get_output_name("Windows") == "wegent-executor.exe" +``` + +- [ ] **Step 2: Run focused test** + +Run: `cd executor && uv run pytest tests/scripts/test_build_local.py -q` + +Expected: FAIL if `get_output_name` is not exported. + +- [ ] **Step 3: Add sidecar prepare script** + +Create `wework/scripts/prepare-local-executor-sidecar.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WEWORK_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_DIR="$(cd "$WEWORK_DIR/.." && pwd)" +EXECUTOR_DIR="$REPO_DIR/executor" +TARGET_DIR="$WEWORK_DIR/src-tauri/binaries" + +mkdir -p "$TARGET_DIR" + +if [[ "${SKIP_EXECUTOR_BUILD:-}" != "1" ]]; then + (cd "$EXECUTOR_DIR" && uv run python scripts/build_local.py) +fi + +if [[ "$OSTYPE" == "msys"* || "$OSTYPE" == "win32"* ]]; then + SOURCE="$EXECUTOR_DIR/dist/wegent-executor.exe" + TARGET="$TARGET_DIR/wegent-executor.exe" +else + SOURCE="$EXECUTOR_DIR/dist/wegent-executor" + TARGET="$TARGET_DIR/wegent-executor" +fi + +if [[ ! -x "$SOURCE" ]]; then + echo "Executor binary not found or not executable: $SOURCE" >&2 + exit 1 +fi + +cp "$SOURCE" "$TARGET" +chmod +x "$TARGET" +echo "Prepared local executor sidecar: $TARGET" +``` + +- [ ] **Step 4: Add package script** + +In `wework/package.json`, add: + +```json +"prepare:executor-sidecar": "bash scripts/prepare-local-executor-sidecar.sh" +``` + +Do not wire this into every `build` run yet; desktop release scripts can call it explicitly before `tauri build`. + +- [ ] **Step 5: Run focused tests and script dry run** + +Run: + +```bash +cd executor && uv run pytest tests/scripts/test_build_local.py -q +pnpm --dir wework run prepare:executor-sidecar +``` + +Expected: pytest PASS; sidecar script creates `wework/src-tauri/binaries/wegent-executor` or `.exe`. + +- [ ] **Step 6: Commit** + +```bash +git add wework/scripts/prepare-local-executor-sidecar.sh wework/package.json executor/scripts/build_local.py executor/tests/scripts/test_build_local.py +git commit -m "build(wework): prepare executor sidecar" +``` + +### Task 11: Local-First App Integration Tests + +**Files:** + +- Modify: `wework/src/App.test.tsx` +- Modify: `wework/src/features/workbench/WorkbenchProvider.test.tsx` +- Modify: `wework/src/test/setup.ts` + +- [ ] **Step 1: Add app-level local-first test** + +Add this test to `wework/src/App.test.tsx`: + +```ts +test('renders app shell in local-first mode without backend auth', async () => { + window.__WEWORK_RUNTIME_CONFIG__ = { + runtimeMode: 'local-first', + } + + render() + + await waitFor(() => { + expect(window.location.pathname).not.toBe('/login') + }) +}) +``` + +- [ ] **Step 2: Add Workbench send integration test** + +Add this test to `wework/src/features/workbench/WorkbenchProvider.test.tsx` using the existing `ProjectSendProbe` helpers: + +```ts +test('creates runtime task through local runtime work api', async () => { + const services = createWorkbenchServices() + renderWorkbench(, services) + + await waitFor(() => expect(screen.getByTestId('boot-state')).not.toHaveTextContent('loading')) + await userEvent.click(screen.getByRole('button', { name: 'select project' })) + await userEvent.type(screen.getByRole('textbox'), 'local task') + await userEvent.click(screen.getByRole('button', { name: 'send' })) + + await waitFor(() => + expect(services.runtimeWorkApi?.createRuntimeTask).toHaveBeenCalledWith( + expect.objectContaining({ + deviceId: 'device-1', + }) + ) + ) +}) +``` + +- [ ] **Step 3: Run local-first frontend tests** + +Run: + +```bash +pnpm --dir wework test src/App.test.tsx src/features/auth/AuthProvider.test.tsx src/features/workbench/WorkbenchProvider.test.tsx src/api/local/localServices.test.ts src/api/local/localChatStream.test.ts src/tauri/localExecutor.test.ts +``` + +Expected: PASS. + +- [ ] **Step 4: Run WeWork lint** + +Run: `pnpm --dir wework run lint` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add wework/src/App.test.tsx wework/src/features/workbench/WorkbenchProvider.test.tsx wework/src/test/setup.ts +git commit -m "test(wework): cover local-first app flow" +``` + +### Task 12: Executor Verification Suite + +**Files:** + +- Modify: executor tests from earlier tasks only if verification exposes failures. + +- [ ] **Step 1: Run executor focused suite** + +Run: + +```bash +cd executor && uv run pytest \ + tests/test_local_app_ipc.py \ + tests/test_local_command_handler.py \ + tests/test_local_websocket_client.py \ + tests/runtime_work/test_runtime_work_runner_registration.py \ + tests/config/test_device_config_update.py \ + tests/scripts/test_build_local.py \ + -q +``` + +Expected: PASS. + +- [ ] **Step 2: Run executor broader local suite** + +Run: + +```bash +cd executor && uv run pytest tests/test_local_heartbeat.py tests/runtime_work tests/agents/test_codex_event_mapper.py -q +``` + +Expected: PASS. + +- [ ] **Step 3: Commit verification-only fixes after failures** + +If a verification command reveals a defect caused by this work, fix it in the owning file and commit: + +```bash +git add executor wework +git commit -m "fix: stabilize local-first runtime tests" +``` + +No commit is needed when Step 1 and Step 2 pass with no file changes. + +### Task 13: Documentation + +**Files:** + +- Create: `docs/zh/developer-guide/wework-local-first-app.md` +- Create: `docs/en/developer-guide/wework-local-first-app.md` +- Modify: `docs/zh/developer-guide/local-device-command-rpc.md` +- Modify: `docs/en/developer-guide/local-device-command-rpc.md` +- Modify: `executor/docs/LOCAL_MODE.md` + +- [ ] **Step 1: Write Chinese local-first docs** + +Create `docs/zh/developer-guide/wework-local-first-app.md`: + +```markdown +--- +sidebar_position: 1 +--- + +# WeWork 本地优先应用 + +打包后的 WeWork 桌面应用默认使用本地优先模式。主工作流不依赖 Backend:应用启动后由 Tauri 管理一个本地 executor 子进程,并通过标准输入/标准输出上的 JSON-RPC 与 executor 通信。 + +本地运行时只包含两个进程: + +- WeWork 桌面应用 +- 本地 executor + +Backend 是可选能力。用户登录 Backend 后,可以同步模型、访问云端项目,并让网页版通过 Backend 控制本机 executor。Backend 断开不会影响已经在本地运行的任务。 + +## 数据流 + +1. React 通过本地服务适配器请求 Team、模型、设备和 Runtime Work。 +2. Tauri 命令把请求写入 executor stdin。 +3. executor 复用 Runtime Work RPC 和设备命令处理器执行请求。 +4. executor 把响应和流式事件写到 stdout。 +5. Tauri 把事件转发给当前 WeWork 窗口。 + +## Backend 可选连接 + +executor 可以同时启用 Backend Socket.IO 通道。该通道服务网页版控制本机,不是本地 WeWork 执行任务的前置条件。 + +## 安全边界 + +渲染进程不能直接执行 shell 命令。设备命令必须通过已注册的命令键进入 executor,由 executor 侧命令处理器执行。 +``` + +- [ ] **Step 2: Write English local-first docs** + +Create `docs/en/developer-guide/wework-local-first-app.md`: + +```markdown +--- +sidebar_position: 1 +--- + +# WeWork Local-First App + +The packaged WeWork desktop app defaults to local-first mode. The main workflow does not depend on Backend: Tauri manages one local executor child process and communicates with it through JSON-RPC over stdin/stdout. + +The local runtime has two processes: + +- WeWork desktop app +- Local executor + +Backend is optional. After signing in to Backend, users can sync models, access cloud projects, and let the web app control the local executor through Backend. Backend disconnects do not stop local tasks that are already running. + +## Data Flow + +1. React requests Team, model, device, and Runtime Work data through local service adapters. +2. Tauri commands write requests to executor stdin. +3. The executor reuses Runtime Work RPC and device command handlers. +4. The executor writes responses and streaming events to stdout. +5. Tauri forwards events to the current WeWork window. + +## Optional Backend Connection + +The executor can also enable its Backend Socket.IO channel. That channel serves web control of the local computer and is not required for local WeWork task execution. + +## Security Boundary + +The renderer cannot execute raw shell commands. Device commands must enter the executor through registered command keys and are executed by the executor-side command handler. +``` + +- [ ] **Step 3: Update existing command RPC and local mode docs** + +In both local-device command RPC docs, add a section named `Direct App IPC` explaining: + +```markdown +## Direct App IPC + +Packaged WeWork can send device command requests directly to its managed executor through Tauri app IPC. The renderer still sends command keys, not raw shell commands. The executor reuses the same command registry and `CommandHandler` result shape used by Backend-controlled local devices. +``` + +In `executor/docs/LOCAL_MODE.md`, add: + +```markdown +## WeWork Desktop App IPC + +When started with `--app-ipc --no-backend`, the executor reads newline-delimited JSON requests from stdin and writes JSON responses or events to stdout. This mode is used by packaged WeWork to run local tasks without Backend. Backend WebSocket registration remains available when a Backend channel is configured and `--no-backend` is not used. +``` + +- [ ] **Step 4: Commit docs** + +```bash +git add docs/zh/developer-guide/wework-local-first-app.md docs/en/developer-guide/wework-local-first-app.md docs/zh/developer-guide/local-device-command-rpc.md docs/en/developer-guide/local-device-command-rpc.md executor/docs/LOCAL_MODE.md +git commit -m "docs: describe wework local-first app ipc" +``` + +### Task 14: Final Verification And PR + +**Files:** + +- No planned file edits. + +- [ ] **Step 1: Run frontend verification** + +Run: + +```bash +pnpm --dir wework test +pnpm --dir wework run lint +pnpm --dir wework run build +``` + +Expected: PASS. + +- [ ] **Step 2: Run Tauri verification** + +Run: + +```bash +cargo test --manifest-path wework/src-tauri/Cargo.toml +cargo check --manifest-path wework/src-tauri/Cargo.toml +``` + +Expected: PASS. + +- [ ] **Step 3: Run executor verification** + +Run: + +```bash +cd executor && uv run pytest +``` + +Expected: PASS. + +- [ ] **Step 4: Inspect worktree** + +Run: `git status --short` + +Expected: no unstaged changes. + +- [ ] **Step 5: Push branch and create PR** + +Run: + +```bash +git push -u origin codex/wework-local-first-app +``` + +Open a draft PR with title: + +```text +feat(wework): run packaged app without backend +``` + +PR summary: + +```markdown +## Summary + +- adds WeWork local-first mode for packaged desktop app runtime +- bridges React local services to a Tauri-managed executor over stdio JSON-RPC +- lets executor run direct app IPC without Backend while preserving optional Backend web-control channel +- documents the two-process local runtime + +## Verification + +- pnpm --dir wework test +- pnpm --dir wework run lint +- pnpm --dir wework run build +- cargo test --manifest-path wework/src-tauri/Cargo.toml +- cargo check --manifest-path wework/src-tauri/Cargo.toml +- cd executor && uv run pytest +``` + +- [ ] **Step 6: Fix CI and review feedback** + +Use GitHub checks and review comments as authoritative state. For each failing check or actionable review comment: + +```bash +gh pr checks --watch +gh pr view --comments +``` + +Fix the owning code or test, run the smallest command that proves the fix, commit with a scoped Conventional Commit message, push, and re-check CI until the PR is green and actionable review threads are addressed. From 795d6866469130e9c9048ad3b804712f5b799ca6 Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 25 Jun 2026 14:48:30 +0800 Subject: [PATCH 04/17] feat(wework): add runtime mode config --- wework/src/config/runtime.test.ts | 27 +++++++++++++++++++++++++++ wework/src/config/runtime.ts | 22 ++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/wework/src/config/runtime.test.ts b/wework/src/config/runtime.test.ts index 8e99da3861..c21074cebc 100644 --- a/wework/src/config/runtime.test.ts +++ b/wework/src/config/runtime.test.ts @@ -45,4 +45,31 @@ describe('getRuntimeConfig', () => { expect(getRuntimeConfig().loginMode).toBe('all') }) + + test('defaults to backend mode in browser development', () => { + expect(getRuntimeConfig().runtimeMode).toBe('backend') + }) + + test('uses local-first mode from runtime config override', () => { + window.__WEWORK_RUNTIME_CONFIG__ = { + runtimeMode: 'local-first', + } + + expect(getRuntimeConfig().runtimeMode).toBe('local-first') + }) + + test('uses local-first mode from build-time environment', () => { + vi.stubEnv('VITE_WEWORK_RUNTIME_MODE', 'local-first') + + expect(getRuntimeConfig().runtimeMode).toBe('local-first') + }) + + test('ignores invalid runtime modes', () => { + vi.stubEnv('VITE_WEWORK_RUNTIME_MODE', 'invalid-mode') + window.__WEWORK_RUNTIME_CONFIG__ = { + runtimeMode: 'invalid-runtime-mode' as never, + } + + expect(getRuntimeConfig().runtimeMode).toBe('backend') + }) }) diff --git a/wework/src/config/runtime.ts b/wework/src/config/runtime.ts index e2042ad245..5a8f5cb298 100644 --- a/wework/src/config/runtime.ts +++ b/wework/src/config/runtime.ts @@ -1,8 +1,11 @@ +export type RuntimeMode = 'local-first' | 'backend' + export interface RuntimeConfig { appBasePath: string apiBaseUrl: string socketBaseUrl: string socketPath: string + runtimeMode: RuntimeMode loginMode: 'password' | 'oidc' | 'all' oidcLoginText: string cloudDeviceScalingWikiUrl: string @@ -40,6 +43,24 @@ function isValidLoginMode(value: string): value is RuntimeConfig['loginMode'] { return value === 'password' || value === 'oidc' || value === 'all' } +function isValidRuntimeMode(value: string): value is RuntimeMode { + return value === 'local-first' || value === 'backend' +} + +function resolveRuntimeMode(overrides: RuntimeConfigOverrides): RuntimeMode { + const runtimeValue = runtimeString(overrides, 'runtimeMode') + if (runtimeValue && isValidRuntimeMode(runtimeValue)) { + return runtimeValue + } + + const envValue = import.meta.env.VITE_WEWORK_RUNTIME_MODE + if (envValue && isValidRuntimeMode(envValue)) { + return envValue + } + + return 'backend' +} + function resolveLoginMode(overrides: RuntimeConfigOverrides): RuntimeConfig['loginMode'] { const runtimeValue = runtimeString(overrides, 'loginMode') if (runtimeValue && isValidLoginMode(runtimeValue)) { @@ -116,6 +137,7 @@ export function getRuntimeConfig(): RuntimeConfig { apiBaseUrl: trimTrailingSlash(apiBaseUrl), socketBaseUrl: trimTrailingSlash(socketBaseUrl), socketPath, + runtimeMode: resolveRuntimeMode(overrides), loginMode: resolveLoginMode(overrides), oidcLoginText: runtimeString(overrides, 'oidcLoginText') || import.meta.env.VITE_OIDC_LOGIN_TEXT || '', From dce70ce137b934c9ed298f8ecb32064c0add63da Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 25 Jun 2026 14:49:51 +0800 Subject: [PATCH 05/17] feat(wework): bootstrap local auth session --- wework/src/api/local/localSession.ts | 12 ++++++++ .../src/features/auth/AuthProvider.test.tsx | 29 +++++++++++++++++++ wework/src/features/auth/AuthProvider.tsx | 21 ++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 wework/src/api/local/localSession.ts diff --git a/wework/src/api/local/localSession.ts b/wework/src/api/local/localSession.ts new file mode 100644 index 0000000000..db514d1c46 --- /dev/null +++ b/wework/src/api/local/localSession.ts @@ -0,0 +1,12 @@ +import type { User } from '@/types/api' + +export const LOCAL_USER = { + id: 0, + user_name: 'local', + email: 'local@wework.local', + preferences: {}, +} satisfies User + +export function getLocalUser(): User { + return LOCAL_USER +} diff --git a/wework/src/features/auth/AuthProvider.test.tsx b/wework/src/features/auth/AuthProvider.test.tsx index 56eba30d3d..c8e047ccd2 100644 --- a/wework/src/features/auth/AuthProvider.test.tsx +++ b/wework/src/features/auth/AuthProvider.test.tsx @@ -2,6 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, test, vi } from 'vitest' import { setToken } from '@/api/auth' import { ApiError } from '@/api/http' +import { LOCAL_USER } from '@/api/local/localSession' import { AuthProvider } from './AuthProvider' import { useAuth } from './useAuth' @@ -24,6 +25,7 @@ describe('AuthProvider', () => { beforeEach(() => { localStorage.clear() sessionStorage.clear() + delete window.__WEWORK_RUNTIME_CONFIG__ window.history.pushState({}, '', '/') }) @@ -85,4 +87,31 @@ describe('AuthProvider', () => { await waitFor(() => expect(screen.getByTestId('auth-probe')).toHaveTextContent('none')) expect(window.location.pathname).toBe('/login') }) + + test('creates local user without redirect or backend calls in local-first mode', async () => { + window.__WEWORK_RUNTIME_CONFIG__ = { + runtimeMode: 'local-first', + } + const authApi = { + getCurrentUser: vi.fn(), + getCurrentUserWithoutAuthRedirect: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + loginWithOidcToken: vi.fn(), + setupAdminPassword: vi.fn(), + } + + render( + + + + ) + + await waitFor(() => + expect(screen.getByTestId('auth-probe')).toHaveTextContent(`${LOCAL_USER.user_name}:ready`) + ) + expect(window.location.pathname).toBe('/') + expect(authApi.getCurrentUser).not.toHaveBeenCalled() + expect(authApi.getCurrentUserWithoutAuthRedirect).not.toHaveBeenCalled() + }) }) diff --git a/wework/src/features/auth/AuthProvider.tsx b/wework/src/features/auth/AuthProvider.tsx index 67505ac10b..72470536af 100644 --- a/wework/src/features/auth/AuthProvider.tsx +++ b/wework/src/features/auth/AuthProvider.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ReactNode } from 'react' import { createAuthApi, isAuthenticated, removeToken, type LoginRequest } from '@/api/auth' import { ApiError, createHttpClient } from '@/api/http' +import { getLocalUser } from '@/api/local/localSession' import { getRuntimeConfig, stripAppBasePath } from '@/config/runtime' import type { User } from '@/types/api' import { @@ -24,6 +25,10 @@ function createDefaultAuthApi(): AuthApi { return createAuthApi(createHttpClient({ baseUrl: apiBaseUrl })) } +function isLocalFirstRuntime(): boolean { + return getRuntimeConfig().runtimeMode === 'local-first' +} + function isAuthRoute(pathname: string) { const appPath = stripAppBasePath(pathname) return appPath === LOGIN_PATH || appPath === OIDC_CALLBACK_PATH @@ -76,6 +81,12 @@ export function AuthProvider({ children, authApi }: AuthProviderProps) { setIsLoading(true) try { + if (isLocalFirstRuntime()) { + setUser(getLocalUser()) + clearAdminPasswordSetupState() + return + } + if (!isAuthenticated()) { setUser(null) if (isAuthRoute(window.location.pathname)) { @@ -115,6 +126,9 @@ export function AuthProvider({ children, authApi }: AuthProviderProps) { void Promise.resolve().then(() => refresh()) const interval = window.setInterval(() => { + if (isLocalFirstRuntime()) { + return + } if (!isAuthenticated() && userRef.current) { setUser(null) clearAdminPasswordSetupState() @@ -146,6 +160,13 @@ export function AuthProvider({ children, authApi }: AuthProviderProps) { ) const logout = useCallback(() => { + if (isLocalFirstRuntime()) { + removeToken() + setUser(getLocalUser()) + clearAdminPasswordSetupState() + return + } + resolvedAuthApi.logout() setUser(null) clearAdminPasswordSetupState() From 3c33929b960c63f1c0909a22983e036d02fed8ab Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 25 Jun 2026 14:50:59 +0800 Subject: [PATCH 06/17] feat(wework): add local executor tauri client --- wework/src/tauri/localExecutor.test.ts | 104 +++++++++++++++++++++++++ wework/src/tauri/localExecutor.ts | 50 ++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 wework/src/tauri/localExecutor.test.ts create mode 100644 wework/src/tauri/localExecutor.ts diff --git a/wework/src/tauri/localExecutor.test.ts b/wework/src/tauri/localExecutor.test.ts new file mode 100644 index 0000000000..8610354a1f --- /dev/null +++ b/wework/src/tauri/localExecutor.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { invoke } from '@tauri-apps/api/core' +import { listen } from '@tauri-apps/api/event' +import { + LOCAL_EXECUTOR_COMMANDS, + LOCAL_EXECUTOR_EVENT, + ensureLocalExecutorStarted, + getLocalExecutorStatus, + requestLocalExecutor, + restartLocalExecutor, + subscribeLocalExecutorEvents, +} from './localExecutor' + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})) + +vi.mock('@tauri-apps/api/event', () => ({ + listen: vi.fn(), +})) + +const invokeMock = vi.mocked(invoke) +const listenMock = vi.mocked(listen) + +describe('localExecutor', () => { + beforeEach(() => { + invokeMock.mockReset() + listenMock.mockReset() + }) + + test('ensures the local executor through the native app command', async () => { + invokeMock.mockResolvedValue({ running: true, ready: true, deviceId: 'local-device' }) + + await expect(ensureLocalExecutorStarted()).resolves.toEqual({ + running: true, + ready: true, + deviceId: 'local-device', + }) + expect(invokeMock).toHaveBeenCalledWith(LOCAL_EXECUTOR_COMMANDS.ensure) + }) + + test('reads local executor status through the native app command', async () => { + invokeMock.mockResolvedValue({ running: false, ready: false, error: 'missing binary' }) + + await expect(getLocalExecutorStatus()).resolves.toEqual({ + running: false, + ready: false, + error: 'missing binary', + }) + expect(invokeMock).toHaveBeenCalledWith(LOCAL_EXECUTOR_COMMANDS.status) + }) + + test('restarts the local executor through the native app command', async () => { + invokeMock.mockResolvedValue({ running: true, ready: true, deviceId: 'local-device' }) + + await expect(restartLocalExecutor()).resolves.toEqual({ + running: true, + ready: true, + deviceId: 'local-device', + }) + expect(invokeMock).toHaveBeenCalledWith(LOCAL_EXECUTOR_COMMANDS.restart) + }) + + test('sends local executor requests through the native app command', async () => { + invokeMock.mockResolvedValue({ projects: [], chats: [], totalLocalTasks: 0 }) + + await expect( + requestLocalExecutor('runtime.tasks.list', { includeArchived: false }) + ).resolves.toEqual({ + projects: [], + chats: [], + totalLocalTasks: 0, + }) + expect(invokeMock).toHaveBeenCalledWith(LOCAL_EXECUTOR_COMMANDS.request, { + method: 'runtime.tasks.list', + params: { includeArchived: false }, + }) + }) + + test('subscribes to local executor native events', async () => { + const unlisten = vi.fn() + listenMock.mockResolvedValue(unlisten) + const handler = vi.fn() + + const cleanup = await subscribeLocalExecutorEvents(handler) + const [, callback] = listenMock.mock.calls[0] + callback({ + event: LOCAL_EXECUTOR_EVENT, + id: 1, + payload: { + event: 'response.completed', + payload: { localTaskId: 'task-1' }, + }, + }) + cleanup() + + expect(listenMock).toHaveBeenCalledWith(LOCAL_EXECUTOR_EVENT, expect.any(Function)) + expect(handler).toHaveBeenCalledWith({ + event: 'response.completed', + payload: { localTaskId: 'task-1' }, + }) + expect(unlisten).toHaveBeenCalled() + }) +}) diff --git a/wework/src/tauri/localExecutor.ts b/wework/src/tauri/localExecutor.ts new file mode 100644 index 0000000000..17f15def9a --- /dev/null +++ b/wework/src/tauri/localExecutor.ts @@ -0,0 +1,50 @@ +import { invoke } from '@tauri-apps/api/core' +import { listen, type UnlistenFn } from '@tauri-apps/api/event' + +export const LOCAL_EXECUTOR_COMMANDS = { + ensure: 'local_executor_ensure_started', + status: 'local_executor_status', + request: 'local_executor_request', + restart: 'local_executor_restart', +} as const + +export const LOCAL_EXECUTOR_EVENT = 'local-executor:event' + +export interface LocalExecutorStatus { + running: boolean + ready?: boolean + deviceId?: string + error?: string +} + +export interface LocalExecutorEvent { + event: string + payload: Record +} + +export function ensureLocalExecutorStarted(): Promise { + return invoke(LOCAL_EXECUTOR_COMMANDS.ensure) +} + +export function getLocalExecutorStatus(): Promise { + return invoke(LOCAL_EXECUTOR_COMMANDS.status) +} + +export function restartLocalExecutor(): Promise { + return invoke(LOCAL_EXECUTOR_COMMANDS.restart) +} + +export function requestLocalExecutor( + method: string, + params: Record = {} +): Promise { + return invoke(LOCAL_EXECUTOR_COMMANDS.request, { method, params }) +} + +export function subscribeLocalExecutorEvents( + handler: (event: LocalExecutorEvent) => void +): Promise { + return listen(LOCAL_EXECUTOR_EVENT, event => { + handler(event.payload) + }) +} From 4cb7f44e623d286902a8d782902afbfd1c13fe70 Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 25 Jun 2026 14:53:17 +0800 Subject: [PATCH 07/17] feat(wework): stream local executor events --- wework/src/api/local/localChatStream.test.ts | 102 +++++++++++++++ wework/src/api/local/localChatStream.ts | 123 +++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 wework/src/api/local/localChatStream.test.ts create mode 100644 wework/src/api/local/localChatStream.ts diff --git a/wework/src/api/local/localChatStream.test.ts b/wework/src/api/local/localChatStream.test.ts new file mode 100644 index 0000000000..fb1531ab4f --- /dev/null +++ b/wework/src/api/local/localChatStream.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' +import type { LocalExecutorEvent } from '@/tauri/localExecutor' +import { createLocalChatStream } from './localChatStream' + +describe('createLocalChatStream', () => { + const subscribe = vi.fn() + const request = vi.fn() + + beforeEach(() => { + subscribe.mockReset() + request.mockReset() + }) + + test('maps executor text delta events to chat chunks', async () => { + let listener!: (event: LocalExecutorEvent) => void + subscribe.mockImplementation(async handler => { + listener = handler + return vi.fn() + }) + const onChatChunk = vi.fn() + const stream = createLocalChatStream({ subscribe, request }) + + stream.subscribe({ onChatChunk }) + await Promise.resolve() + listener({ + event: 'response.output_text.delta', + payload: { + task_id: 0, + subtask_id: 1001, + local_task_id: 'task-1', + device_id: 'local-device', + data: { delta: 'hello' }, + }, + }) + + expect(onChatChunk).toHaveBeenCalledWith({ + task_id: 0, + subtask_id: 1001, + local_task_id: 'task-1', + device_id: 'local-device', + content: 'hello', + offset: 0, + result: { delta: 'hello' }, + }) + }) + + test('maps executor terminal events to chat done callbacks', async () => { + let listener!: (event: LocalExecutorEvent) => void + subscribe.mockImplementation(async handler => { + listener = handler + return vi.fn() + }) + const onChatDone = vi.fn() + const stream = createLocalChatStream({ subscribe, request }) + + stream.subscribe({ onChatDone }) + await Promise.resolve() + listener({ + event: 'response.completed', + payload: { + task_id: 0, + subtask_id: 1001, + local_task_id: 'task-1', + device_id: 'local-device', + data: { value: 'complete' }, + }, + }) + + expect(onChatDone).toHaveBeenCalledWith({ + task_id: 0, + subtask_id: 1001, + local_task_id: 'task-1', + device_id: 'local-device', + offset: 0, + result: { value: 'complete' }, + }) + }) + + test('routes guidance and cancel requests through app ipc', async () => { + request.mockResolvedValueOnce({ success: true, guidance_id: 'guide-1' }) + request.mockResolvedValueOnce({ success: true }) + const stream = createLocalChatStream({ subscribe, request }) + + await expect( + stream.sendGuidance({ + task_id: 0, + subtask_id: 1001, + team_id: 0, + message: 'continue', + }) + ).resolves.toEqual({ success: true, guidance_id: 'guide-1' }) + await expect(stream.cancelStream({ subtask_id: 1001 })).resolves.toEqual({ success: true }) + + expect(request).toHaveBeenNthCalledWith(1, 'runtime.tasks.guidance', { + task_id: 0, + subtask_id: 1001, + team_id: 0, + message: 'continue', + }) + expect(request).toHaveBeenNthCalledWith(2, 'runtime.tasks.cancel', { subtask_id: 1001 }) + }) +}) diff --git a/wework/src/api/local/localChatStream.ts b/wework/src/api/local/localChatStream.ts new file mode 100644 index 0000000000..c76912b193 --- /dev/null +++ b/wework/src/api/local/localChatStream.ts @@ -0,0 +1,123 @@ +import type { ChatCancelAck, ChatCancelPayload, ChatGuideAck, ChatGuidePayload } from '@/types/api' +import type { ChatStreamHandlers } from '@/stream/chatStream' +import type { LocalExecutorEvent } from '@/tauri/localExecutor' + +interface LocalChatStreamDeps { + subscribe: (handler: (event: LocalExecutorEvent) => void) => Promise<() => void> + request: (method: string, params?: Record) => Promise +} + +function asRecord(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {} +} + +function stringField(record: Record, key: string): string | undefined { + const value = record[key] + return typeof value === 'string' ? value : undefined +} + +function numberField(record: Record, key: string): number { + const value = record[key] + return typeof value === 'number' ? value : 0 +} + +function eventBase(payload: Record) { + return { + task_id: numberField(payload, 'task_id'), + subtask_id: numberField(payload, 'subtask_id'), + device_id: stringField(payload, 'device_id'), + local_task_id: stringField(payload, 'local_task_id'), + } +} + +function eventResult(payload: Record): Record { + return asRecord(payload.data) +} + +function eventContent(payload: Record): string { + const result = eventResult(payload) + return ( + stringField(result, 'delta') ?? + stringField(result, 'value') ?? + stringField(result, 'output_text') ?? + '' + ) +} + +function emitResponseEvent(handlers: ChatStreamHandlers, event: LocalExecutorEvent): void { + const payload = asRecord(event.payload) + const base = eventBase(payload) + + if (event.event === 'response.created' || event.event === 'response.in_progress') { + handlers.onChatStart?.({ + ...base, + shell_type: stringField(payload, 'runtime'), + }) + return + } + + if (event.event === 'response.output_text.delta') { + handlers.onChatChunk?.({ + ...base, + content: eventContent(payload), + offset: numberField(payload, 'offset'), + result: eventResult(payload), + }) + return + } + + if (event.event === 'response.completed') { + handlers.onChatDone?.({ + ...base, + offset: numberField(payload, 'offset'), + result: eventResult(payload), + }) + return + } + + if (event.event === 'response.incomplete' || event.event === 'error') { + handlers.onChatError?.({ + ...base, + error: stringField(payload, 'error') ?? (eventContent(payload) || 'Local executor error'), + type: stringField(payload, 'type') ?? event.event, + }) + } +} + +export function createLocalChatStream(deps: LocalChatStreamDeps) { + return { + sendGuidance(payload: ChatGuidePayload): Promise { + return deps.request( + 'runtime.tasks.guidance', + payload as unknown as Record + ) + }, + cancelStream(payload: ChatCancelPayload): Promise { + return deps.request( + 'runtime.tasks.cancel', + payload as unknown as Record + ) + }, + subscribe(handlers: ChatStreamHandlers): () => void { + let cleanup: (() => void) | null = null + let active = true + + void deps.subscribe(event => { + if (active) { + emitResponseEvent(handlers, event) + } + }).then(unlisten => { + if (active) { + cleanup = unlisten + return + } + unlisten() + }) + + return () => { + active = false + cleanup?.() + } + }, + } +} From 1d1e04d3a028e989214bf660a41341776c9fb88f Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 25 Jun 2026 15:02:02 +0800 Subject: [PATCH 08/17] feat(wework): add local workbench services --- wework/src/api/local/localChatStream.ts | 4 +- wework/src/api/local/localServices.test.ts | 99 ++++++ wework/src/api/local/localServices.ts | 315 ++++++++++++++++++ .../layout/DesktopWorkbenchLayout.tsx | 1 - .../workbench/WorkbenchProvider.test.tsx | 44 +++ .../features/workbench/WorkbenchProvider.tsx | 24 +- wework/src/pages/PluginManagementPage.tsx | 7 - wework/src/pages/PluginsPage.tsx | 7 - 8 files changed, 478 insertions(+), 23 deletions(-) create mode 100644 wework/src/api/local/localServices.test.ts create mode 100644 wework/src/api/local/localServices.ts diff --git a/wework/src/api/local/localChatStream.ts b/wework/src/api/local/localChatStream.ts index c76912b193..3ee00511e7 100644 --- a/wework/src/api/local/localChatStream.ts +++ b/wework/src/api/local/localChatStream.ts @@ -8,7 +8,9 @@ interface LocalChatStreamDeps { } function asRecord(value: unknown): Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {} + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : {} } function stringField(record: Record, key: string): string | undefined { diff --git a/wework/src/api/local/localServices.test.ts b/wework/src/api/local/localServices.test.ts new file mode 100644 index 0000000000..bcea00c1f8 --- /dev/null +++ b/wework/src/api/local/localServices.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test, vi } from 'vitest' +import { LOCAL_USER } from './localSession' +import { createLocalAppServices } from './localServices' + +describe('createLocalAppServices', () => { + test('returns local bootstrap data without backend', async () => { + const request = vi.fn().mockResolvedValue({ projects: [], chats: [], totalLocalTasks: 0 }) + const ensure = vi.fn().mockResolvedValue({ + running: true, + ready: true, + deviceId: 'local-device', + version: '1.9.0', + }) + const services = createLocalAppServices({ + ensure, + request, + subscribe: vi.fn(), + }) + + await expect(services.teamApi.getDefaultWorkbenchTeam()).resolves.toMatchObject({ + id: 0, + name: 'local-wework', + is_active: true, + }) + await expect(services.modelApi.listModels()).resolves.toEqual({ + data: [ + expect.objectContaining({ + name: 'local-codex', + type: 'runtime', + runtime: { family: 'openai.openai-responses', provider: 'local' }, + }), + ], + }) + await expect(services.deviceApi.listDevices()).resolves.toEqual([ + expect.objectContaining({ + device_id: 'local-device', + name: 'Local Executor', + status: 'online', + device_type: 'local', + executor_version: '1.9.0', + }), + ]) + await expect(services.userApi?.updateCurrentUser({ preferences: {} })).resolves.toEqual( + LOCAL_USER + ) + await expect(services.runtimeWorkApi?.listRuntimeWork()).resolves.toEqual({ + projects: [], + chats: [], + totalLocalTasks: 0, + }) + expect(request).toHaveBeenCalledWith('runtime.tasks.list', {}) + }) + + test('routes runtime task creation and device commands through app ipc', async () => { + const request = vi.fn().mockImplementation(async (method: string) => { + if (method === 'runtime.tasks.create') { + return { + accepted: true, + deviceId: 'local-device', + localTaskId: 'task-1', + workspacePath: '/Users/me/project', + runtime: 'codex', + } + } + if (method === 'device.execute_command') { + return { success: true, stdout: '/Users/me', stderr: '', exit_code: 0 } + } + return {} + }) + const services = createLocalAppServices({ + ensure: vi.fn().mockResolvedValue({ running: true, ready: true, deviceId: 'local-device' }), + request, + subscribe: vi.fn(), + }) + + await services.runtimeWorkApi?.createRuntimeTask({ + teamId: 0, + deviceId: 'local-device', + runtime: 'codex', + message: 'hello', + }) + await services.deviceApi.executeCommand('local-device', { + command_key: 'home_dir', + timeout_seconds: 10, + }) + + expect(request).toHaveBeenCalledWith('runtime.tasks.create', { + teamId: 0, + deviceId: 'local-device', + runtime: 'codex', + message: 'hello', + }) + expect(request).toHaveBeenCalledWith('device.execute_command', { + deviceId: 'local-device', + command_key: 'home_dir', + timeout_seconds: 10, + }) + }) +}) diff --git a/wework/src/api/local/localServices.ts b/wework/src/api/local/localServices.ts new file mode 100644 index 0000000000..4a4ebf73ff --- /dev/null +++ b/wework/src/api/local/localServices.ts @@ -0,0 +1,315 @@ +import type { WorkbenchServices } from '@/features/workbench/WorkbenchProvider' +import type { + DeviceCommandResponse, + DeviceInfo, + LocalDeviceSkill, + RuntimeTaskAddress, + RuntimeTaskCreateRequest, + RuntimeWorkListResponse, + Team, + UnifiedModel, + User, +} from '@/types/api' +import { + ensureLocalExecutorStarted, + requestLocalExecutor, + subscribeLocalExecutorEvents, + type LocalExecutorEvent, + type LocalExecutorStatus, +} from '@/tauri/localExecutor' +import { createLocalChatStream } from './localChatStream' +import { LOCAL_USER } from './localSession' + +const LOCAL_DEVICE_ID = 'local-device' + +export const LOCAL_WORKBENCH_TEAM = { + id: 0, + name: 'local-wework', + displayName: 'Local WeWork', + is_active: true, + default_for_modes: ['wework'], + recommended_mode: 'code', +} satisfies Team + +const LOCAL_CODEX_MODEL = { + name: 'local-codex', + type: 'runtime', + displayName: 'Local Codex', + provider: 'local', + modelId: 'local-codex', + runtime: { + family: 'openai.openai-responses', + provider: 'local', + }, + isActive: true, +} satisfies UnifiedModel + +interface LocalAppServicesDeps { + ensure?: () => Promise + request?: (method: string, params?: Record) => Promise + subscribe?: (handler: (event: LocalExecutorEvent) => void) => Promise<() => void> +} + +function cloudConnectionRequired(name: string): never { + throw new Error(`${name} requires cloud connection`) +} + +function localDeviceFromStatus(status: LocalExecutorStatus): DeviceInfo { + const online = status.running && status.ready !== false && !status.error + return { + id: 0, + device_id: status.deviceId || LOCAL_DEVICE_ID, + name: 'Local Executor', + status: online ? 'online' : 'offline', + is_default: true, + device_type: 'local', + capabilities: ['runtime-work', 'device-commands'], + slot_used: 0, + slot_max: 5, + executor_version: (status as LocalExecutorStatus & { version?: string }).version ?? null, + latest_version: null, + update_available: false, + bind_shell: 'openclaw', + runtime_transfer_host: null, + } +} + +function commandText(response: DeviceCommandResponse): string { + const output = Array.isArray(response.stdout) + ? response.stdout.join('\n') + : typeof response.stdout === 'string' + ? response.stdout + : JSON.stringify(response.stdout ?? '') + return output.trim() +} + +function commandStringList(response: DeviceCommandResponse): string[] { + return Array.isArray(response.stdout) + ? response.stdout.filter((item): item is string => typeof item === 'string') + : [] +} + +function commandSkills(response: DeviceCommandResponse): LocalDeviceSkill[] { + const output = typeof response.stdout === 'string' ? JSON.parse(response.stdout) : response.stdout + return Array.isArray(output) + ? output.filter( + (item): item is LocalDeviceSkill => + typeof item === 'object' && item !== null && 'name' in item && 'path' in item + ) + : [] +} + +function assertCommandSuccess(response: DeviceCommandResponse, fallback: string): void { + if (!response.success) { + throw new Error(response.error || response.stderr || fallback) + } +} + +function createRuntimeWorkApi( + request: (method: string, params?: Record) => Promise +) { + return { + listRuntimeWork(): Promise { + return request('runtime.tasks.list', {}) + }, + upsertDeviceWorkspace() { + return cloudConnectionRequired('upsertDeviceWorkspace') + }, + prepareDeviceWorkspace(data: Record) { + return request('runtime.workspaces.prepare', data) + }, + deleteDeviceWorkspace(data: Record) { + return request('runtime.workspaces.delete', data) + }, + getRuntimeTranscript(data: Record) { + return request('runtime.tasks.transcript', data) + }, + searchRuntimeWork(data: Record) { + return request('runtime.tasks.search', data) + }, + revertRuntimeFileChanges(data: Record) { + return request('runtime.tasks.revert_file_changes', data) + }, + sendRuntimeMessage(data: Record) { + return request('runtime.tasks.send', data) + }, + openRuntimeWorkspace(data: Record) { + return request('runtime.workspaces.open', data) + }, + renameRuntimeWorkspace(data: Record) { + return request('runtime.workspaces.rename', data) + }, + removeRuntimeWorkspace(data: Record) { + return request('runtime.workspaces.remove', data) + }, + bindRuntimeTaskImSessions() { + return cloudConnectionRequired('bindRuntimeTaskImSessions') + }, + async getImNotificationSettings() { + return { + global: { enabled: false, sessionKey: null, session: null }, + runtimeTaskSubscriptions: [], + } + }, + updateGlobalImNotification() { + return cloudConnectionRequired('updateGlobalImNotification') + }, + subscribeRuntimeTaskNotifications() { + return cloudConnectionRequired('subscribeRuntimeTaskNotifications') + }, + unsubscribeRuntimeTaskNotifications(address: RuntimeTaskAddress) { + return Promise.resolve({ address, subscribed: false, sessionKeys: [] }) + }, + archiveRuntimeTask(data: RuntimeTaskAddress) { + return request('runtime.tasks.archive', data as unknown as Record) + }, + renameRuntimeTask(data: Record) { + return request('runtime.tasks.rename', data) + }, + listArchivedConversations(data: Record = {}) { + return request('runtime.archived_conversations.list', data) + }, + archiveConversation(data: RuntimeTaskAddress) { + return request('runtime.tasks.archive', data as unknown as Record) + }, + archiveProjectConversations(data: Record) { + return request('runtime.archived_conversations.archive_project', data) + }, + archiveAllConversations() { + return request('runtime.archived_conversations.archive_all', {}) + }, + unarchiveConversation(data: RuntimeTaskAddress) { + return request( + 'runtime.archived_conversations.unarchive', + data as unknown as Record + ) + }, + deleteArchivedConversation(data: RuntimeTaskAddress) { + return request( + 'runtime.archived_conversations.delete', + data as unknown as Record + ) + }, + deleteArchivedConversationsBulk(data: Record) { + return request('runtime.archived_conversations.delete_bulk', data) + }, + cancelRuntimeTask(data: RuntimeTaskAddress) { + return request('runtime.tasks.cancel', data as unknown as Record) + }, + createRuntimeTask(data: RuntimeTaskCreateRequest) { + return request('runtime.tasks.create', data as unknown as Record) + }, + forkRuntimeTask(data: Record) { + return request('runtime.tasks.import_fork', data) + }, + } +} + +export function createLocalAppServices(deps: LocalAppServicesDeps = {}): WorkbenchServices { + const ensure = deps.ensure ?? ensureLocalExecutorStarted + const request = deps.request ?? requestLocalExecutor + const subscribe = deps.subscribe ?? subscribeLocalExecutorEvents + + const executeCommand = ( + deviceId: string, + data: { + command_key: string + path?: string + cwd?: string + args?: string[] + env?: Record + timeout_seconds?: number + max_output_bytes?: number + } + ) => request('device.execute_command', { deviceId, ...data }) + + return { + teamApi: { + listTeams: async () => [LOCAL_WORKBENCH_TEAM], + getDefaultWorkbenchTeam: async () => LOCAL_WORKBENCH_TEAM, + }, + modelApi: { + listModels: async () => ({ data: [LOCAL_CODEX_MODEL] }), + }, + skillApi: { + listSkills: async () => [], + getTeamSkills: async () => ({ skills: [], preload_skills: [] }), + }, + projectApi: { + listProjects: async () => ({ items: [] }), + getProject: () => cloudConnectionRequired('getProject'), + createProject: () => cloudConnectionRequired('createProject'), + updateProject: () => cloudConnectionRequired('updateProject'), + deleteProject: () => cloudConnectionRequired('deleteProject'), + }, + taskApi: { + getTurnFileChangesDiff: () => cloudConnectionRequired('getTurnFileChangesDiff'), + revertTurnFileChanges: () => cloudConnectionRequired('revertTurnFileChanges'), + }, + deviceApi: { + listDevices: async () => [localDeviceFromStatus(await ensure())], + async getHomeDirectory(deviceId: string) { + const response = await executeCommand(deviceId, { + command_key: 'home_dir', + timeout_seconds: 10, + max_output_bytes: 4096, + }) + assertCommandSuccess(response, 'Failed to resolve home directory') + return commandText(response) + }, + async getProjectWorkspaceRoot(deviceId: string) { + const response = await executeCommand(deviceId, { + command_key: 'project_workspace_root', + timeout_seconds: 10, + max_output_bytes: 4096, + }) + assertCommandSuccess(response, 'Failed to resolve project directory') + return commandText(response) + }, + async listDirectories(deviceId: string, path: string) { + const response = await executeCommand(deviceId, { + command_key: 'ls_dirs', + path, + timeout_seconds: 15, + max_output_bytes: 1024 * 64, + }) + assertCommandSuccess(response, 'Failed to list directories') + return commandStringList(response) + }, + async createDirectory(deviceId: string, path: string) { + const response = await executeCommand(deviceId, { + command_key: 'mkdir_p', + args: [path], + timeout_seconds: 15, + max_output_bytes: 4096, + }) + assertCommandSuccess(response, 'Failed to create directory') + }, + executeCommand, + upgradeDevice: () => cloudConnectionRequired('upgradeDevice'), + async listSkills(deviceId: string) { + const response = await executeCommand(deviceId, { + command_key: 'ls_skills', + timeout_seconds: 15, + max_output_bytes: 1024 * 256, + }) + assertCommandSuccess(response, 'Failed to list skills') + return commandSkills(response) + }, + }, + runtimeWorkApi: createRuntimeWorkApi(request), + userApi: { + updateCurrentUser: async (data: { preferences?: User['preferences'] }) => ({ + ...LOCAL_USER, + preferences: data.preferences ?? LOCAL_USER.preferences, + }), + getRuntimeConfig: () => cloudConnectionRequired('getRuntimeConfig'), + updateRuntimeConfig: () => cloudConnectionRequired('updateRuntimeConfig'), + getProxyConfig: () => cloudConnectionRequired('getProxyConfig'), + updateProxyConfig: () => cloudConnectionRequired('updateProxyConfig'), + uploadRuntimeAuthJson: () => cloudConnectionRequired('uploadRuntimeAuthJson'), + importRuntimeAuthJson: () => cloudConnectionRequired('importRuntimeAuthJson'), + }, + chatStream: createLocalChatStream({ subscribe, request }), + } as unknown as WorkbenchServices +} diff --git a/wework/src/components/layout/DesktopWorkbenchLayout.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.tsx index 4322bf075b..a6ad47529b 100644 --- a/wework/src/components/layout/DesktopWorkbenchLayout.tsx +++ b/wework/src/components/layout/DesktopWorkbenchLayout.tsx @@ -723,7 +723,6 @@ export function DesktopWorkbenchLayout({ preferredDeviceId={ state.standaloneDeviceId ?? state.user?.preferences?.default_execution_target } - upgradingDevices={upgradingDevices} activeItem={activeItem} onCollapse={() => setSidebarCollapsed(true)} onNewChat={onNewChat} diff --git a/wework/src/features/workbench/WorkbenchProvider.test.tsx b/wework/src/features/workbench/WorkbenchProvider.test.tsx index 4918c1ecb6..cbd94c5258 100644 --- a/wework/src/features/workbench/WorkbenchProvider.test.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.test.tsx @@ -2,6 +2,7 @@ import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useState } from 'react' import { beforeEach, describe, expect, test, vi } from 'vitest' +import { LOCAL_USER } from '@/api/local/localSession' import { WorkbenchProvider, type WorkbenchServices } from './WorkbenchProvider' import { useWorkbench } from './useWorkbench' import { MessageList } from '@/components/chat/MessageList' @@ -18,6 +19,18 @@ import type { UnifiedModel, } from '@/types/api' +const localExecutorMocks = vi.hoisted(() => ({ + ensureLocalExecutorStarted: vi.fn(), + requestLocalExecutor: vi.fn(), + subscribeLocalExecutorEvents: vi.fn(), +})) + +vi.mock('@/tauri/localExecutor', () => ({ + ensureLocalExecutorStarted: localExecutorMocks.ensureLocalExecutorStarted, + requestLocalExecutor: localExecutorMocks.requestLocalExecutor, + subscribeLocalExecutorEvents: localExecutorMocks.subscribeLocalExecutorEvents, +})) + function deferred() { let resolve!: (value: T) => void let reject!: (error: unknown) => void @@ -269,6 +282,10 @@ function renderWorkbench(children: React.ReactNode, services = createWorkbenchSe ) } +function renderWorkbenchWithDefaultServices(children: React.ReactNode) { + return render({children}) +} + function BootstrapProbe() { const workbench = useWorkbench() return ( @@ -583,10 +600,37 @@ function RuntimeTaskSkillsProbe() { describe('WorkbenchProvider runtime tasks', () => { beforeEach(() => { + delete window.__WEWORK_RUNTIME_CONFIG__ window.history.pushState({}, '', '/') localStorage.clear() sessionStorage.clear() vi.clearAllMocks() + localExecutorMocks.ensureLocalExecutorStarted.mockResolvedValue({ + running: true, + ready: true, + deviceId: 'local-device', + }) + localExecutorMocks.requestLocalExecutor.mockImplementation(async (method: string) => { + if (method === 'runtime.tasks.list') { + return { projects: [], chats: [], totalLocalTasks: 0 } + } + return {} + }) + localExecutorMocks.subscribeLocalExecutorEvents.mockResolvedValue(vi.fn()) + }) + + test('bootstraps with local app services in local-first runtime mode', async () => { + window.__WEWORK_RUNTIME_CONFIG__ = { + runtimeMode: 'local-first', + } + + renderWorkbenchWithDefaultServices() + + await waitFor(() => expect(screen.getByTestId('boot-state')).toHaveTextContent('local')) + expect(screen.getByTestId('project-count')).toHaveTextContent('0') + expect(screen.getByTestId('runtime-total')).toHaveTextContent('0') + expect(localExecutorMocks.ensureLocalExecutorStarted).toHaveBeenCalled() + expect(localExecutorMocks.requestLocalExecutor).toHaveBeenCalledWith('runtime.tasks.list', {}) }) test('bootstraps projects and runtime work without DB task APIs', async () => { diff --git a/wework/src/features/workbench/WorkbenchProvider.tsx b/wework/src/features/workbench/WorkbenchProvider.tsx index f533a09751..7c43ac0ff7 100644 --- a/wework/src/features/workbench/WorkbenchProvider.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.tsx @@ -13,6 +13,7 @@ import { import { createGitApi } from '@/api/git' import { ApiError, createHttpClient } from '@/api/http' import { createImSessionApi } from '@/api/imSessions' +import { createLocalAppServices } from '@/api/local/localServices' import { createModelApi } from '@/api/models' import { createProjectApi } from '@/api/projects' import { createRuntimeWorkApi } from '@/api/runtimeWork' @@ -407,7 +408,7 @@ interface WorkbenchProviderProps { services?: WorkbenchServices } -function createDefaultServices(): WorkbenchServices { +function createBackendServices(): WorkbenchServices { const { apiBaseUrl, socketBaseUrl, socketPath } = getRuntimeConfig() const client = createHttpClient({ baseUrl: apiBaseUrl }) const socketClient = createSocketClient({ @@ -434,6 +435,15 @@ function createDefaultServices(): WorkbenchServices { } } +function createDefaultServices(): WorkbenchServices { + const { runtimeMode } = getRuntimeConfig() + if (runtimeMode === 'local-first') { + return createLocalAppServices() + } + + return createBackendServices() +} + function getCurrentAppPath(): string { return stripAppBasePath(window.location.pathname) } @@ -1913,7 +1923,8 @@ export function WorkbenchProvider({ children, user, services }: WorkbenchProvide const archiveProjectsConversations = useCallback( async (runtimeProjectKeys: string[]) => { - if (!resolvedServices.runtimeWorkApi) { + const runtimeWorkApi = resolvedServices.runtimeWorkApi + if (!runtimeWorkApi) { dispatch({ type: 'error_set', error: 'Local runtime work is unavailable' }) return } @@ -1924,7 +1935,7 @@ export function WorkbenchProvider({ children, user, services }: WorkbenchProvide const archivedAddresses = projectTaskAddresses(state.runtimeWork, uniqueProjectKeys) const responses = await Promise.all( uniqueProjectKeys.map(runtimeProjectKey => - resolvedServices.runtimeWorkApi.archiveProjectConversations({ runtimeProjectKey }) + runtimeWorkApi.archiveProjectConversations({ runtimeProjectKey }) ) ) const failedResponse = responses.find(response => !response.accepted) @@ -1949,16 +1960,15 @@ export function WorkbenchProvider({ children, user, services }: WorkbenchProvide const archiveChatConversations = useCallback( async (addresses: RuntimeTaskAddress[]) => { - if (!resolvedServices.runtimeWorkApi) { + const runtimeWorkApi = resolvedServices.runtimeWorkApi + if (!runtimeWorkApi) { dispatch({ type: 'error_set', error: 'Local runtime work is unavailable' }) return } if (addresses.length === 0) return - const responses = await Promise.all( - addresses.map(address => resolvedServices.runtimeWorkApi.archiveConversation(address)) - ) + const responses = await Promise.all(addresses.map(address => runtimeWorkApi.archiveConversation(address))) const failedResponse = responses.find(response => !response.accepted) if (failedResponse) { dispatch({ diff --git a/wework/src/pages/PluginManagementPage.tsx b/wework/src/pages/PluginManagementPage.tsx index 3204a7f795..6791316870 100644 --- a/wework/src/pages/PluginManagementPage.tsx +++ b/wework/src/pages/PluginManagementPage.tsx @@ -114,16 +114,9 @@ export function PluginManagementPage() { onOpenStandaloneWorkspace={openStandaloneWorkspace} onOpenPlugins={handleOpenPlugins} onRefreshDevices={refreshDevices} - onCreateProject={createProject} - onCreateGitWorkspaceProject={createGitWorkspaceProject} - onPrepareDeviceWorkspace={prepareDeviceWorkspace} - onDeleteDeviceWorkspace={deleteDeviceWorkspace} - onListGitRepositories={listGitRepositories} - onListGitBranches={listGitBranches} onUpdateProjectName={updateProjectName} onRemoveProject={removeProject} onGetDeviceHomeDirectory={getDeviceHomeDirectory} - onGetProjectWorkspaceRoot={getProjectWorkspaceRoot} onListDeviceDirectories={listDeviceDirectories} onCreateDeviceDirectory={createDeviceDirectory} onOpenSettings={() => setSettingsOpen(true)} diff --git a/wework/src/pages/PluginsPage.tsx b/wework/src/pages/PluginsPage.tsx index ec71efa5c9..228c5a89a3 100644 --- a/wework/src/pages/PluginsPage.tsx +++ b/wework/src/pages/PluginsPage.tsx @@ -114,16 +114,9 @@ export function PluginsPage() { onOpenStandaloneWorkspace={openStandaloneWorkspace} onOpenPlugins={handleOpenPlugins} onRefreshDevices={refreshDevices} - onCreateProject={createProject} - onCreateGitWorkspaceProject={createGitWorkspaceProject} - onPrepareDeviceWorkspace={prepareDeviceWorkspace} - onDeleteDeviceWorkspace={deleteDeviceWorkspace} - onListGitRepositories={listGitRepositories} - onListGitBranches={listGitBranches} onUpdateProjectName={updateProjectName} onRemoveProject={removeProject} onGetDeviceHomeDirectory={getDeviceHomeDirectory} - onGetProjectWorkspaceRoot={getProjectWorkspaceRoot} onListDeviceDirectories={listDeviceDirectories} onCreateDeviceDirectory={createDeviceDirectory} onOpenSettings={() => setSettingsOpen(true)} From 387a3cc32f406fddea0e51530d071b85f08ff3c2 Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 25 Jun 2026 15:13:09 +0800 Subject: [PATCH 09/17] feat(wework): bridge tauri app to local executor --- .gitignore | 1 + wework/src-tauri/Cargo.lock | 64 ++++ wework/src-tauri/Cargo.toml | 1 + wework/src-tauri/build.rs | 235 ++++++++++++ wework/src-tauri/capabilities/default.json | 10 + wework/src-tauri/src/lib.rs | 7 + wework/src-tauri/src/local_executor.rs | 426 +++++++++++++++++++++ wework/src-tauri/tauri.conf.json | 1 + 8 files changed, 745 insertions(+) create mode 100644 wework/src-tauri/src/local_executor.rs diff --git a/.gitignore b/.gitignore index f8bda322d2..b57f685800 100644 --- a/.gitignore +++ b/.gitignore @@ -105,5 +105,6 @@ coverage/ *.spec executor/build/ executor/dist/ +wework/src-tauri/binaries/ docs/superpowers/ .worktrees/ diff --git a/wework/src-tauri/Cargo.lock b/wework/src-tauri/Cargo.lock index 6fe62c2d45..7530daf1ef 100644 --- a/wework/src-tauri/Cargo.lock +++ b/wework/src-tauri/Cargo.lock @@ -89,6 +89,7 @@ dependencies = [ "tauri-plugin-log", "tauri-plugin-opener", "tauri-plugin-process", + "tauri-plugin-shell", "tauri-plugin-updater", ] @@ -2736,6 +2737,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "osakit" version = "0.3.1" @@ -3956,6 +3967,17 @@ dependencies = [ "digest", ] +[[package]] +name = "shared_child" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.60.2", +] + [[package]] name = "shared_library" version = "0.1.9" @@ -3978,6 +4000,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "sigchld" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -4520,6 +4563,27 @@ dependencies = [ "tauri-plugin", ] +[[package]] +name = "tauri-plugin-shell" +version = "2.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.1" diff --git a/wework/src-tauri/Cargo.toml b/wework/src-tauri/Cargo.toml index a701d49603..8c249f170d 100644 --- a/wework/src-tauri/Cargo.toml +++ b/wework/src-tauri/Cargo.toml @@ -27,6 +27,7 @@ tauri-plugin-http = "2" tauri-plugin-opener = "2" portable-pty = "0.8" tauri-plugin-process = "2.3.1" +tauri-plugin-shell = "2" [target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies] tauri-plugin-updater = "2.10.1" diff --git a/wework/src-tauri/build.rs b/wework/src-tauri/build.rs index d860e1e6a7..50299b3686 100644 --- a/wework/src-tauri/build.rs +++ b/wework/src-tauri/build.rs @@ -1,3 +1,238 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const SIDECAR_NAME: &str = "wegent-executor"; +const SIDECAR_ENV: &str = "WEWORK_EXECUTOR_SIDECAR"; + fn main() { + prepare_local_executor_sidecar(); tauri_build::build() } + +fn prepare_local_executor_sidecar() { + println!("cargo:rerun-if-env-changed={SIDECAR_ENV}"); + + let manifest_dir = PathBuf::from( + env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be set by Cargo"), + ); + let target = env::var("TARGET").expect("TARGET must be set by Cargo"); + let sidecar_path = manifest_dir + .join("binaries") + .join(sidecar_file_name(&target)); + let marker_path = sidecar_path.with_extension("debug-stub"); + + fs::create_dir_all( + sidecar_path + .parent() + .expect("sidecar path must have parent"), + ) + .expect("failed to create Tauri sidecar directory"); + + if let Some(source) = configured_sidecar_source() { + copy_sidecar(&source, &sidecar_path); + remove_debug_marker(&marker_path); + return; + } + + let default_source = default_executor_dist_path(&manifest_dir, &target); + println!("cargo:rerun-if-changed={}", default_source.display()); + if default_source.exists() { + copy_sidecar(&default_source, &sidecar_path); + remove_debug_marker(&marker_path); + return; + } + + if env::var("PROFILE").as_deref() == Ok("release") { + if sidecar_path.exists() && !marker_path.exists() { + println!( + "cargo:warning=Using preexisting Tauri sidecar at {}", + sidecar_path.display() + ); + return; + } + panic!( + "Missing local executor sidecar. Build executor/dist/{default_name} or set {SIDECAR_ENV} before creating a release app bundle.", + default_name = default_executor_file_name(&target) + ); + } + + if !sidecar_path.exists() || marker_path.exists() { + build_debug_stub(&sidecar_path, &marker_path, &target); + } +} + +fn configured_sidecar_source() -> Option { + env::var_os(SIDECAR_ENV) + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) +} + +fn default_executor_dist_path(manifest_dir: &Path, target: &str) -> PathBuf { + manifest_dir + .parent() + .and_then(Path::parent) + .expect("src-tauri must be inside the wework directory") + .join("executor") + .join("dist") + .join(default_executor_file_name(target)) +} + +fn sidecar_file_name(target: &str) -> String { + if target.contains("windows") { + format!("{SIDECAR_NAME}-{target}.exe") + } else { + format!("{SIDECAR_NAME}-{target}") + } +} + +fn default_executor_file_name(target: &str) -> &'static str { + if target.contains("windows") { + "wegent-executor.exe" + } else { + "wegent-executor" + } +} + +fn copy_sidecar(source: &Path, destination: &Path) { + if !source.exists() { + panic!( + "Configured local executor sidecar does not exist: {}", + source.display() + ); + } + if source != destination { + fs::copy(source, destination).unwrap_or_else(|error| { + panic!( + "Failed to copy local executor sidecar from {} to {}: {error}", + source.display(), + destination.display() + ) + }); + } + make_executable(destination); +} + +fn remove_debug_marker(marker_path: &Path) { + if marker_path.exists() { + fs::remove_file(marker_path).unwrap_or_else(|error| { + panic!( + "Failed to remove local executor sidecar debug marker {}: {error}", + marker_path.display() + ) + }); + } +} + +fn build_debug_stub(destination: &Path, marker_path: &Path, target: &str) { + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR must be set by Cargo")); + let source_path = out_dir.join("wegent_executor_debug_stub.rs"); + fs::write(&source_path, DEBUG_STUB_SOURCE).unwrap_or_else(|error| { + panic!( + "Failed to write local executor debug stub source {}: {error}", + source_path.display() + ) + }); + + let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string()); + let mut command = Command::new(rustc); + command + .arg("--edition=2021") + .arg(&source_path) + .arg("-o") + .arg(destination); + + if env::var("HOST").as_deref() != Ok(target) { + command.arg("--target").arg(target); + } + + let status = command.status().unwrap_or_else(|error| { + panic!("Failed to invoke rustc for local executor debug stub: {error}") + }); + if !status.success() { + panic!("Failed to compile local executor debug stub"); + } + + make_executable(destination); + fs::write(marker_path, b"debug stub\n").unwrap_or_else(|error| { + panic!( + "Failed to write local executor debug marker {}: {error}", + marker_path.display() + ) + }); +} + +#[cfg(unix)] +fn make_executable(path: &Path) { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = fs::metadata(path) + .unwrap_or_else(|error| { + panic!( + "Failed to read sidecar metadata {}: {error}", + path.display() + ) + }) + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap_or_else(|error| { + panic!( + "Failed to mark sidecar executable {}: {error}", + path.display() + ) + }); +} + +#[cfg(not(unix))] +fn make_executable(_path: &Path) {} + +const DEBUG_STUB_SOURCE: &str = r##" +use std::io::{self, BufRead, Write}; + +fn json_escape(value: &str) -> String { + value + .chars() + .flat_map(|ch| match ch { + '"' => "\\\"".chars().collect::>(), + '\\' => "\\\\".chars().collect::>(), + '\n' => "\\n".chars().collect::>(), + '\r' => "\\r".chars().collect::>(), + '\t' => "\\t".chars().collect::>(), + other => vec![other], + }) + .collect() +} + +fn extract_id(line: &str) -> String { + let Some(id_key_start) = line.find("\"id\"") else { + return "unknown".to_string(); + }; + let Some(colon_offset) = line[id_key_start..].find(':') else { + return "unknown".to_string(); + }; + let after_colon = &line[id_key_start + colon_offset + 1..]; + let Some(value_start) = after_colon.find('"') else { + return "unknown".to_string(); + }; + let after_quote = &after_colon[value_start + 1..]; + let Some(value_end) = after_quote.find('"') else { + return "unknown".to_string(); + }; + after_quote[..value_end].to_string() +} + +fn main() { + println!(r#"{{"type":"event","event":"executor.ready","payload":{{"deviceId":"local-device"}}}}"#); + let stdin = io::stdin(); + for line in stdin.lock().lines() { + let line = line.unwrap_or_default(); + let id = json_escape(&extract_id(&line)); + println!( + r#"{{"type":"response","id":"{}","ok":false,"error":{{"code":"SIDECAR_NOT_BUILT","message":"Local executor sidecar is not built. Run executor/scripts/build_local.py or set WEWORK_EXECUTOR_SIDECAR before launching WeWork local-first."}}}}"#, + id + ); + let _ = io::stdout().flush(); + } +} +"##; diff --git a/wework/src-tauri/capabilities/default.json b/wework/src-tauri/capabilities/default.json index 8bdf5ca6b5..62785b9b55 100644 --- a/wework/src-tauri/capabilities/default.json +++ b/wework/src-tauri/capabilities/default.json @@ -10,6 +10,16 @@ "opener:allow-default-urls", "updater:default", "process:allow-restart", + { + "identifier": "shell:allow-execute", + "allow": [ + { + "name": "binaries/wegent-executor", + "sidecar": true, + "args": ["--app-ipc", "--no-backend"] + } + ] + }, { "identifier": "http:default", "allow": [ diff --git a/wework/src-tauri/src/lib.rs b/wework/src-tauri/src/lib.rs index 8cc551e24f..344fa799bb 100644 --- a/wework/src-tauri/src/lib.rs +++ b/wework/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod local_executor; mod local_terminal; fn normalized_non_empty(value: String) -> Option { @@ -259,6 +260,8 @@ pub fn run() { .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_shell::init()) + .manage(local_executor::LocalExecutorState::default()) .manage(local_terminal::LocalTerminalState::default()) .setup(|app| { #[cfg(desktop)] @@ -285,6 +288,10 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ local_terminal::close_local_terminal, get_local_executor_device_id, + local_executor::local_executor_ensure_started, + local_executor::local_executor_request, + local_executor::local_executor_restart, + local_executor::local_executor_status, local_path_exists, read_dropped_files, local_terminal::resize_local_terminal, diff --git a/wework/src-tauri/src/local_executor.rs b/wework/src-tauri/src/local_executor.rs new file mode 100644 index 0000000000..21caced5da --- /dev/null +++ b/wework/src-tauri/src/local_executor.rs @@ -0,0 +1,426 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use tauri::async_runtime::{channel, Sender}; +use tauri::{Emitter, State}; +use tauri_plugin_shell::process::{CommandChild, CommandEvent}; +use tauri_plugin_shell::ShellExt; + +const LOCAL_EXECUTOR_EVENT: &str = "local-executor:event"; +const LOCAL_EXECUTOR_SIDECAR: &str = "binaries/wegent-executor"; +const LOCAL_EXECUTOR_DEVICE_ID: &str = "local-device"; + +type PendingSender = Sender>; +type SharedExecutorInner = Arc>; + +pub struct LocalExecutorState { + inner: SharedExecutorInner, + next_id: AtomicU64, +} + +#[derive(Default)] +struct LocalExecutorInner { + child: Option, + pending: HashMap, + running: bool, + ready: bool, + device_id: Option, + error: Option, +} + +impl Default for LocalExecutorState { + fn default() -> Self { + Self { + inner: Arc::new(Mutex::new(LocalExecutorInner::default())), + next_id: AtomicU64::new(1), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub enum ExecutorLine { + #[serde(rename = "response")] + Response(ExecutorResponse), + #[serde(rename = "event")] + Event(ExecutorEvent), +} + +#[derive(Debug, Deserialize)] +pub struct ExecutorResponse { + pub id: String, + pub ok: bool, + pub result: Option, + pub error: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ExecutorEvent { + pub event: String, + pub payload: Value, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ExecutorError { + pub code: String, + pub message: String, +} + +#[derive(Deserialize)] +pub struct LocalExecutorRequest { + method: String, + params: Value, +} + +#[derive(Serialize)] +pub struct LocalExecutorStatus { + running: bool, + ready: bool, + #[serde(rename = "deviceId")] + device_id: Option, + error: Option, +} + +pub fn parse_executor_line(line: &str) -> Result { + serde_json::from_str::(line).map_err(|error| error.to_string()) +} + +fn next_request_id(state: &LocalExecutorState) -> String { + let id = state.next_id.fetch_add(1, Ordering::Relaxed); + format!("local-req-{id}") +} + +fn status_from_inner(inner: &LocalExecutorInner) -> LocalExecutorStatus { + LocalExecutorStatus { + running: inner.running, + ready: inner.ready, + device_id: inner.device_id.clone(), + error: inner.error.clone(), + } +} + +fn status_from_state(state: &LocalExecutorState) -> Result { + let inner = state + .inner + .lock() + .map_err(|_| "Failed to lock local executor state".to_string())?; + Ok(status_from_inner(&inner)) +} + +fn response_error(response: ExecutorResponse) -> String { + response + .error + .map(|error| { + if error.code.is_empty() { + error.message + } else { + format!("{}: {}", error.code, error.message) + } + }) + .unwrap_or_else(|| "Local executor request failed".to_string()) +} + +async fn resolve_response_inner(inner: &SharedExecutorInner, response: ExecutorResponse) { + let sender = inner + .lock() + .ok() + .and_then(|mut inner| inner.pending.remove(&response.id)); + + if let Some(sender) = sender { + let result = if response.ok { + Ok(response.result.unwrap_or(Value::Null)) + } else { + Err(response_error(response)) + }; + let _ = sender.send(result).await; + } +} + +async fn fail_pending_requests(state: &LocalExecutorState, message: String) { + fail_pending_requests_inner(&state.inner, message).await; +} + +async fn fail_pending_requests_inner(inner: &SharedExecutorInner, message: String) { + let pending = inner + .lock() + .map(|mut inner| { + inner + .pending + .drain() + .map(|(_, sender)| sender) + .collect::>() + }) + .unwrap_or_default(); + + for sender in pending { + let _ = sender.send(Err(message.clone())).await; + } +} + +fn set_executor_error(state: &LocalExecutorState, error: String) { + set_executor_error_inner(&state.inner, error); +} + +fn set_executor_error_inner(inner: &SharedExecutorInner, error: String) { + if let Ok(mut inner) = inner.lock() { + inner.running = false; + inner.ready = false; + inner.child = None; + inner.error = Some(error); + } +} + +async fn handle_executor_line_inner( + app: &tauri::AppHandle, + inner: &SharedExecutorInner, + line: &str, +) -> Result<(), String> { + if line.trim().is_empty() { + return Ok(()); + } + + match parse_executor_line(line)? { + ExecutorLine::Response(response) => { + resolve_response_inner(inner, response).await; + } + ExecutorLine::Event(event) => { + app.emit(LOCAL_EXECUTOR_EVENT, event) + .map_err(|error| error.to_string())?; + } + } + + Ok(()) +} + +fn drain_complete_lines(buffer: &mut String, chunk: &str) -> Vec { + buffer.push_str(chunk); + let mut lines = Vec::new(); + + while let Some(index) = buffer.find('\n') { + let mut line = buffer.drain(..=index).collect::(); + if line.ends_with('\n') { + line.pop(); + } + if line.ends_with('\r') { + line.pop(); + } + lines.push(line); + } + + lines +} + +async fn start_executor_if_needed( + app: tauri::AppHandle, + state: &LocalExecutorState, +) -> Result<(), String> { + { + let inner = state + .inner + .lock() + .map_err(|_| "Failed to lock local executor state".to_string())?; + if inner.running && inner.child.is_some() { + return Ok(()); + } + } + + let sidecar = app + .shell() + .sidecar(LOCAL_EXECUTOR_SIDECAR) + .map_err(|error| error.to_string())? + .args(["--app-ipc", "--no-backend"]); + let (mut rx, child) = sidecar.spawn().map_err(|error| error.to_string())?; + + { + let mut inner = state + .inner + .lock() + .map_err(|_| "Failed to lock local executor state".to_string())?; + inner.child = Some(child); + inner.running = true; + inner.ready = true; + inner.device_id = Some(LOCAL_EXECUTOR_DEVICE_ID.to_string()); + inner.error = None; + } + + let app_handle = app.clone(); + let state_handle = state.inner.clone(); + tauri::async_runtime::spawn(async move { + let mut stdout_buffer = String::new(); + + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Stdout(bytes) => { + let text = String::from_utf8_lossy(&bytes); + for line in drain_complete_lines(&mut stdout_buffer, &text) { + if let Err(error) = + handle_executor_line_inner(&app_handle, &state_handle, &line).await + { + log::warn!("Failed to handle local executor line: {error}"); + } + } + } + CommandEvent::Stderr(bytes) => { + let text = String::from_utf8_lossy(&bytes); + log::warn!("Local executor stderr: {}", text.trim()); + } + CommandEvent::Terminated(payload) => { + if !stdout_buffer.trim().is_empty() { + if let Err(error) = + handle_executor_line_inner(&app_handle, &state_handle, &stdout_buffer) + .await + { + log::warn!("Failed to handle local executor line: {error}"); + } + } + let message = format!("Local executor exited: {payload:?}"); + set_executor_error_inner(&state_handle, message.clone()); + fail_pending_requests_inner(&state_handle, message).await; + break; + } + _ => {} + } + } + }); + + Ok(()) +} + +async fn restart_executor(app: tauri::AppHandle, state: &LocalExecutorState) -> Result<(), String> { + let old_child = state + .inner + .lock() + .map_err(|_| "Failed to lock local executor state".to_string())? + .child + .take(); + + if let Some(child) = old_child { + let _ = child.kill(); + } + + set_executor_error(state, "Local executor restarting".to_string()); + fail_pending_requests(state, "Local executor restarting".to_string()).await; + start_executor_if_needed(app, state).await +} + +async fn send_executor_request( + app: tauri::AppHandle, + state: &LocalExecutorState, + request: LocalExecutorRequest, +) -> Result { + start_executor_if_needed(app, state).await?; + + let request_id = next_request_id(state); + let (sender, mut receiver) = channel::>(1); + let message = json!({ + "type": "request", + "id": request_id, + "method": request.method, + "params": request.params, + }); + let line = format!( + "{}\n", + serde_json::to_string(&message).map_err(|error| error.to_string())? + ); + + { + let mut inner = state + .inner + .lock() + .map_err(|_| "Failed to lock local executor state".to_string())?; + inner.pending.insert(request_id.clone(), sender); + let Some(child) = inner.child.as_mut() else { + inner.pending.remove(&request_id); + return Err("Local executor is not running".to_string()); + }; + if let Err(error) = child.write(line.as_bytes()) { + inner.pending.remove(&request_id); + return Err(format!("Failed to write local executor request: {error}")); + } + } + + receiver + .recv() + .await + .unwrap_or_else(|| Err("Local executor disconnected".to_string())) +} + +#[tauri::command] +pub async fn local_executor_status( + state: State<'_, LocalExecutorState>, +) -> Result { + status_from_state(&state) +} + +#[tauri::command] +pub async fn local_executor_ensure_started( + app: tauri::AppHandle, + state: State<'_, LocalExecutorState>, +) -> Result { + start_executor_if_needed(app, &state).await?; + status_from_state(&state) +} + +#[tauri::command] +pub async fn local_executor_restart( + app: tauri::AppHandle, + state: State<'_, LocalExecutorState>, +) -> Result { + restart_executor(app, &state).await?; + status_from_state(&state) +} + +#[tauri::command] +pub async fn local_executor_request( + app: tauri::AppHandle, + state: State<'_, LocalExecutorState>, + method: String, + params: Value, +) -> Result { + send_executor_request(app, &state, LocalExecutorRequest { method, params }).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_success_response_line() { + let line = r#"{"type":"response","id":"req-1","ok":true,"result":{"value":1}}"#; + let message = parse_executor_line(line).expect("line should parse"); + + match message { + ExecutorLine::Response(response) => assert_eq!(response.id, "req-1"), + ExecutorLine::Event(_) => panic!("expected response line"), + } + } + + #[test] + fn parses_event_line() { + let line = + r#"{"type":"event","event":"response.completed","payload":{"localTaskId":"task-1"}}"#; + let message = parse_executor_line(line).expect("line should parse"); + + assert!(matches!(message, ExecutorLine::Event(_))); + } + + #[test] + fn drains_complete_stdout_lines() { + let mut buffer = String::new(); + + assert!(drain_complete_lines(&mut buffer, r#"{"type":"event""#).is_empty()); + let lines = drain_complete_lines(&mut buffer, r#","event":"ready","payload":{}}"#); + assert!(lines.is_empty()); + let lines = drain_complete_lines( + &mut buffer, + "\n{\"type\":\"response\",\"id\":\"req-1\",\"ok\":true}\npartial", + ); + + assert_eq!(lines.len(), 2); + assert_eq!(lines[0], r#"{"type":"event","event":"ready","payload":{}}"#); + assert_eq!(lines[1], r#"{"type":"response","id":"req-1","ok":true}"#); + assert_eq!(buffer, "partial"); + } +} diff --git a/wework/src-tauri/tauri.conf.json b/wework/src-tauri/tauri.conf.json index 74149e0b94..5419da1212 100644 --- a/wework/src-tauri/tauri.conf.json +++ b/wework/src-tauri/tauri.conf.json @@ -42,6 +42,7 @@ "icons/icon.icns", "icons/icon.ico" ], + "externalBin": ["binaries/wegent-executor"], "android": { "debugApplicationIdSuffix": ".debug" } From eef1096cf2e3a894694ee21ca3df36698b07cf3b Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 25 Jun 2026 15:24:02 +0800 Subject: [PATCH 10/17] feat(executor): add app ipc stdio mode --- executor/main.py | 71 +++++- executor/modes/local/app_ipc.py | 365 +++++++++++++++++++++++++++ executor/tests/test_local_app_ipc.py | 162 ++++++++++++ shared/logger.py | 12 +- 4 files changed, 603 insertions(+), 7 deletions(-) create mode 100644 executor/modes/local/app_ipc.py create mode 100644 executor/tests/test_local_app_ipc.py diff --git a/executor/main.py b/executor/main.py index 932c80f382..58009db2c6 100644 --- a/executor/main.py +++ b/executor/main.py @@ -23,6 +23,8 @@ """ import argparse +import contextlib +import logging import multiprocessing import os import sys @@ -98,6 +100,16 @@ def _parse_args() -> argparse.Namespace: type=str, help="Path to config file (default: ~/.wegent-executor/device-config.json)", ) + parser.add_argument( + "--app-ipc", + action="store_true", + help="Serve newline-delimited app IPC on stdin/stdout", + ) + parser.add_argument( + "--no-backend", + action="store_true", + help="Disable the Backend WebSocket channel when app IPC is enabled", + ) parser.add_argument( "-h", "--help", action="store_true", help="Show this help message and exit" ) @@ -107,6 +119,41 @@ def _parse_args() -> argparse.Namespace: return args +def _configure_app_ipc_stdio() -> None: + """Keep stdout reserved for app IPC JSON messages.""" + os.environ["WEGENT_LOG_TO_STDERR"] = "1" + + loggers = [logging.getLogger()] + for candidate in logging.Logger.manager.loggerDict.values(): + if isinstance(candidate, logging.Logger): + loggers.append(candidate) + + for current_logger in loggers: + for handler in current_logger.handlers: + _redirect_stream_handler(handler) + listener = getattr(current_logger, "_queue_listener", None) + for handler in getattr(listener, "handlers", []) or []: + _redirect_stream_handler(handler) + + +def _redirect_stream_handler(handler: logging.Handler) -> None: + if not isinstance(handler, logging.StreamHandler): + return + try: + handler.setStream(sys.stderr) + except Exception: + handler.stream = sys.stderr + + +def _load_device_config_for_mode(config_path: str | None, *, quiet_stdout: bool): + from executor.config.device_config import load_device_config + + if quiet_stdout: + with contextlib.redirect_stdout(sys.stderr): + return load_device_config(config_path) + return load_device_config(config_path) + + def main() -> None: """ Main function for running the executor. @@ -122,6 +169,9 @@ def main() -> None: # Parse arguments first args = _parse_args() + if args.app_ipc: + _configure_app_ipc_stdio() + # Handle version flag first (before any heavy initialization) if args.version: from executor.version import get_version @@ -135,7 +185,6 @@ def main() -> None: from executor.config.device_config import ( get_config_path_from_args, - load_device_config, should_use_local_mode, ) @@ -143,15 +192,16 @@ def main() -> None: config_path = get_config_path_from_args() # Determine if we should run in local mode - if should_use_local_mode(config_path): + if args.app_ipc or should_use_local_mode(config_path): # Local mode: Run WebSocket-based executor import asyncio - from executor.modes.local.runner import LocalRunner - # Load full configuration for local mode try: - device_config = load_device_config(config_path) + device_config = _load_device_config_for_mode( + config_path, + quiet_stdout=args.app_ipc, + ) # Sync device config values to global config for modules that read # from config directly. device_config already has env overrides applied. @@ -161,6 +211,17 @@ def main() -> None: import executor.config.config as config + if args.app_ipc and args.no_backend: + from executor.modes.local.app_ipc import run_app_ipc + + logger.info("Starting executor in APP IPC mode without Backend") + asyncio.run( + run_app_ipc(device_id=device_config.device_id or "local-device") + ) + return + + from executor.modes.local.runner import LocalRunner + logger.info("Starting executor in LOCAL mode") logger.info(f"Device ID: {device_config.device_id}") logger.info(f"Device Name: {device_config.device_name}") diff --git a/executor/modes/local/app_ipc.py b/executor/modes/local/app_ipc.py new file mode 100644 index 0000000000..867619ff6a --- /dev/null +++ b/executor/modes/local/app_ipc.py @@ -0,0 +1,365 @@ +# SPDX-FileCopyrightText: 2026 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Newline-delimited JSON-RPC bridge between WeWork and the local executor.""" + +from __future__ import annotations + +import asyncio +import json +import shlex +import sys +from dataclasses import dataclass +from typing import Any, TextIO + +from executor.modes.local.command_handler import CommandHandler +from executor.runtime_work.rpc_handler import RuntimeWorkRpcHandler +from shared.logger import setup_logger + +logger = setup_logger("local_app_ipc") + +DEFAULT_DEVICE_ID = "local-device" +DEFAULT_TIMEOUT_SECONDS = 60 +DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024 + + +class AppIpcError(RuntimeError): + """Raised when an app IPC request cannot be handled.""" + + def __init__(self, code: str, message: str): + super().__init__(message) + self.code = code + self.message = message + + +@dataclass(frozen=True) +class LocalAppCommandDefinition: + """Executor-side command definition for app-originated device commands.""" + + command: str + post_processor: str | None = None + + +LOCAL_APP_COMMANDS: dict[str, LocalAppCommandDefinition] = { + "pwd": LocalAppCommandDefinition(command="pwd"), + "home_dir": LocalAppCommandDefinition(command="printenv HOME"), + "project_workspace_root": LocalAppCommandDefinition( + command=( + "sh -c " + "'printf %s " + '"${WEGENT_EXECUTOR_PROJECTS_DIR:-${WECODE_HOME:-$HOME/.wecode}/wegent-executor/workspace/projects}"\'' + ) + ), + "ls_dirs": LocalAppCommandDefinition( + command="ls -a -p", + post_processor="directory_list", + ), + "mkdir_p": LocalAppCommandDefinition(command="mkdir -p"), + "path_exists": LocalAppCommandDefinition(command="test -e"), + "ls_skills": LocalAppCommandDefinition( + command="python3 -c 'import json; print(json.dumps([]))'", + post_processor="json", + ), +} + + +class AppIpcServer: + """Serve app-originated JSON-RPC requests over stdin/stdout.""" + + def __init__( + self, + *, + input_stream: TextIO | None = None, + output: TextIO | None = None, + runtime_work_handler: Any | None = None, + command_handler: CommandHandler | None = None, + device_id: str = DEFAULT_DEVICE_ID, + ) -> None: + self.input_stream = input_stream or sys.stdin + self.output = output or sys.stdout + self.runtime_work_handler = runtime_work_handler + self.command_handler = command_handler or CommandHandler() + self.device_id = device_id or DEFAULT_DEVICE_ID + self._write_lock = asyncio.Lock() + self._running = False + + async def serve(self) -> None: + """Read request lines until stdin closes.""" + self._running = True + await self.emit_event( + "executor.ready", + {"device_id": self.device_id, "ready": True}, + ) + while self._running: + line = await asyncio.to_thread(self.input_stream.readline) + if line == "": + break + await self.handle_line(line) + + def stop(self) -> None: + """Stop the request loop after the current line is processed.""" + self._running = False + + async def handle_line(self, line: str) -> None: + """Parse and dispatch one newline-delimited app IPC message.""" + if not line.strip(): + return + + request_id: str | None = None + try: + message = json.loads(line) + if not isinstance(message, dict): + raise AppIpcError("invalid_request", "Request must be a JSON object") + + request_id = self._request_id(message) + if message.get("type") != "request": + raise AppIpcError("invalid_request", "Request type must be 'request'") + + method = message.get("method") + if not isinstance(method, str) or not method.strip(): + raise AppIpcError("invalid_request", "Request method is required") + + params = message.get("params") + if params is None: + params = {} + if not isinstance(params, dict): + raise AppIpcError("invalid_request", "Request params must be an object") + + result = await self.dispatch(method.strip(), params) + await self._write( + { + "type": "response", + "id": request_id, + "ok": True, + "result": result, + } + ) + except AppIpcError as exc: + await self._write_error(request_id, exc.code, exc.message) + except json.JSONDecodeError as exc: + await self._write_error(request_id, "invalid_json", str(exc)) + except Exception as exc: + logger.exception("Unhandled app IPC request error") + await self._write_error(request_id, "internal_error", str(exc)) + + async def dispatch(self, method: str, params: dict[str, Any]) -> Any: + """Dispatch one app IPC request to runtime work or device command handlers.""" + if method == "device.execute_command": + return await self._handle_device_command(params) + + if method == "runtime.tasks.guidance": + method = "runtime.tasks.send" + + if method.startswith("runtime."): + if self.runtime_work_handler is None: + raise AppIpcError( + "runtime_unavailable", + "Runtime work handler is not available", + ) + return await self.runtime_work_handler.handle_runtime_rpc( + {"method": method, "payload": params} + ) + + raise AppIpcError("unsupported_method", f"Unsupported app IPC method: {method}") + + async def emit_event(self, event: str, payload: dict[str, Any]) -> None: + """Emit one app IPC event line.""" + normalized_payload = dict(payload) + normalized_payload.setdefault("device_id", self.device_id) + await self._write( + { + "type": "event", + "event": event, + "payload": normalized_payload, + } + ) + + async def _handle_device_command(self, params: dict[str, Any]) -> dict[str, Any]: + command_key = params.get("command_key") + if not isinstance(command_key, str) or not command_key.strip(): + raise AppIpcError("bad_request", "command_key is required") + + command_definition = LOCAL_APP_COMMANDS.get(command_key.strip()) + if command_definition is None: + raise AppIpcError( + "unknown_command", + f"Device command key '{command_key}' is not configured", + ) + + args = self._string_list(params.get("args")) + command_payload = { + "command": command_definition.command, + "argv": self._build_argv(command_definition.command, args), + "cwd": self._string_or_none(params.get("path")) + or self._string_or_none(params.get("cwd")), + "env": self._string_env(params.get("env")), + "timeout_seconds": self._positive_number( + params.get("timeout_seconds"), + default=DEFAULT_TIMEOUT_SECONDS, + ), + "max_output_bytes": int( + self._positive_number( + params.get("max_output_bytes"), + default=DEFAULT_MAX_OUTPUT_BYTES, + ) + ), + } + result = await self.command_handler.handle_execute_command(command_payload) + return self._apply_post_processor(result, command_definition.post_processor) + + def _request_id(self, message: dict[str, Any]) -> str: + request_id = message.get("id") + if not isinstance(request_id, str) or not request_id.strip(): + raise AppIpcError("invalid_request", "Request id is required") + return request_id + + async def _write_error( + self, + request_id: str | None, + code: str, + message: str, + ) -> None: + await self._write( + { + "type": "response", + "id": request_id, + "ok": False, + "error": { + "code": code, + "message": message, + }, + } + ) + + async def _write(self, message: dict[str, Any]) -> None: + line = json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n" + async with self._write_lock: + self.output.write(line) + self.output.flush() + + def _build_argv(self, command: str, args: list[str]) -> list[str]: + argv = shlex.split(command) + if not argv: + raise AppIpcError("bad_command", "Command resolved to an empty argv") + return [*argv, *args] + + def _string_list(self, value: Any) -> list[str]: + if value is None: + return [] + if not isinstance(value, list): + raise AppIpcError("bad_request", "args must be a list") + if not all(isinstance(item, str) for item in value): + raise AppIpcError("bad_request", "args must contain only strings") + return value + + def _string_env(self, value: Any) -> dict[str, str]: + if value is None: + return {} + if not isinstance(value, dict): + raise AppIpcError("bad_request", "env must be an object") + env: dict[str, str] = {} + for key, item in value.items(): + if isinstance(key, str) and key: + env[key] = "" if item is None else str(item) + return env + + def _string_or_none(self, value: Any) -> str | None: + if not isinstance(value, str): + return None + value = value.strip() + return value or None + + def _positive_number(self, value: Any, *, default: float) -> float: + try: + parsed = float(value) + except (TypeError, ValueError): + return default + return parsed if parsed > 0 else default + + def _apply_post_processor( + self, + result: dict[str, Any], + processor_name: str | None, + ) -> dict[str, Any]: + if not processor_name: + return result + if processor_name == "file_list": + return self._file_list_result(result) + if processor_name == "directory_list": + return self._directory_list_result(result) + if processor_name == "json": + return self._json_result(result) + raise AppIpcError( + "bad_command", + f"Unknown local command post processor: {processor_name}", + ) + + def _file_list_result(self, result: dict[str, Any]) -> dict[str, Any]: + if not result.get("success"): + return result + processed = dict(result) + entries = [ + line.strip() + for line in str(processed.get("stdout") or "").splitlines() + if line.strip() + ] + processed["stdout"] = [entry for entry in entries if entry not in {".", ".."}] + return processed + + def _directory_list_result(self, result: dict[str, Any]) -> dict[str, Any]: + if not result.get("success"): + return result + processed = dict(result) + entries = [ + line.strip() + for line in str(processed.get("stdout") or "").splitlines() + if line.strip() + ] + processed["stdout"] = [ + entry.rstrip("/") + for entry in entries + if entry.endswith("/") and entry.rstrip("/") not in {".", ".."} + ] + return processed + + def _json_result(self, result: dict[str, Any]) -> dict[str, Any]: + processed = dict(result) + if processed.get("stdout_truncated"): + processed["success"] = False + processed["error"] = ( + "Command output exceeded max_output_bytes and was truncated; " + "JSON is incomplete and cannot be parsed" + ) + return processed + + try: + parsed_stdout = json.loads(str(processed.get("stdout") or "")) + except json.JSONDecodeError as exc: + if not processed.get("success"): + return processed + processed["success"] = False + processed["error"] = f"Failed to parse command JSON output: {exc}" + return processed + + processed["stdout"] = parsed_stdout + if not processed.get("success") and isinstance(parsed_stdout, dict): + error = parsed_stdout.get("error") + if isinstance(error, str) and error.strip() and not processed.get("error"): + processed["error"] = error + return processed + + +async def run_app_ipc(device_id: str = DEFAULT_DEVICE_ID) -> None: + """Run the standalone app IPC server without a Backend channel.""" + server = AppIpcServer(device_id=device_id) + runtime_work_handler = RuntimeWorkRpcHandler( + responses_event_emitter=server.emit_event, + ) + server.runtime_work_handler = runtime_work_handler + + await runtime_work_handler.start_codex_watcher() + try: + await server.serve() + finally: + await runtime_work_handler.stop_codex_watcher() diff --git a/executor/tests/test_local_app_ipc.py b/executor/tests/test_local_app_ipc.py new file mode 100644 index 0000000000..d4d71f8a8a --- /dev/null +++ b/executor/tests/test_local_app_ipc.py @@ -0,0 +1,162 @@ +# SPDX-FileCopyrightText: 2026 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the local app IPC stdio bridge.""" + +import io +import json +import sys +from unittest.mock import patch + +import pytest + +from executor.modes.local.app_ipc import AppIpcServer + + +def _json_lines(output: io.StringIO) -> list[dict]: + return [json.loads(line) for line in output.getvalue().splitlines()] + + +@pytest.mark.asyncio +async def test_app_ipc_routes_runtime_rpc_request(): + class RuntimeHandler: + async def handle_runtime_rpc(self, data): + assert data == { + "method": "runtime.tasks.list", + "payload": {"workspacePath": "/tmp/project"}, + } + return {"success": True, "workspaces": []} + + output = io.StringIO() + server = AppIpcServer( + output=output, + runtime_work_handler=RuntimeHandler(), + ) + + await server.handle_line( + json.dumps( + { + "type": "request", + "id": "req-1", + "method": "runtime.tasks.list", + "params": {"workspacePath": "/tmp/project"}, + } + ) + ) + + assert _json_lines(output) == [ + { + "type": "response", + "id": "req-1", + "ok": True, + "result": {"success": True, "workspaces": []}, + } + ] + + +@pytest.mark.asyncio +async def test_app_ipc_emits_runtime_events_with_device_id(): + output = io.StringIO() + server = AppIpcServer(output=output, device_id="device-1") + + await server.emit_event( + "response.output_text.delta", + {"local_task_id": "task-1", "data": {"delta": "hi"}}, + ) + + assert _json_lines(output) == [ + { + "type": "event", + "event": "response.output_text.delta", + "payload": { + "device_id": "device-1", + "local_task_id": "task-1", + "data": {"delta": "hi"}, + }, + } + ] + + +@pytest.mark.asyncio +async def test_app_ipc_resolves_configured_device_command(): + class CommandHandler: + def __init__(self): + self.payload = None + + async def handle_execute_command(self, data): + self.payload = data + return { + "success": True, + "exit_code": 0, + "stdout": ".\n..\nsrc/\nREADME.md\n", + "stderr": "", + "duration": 0.01, + "timed_out": False, + "stdout_truncated": False, + "stderr_truncated": False, + } + + command_handler = CommandHandler() + output = io.StringIO() + server = AppIpcServer( + output=output, + command_handler=command_handler, + ) + + await server.handle_line( + json.dumps( + { + "type": "request", + "id": "req-2", + "method": "device.execute_command", + "params": { + "command_key": "ls_dirs", + "path": "/tmp/project", + "timeout_seconds": 10, + "max_output_bytes": 4096, + }, + } + ) + ) + + assert command_handler.payload == { + "command": "ls -a -p", + "argv": ["ls", "-a", "-p"], + "cwd": "/tmp/project", + "env": {}, + "timeout_seconds": 10, + "max_output_bytes": 4096, + } + assert _json_lines(output)[0]["result"]["stdout"] == ["src"] + + +@pytest.mark.asyncio +async def test_app_ipc_unknown_method_returns_protocol_error(): + output = io.StringIO() + server = AppIpcServer(output=output) + + await server.handle_line( + json.dumps( + { + "type": "request", + "id": "req-3", + "method": "unknown.method", + "params": {}, + } + ) + ) + + response = _json_lines(output)[0] + assert response["ok"] is False + assert response["error"]["code"] == "unsupported_method" + + +def test_executor_cli_parses_app_ipc_flags(): + from executor.main import _parse_args + + with patch.object(sys, "argv", ["main.py", "--app-ipc", "--no-backend"]): + args = _parse_args() + + assert args.app_ipc is True + assert args.no_backend is True diff --git a/shared/logger.py b/shared/logger.py index 75f48c9d21..fe49f084d4 100644 --- a/shared/logger.py +++ b/shared/logger.py @@ -86,6 +86,14 @@ def _stop_queue_listener_safely(listener: QueueListener) -> None: raise +def _log_stream(): + """Return the configured logging stream.""" + value = os.environ.get("WEGENT_LOG_TO_STDERR", "").strip().lower() + if value in {"1", "true", "yes", "on"}: + return sys.stderr + return sys.stdout + + def setup_logger( name, level=logging.INFO, @@ -151,7 +159,7 @@ def setup_logger( if include_request_id: queue_handler.addFilter(RequestIdFilter()) - listener_handler = NonBlockingStreamHandler(sys.stdout) + listener_handler = NonBlockingStreamHandler(_log_stream()) listener_handler.setLevel(level) listener_handler.setFormatter(formatter) @@ -168,7 +176,7 @@ def setup_logger( handler_configured = False if not handler_configured: - console_handler = NonBlockingStreamHandler(sys.stdout) + console_handler = NonBlockingStreamHandler(_log_stream()) console_handler.setLevel(level) console_handler.setFormatter(formatter) From cb5081030f45058fca782c7a5fa8827dccebeb57 Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 25 Jun 2026 15:24:29 +0800 Subject: [PATCH 11/17] fix(wework): adapt local runtime work list --- wework/src/api/local/localServices.test.ts | 79 +++++++++++++ wework/src/api/local/localServices.ts | 127 ++++++++++++++++++++- 2 files changed, 202 insertions(+), 4 deletions(-) diff --git a/wework/src/api/local/localServices.test.ts b/wework/src/api/local/localServices.test.ts index bcea00c1f8..a50b83aff7 100644 --- a/wework/src/api/local/localServices.test.ts +++ b/wework/src/api/local/localServices.test.ts @@ -96,4 +96,83 @@ describe('createLocalAppServices', () => { timeout_seconds: 10, }) }) + + test('adapts executor runtime workspace list to workbench shape', async () => { + const request = vi.fn().mockResolvedValue({ + success: true, + workspaces: [ + { + workspacePath: '/Users/me/project', + label: 'Project', + workspaceSource: 'local', + localTasks: [ + { + localTaskId: 'task-1', + workspacePath: '/Users/me/project', + title: 'Build', + runtime: 'codex', + workspaceKind: 'workspace', + }, + ], + }, + { + workspacePath: '/Users/me/chat', + localTasks: [ + { + localTaskId: 'chat-1', + workspacePath: '/Users/me/chat', + title: 'Chat', + runtime: 'codex', + workspaceKind: 'chat', + }, + ], + }, + ], + }) + const services = createLocalAppServices({ + ensure: vi.fn().mockResolvedValue({ running: true, ready: true, deviceId: 'local-device' }), + request, + subscribe: vi.fn(), + }) + + await expect(services.runtimeWorkApi?.listRuntimeWork()).resolves.toEqual({ + projects: [ + { + project: { + key: 'local:/Users/me/project', + id: expect.any(Number), + name: 'Project', + }, + deviceWorkspaces: [ + expect.objectContaining({ + deviceId: 'local-device', + workspacePath: '/Users/me/project', + workspaceKind: 'workspace', + workspaceSource: 'local', + label: 'Project', + localTasks: [ + expect.objectContaining({ + localTaskId: 'task-1', + }), + ], + }), + ], + totalLocalTasks: 1, + }, + ], + chats: [ + expect.objectContaining({ + deviceId: 'local-device', + workspacePath: '/Users/me/chat', + workspaceKind: 'chat', + localTasks: [ + expect.objectContaining({ + localTaskId: 'chat-1', + }), + ], + }), + ], + totalLocalTasks: 2, + }) + }) }) diff --git a/wework/src/api/local/localServices.ts b/wework/src/api/local/localServices.ts index 4a4ebf73ff..85f8a4532f 100644 --- a/wework/src/api/local/localServices.ts +++ b/wework/src/api/local/localServices.ts @@ -2,7 +2,9 @@ import type { WorkbenchServices } from '@/features/workbench/WorkbenchProvider' import type { DeviceCommandResponse, DeviceInfo, + LocalTaskSummary, LocalDeviceSkill, + RuntimeDeviceWorkspace, RuntimeTaskAddress, RuntimeTaskCreateRequest, RuntimeWorkListResponse, @@ -105,12 +107,122 @@ function assertCommandSuccess(response: DeviceCommandResponse, fallback: string) } } +function stableLocalId(value: string): number { + let hash = 0 + for (const char of value) { + hash = (hash * 31 + char.charCodeAt(0)) >>> 0 + } + return (hash % 1_000_000_000) + 1 +} + +function recordValue(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : {} +} + +function stringValue(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null +} + +function workspaceLabel(workspacePath: string, label: unknown): string { + const explicitLabel = stringValue(label) + if (explicitLabel) return explicitLabel + return workspacePath.split('/').filter(Boolean).at(-1) || workspacePath +} + +function adaptRuntimeWorkListResponse(response: unknown): RuntimeWorkListResponse { + const record = recordValue(response) + if (Array.isArray(record.projects) && Array.isArray(record.chats)) { + return response as RuntimeWorkListResponse + } + + const workspaces = Array.isArray(record.workspaces) ? record.workspaces : [] + const projects: RuntimeWorkListResponse['projects'] = [] + const chats: RuntimeWorkListResponse['chats'] = [] + let totalLocalTasks = 0 + + for (const rawWorkspace of workspaces) { + const workspace = recordValue(rawWorkspace) + const workspacePath = + stringValue(workspace.workspacePath) ?? stringValue(workspace.workspace_path) + if (!workspacePath) continue + + const rawTasks = Array.isArray(workspace.localTasks) + ? workspace.localTasks + : Array.isArray(workspace.local_tasks) + ? workspace.local_tasks + : [] + const localTasks = rawTasks.reduce((items, task) => { + const taskRecord = recordValue(task) + const localTaskId = + stringValue(taskRecord.localTaskId) ?? stringValue(taskRecord.local_task_id) + if (!localTaskId) { + return items + } + const taskWorkspacePath = stringValue(taskRecord.workspacePath) ?? workspacePath + items.push({ + ...taskRecord, + localTaskId, + workspacePath: taskWorkspacePath, + title: stringValue(taskRecord.title) ?? localTaskId, + runtime: stringValue(taskRecord.runtime) ?? 'codex', + }) + return items + }, []) + totalLocalTasks += localTasks.length + + const firstTask = recordValue(localTasks[0]) + const workspaceKind = + stringValue(workspace.workspaceKind) ?? + stringValue(workspace.workspace_kind) ?? + stringValue(firstTask.workspaceKind) ?? + stringValue(firstTask.workspace_kind) ?? + 'workspace' + const label = workspaceLabel(workspacePath, workspace.label) + const deviceWorkspace: RuntimeDeviceWorkspace = { + id: null, + projectId: null, + deviceId: + stringValue(workspace.deviceId) ?? stringValue(workspace.device_id) ?? LOCAL_DEVICE_ID, + deviceName: 'Local Executor', + deviceStatus: 'online', + available: true, + workspacePath, + workspaceKind, + label, + workspaceSource: + stringValue(workspace.workspaceSource) ?? stringValue(workspace.workspace_source), + remoteHostId: stringValue(workspace.remoteHostId) ?? stringValue(workspace.remote_host_id), + mapped: true, + localTasks, + } + + if (workspaceKind === 'chat') { + chats.push(deviceWorkspace) + continue + } + + projects.push({ + project: { + key: `local:${workspacePath}`, + id: stableLocalId(workspacePath), + name: label, + }, + deviceWorkspaces: [deviceWorkspace], + totalLocalTasks: localTasks.length, + }) + } + + return { projects, chats, totalLocalTasks } +} + function createRuntimeWorkApi( request: (method: string, params?: Record) => Promise ) { return { - listRuntimeWork(): Promise { - return request('runtime.tasks.list', {}) + async listRuntimeWork(): Promise { + return adaptRuntimeWorkListResponse(await request('runtime.tasks.list', {})) }, upsertDeviceWorkspace() { return cloudConnectionRequired('upsertDeviceWorkspace') @@ -196,8 +308,15 @@ function createRuntimeWorkApi( cancelRuntimeTask(data: RuntimeTaskAddress) { return request('runtime.tasks.cancel', data as unknown as Record) }, - createRuntimeTask(data: RuntimeTaskCreateRequest) { - return request('runtime.tasks.create', data as unknown as Record) + async createRuntimeTask(data: RuntimeTaskCreateRequest) { + const response = await request>( + 'runtime.tasks.create', + data as unknown as Record + ) + return { + ...response, + deviceId: stringValue(response.deviceId) ?? data.deviceId ?? LOCAL_DEVICE_ID, + } }, forkRuntimeTask(data: Record) { return request('runtime.tasks.import_fork', data) From 313d44e8581f7965d6c2a3e3cc2826ad04613caa Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 25 Jun 2026 15:25:27 +0800 Subject: [PATCH 12/17] fix(executor): bundle app ipc in local binary --- executor/scripts/build_local.py | 1 + executor/tests/scripts/test_build_local.py | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/executor/scripts/build_local.py b/executor/scripts/build_local.py index 74b563bab1..21b32d17b6 100644 --- a/executor/scripts/build_local.py +++ b/executor/scripts/build_local.py @@ -461,6 +461,7 @@ def build_executable( "--hidden-import=executor.config", "--hidden-import=executor.modes", "--hidden-import=executor.modes.local", + "--hidden-import=executor.modes.local.app_ipc", "--hidden-import=executor.modes.local.runner", "--hidden-import=executor.agents", "--hidden-import=executor.agents.claude_code", diff --git a/executor/tests/scripts/test_build_local.py b/executor/tests/scripts/test_build_local.py index 2a813de0a3..fae4476071 100644 --- a/executor/tests/scripts/test_build_local.py +++ b/executor/tests/scripts/test_build_local.py @@ -176,3 +176,11 @@ def test_append_codex_pyinstaller_options_collects_sdk_modules(): assert "--hidden-import=openai_codex" in cmd assert "--hidden-import=openai_codex.generated.v2_all" in cmd assert "--collect-all=openai_codex" in cmd + + +def test_build_script_includes_app_ipc_hidden_import(): + build_local = load_build_local_module() + + script = Path(build_local.__file__).read_text(encoding="utf-8") + + assert "--hidden-import=executor.modes.local.app_ipc" in script From 90f77bfaea0cf4c839cca476f05c7d93c322e0c2 Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 25 Jun 2026 15:33:23 +0800 Subject: [PATCH 13/17] fix(wework): default tauri app to local runtime --- wework/src/api/local/localServices.test.ts | 88 +++++++++++- wework/src/api/local/localServices.ts | 160 ++++++++++++++++++++- wework/src/config/runtime.test.ts | 8 ++ wework/src/config/runtime.ts | 6 + 4 files changed, 257 insertions(+), 5 deletions(-) diff --git a/wework/src/api/local/localServices.test.ts b/wework/src/api/local/localServices.test.ts index a50b83aff7..8af7c3b882 100644 --- a/wework/src/api/local/localServices.test.ts +++ b/wework/src/api/local/localServices.test.ts @@ -25,8 +25,9 @@ describe('createLocalAppServices', () => { await expect(services.modelApi.listModels()).resolves.toEqual({ data: [ expect.objectContaining({ - name: 'local-codex', + name: 'codex-gpt-5.5', type: 'runtime', + modelId: 'gpt-5.5', runtime: { family: 'openai.openai-responses', provider: 'local' }, }), ], @@ -76,8 +77,22 @@ describe('createLocalAppServices', () => { await services.runtimeWorkApi?.createRuntimeTask({ teamId: 0, deviceId: 'local-device', + workspacePath: '/Users/me/project', + localTaskId: 'task-1', runtime: 'codex', message: 'hello', + title: 'Hello', + modelId: 'gpt-5', + modelOptions: { + reasoning: 'medium', + }, + additionalSkills: [{ name: 'planner', namespace: 'default' }], + execution: { + workspace: { + source: 'local_path', + path: '/Users/me/project', + }, + }, }) await services.deviceApi.executeCommand('local-device', { command_key: 'home_dir', @@ -87,8 +102,60 @@ describe('createLocalAppServices', () => { expect(request).toHaveBeenCalledWith('runtime.tasks.create', { teamId: 0, deviceId: 'local-device', + workspacePath: '/Users/me/project', + localTaskId: 'task-1', runtime: 'codex', message: 'hello', + title: 'Hello', + modelId: 'gpt-5', + modelOptions: { + reasoning: 'medium', + }, + additionalSkills: [{ name: 'planner', namespace: 'default' }], + execution: { + workspace: { + source: 'local_path', + path: '/Users/me/project', + }, + }, + executionRequest: expect.objectContaining({ + task_id: expect.any(Number), + subtask_id: expect.any(Number), + team_id: 0, + team_name: 'local-wework', + task_title: 'Hello', + subtask_title: 'Hello - Assistant', + prompt: 'hello', + model_config: expect.objectContaining({ + model: 'openai', + model_id: 'gpt-5', + api_format: 'responses', + protocol: 'openai-responses', + runtime_config: { + codex: { + use_user_config: true, + configured: true, + }, + }, + reasoning: { + effort: 'medium', + }, + }), + workspace: { + project: { + source: 'local_path', + path: '/Users/me/project', + }, + }, + device_id: 'local-device', + execution_target_type: 'local', + workspace_source: 'local_path', + project_workspace_path: '/Users/me/project', + new_session: true, + skill_names: ['planner'], + preload_skills: [{ name: 'planner', namespace: 'default' }], + user_selected_skills: [{ name: 'planner', namespace: 'default' }], + }), }) expect(request).toHaveBeenCalledWith('device.execute_command', { deviceId: 'local-device', @@ -97,6 +164,25 @@ describe('createLocalAppServices', () => { }) }) + test('rejects local runtime task creation without a workspace path', async () => { + const request = vi.fn() + const services = createLocalAppServices({ + ensure: vi.fn().mockResolvedValue({ running: true, ready: true, deviceId: 'local-device' }), + request, + subscribe: vi.fn(), + }) + + await expect( + services.runtimeWorkApi?.createRuntimeTask({ + teamId: 0, + deviceId: 'local-device', + runtime: 'codex', + message: 'hello', + }) + ).rejects.toThrow('workspacePath is required') + expect(request).not.toHaveBeenCalled() + }) + test('adapts executor runtime workspace list to workbench shape', async () => { const request = vi.fn().mockResolvedValue({ success: true, diff --git a/wework/src/api/local/localServices.ts b/wework/src/api/local/localServices.ts index 85f8a4532f..bddd95691e 100644 --- a/wework/src/api/local/localServices.ts +++ b/wework/src/api/local/localServices.ts @@ -23,6 +23,10 @@ import { createLocalChatStream } from './localChatStream' import { LOCAL_USER } from './localSession' const LOCAL_DEVICE_ID = 'local-device' +const CODEX_RUNTIME_MODEL_NAME = 'codex-gpt-5.5' +const CODEX_RUNTIME_MODEL_ID = 'gpt-5.5' +const OPENAI_RESPONSES_PROTOCOL = 'openai-responses' +const RESPONSES_API_FORMAT = 'responses' export const LOCAL_WORKBENCH_TEAM = { id: 0, @@ -34,11 +38,21 @@ export const LOCAL_WORKBENCH_TEAM = { } satisfies Team const LOCAL_CODEX_MODEL = { - name: 'local-codex', + name: CODEX_RUNTIME_MODEL_NAME, type: 'runtime', - displayName: 'Local Codex', + displayName: 'GPT-5.5 (Codex)', provider: 'local', - modelId: 'local-codex', + modelId: CODEX_RUNTIME_MODEL_ID, + config: { + protocol: OPENAI_RESPONSES_PROTOCOL, + apiFormat: RESPONSES_API_FORMAT, + ui: { + family: 'gpt', + modelLabel: 'GPT-5.5', + controls: ['speed'], + sortOrder: 10, + }, + }, runtime: { family: 'openai.openai-responses', provider: 'local', @@ -131,6 +145,143 @@ function workspaceLabel(workspacePath: string, label: unknown): string { return workspacePath.split('/').filter(Boolean).at(-1) || workspacePath } +function createRuntimeExecutionIds(data: RuntimeTaskCreateRequest): [number, number] { + const seed = data.localTaskId || `${data.runtime}:${data.workspacePath ?? ''}:${data.message}` + const taskId = 10_000_000_000_000 + stableLocalId(seed) + return [taskId, taskId + 1] +} + +function runtimeTaskTitle(data: RuntimeTaskCreateRequest): string { + const title = data.title?.trim() + if (title) return title + const firstLine = data.message.trim().split(/\r?\n/)[0] ?? '' + return firstLine.slice(0, 80) || 'Untitled runtime task' +} + +function runtimeWorkspacePath(data: RuntimeTaskCreateRequest): string | null { + const explicitPath = stringValue(data.workspacePath) + if (explicitPath) return explicitPath + const execution = recordValue(data.execution) + const workspace = recordValue(execution.workspace) + return stringValue(workspace.path) +} + +function requiredRuntimeWorkspacePath(data: RuntimeTaskCreateRequest): string { + const workspacePath = runtimeWorkspacePath(data) + if (!workspacePath) { + throw new Error('workspacePath is required') + } + return workspacePath +} + +function codexModelId(modelId?: string): string { + if (!modelId || modelId === CODEX_RUNTIME_MODEL_NAME) { + return CODEX_RUNTIME_MODEL_ID + } + return modelId +} + +function runtimeReasoning(modelOptions?: Record): Record | null { + const reasoning = modelOptions?.reasoning + const summary = modelOptions?.summary + const result: Record = {} + if (reasoning) result.effort = reasoning + if (summary) result.summary = summary + return Object.keys(result).length > 0 ? result : null +} + +function runtimeServiceTier(modelOptions?: Record): string | null { + return modelOptions?.speed || modelOptions?.service_tier || null +} + +function skillName(skill: unknown): string | null { + if (typeof skill === 'string') return skill + const skillRecord = recordValue(skill) + return stringValue(skillRecord.name) +} + +function isNonEmptyString(value: string | null): value is string { + return Boolean(value) +} + +function createLocalExecutionRequest(data: RuntimeTaskCreateRequest): Record { + const workspacePath = requiredRuntimeWorkspacePath(data) + const [taskId, subtaskId] = createRuntimeExecutionIds(data) + const title = runtimeTaskTitle(data) + const modelConfig: Record = { + model: 'openai', + model_id: codexModelId(data.modelId), + api_format: RESPONSES_API_FORMAT, + protocol: OPENAI_RESPONSES_PROTOCOL, + runtime_config: { + codex: { + use_user_config: true, + configured: true, + }, + }, + } + const reasoning = runtimeReasoning(data.modelOptions) + if (reasoning) modelConfig.reasoning = reasoning + const serviceTier = runtimeServiceTier(data.modelOptions) + if (serviceTier) modelConfig.service_tier = serviceTier + + const skillNames = (data.additionalSkills ?? []).map(skillName).filter(isNonEmptyString) + + return { + task_id: taskId, + subtask_id: subtaskId, + team_id: data.teamId, + team_name: LOCAL_WORKBENCH_TEAM.name, + team_namespace: 'default', + task_title: title, + subtask_title: `${title} - Assistant`, + user: { + id: LOCAL_USER.id, + name: LOCAL_USER.user_name, + user_name: LOCAL_USER.user_name, + email: LOCAL_USER.email, + }, + user_id: LOCAL_USER.id, + user_name: LOCAL_USER.user_name, + bot: [], + model_config: modelConfig, + prompt: data.message, + enable_tools: true, + enable_deep_thinking: true, + skill_names: skillNames, + preload_skills: data.additionalSkills ?? [], + user_selected_skills: data.additionalSkills ?? [], + workspace: { + project: { + source: 'local_path', + path: workspacePath, + }, + }, + workspace_source: 'local_path', + project_workspace_path: workspacePath, + execution_target_type: 'local', + device_id: data.deviceId ?? LOCAL_DEVICE_ID, + new_session: true, + is_group_chat: false, + collaboration_model: 'single', + mode: 'code', + task_mode: 'code', + attachments: [], + reasoning_config: reasoning, + } +} + +function createLocalRuntimeTaskPayload(data: RuntimeTaskCreateRequest): Record { + const workspacePath = requiredRuntimeWorkspacePath(data) + return { + ...data, + deviceId: data.deviceId ?? LOCAL_DEVICE_ID, + workspacePath, + title: runtimeTaskTitle(data), + executionRequest: createLocalExecutionRequest(data), + } as unknown as Record +} + function adaptRuntimeWorkListResponse(response: unknown): RuntimeWorkListResponse { const record = recordValue(response) if (Array.isArray(record.projects) && Array.isArray(record.chats)) { @@ -309,9 +460,10 @@ function createRuntimeWorkApi( return request('runtime.tasks.cancel', data as unknown as Record) }, async createRuntimeTask(data: RuntimeTaskCreateRequest) { + const payload = createLocalRuntimeTaskPayload(data) const response = await request>( 'runtime.tasks.create', - data as unknown as Record + payload ) return { ...response, diff --git a/wework/src/config/runtime.test.ts b/wework/src/config/runtime.test.ts index c21074cebc..0e5190a340 100644 --- a/wework/src/config/runtime.test.ts +++ b/wework/src/config/runtime.test.ts @@ -4,6 +4,7 @@ import { getRuntimeConfig } from './runtime' describe('getRuntimeConfig', () => { afterEach(() => { delete window.__WEWORK_RUNTIME_CONFIG__ + delete (globalThis as typeof globalThis & { isTauri?: boolean }).isTauri vi.unstubAllEnvs() }) @@ -50,6 +51,13 @@ describe('getRuntimeConfig', () => { expect(getRuntimeConfig().runtimeMode).toBe('backend') }) + test('defaults to local-first mode inside tauri runtime', () => { + const globalWithTauri = globalThis as typeof globalThis & { isTauri?: boolean } + globalWithTauri.isTauri = true + + expect(getRuntimeConfig().runtimeMode).toBe('local-first') + }) + test('uses local-first mode from runtime config override', () => { window.__WEWORK_RUNTIME_CONFIG__ = { runtimeMode: 'local-first', diff --git a/wework/src/config/runtime.ts b/wework/src/config/runtime.ts index 5a8f5cb298..a1bf342a93 100644 --- a/wework/src/config/runtime.ts +++ b/wework/src/config/runtime.ts @@ -1,3 +1,5 @@ +import { isTauri } from '@tauri-apps/api/core' + export type RuntimeMode = 'local-first' | 'backend' export interface RuntimeConfig { @@ -58,6 +60,10 @@ function resolveRuntimeMode(overrides: RuntimeConfigOverrides): RuntimeMode { return envValue } + if (isTauri()) { + return 'local-first' + } + return 'backend' } From 93995401080825c7c8a8a2ee31e862fdf18b2dec Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 25 Jun 2026 15:39:30 +0800 Subject: [PATCH 14/17] feat(executor): support app ipc with backend runner --- .../local-device-architecture.md | 25 +++++++++ docs/en/developer-guide/runtime-local-work.md | 2 + .../local-device-architecture.md | 25 +++++++++ docs/zh/developer-guide/runtime-local-work.md | 2 + executor/main.py | 36 ++++++++++-- executor/modes/local/runner.py | 50 ++++++++++++++++- executor/tests/test_local_app_ipc.py | 55 +++++++++++++++++++ 7 files changed, 188 insertions(+), 7 deletions(-) diff --git a/docs/en/developer-guide/local-device-architecture.md b/docs/en/developer-guide/local-device-architecture.md index 3aa1d03173..d5c82c30aa 100644 --- a/docs/en/developer-guide/local-device-architecture.md +++ b/docs/en/developer-guide/local-device-architecture.md @@ -34,6 +34,31 @@ flowchart LR style BE fill:#14B8A6,color:#fff ``` +### Wework Packaged App Local-First Channel + +The packaged Wework Tauri App defaults to local-first mode. This mode does not start the frontend Node dev server and does not start an extra local HTTP Backend service. The React UI runs inside the Tauri WebView, while the Tauri Rust side is only the app's internal command layer. + +Local-first mode needs only two local processes: + +```mermaid +flowchart LR + subgraph "User's Computer" + APP["Wework Tauri App"] + UI["React UI"] + TAURI["Tauri Commands"] + EX["Executor Sidecar"] + FS["Local Files"] + end + + UI --> TAURI + TAURI <-->|"stdin/stdout JSON"| EX + EX --> FS +``` + +Tauri starts the executor as a sidecar with `--app-ipc --no-backend` by default. stdout is reserved for the newline-delimited JSON protocol, and logs are written to stderr so app IPC is not polluted. The Wework renderer sends `runtime.*` and `device.execute_command` requests through Tauri commands and subscribes to Responses stream events emitted by the sidecar. + +Backend connectivity is optional, not a required dependency for the local app. When login, model/capability sync, cloud projects, or web control of the local computer are needed, the executor can register as a local device over the Backend WebSocket channel. If app IPC is enabled at the same time, the same executor process reuses one command handler and one runtime work handler while serving Wework App over stdin/stdout and Backend over WebSocket. This design does not introduce a local HTTP gateway and does not require Wework App to start Backend itself. + ### Communication Architecture The following diagram shows how local devices communicate with the Wegent system: diff --git a/docs/en/developer-guide/runtime-local-work.md b/docs/en/developer-guide/runtime-local-work.md index 9c5120eb2f..58c965232d 100644 --- a/docs/en/developer-guide/runtime-local-work.md +++ b/docs/en/developer-guide/runtime-local-work.md @@ -137,6 +137,8 @@ POST /api/runtime-work/create Backend resolves the target device and directory from either a Project mapping or a standalone device workspace, builds a transient execution request, and calls device RPC `runtime.tasks.create`. This flow does not `db.add()` any `TaskResource` or `Subtask`. +In packaged Wework App `local-first` mode, task creation does not go through the Backend HTTP API. Wework builds the minimal `executionRequest` required by the executor inside the frontend local service from the selected `deviceId + workspacePath`, sends it through a Tauri command to the executor sidecar app IPC channel, and the executor directly runs `runtime.tasks.create`. The payload must include `workspacePath`, the user message, runtime model configuration, and local user context; if no workspace path is available, Wework must fail before calling the executor. This path still uses only the app UI and executor as local processes and does not start a local Backend. + Before calling create, Wework generates a client-side `localTaskId` and sends it to Backend as `localTaskId`. Backend only forwards that value to the target device; it does not write it to the central database. The frontend immediately opens the runtime URL from `deviceId + localTaskId`, renders the user message, and shows the waiting state. If the device returns a different `localTaskId`, the frontend switches to the device-confirmed address. This lets a newly created task appear before the Backend RPC completes or the next list refresh runs, and queued sends wait until the current waiting state becomes a real assistant turn before continuing. The runtime owns persistence for newly created tasks: diff --git a/docs/zh/developer-guide/local-device-architecture.md b/docs/zh/developer-guide/local-device-architecture.md index 5d160b0038..38347fcd4b 100644 --- a/docs/zh/developer-guide/local-device-architecture.md +++ b/docs/zh/developer-guide/local-device-architecture.md @@ -34,6 +34,31 @@ flowchart LR style BE fill:#14B8A6,color:#fff ``` +### Wework 打包 App 本地优先通道 + +打包后的 Wework Tauri App 默认走本地优先模式。该模式不启动前端 Node dev server,也不在本机额外启动一个 HTTP Backend 服务;React 界面运行在 Tauri WebView 内,Tauri Rust 侧只作为 app 内部命令层存在。 + +本地优先模式只需要两个本机进程: + +```mermaid +flowchart LR + subgraph "用户电脑" + APP["Wework Tauri App"] + UI["React UI"] + TAURI["Tauri Commands"] + EX["Executor Sidecar"] + FS["本地文件"] + end + + UI --> TAURI + TAURI <-->|"stdin/stdout JSON"| EX + EX --> FS +``` + +Tauri 通过 sidecar 启动 executor,默认参数为 `--app-ipc --no-backend`。stdout 只承载换行分隔 JSON 协议,日志统一写到 stderr,避免污染 app IPC。Wework renderer 通过 Tauri command 向 sidecar 发送 `runtime.*` 和 `device.execute_command` 请求,并订阅 sidecar 发回的 Responses stream 事件。 + +Backend 是可选能力,而不是本地 app 的必需依赖。需要登录、模型/能力同步、云端项目或网页版控制本机时,executor 可以使用 Backend WebSocket 通道注册为本地设备;如果同时启用 app IPC,同一个 executor 进程会复用同一个 command handler 和 runtime work handler,一边通过 stdin/stdout 服务 Wework App,一边通过 WebSocket 服务 Backend。这个设计不引入本机 HTTP gateway,也不要求 Wework App 自己启动 Backend。 + ### 通信架构 下图展示了本地设备如何与 Wegent 系统通信: diff --git a/docs/zh/developer-guide/runtime-local-work.md b/docs/zh/developer-guide/runtime-local-work.md index ca83094180..c384ddf189 100644 --- a/docs/zh/developer-guide/runtime-local-work.md +++ b/docs/zh/developer-guide/runtime-local-work.md @@ -137,6 +137,8 @@ POST /api/runtime-work/create Backend 根据请求中的项目映射或独立设备工作区解析目标设备和目录,构造一次临时 execution request,然后调用设备 RPC `runtime.tasks.create`。这个流程不会 `db.add()` 任何 `TaskResource` 或 `Subtask`。 +在打包 Wework App 的 `local-first` 模式下,创建任务不经过 Backend HTTP API。Wework 在前端本地 service 中根据选中的 `deviceId + workspacePath` 构造 executor 需要的最小 `executionRequest`,通过 Tauri command 发送到 executor sidecar 的 app IPC,再由 executor 直接执行 `runtime.tasks.create`。这个 payload 必须包含 `workspacePath`、用户消息、运行时模型配置和本地用户上下文;如果没有工作区路径,Wework 必须在调用 executor 前失败。该路径仍然只使用 app 界面和 executor 两个本机进程,不启动本地 Backend。 + Wework 在调用 create 前先生成客户端侧 `localTaskId`,并在请求体中作为 `localTaskId` 传给 Backend。Backend 只把这个值转发给目标设备,不把它写入中心数据库。前端会立即用 `deviceId + localTaskId` 打开运行时 URL、展示用户消息和等待态;如果设备返回了不同的 `localTaskId`,前端再切换到设备确认的地址。这样新建任务不需要等待 Backend RPC 完成或下一次列表刷新,队列发送也会等当前等待态进入真实 assistant turn 后再继续。 运行时创建的持久化位置由具体 runtime 决定: diff --git a/executor/main.py b/executor/main.py index 58009db2c6..7c4f4cdb54 100644 --- a/executor/main.py +++ b/executor/main.py @@ -23,6 +23,7 @@ """ import argparse +import asyncio import contextlib import logging import multiprocessing @@ -154,6 +155,24 @@ def _load_device_config_for_mode(config_path: str | None, *, quiet_stdout: bool) return load_device_config(config_path) +async def _run_local_runner_with_app_ipc(runner, app_ipc_server) -> None: + """Run Backend LocalRunner and app IPC in one executor process.""" + runtime_work_handler = getattr(runner, "runtime_work_handler", None) + if runtime_work_handler is not None: + await runtime_work_handler.start_codex_watcher() + + runner_task = asyncio.create_task(runner.start()) + await asyncio.sleep(0) + try: + await app_ipc_server.serve() + finally: + app_ipc_server.stop() + runner_task.cancel() + await asyncio.gather(runner_task, return_exceptions=True) + if runtime_work_handler is not None: + await runtime_work_handler.stop_codex_watcher() + + def main() -> None: """ Main function for running the executor. @@ -194,8 +213,6 @@ def main() -> None: # Determine if we should run in local mode if args.app_ipc or should_use_local_mode(config_path): # Local mode: Run WebSocket-based executor - import asyncio - # Load full configuration for local mode try: device_config = _load_device_config_for_mode( @@ -230,9 +247,20 @@ def main() -> None: f"Auth Token: {'***' if config.WEGENT_AUTH_TOKEN else 'NOT SET'}" ) - # Pass config to runner runner = LocalRunner(device_config=device_config) - asyncio.run(runner.start()) + if args.app_ipc: + from executor.modes.local.app_ipc import AppIpcServer + + app_ipc_server = AppIpcServer( + device_id=device_config.device_id or "local-device", + runtime_work_handler=runner.runtime_work_handler, + command_handler=runner.command_handler, + ) + runner.set_app_event_emitter(app_ipc_server.emit_event) + logger.info("Starting executor with Backend and APP IPC channels") + asyncio.run(_run_local_runner_with_app_ipc(runner, app_ipc_server)) + else: + asyncio.run(runner.start()) except FileNotFoundError as e: logger.error(f"Configuration error: {e}") sys.exit(1) diff --git a/executor/modes/local/runner.py b/executor/modes/local/runner.py index a6bc5f75d7..30d38b790b 100644 --- a/executor/modes/local/runner.py +++ b/executor/modes/local/runner.py @@ -22,7 +22,7 @@ from dataclasses import dataclass from logging.handlers import RotatingFileHandler from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Callable, Dict, Optional from executor.config import config from executor.config.device_config import DeviceConfig @@ -97,7 +97,11 @@ class LocalRunner: - Graceful shutdown handling via SIGINT/SIGTERM """ - def __init__(self, device_config: Optional[DeviceConfig] = None): + def __init__( + self, + device_config: Optional[DeviceConfig] = None, + app_event_emitter: Optional[Callable[[str, dict[str, Any]], Any]] = None, + ): """Initialize the local runner. Args: @@ -105,6 +109,7 @@ def __init__(self, device_config: Optional[DeviceConfig] = None): will use environment variables for backward compatibility. """ self.device_config = device_config + self.app_event_emitter = app_event_emitter # WebSocket client self.websocket_client = WebSocketClient(device_config=device_config) @@ -125,7 +130,7 @@ def __init__(self, device_config: Optional[DeviceConfig] = None): self.upgrade_handler = UpgradeHandler(self) self.extension_handler = DeviceExtensionHandler(self) self.runtime_work_handler = RuntimeWorkRpcHandler( - responses_event_emitter=self.websocket_client.emit, + responses_event_emitter=self._emit_runtime_work_event, ) # Task queue for execution @@ -146,6 +151,45 @@ def __init__(self, device_config: Optional[DeviceConfig] = None): # Process manager for PID file and auto-restart support self._process_manager = ProcessManager() + def set_app_event_emitter( + self, emitter: Optional[Callable[[str, dict[str, Any]], Any]] + ) -> None: + """Attach or detach the optional app IPC runtime event emitter.""" + self.app_event_emitter = emitter + + async def _emit_runtime_work_event( + self, event: str, payload: dict[str, Any] + ) -> None: + """Broadcast runtime work events to Backend and optional app IPC.""" + if self.websocket_client.connected: + await self._emit_runtime_work_target( + "Backend", + self.websocket_client.emit, + event, + payload, + ) + if self.app_event_emitter is not None: + await self._emit_runtime_work_target( + "app IPC", + self.app_event_emitter, + event, + payload, + ) + + async def _emit_runtime_work_target( + self, + target: str, + emitter: Callable[[str, dict[str, Any]], Any], + event: str, + payload: dict[str, Any], + ) -> None: + try: + result = emitter(event, payload) + if asyncio.iscoroutine(result): + await result + except Exception: + logger.exception("Failed to emit runtime work event to %s", target) + def _handle_signal(self, signum: int, frame: Any) -> None: """Handle shutdown signals.""" signal_name = signal.Signals(signum).name diff --git a/executor/tests/test_local_app_ipc.py b/executor/tests/test_local_app_ipc.py index d4d71f8a8a..d4ca9a8046 100644 --- a/executor/tests/test_local_app_ipc.py +++ b/executor/tests/test_local_app_ipc.py @@ -4,6 +4,7 @@ """Tests for the local app IPC stdio bridge.""" +import asyncio import io import json import sys @@ -12,6 +13,7 @@ import pytest from executor.modes.local.app_ipc import AppIpcServer +from executor.modes.local.runner import LocalRunner def _json_lines(output: io.StringIO) -> list[dict]: @@ -160,3 +162,56 @@ def test_executor_cli_parses_app_ipc_flags(): assert args.app_ipc is True assert args.no_backend is True + + +@pytest.mark.asyncio +async def test_local_runner_runtime_events_broadcast_to_backend_and_app(): + backend_events = [] + app_events = [] + + class WebSocketClient: + connected = True + + async def emit(self, event, payload): + backend_events.append((event, payload)) + + async def emit_app(event, payload): + app_events.append((event, payload)) + + runner = LocalRunner.__new__(LocalRunner) + runner.websocket_client = WebSocketClient() + runner.app_event_emitter = emit_app + + await LocalRunner._emit_runtime_work_event( + runner, "response.created", {"task_id": 1} + ) + + assert backend_events == [("response.created", {"task_id": 1})] + assert app_events == [("response.created", {"task_id": 1})] + + +@pytest.mark.asyncio +async def test_app_ipc_runner_cancels_local_runner_when_app_channel_closes(): + from executor.main import _run_local_runner_with_app_ipc + + events = [] + + class Runner: + async def start(self): + events.append("runner:start") + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + events.append("runner:cancelled") + raise + + class Server: + def stop(self): + events.append("server:stop") + + async def serve(self): + events.append("server:serve") + + await _run_local_runner_with_app_ipc(Runner(), Server()) + + assert events == ["runner:start", "server:serve", "server:stop", "runner:cancelled"] From 121afa828b923445b212ec93f49a80c02e488748 Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 25 Jun 2026 17:54:27 +0800 Subject: [PATCH 15/17] feat(wework): support local-first executor sidecar --- backend/app/api/endpoints/remote_devices.py | 1 - backend/app/api/ws/chat_namespace.py | 31 +- backend/app/api/ws/device_namespace.py | 107 +-- backend/app/api/ws/local_task_responses.py | 44 ++ backend/app/core/constants.py | 10 + .../api/ws/test_chat_namespace_connect.py | 19 + .../api/ws/test_chat_namespace_task_join.py | 32 + .../api/ws/test_device_capabilities_state.py | 165 ++++- .../docker/test_executor_docker_contract.py | 24 + .../docker/test_standalone_wework_contract.py | 1 - docker/device/Dockerfile | 2 - docker/executor/Dockerfile | 1 + docker/standalone/start.sh | 1 - .../local-device-architecture.md | 8 +- .../ai-devices/local-device-support.md | 4 +- .../local-device-architecture.md | 8 +- .../ai-devices/local-device-support.md | 4 +- executor/docs/LOCAL_MODE.md | 4 +- executor/local.sh | 4 - executor/main.py | 256 +++++--- executor/modes/local/app_ipc.py | 375 ++++++++--- executor/modes/local/runner.py | 8 + executor/scripts/local_executor_install.ps1 | 2 - executor/scripts/local_executor_install.sh | 1 - executor/tests/scripts/test_local_sh.py | 5 +- executor/tests/test_local_app_ipc.py | 158 +++-- .../fixtures/claudecode-executor/Dockerfile | 1 + .../socket/authenticatedSocketClient.test.ts | 21 + .../src/socket/authenticatedSocketClient.ts | 3 +- shared/logger.py | 110 +++- wework/scripts/dev-mac-app.sh | 5 + wework/src-tauri/capabilities/default.json | 2 +- wework/src-tauri/src/lib.rs | 13 + wework/src-tauri/src/local_executor.rs | 619 +++++++++++++++--- wework/src/App.apps.test.tsx | 8 +- wework/src/App.plugins.test.tsx | 6 + wework/src/App.tsx | 27 +- wework/src/api/local/localChatStream.ts | 107 +-- wework/src/api/local/localServices.test.ts | 56 +- wework/src/api/local/localServices.ts | 216 ++++-- .../src/components/chat/MessageList.test.tsx | 2 + wework/src/config/runtime.test.ts | 24 +- wework/src/config/runtime.ts | 12 +- .../src/features/auth/AuthProvider.test.tsx | 9 + .../LocalRuntimeInitializer.test.tsx | 76 +++ .../local-runtime/LocalRuntimeInitializer.tsx | 147 +++++ .../workbench/WorkbenchProvider.test.tsx | 72 +- .../features/workbench/WorkbenchProvider.tsx | 288 +++----- .../workbench/workbenchReducer.test.ts | 49 ++ .../features/workbench/workbenchReducer.ts | 80 ++- wework/src/i18n/index.ts | 6 +- wework/src/i18n/locales/en/localRuntime.json | 9 + .../src/i18n/locales/zh-CN/localRuntime.json | 9 + wework/src/pages/LoginPage.test.tsx | 3 + wework/src/stream/chatStream.test.ts | 81 ++- wework/src/stream/chatStream.ts | 95 +-- wework/src/stream/responseApiStream.ts | 482 ++++++++++++++ wework/src/tauri/localExecutor.ts | 1 + wework/src/types/api.ts | 1 + 59 files changed, 3021 insertions(+), 894 deletions(-) create mode 100644 backend/tests/docker/test_executor_docker_contract.py create mode 100644 wework/src/features/local-runtime/LocalRuntimeInitializer.test.tsx create mode 100644 wework/src/features/local-runtime/LocalRuntimeInitializer.tsx create mode 100644 wework/src/i18n/locales/en/localRuntime.json create mode 100644 wework/src/i18n/locales/zh-CN/localRuntime.json create mode 100644 wework/src/stream/responseApiStream.ts diff --git a/backend/app/api/endpoints/remote_devices.py b/backend/app/api/endpoints/remote_devices.py index 20ceafa473..34a2e09b9b 100644 --- a/backend/app/api/endpoints/remote_devices.py +++ b/backend/app/api/endpoints/remote_devices.py @@ -313,7 +313,6 @@ async def create_docker_start_command( ) env = { - "EXECUTOR_MODE": "local", "DEVICE_TYPE": DeviceType.REMOTE.value, "DEVICE_ID": device_id, "DEVICE_NAME": device_name, diff --git a/backend/app/api/ws/chat_namespace.py b/backend/app/api/ws/chat_namespace.py index dd8add5ca8..e876ca9b97 100644 --- a/backend/app/api/ws/chat_namespace.py +++ b/backend/app/api/ws/chat_namespace.py @@ -46,7 +46,11 @@ TaskJoinPayload, TaskLeavePayload, ) -from app.core.constants import CLIENT_ORIGIN_WEWORK +from app.core.constants import ( + CLIENT_ORIGIN_WEWORK, + get_wework_task_room, + get_wework_user_room, +) from app.db.session import SessionLocal from app.models.kind import Kind from app.models.subtask import Subtask, SubtaskRole, SubtaskStatus @@ -351,6 +355,11 @@ async def on_connect(self, sid: str, environ: dict, auth: Optional[dict] = None) # Extract token expiry for later validation token_exp = get_token_expiry(token) + client_origin = ( + CLIENT_ORIGIN_WEWORK + if auth.get("client_origin") == CLIENT_ORIGIN_WEWORK + else None + ) await save_connect_session( self, @@ -361,6 +370,7 @@ async def on_connect(self, sid: str, environ: dict, auth: Optional[dict] = None) "request_id": request_id, "token_exp": token_exp, "auth_token": token, + "client_origin": client_origin, }, logger=logger, log_prefix="[WS]", @@ -369,8 +379,14 @@ async def on_connect(self, sid: str, environ: dict, auth: Optional[dict] = None) # Set user context for trace logging set_user_context(user_id=str(user.id), user_name=user.user_name) - # Join user room - user_room = f"user:{user.id}" + # Join only the stream room for this client origin. Wework gets raw + # Responses API events in a dedicated room and should not receive the + # legacy chat:* compatibility stream. + user_room = ( + get_wework_user_room(user.id) + if client_origin == CLIENT_ORIGIN_WEWORK + else f"user:{user.id}" + ) await enter_connect_room( self, sid, @@ -451,8 +467,13 @@ async def on_task_join(self, sid: str, data: dict) -> dict: ) return {"error": "Access denied"} - # Join task room - task_room = f"task:{payload.task_id}" + # Join only the stream room for this client origin. Wework uses the + # raw response.* stream; frontend keeps the legacy chat:* stream. + task_room = ( + get_wework_task_room(payload.task_id) + if session.get("client_origin") == CLIENT_ORIGIN_WEWORK + else f"task:{payload.task_id}" + ) await self.enter_room(sid, task_room) logger.info( diff --git a/backend/app/api/ws/device_namespace.py b/backend/app/api/ws/device_namespace.py index 3c58fffc57..433578f36a 100644 --- a/backend/app/api/ws/device_namespace.py +++ b/backend/app/api/ws/device_namespace.py @@ -41,8 +41,12 @@ from app.api.ws.connection_utils import enter_connect_room, save_connect_session from app.api.ws.decorators import trace_websocket_event from app.api.ws.events import ServerEvents -from app.api.ws.local_task_responses import LocalTaskResponsesHandler +from app.api.ws.local_task_responses import ( + LocalTaskResponsesHandler, + emit_response_api_event, +) from app.core.auth_utils import is_api_key, verify_api_key +from app.core.constants import get_wework_task_room, get_wework_user_room from app.core.events import TaskCompletedEvent, get_event_bus from app.core.socketio import get_sio from app.db.session import SessionLocal @@ -537,6 +541,25 @@ def _normalize_runtime_task_status(status: Any) -> str: return status.strip().replace("_", "").replace("-", "").lower() +def response_api_payload(*, data: dict[str, Any], device_id: str) -> dict[str, Any]: + payload = dict(data) + payload["device_id"] = device_id + return payload + + +async def emit_chat_user_event( + *, event_name: str, payload: dict[str, Any], user_id: int +) -> None: + sio = get_sio() + for room in (f"user:{user_id}", get_wework_user_room(user_id)): + await sio.emit( + event_name, + payload, + room=room, + namespace="/chat", + ) + + class DeviceNamespace(socketio.AsyncNamespace): """ Socket.IO namespace for local executor connections. @@ -567,8 +590,9 @@ def __init__(self, namespace: str = "/local-executor"): self._event_parser = ResponsesAPIEventParser() self._local_task_responses = LocalTaskResponsesHandler(self._event_parser) - # Known OpenAI Responses API event prefixes - self._responses_api_prefixes = ("response.", "error") + # Known Responses API event prefixes. Payload shape detection below keeps + # Wework pass-through from depending on a closed event-name list. + self._responses_api_prefixes = ("response.", "error", "image_generation.") # Per-subtask locks to ensure events are processed in order # This prevents race conditions when multiple response.output_text.delta @@ -689,11 +713,22 @@ async def _execute_handler(self, event: str, sid: str, *args): return await handler(sid, *args) # Handle OpenAI Responses API events (e.g., response.output_text.delta) - if event.startswith(self._responses_api_prefixes): + if self._is_responses_api_event(event, args): return await self._handle_responses_api_event(sid, event, *args) return await super().trigger_event(event, sid, *args) + def _is_responses_api_event(self, event: str, args: tuple[Any, ...]) -> bool: + if event.startswith(self._responses_api_prefixes): + return True + if not args or not isinstance(args[0], dict): + return False + + data = args[0] + if not isinstance(data.get("data"), dict): + return False + return any(key in data for key in ("task_id", "subtask_id", "local_task_id")) + def _get_client_ip(self, environ: dict) -> Optional[str]: """Extract the TCP peer IP from WSGI environ.""" return environ.get("REMOTE_ADDR") @@ -1544,6 +1579,11 @@ async def _handle_responses_api_event( # Acquire lock to ensure sequential processing of events for this subtask async with lock: # Parse using shared ResponsesAPIEventParser + await emit_response_api_event( + event_name=event_type, + payload=response_api_payload(data=data, device_id=device_id), + room=get_wework_task_room(task_id), + ) event = self._event_parser.parse( task_id=task_id, subtask_id=subtask_id, @@ -1772,61 +1812,47 @@ async def _broadcast_device_online( self, user_id: int, device_id: str, name: str ) -> None: """Broadcast device:online event to user room via chat namespace.""" - from app.core.socketio import get_sio from app.schemas.device import DeviceStatusEnum - sio = get_sio() event_data = DeviceOnlineEvent( device_id=device_id, name=name, status=DeviceStatusEnum.ONLINE, ).model_dump() - await sio.emit( - "device:online", - event_data, - room=f"user:{user_id}", - namespace="/chat", + await emit_chat_user_event( + event_name="device:online", payload=event_data, user_id=user_id ) - logger.debug(f"[Device WS] Broadcast device:online to user:{user_id}") + logger.debug(f"[Device WS] Broadcast device:online to user rooms for {user_id}") async def _broadcast_device_offline(self, user_id: int, device_id: str) -> None: """Broadcast device:offline event to user room via chat namespace.""" - from app.core.socketio import get_sio - - sio = get_sio() event_data = DeviceOfflineEvent(device_id=device_id).model_dump() - await sio.emit( - "device:offline", - event_data, - room=f"user:{user_id}", - namespace="/chat", + await emit_chat_user_event( + event_name="device:offline", payload=event_data, user_id=user_id + ) + logger.debug( + f"[Device WS] Broadcast device:offline to user rooms for {user_id}" ) - logger.debug(f"[Device WS] Broadcast device:offline to user:{user_id}") async def _broadcast_device_status( self, user_id: int, device_id: str, status: str ) -> None: """Broadcast device:status event to user room via chat namespace.""" - from app.core.socketio import get_sio from app.schemas.device import DeviceStatusEnum - sio = get_sio() # Convert string status to enum status_enum = DeviceStatusEnum(status) event_data = DeviceStatusEvent( device_id=device_id, status=status_enum ).model_dump() - await sio.emit( - "device:status", - event_data, - room=f"user:{user_id}", - namespace="/chat", + await emit_chat_user_event( + event_name="device:status", payload=event_data, user_id=user_id ) logger.debug( - f"[Device WS] Broadcast device:status to user:{user_id}, status={status}" + f"[Device WS] Broadcast device:status to user rooms for {user_id}, status={status}" ) async def _broadcast_device_slot_update(self, user_id: int, device_id: str) -> None: @@ -1836,7 +1862,6 @@ async def _broadcast_device_slot_update(self, user_id: int, device_id: str) -> N Queries current slot usage and emits the update. Uses run_sync_in_executor to avoid blocking the event loop. """ - from app.core.socketio import get_sio from app.schemas.device import DeviceRunningTask try: @@ -1845,7 +1870,6 @@ async def _broadcast_device_slot_update(self, user_id: int, device_id: str) -> N _get_device_slot_usage_sync, user_id, device_id ) - sio = get_sio() event_data = DeviceSlotUpdateEvent( device_id=device_id, slot_used=slot_info["used"], @@ -1855,14 +1879,11 @@ async def _broadcast_device_slot_update(self, user_id: int, device_id: str) -> N ], ).model_dump() - await sio.emit( - "device:slot_update", - event_data, - room=f"user:{user_id}", - namespace="/chat", + await emit_chat_user_event( + event_name="device:slot_update", payload=event_data, user_id=user_id ) logger.debug( - f"[Device WS] Broadcast device:slot_update to user:{user_id}, " + f"[Device WS] Broadcast device:slot_update to user rooms for {user_id}, " f"slot_used={slot_info['used']}" ) except Exception as e: @@ -1903,10 +1924,7 @@ async def _broadcast_device_upgrade_status( user_id: Device owner's user ID payload: DeviceUpgradeStatusEvent payload """ - from app.core.socketio import get_sio - try: - sio = get_sio() event_data = payload.model_dump() target_user_ids = {user_id} @@ -1919,11 +1937,10 @@ async def _broadcast_device_upgrade_status( target_user_ids.update(admin_id for (admin_id,) in admin_user_ids) for target_user_id in target_user_ids: - await sio.emit( - "device:upgrade_status", - event_data, - room=f"user:{target_user_id}", - namespace="/chat", + await emit_chat_user_event( + event_name="device:upgrade_status", + payload=event_data, + user_id=target_user_id, ) logger.debug( f"[Device WS] Broadcast device:upgrade_status to users={sorted(target_user_ids)}, " diff --git a/backend/app/api/ws/local_task_responses.py b/backend/app/api/ws/local_task_responses.py index 81bc9d880b..828e252e5a 100644 --- a/backend/app/api/ws/local_task_responses.py +++ b/backend/app/api/ws/local_task_responses.py @@ -10,6 +10,7 @@ from typing import Any, Callable, Optional from app.api.ws.events import ServerEvents +from app.core.constants import get_wework_user_room from app.core.socketio import get_sio from app.services.channels.callback import ( forward_event_to_channel_callbacks, @@ -54,6 +55,17 @@ async def handle( source = ( data.get("source") if isinstance(data.get("source"), dict) else None ) + await emit_response_api_event( + event_name=event_type, + payload=local_task_response_payload( + data=data, + device_id=device_id, + local_task_id=local_task_id, + subtask_id=subtask_id, + message_id=message_id, + ), + room=get_wework_user_room(user_id), + ) event = self.execution_event( event_type=event_type, event_data=event_data, @@ -383,6 +395,38 @@ async def emit_local_task_chat_event( ) +async def emit_response_api_event( + *, + event_name: str, + payload: dict[str, Any], + room: str, +) -> None: + await get_sio().emit( + event_name, + payload, + room=room, + namespace="/chat", + ) + + +def local_task_response_payload( + *, + data: dict, + device_id: str, + local_task_id: str, + subtask_id: int, + message_id: Optional[int], +) -> dict[str, Any]: + payload = dict(data) + payload["task_id"] = payload.get("task_id") if payload.get("task_id") else 0 + payload["subtask_id"] = subtask_id + payload["device_id"] = device_id + payload["local_task_id"] = local_task_id + if message_id is not None: + payload["message_id"] = message_id + return payload + + def runtime_subtask_id(data: dict, device_id: str, local_task_id: str) -> int: value = data.get("subtask_id") if isinstance(value, int) and value > 0: diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py index 7089f3d9a2..9d8cde30c5 100644 --- a/backend/app/core/constants.py +++ b/backend/app/core/constants.py @@ -15,6 +15,8 @@ # Used to broadcast task events (messages, status updates) to connected clients # Format: "task:{task_id}" TASK_ROOM_PREFIX = "task:" +WEWORK_TASK_ROOM_PREFIX = "wework:task:" +WEWORK_USER_ROOM_PREFIX = "wework:user:" def get_task_room(task_id: int) -> str: @@ -30,6 +32,14 @@ def get_task_room(task_id: int) -> str: return f"{TASK_ROOM_PREFIX}{task_id}" +def get_wework_task_room(task_id: int) -> str: + return f"{WEWORK_TASK_ROOM_PREFIX}{task_id}" + + +def get_wework_user_room(user_id: int) -> str: + return f"{WEWORK_USER_ROOM_PREFIX}{user_id}" + + # ========== Kind Names ========== KIND_TEAM = "Team" diff --git a/backend/tests/api/ws/test_chat_namespace_connect.py b/backend/tests/api/ws/test_chat_namespace_connect.py index 8b1b85e9c3..788a913710 100644 --- a/backend/tests/api/ws/test_chat_namespace_connect.py +++ b/backend/tests/api/ws/test_chat_namespace_connect.py @@ -71,3 +71,22 @@ async def test_connect_rejects_when_session_disappears_before_room_join( save_session.assert_awaited_once() enter_room.assert_awaited_once_with("sid-1", "user:7") + + +@pytest.mark.asyncio +async def test_wework_connect_joins_wework_user_room(valid_jwt_auth, monkeypatch): + namespace = chat_namespace.ChatNamespace() + save_session = AsyncMock() + enter_room = AsyncMock() + monkeypatch.setattr(namespace, "save_session", save_session) + monkeypatch.setattr(namespace, "enter_room", enter_room) + + await namespace.on_connect( + "sid-1", + {"REMOTE_ADDR": "127.0.0.1"}, + {"token": "jwt-token", "client_origin": "wework"}, + ) + + save_session.assert_awaited_once() + assert save_session.await_args.args[1]["client_origin"] == "wework" + enter_room.assert_awaited_once_with("sid-1", "wework:user:7") diff --git a/backend/tests/api/ws/test_chat_namespace_task_join.py b/backend/tests/api/ws/test_chat_namespace_task_join.py index cea01a4aa8..4acc83afc0 100644 --- a/backend/tests/api/ws/test_chat_namespace_task_join.py +++ b/backend/tests/api/ws/test_chat_namespace_task_join.py @@ -74,6 +74,38 @@ async def test_task_join_replays_cached_context_metrics_for_active_stream() -> N ) +@pytest.mark.asyncio +async def test_wework_task_join_joins_wework_task_room() -> None: + namespace = ChatNamespace() + namespace.get_session = AsyncMock( + return_value={"user_id": 1, "client_origin": "wework"} + ) + namespace._check_token_expiry = AsyncMock(return_value=False) + namespace.enter_room = AsyncMock() + namespace.emit = AsyncMock() + + with ( + patch( + "app.api.ws.chat_namespace.can_access_task", AsyncMock(return_value=True) + ), + patch( + "app.api.ws.chat_namespace.run_sync_in_executor", + AsyncMock(return_value=[]), + ), + patch( + "app.api.ws.chat_namespace.get_active_streaming", + AsyncMock(return_value=None), + ), + ): + result = await namespace.on_task_join( + "sid-1", + {"task_id": 101, "after_message_id": None}, + ) + + assert result["subtasks"] == [] + namespace.enter_room.assert_awaited_once_with("sid-1", "wework:task:101") + + @pytest.mark.asyncio async def test_chat_cancel_cleans_cached_streaming_state() -> None: """Cancelling a stream should remove refresh recovery cache immediately.""" diff --git a/backend/tests/api/ws/test_device_capabilities_state.py b/backend/tests/api/ws/test_device_capabilities_state.py index 46b966f9d4..f0a97c1a6f 100644 --- a/backend/tests/api/ws/test_device_capabilities_state.py +++ b/backend/tests/api/ws/test_device_capabilities_state.py @@ -14,6 +14,14 @@ from app.services.device.terminal_session_service import TerminalSessionRecord +def find_emit_call(sio, event_name: str): + return next(call for call in sio.emit.await_args_list if call.args[0] == event_name) + + +def find_emit_calls(sio, event_name: str): + return [call for call in sio.emit.await_args_list if call.args[0] == event_name] + + @pytest.mark.asyncio async def test_store_device_capabilities_state_preserves_plugin_report(monkeypatch): stored = {} @@ -101,6 +109,47 @@ def test_runtime_subtask_id_fallback_is_scoped_by_device(): ) +@pytest.mark.asyncio +async def test_response_like_event_name_routes_to_response_api_handler(monkeypatch): + namespace = device_namespace.DeviceNamespace() + handler = AsyncMock(return_value={"success": True}) + monkeypatch.setattr(namespace, "_handle_responses_api_event", handler) + + result = await namespace._execute_handler( + "future.response.delta", + "sid-1", + { + "local_task_id": "codex-1", + "subtask_id": 202, + "data": {"delta": "hello"}, + }, + ) + + assert result == {"success": True} + handler.assert_awaited_once_with( + "sid-1", + "future.response.delta", + { + "local_task_id": "codex-1", + "subtask_id": 202, + "data": {"delta": "hello"}, + }, + ) + + +@pytest.mark.asyncio +async def test_device_status_broadcast_reaches_frontend_and_wework_rooms(monkeypatch): + namespace = device_namespace.DeviceNamespace() + sio = SimpleNamespace(emit=AsyncMock()) + monkeypatch.setattr(device_namespace, "get_sio", lambda: sio, raising=False) + + await namespace._broadcast_device_status(7, "device-1", "online") + + emit_calls = find_emit_calls(sio, "device:status") + assert [call.kwargs["room"] for call in emit_calls] == ["user:7", "wework:user:7"] + assert all(call.kwargs["namespace"] == "/chat" for call in emit_calls) + + @pytest.mark.asyncio async def test_heartbeat_runtime_auth_sync_uses_user_preferences(monkeypatch): namespace = device_namespace.DeviceNamespace() @@ -254,6 +303,71 @@ async def fake_broadcast_slot_update(*args): assert all("done" not in message for message in messages) +@pytest.mark.asyncio +async def test_responses_api_event_passes_raw_response_event_to_task_room(monkeypatch): + namespace = device_namespace.DeviceNamespace() + sio = SimpleNamespace(emit=AsyncMock()) + event = SimpleNamespace( + type=device_namespace.EventType.CHUNK.value, content="hi", offset=2 + ) + + async def fake_get_session(sid): + return {"user_id": 7, "device_id": "device-1"} + + def fake_parse(**kwargs): + return event + + class FakeWebSocketResultEmitter: + def __init__(self, **kwargs): + assert kwargs["user_id"] == 7 + + class FakeStatusUpdatingEmitter: + def __init__(self, **kwargs): + pass + + async def emit(self, emitted_event): + assert emitted_event is event + + async def close(self): + pass + + monkeypatch.setattr(namespace, "get_session", fake_get_session) + monkeypatch.setattr(namespace._event_parser, "parse", fake_parse) + monkeypatch.setattr( + device_namespace, + "WebSocketResultEmitter", + FakeWebSocketResultEmitter, + ) + monkeypatch.setattr( + device_namespace, + "StatusUpdatingEmitter", + FakeStatusUpdatingEmitter, + ) + monkeypatch.setattr(local_task_responses, "get_sio", lambda: sio, raising=False) + + result = await namespace._handle_responses_api_event( + "sid-1", + "response.output_text.delta", + { + "task_id": 101, + "subtask_id": 202, + "message_id": 303, + "data": {"delta": "hello"}, + }, + ) + + assert result == {"success": True} + raw_emit = find_emit_call(sio, "response.output_text.delta") + assert raw_emit.args[1] == { + "task_id": 101, + "subtask_id": 202, + "message_id": 303, + "device_id": "device-1", + "data": {"delta": "hello"}, + } + assert raw_emit.kwargs == {"room": "wework:task:101", "namespace": "/chat"} + + @pytest.mark.asyncio async def test_responses_api_delta_event_forwards_to_channel_callbacks(monkeypatch): namespace = device_namespace.DeviceNamespace() @@ -323,6 +437,45 @@ async def fake_forward_event_to_channel_callbacks( assert forwarded_events == [(101, 202, event)] +@pytest.mark.asyncio +async def test_local_task_response_event_passes_raw_response_event_to_wework_clients( + monkeypatch, +): + namespace = device_namespace.DeviceNamespace() + sio = SimpleNamespace(emit=AsyncMock()) + + async def fake_get_session(sid): + return {"user_id": 7, "device_id": "device-1"} + + monkeypatch.setattr(namespace, "get_session", fake_get_session) + monkeypatch.setattr(local_task_responses, "get_sio", lambda: sio, raising=False) + + result = await namespace._handle_responses_api_event( + "sid-1", + "response.output_text.delta", + { + "subtask_id": 202, + "local_task_id": "codex-1", + "runtime": "codex", + "message_id": 303, + "data": {"delta": "hello"}, + }, + ) + + assert result == {"success": True} + raw_emit = find_emit_call(sio, "response.output_text.delta") + assert raw_emit.args[1] == { + "task_id": 0, + "subtask_id": 202, + "device_id": "device-1", + "local_task_id": "codex-1", + "runtime": "codex", + "message_id": 303, + "data": {"delta": "hello"}, + } + assert raw_emit.kwargs == {"room": "wework:user:7", "namespace": "/chat"} + + @pytest.mark.asyncio async def test_local_task_reasoning_event_emits_chat_chunk(monkeypatch): namespace = device_namespace.DeviceNamespace() @@ -346,8 +499,8 @@ async def fake_get_session(sid): ) assert result == {"success": True} - sio.emit.assert_awaited_once() - event_name, payload = sio.emit.await_args.args[:2] + call = find_emit_call(sio, device_namespace.ServerEvents.CHAT_CHUNK) + event_name, payload = call.args[:2] assert event_name == device_namespace.ServerEvents.CHAT_CHUNK assert payload["device_id"] == "device-1" assert payload["local_task_id"] == "codex-1" @@ -385,8 +538,8 @@ async def fake_get_session(sid): ) assert result == {"success": True} - sio.emit.assert_awaited_once() - event_name, payload = sio.emit.await_args.args[:2] + call = find_emit_call(sio, device_namespace.ServerEvents.CHAT_BLOCK_CREATED) + event_name, payload = call.args[:2] assert event_name == device_namespace.ServerEvents.CHAT_BLOCK_CREATED assert payload["device_id"] == "device-1" assert payload["local_task_id"] == "codex-1" @@ -425,8 +578,8 @@ async def fake_get_session(sid): ) assert result == {"success": True} - sio.emit.assert_awaited_once() - event_name, payload = sio.emit.await_args.args[:2] + call = find_emit_call(sio, device_namespace.ServerEvents.CHAT_BLOCK_CREATED) + event_name, payload = call.args[:2] assert event_name == device_namespace.ServerEvents.CHAT_BLOCK_CREATED assert payload["device_id"] == "device-1" assert payload["local_task_id"] == "claude-1" diff --git a/backend/tests/docker/test_executor_docker_contract.py b/backend/tests/docker/test_executor_docker_contract.py new file mode 100644 index 0000000000..e3ba80643a --- /dev/null +++ b/backend/tests/docker/test_executor_docker_contract.py @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2026 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Static contract tests for executor container entrypoints.""" + +from pathlib import Path + +REPO_ROOT: Path = Path(__file__).resolve().parents[3] +EXECUTOR_DOCKERFILE: Path = REPO_ROOT / "docker" / "executor" / "Dockerfile" +E2E_EXECUTOR_DOCKERFILE: Path = ( + REPO_ROOT / "frontend" / "e2e" / "fixtures" / "claudecode-executor" / "Dockerfile" +) + + +def test_executor_container_images_start_in_docker_mode() -> None: + """Container executor images must not inherit the local sidecar default.""" + dockerfiles = [ + EXECUTOR_DOCKERFILE.read_text(encoding="utf-8"), + E2E_EXECUTOR_DOCKERFILE.read_text(encoding="utf-8"), + ] + + for dockerfile in dockerfiles: + assert "ENV EXECUTOR_MODE=docker" in dockerfile diff --git a/backend/tests/docker/test_standalone_wework_contract.py b/backend/tests/docker/test_standalone_wework_contract.py index a1cb82235e..2ad6dbe2fb 100644 --- a/backend/tests/docker/test_standalone_wework_contract.py +++ b/backend/tests/docker/test_standalone_wework_contract.py @@ -65,7 +65,6 @@ def test_standalone_start_registers_executor_as_admin_cloud_device() -> None: assert "ensure_standalone_executor_token" in start_script assert "app.scripts.ensure_standalone_executor_token" in start_script assert "start_executor" in start_script - assert "EXECUTOR_MODE=local" in start_script assert "DEVICE_TYPE=cloud" in start_script assert "DEVICE_ID=standalone-admin-device" in start_script assert "WEGENT_AUTH_TOKEN" in start_script diff --git a/docker/device/Dockerfile b/docker/device/Dockerfile index 4a8a5d6180..48c5192634 100644 --- a/docker/device/Dockerfile +++ b/docker/device/Dockerfile @@ -144,7 +144,6 @@ RUN set -e; \ && chown -R wegent:wegent /app /home/wegent ENV PATH="/home/wegent/.wecode/wegent-executor/bin:/home/wegent/.wecode/bin:/home/wegent/.local/bin:${PATH}" -ENV EXECUTOR_MODE=local ENV WEGENT_BACKEND_URL=http://localhost:8000 ENV WEGENT_EXECUTOR_HOME=/home/wegent/.wecode/wegent-executor ENV DEVICE_PUBLIC_BASE_URL= @@ -157,7 +156,6 @@ set -euo pipefail export CODE_SERVER_PASSWORD="${CODE_SERVER_PASSWORD:-${PASSWORD:-wegent}}" export PASSWORD="$CODE_SERVER_PASSWORD" -export EXECUTOR_MODE="${EXECUTOR_MODE:-local}" export WEGENT_BACKEND_URL="${WEGENT_BACKEND_URL:-http://localhost:8000}" export DEVICE_SESSION_GATEWAY_HOST="${DEVICE_SESSION_GATEWAY_HOST:-0.0.0.0}" export DEVICE_SESSION_GATEWAY_PORT="${DEVICE_SESSION_GATEWAY_PORT:-17888}" diff --git a/docker/executor/Dockerfile b/docker/executor/Dockerfile index 1aa2afdf0c..d7a1c9ed81 100644 --- a/docker/executor/Dockerfile +++ b/docker/executor/Dockerfile @@ -72,6 +72,7 @@ RUN mkdir -p /usr/share/fonts/truetype/noto && \ COPY --from=builder /app/executor/dist/executor /app/executor ENV PORT=10001 +ENV EXECUTOR_MODE=docker ENV PYTHONPATH=/app ENV NODE_PATH=/usr/lib/node_modules diff --git a/docker/standalone/start.sh b/docker/standalone/start.sh index 641de8afeb..308d07af55 100755 --- a/docker/standalone/start.sh +++ b/docker/standalone/start.sh @@ -260,7 +260,6 @@ ensure_standalone_executor_token start_executor() { echo "[4/8] Starting Standalone Executor..." - export EXECUTOR_MODE=local export DEVICE_TYPE=cloud export DEVICE_ID=standalone-admin-device export DEVICE_NAME="${STANDALONE_DEVICE_NAME:-Standalone Device}" diff --git a/docs/en/developer-guide/local-device-architecture.md b/docs/en/developer-guide/local-device-architecture.md index d5c82c30aa..c3f3804a68 100644 --- a/docs/en/developer-guide/local-device-architecture.md +++ b/docs/en/developer-guide/local-device-architecture.md @@ -51,13 +51,13 @@ flowchart LR end UI --> TAURI - TAURI <-->|"stdin/stdout JSON"| EX + TAURI <-->|"local socket JSON"| EX EX --> FS ``` -Tauri starts the executor as a sidecar with `--app-ipc --no-backend` by default. stdout is reserved for the newline-delimited JSON protocol, and logs are written to stderr so app IPC is not polluted. The Wework renderer sends `runtime.*` and `device.execute_command` requests through Tauri commands and subscribes to Responses stream events emitted by the sidecar. +Tauri first connects to `~/.wegent-executor/app-ipc.sock`. If the local executor sidecar is not running yet, the App starts the executor with no arguments and retries the socket connection. The executor uses `~/.wegent-executor/app-ipc.lock` to keep one local executor process per user home. When no remote Backend address is configured, the executor only starts the local socket and does not connect to Backend. The App and executor only use newline-delimited JSON over the local socket. Executor logs are written to `~/.wegent-executor/logs/executor.log`, not to the protocol channel. The Wework renderer sends `runtime.*` and `device.execute_command` requests through Tauri commands and subscribes to Responses stream events emitted by the sidecar. -Backend connectivity is optional, not a required dependency for the local app. When login, model/capability sync, cloud projects, or web control of the local computer are needed, the executor can register as a local device over the Backend WebSocket channel. If app IPC is enabled at the same time, the same executor process reuses one command handler and one runtime work handler while serving Wework App over stdin/stdout and Backend over WebSocket. This design does not introduce a local HTTP gateway and does not require Wework App to start Backend itself. +Backend connectivity is optional, not a required dependency for the local app. When login, model/capability sync, cloud projects, or web control of the local computer are needed, the executor can register as a local device over the Backend WebSocket channel. The same executor sidecar reuses one command handler and one runtime work handler while serving Wework App over the local socket and Backend over WebSocket. This design does not introduce a local HTTP gateway and does not require Wework App to start Backend itself. ### Communication Architecture @@ -321,7 +321,7 @@ flowchart LR ### Local Executor Connection Configuration -On startup, the local executor resolves configuration in this order: environment variables, `~/.wegent-executor/device-config.json`, then defaults. The `mode` field selects the startup mode, while `connection.backend_url` and `connection.auth_token` are used to connect to the Backend and authenticate the device. +On startup, the local executor resolves configuration in this order: environment variables, `~/.wegent-executor/device-config.json`, then defaults. Without a remote address, running `wegent-executor` with no arguments listens on `~/.wegent-executor/app-ipc.sock` and does not connect to Backend. After `connection.backend_url` or `WEGENT_BACKEND_URL` is set, the executor keeps the local socket available and also connects to Backend as a local device, using `connection.auth_token` or `WEGENT_AUTH_TOKEN` for authentication. `~/.wegent-executor/app-ipc.lock` limits each user home to one executor process so the web app does not see duplicate connections for the same local device. `EXECUTOR_MODE` overrides `mode`, `WEGENT_BACKEND_URL` overrides `connection.backend_url`, and `WEGENT_AUTH_TOKEN` overrides `connection.auth_token`. This means normal startup scripts do not need to require those environment variables; if the device config already contains valid mode and connection settings, the executor can start directly. diff --git a/docs/en/user-guide/ai-devices/local-device-support.md b/docs/en/user-guide/ai-devices/local-device-support.md index 6fadb4fbff..e8dce42951 100644 --- a/docs/en/user-guide/ai-devices/local-device-support.md +++ b/docs/en/user-guide/ai-devices/local-device-support.md @@ -131,7 +131,6 @@ docker run -d --platform linux/amd64 \ --name wegent-device \ -p 17888:17888 \ -e CODE_SERVER_PASSWORD=wegent \ - -e EXECUTOR_MODE=local \ -e WEGENT_BACKEND_URL=http://host.docker.internal:8000 \ -e WEGENT_AUTH_TOKEN="$WEGENT_AUTH_TOKEN" \ -e DEVICE_PUBLIC_BASE_URL=http://localhost:17888 \ @@ -154,7 +153,6 @@ The generated command contains parameters like: docker run -d \ --name wegent-remote-device \ --restart unless-stopped \ - -e EXECUTOR_MODE=local \ -e DEVICE_TYPE=remote \ -e DEVICE_ID= \ -e DEVICE_NAME= \ @@ -225,7 +223,7 @@ export WEGENT_BACKEND_URL=https://your-wegent-instance.com wegent-executor ``` -The installer and first startup create `~/.wegent-executor/device-config.json`. Configuration priority is environment variables, device config, then defaults. On later startups, if `EXECUTOR_MODE`, `WEGENT_BACKEND_URL`, or `WEGENT_AUTH_TOKEN` is not set, the executor reads `mode`, `connection.backend_url`, and `connection.auth_token` from that file. +The installer and first startup create `~/.wegent-executor/device-config.json`. Configuration priority is environment variables, device config, then defaults. With no arguments and no remote address, the executor listens on `~/.wegent-executor/app-ipc.sock` and does not connect to Backend. After `WEGENT_BACKEND_URL` or `connection.backend_url` is set, the executor keeps the local socket available and also connects to the remote Backend as a local device. `~/.wegent-executor/app-ipc.lock` limits each user home to one executor process so the web app does not see duplicate device connections. Logs are written to `~/.wegent-executor/logs/executor.log`. ### Getting JWT Token diff --git a/docs/zh/developer-guide/local-device-architecture.md b/docs/zh/developer-guide/local-device-architecture.md index 38347fcd4b..ae51b9c87d 100644 --- a/docs/zh/developer-guide/local-device-architecture.md +++ b/docs/zh/developer-guide/local-device-architecture.md @@ -51,13 +51,13 @@ flowchart LR end UI --> TAURI - TAURI <-->|"stdin/stdout JSON"| EX + TAURI <-->|"本机 socket JSON"| EX EX --> FS ``` -Tauri 通过 sidecar 启动 executor,默认参数为 `--app-ipc --no-backend`。stdout 只承载换行分隔 JSON 协议,日志统一写到 stderr,避免污染 app IPC。Wework renderer 通过 Tauri command 向 sidecar 发送 `runtime.*` 和 `device.execute_command` 请求,并订阅 sidecar 发回的 Responses stream 事件。 +Tauri 会先连接 `~/.wegent-executor/app-ipc.sock`;如果本地 executor sidecar 尚未运行,再由 App 无参数启动 executor 并重试连接。executor 使用 `~/.wegent-executor/app-ipc.lock` 保证同一用户目录下最多只有一个本地执行器进程。没有远端 Backend 地址时,executor 默认只启动本地 socket,不连接 Backend;App 与 executor 之间只使用本机 socket 上的换行分隔 JSON 协议。executor 日志写入 `~/.wegent-executor/logs/executor.log`,不占用协议通道。Wework renderer 通过 Tauri command 向 sidecar 发送 `runtime.*` 和 `device.execute_command` 请求,并订阅 sidecar 发回的 Responses stream 事件。 -Backend 是可选能力,而不是本地 app 的必需依赖。需要登录、模型/能力同步、云端项目或网页版控制本机时,executor 可以使用 Backend WebSocket 通道注册为本地设备;如果同时启用 app IPC,同一个 executor 进程会复用同一个 command handler 和 runtime work handler,一边通过 stdin/stdout 服务 Wework App,一边通过 WebSocket 服务 Backend。这个设计不引入本机 HTTP gateway,也不要求 Wework App 自己启动 Backend。 +Backend 是可选能力,而不是本地 app 的必需依赖。需要登录、模型/能力同步、云端项目或网页版控制本机时,executor 可以使用 Backend WebSocket 通道注册为本地设备;同一个 executor sidecar 会复用同一个 command handler 和 runtime work handler,一边通过本机 socket 服务 Wework App,一边通过 WebSocket 服务 Backend。这个设计不引入本机 HTTP gateway,也不要求 Wework App 自己启动 Backend。 ### 通信架构 @@ -321,7 +321,7 @@ flowchart LR ### 本地执行器连接配置 -本地执行器启动时按“环境变量、`~/.wegent-executor/device-config.json`、默认值”的顺序解析配置。其中 `mode` 决定启动模式,`connection.backend_url` 和 `connection.auth_token` 分别用于连接 Backend 和完成设备认证。 +本地执行器启动时按“环境变量、`~/.wegent-executor/device-config.json`、默认值”的顺序解析配置。没有远端地址时,`wegent-executor` 无参数启动会监听 `~/.wegent-executor/app-ipc.sock` 且不会连接 Backend;设置 `connection.backend_url` 或 `WEGENT_BACKEND_URL` 后,executor 继续保留本机 socket,同时以本地设备模式连接 Backend,`connection.auth_token` 或 `WEGENT_AUTH_TOKEN` 用于设备认证。同一用户目录下通过 `~/.wegent-executor/app-ipc.lock` 限制为一个 executor 进程,避免网页版出现同一设备的重复连接。 `EXECUTOR_MODE` 覆盖 `mode`,`WEGENT_BACKEND_URL` 覆盖 `connection.backend_url`,`WEGENT_AUTH_TOKEN` 覆盖 `connection.auth_token`。因此常规启动脚本不需要强制传入这些环境变量;只要设备配置文件中已有有效模式和连接信息,executor 就可以直接启动。 diff --git a/docs/zh/user-guide/ai-devices/local-device-support.md b/docs/zh/user-guide/ai-devices/local-device-support.md index eb4d534342..9eb9d62803 100644 --- a/docs/zh/user-guide/ai-devices/local-device-support.md +++ b/docs/zh/user-guide/ai-devices/local-device-support.md @@ -133,7 +133,6 @@ docker run -d --platform linux/amd64 \ --name wegent-device \ -p 17888:17888 \ -e CODE_SERVER_PASSWORD=wegent \ - -e EXECUTOR_MODE=local \ -e WEGENT_BACKEND_URL=http://host.docker.internal:8000 \ -e WEGENT_AUTH_TOKEN="$WEGENT_AUTH_TOKEN" \ -e DEVICE_PUBLIC_BASE_URL=http://localhost:17888 \ @@ -156,7 +155,6 @@ docker run -d --platform linux/amd64 \ docker run -d \ --name wegent-remote-device \ --restart unless-stopped \ - -e EXECUTOR_MODE=local \ -e DEVICE_TYPE=remote \ -e DEVICE_ID= \ -e DEVICE_NAME= \ @@ -227,7 +225,7 @@ export WEGENT_BACKEND_URL=https://your-wegent-instance.com wegent-executor ``` -安装脚本和首次启动会创建 `~/.wegent-executor/device-config.json`。配置优先级是环境变量、device config、默认值;如果没有传 `EXECUTOR_MODE`、`WEGENT_BACKEND_URL` 或 `WEGENT_AUTH_TOKEN`,executor 会读取该文件中的 `mode`、`connection.backend_url` 和 `connection.auth_token`。 +安装脚本和首次启动会创建 `~/.wegent-executor/device-config.json`。配置优先级是环境变量、device config、默认值;无参数启动且没有远端地址时,executor 监听 `~/.wegent-executor/app-ipc.sock` 并且不连接 Backend。设置 `WEGENT_BACKEND_URL` 或配置文件中的 `connection.backend_url` 后,executor 会继续保留本机 socket,同时以本地设备模式连接远端 Backend。`~/.wegent-executor/app-ipc.lock` 会限制同一用户目录下最多一个 executor 进程,避免网页版看到重复设备连接。日志写入 `~/.wegent-executor/logs/executor.log`。 ### 获取 JWT Token diff --git a/executor/docs/LOCAL_MODE.md b/executor/docs/LOCAL_MODE.md index 9f0a7e6798..061a7749aa 100644 --- a/executor/docs/LOCAL_MODE.md +++ b/executor/docs/LOCAL_MODE.md @@ -91,11 +91,11 @@ sudo cp dist/wegent-executor /usr/local/bin/ ### 连接配置 -Local Executor 的配置优先级是:环境变量、`~/.wegent-executor/device-config.json`、默认值。`EXECUTOR_MODE`、`WEGENT_BACKEND_URL` 和 `WEGENT_AUTH_TOKEN` 都是可选环境变量;设置后分别覆盖配置文件中的 `mode`、`connection.backend_url` 和 `connection.auth_token`。 +Local Executor 的配置优先级是:环境变量、`~/.wegent-executor/device-config.json`、默认值。无参数启动且没有远端地址时,executor 监听 `~/.wegent-executor/app-ipc.sock` 并且不连接 Backend。设置 `WEGENT_BACKEND_URL` 或配置文件中的 `connection.backend_url` 后,executor 会继续保留本机 socket,同时以本地设备模式连接远端 Backend。`~/.wegent-executor/app-ipc.lock` 会限制同一用户目录下最多一个 executor 进程,避免网页版看到重复设备连接。日志写入 `~/.wegent-executor/logs/executor.log`。 | 配置 | 说明 | 示例 | |------|------|------| -| `mode` / `EXECUTOR_MODE` | executor 运行模式 | `local` | +| `mode` / `EXECUTOR_MODE` | executor 运行模式;通常不需要手动设置 | `local` | | `connection.backend_url` / `WEGENT_BACKEND_URL` | Backend 服务地址 | `http://localhost:8000` | | `connection.auth_token` / `WEGENT_AUTH_TOKEN` | WebSocket 认证 Token 或 API Key | `your-auth-token` | diff --git a/executor/local.sh b/executor/local.sh index 1b3ec4f2c1..62c5933da0 100755 --- a/executor/local.sh +++ b/executor/local.sh @@ -12,7 +12,6 @@ PID_FILE="$PID_DIR/wegent-executor.pid" RUN_LOG="$PID_DIR/wegent-executor.log" BUILD_LOG="$PID_DIR/wegent-executor-build.log" BINARY_PATH="${WEGENT_EXECUTOR_BINARY:-$ROOT_DIR/dist/wegent-executor}" -DEVICE_CONFIG_PATH="${WEGENT_EXECUTOR_HOME:-$HOME/.wegent-executor}/device-config.json" DEFAULT_FILE_EDIT_HOOK_COMMAND='tee -a /tmp/hook-debug.log | curl -sS -X POST http://127.0.0.1:3456/api/file-edit-log -H "Content-Type: application/json" -H "wecode-source: wegent-device" --data-binary @-' @@ -96,9 +95,6 @@ start_executor() { echo "Starting local executor. Log: $RUN_LOG" ( cd "$ROOT_DIR" - if [[ ! -f "$DEVICE_CONFIG_PATH" && -z "${EXECUTOR_MODE:-}" ]]; then - export EXECUTOR_MODE=local - fi export WEGENT_FILE_EDIT_HOOK_COMMAND="${WEGENT_FILE_EDIT_HOOK_COMMAND:-$DEFAULT_FILE_EDIT_HOOK_COMMAND}" nohup "$BINARY_PATH" >> "$RUN_LOG" 2>&1 & echo $! > "$PID_FILE" diff --git a/executor/main.py b/executor/main.py index 7c4f4cdb54..a74e7548d7 100644 --- a/executor/main.py +++ b/executor/main.py @@ -9,11 +9,10 @@ """ Executor main entry point. -Supports two modes: -- Local mode: WebSocket-based executor for local deployment - - Configured via device-config.json (preferred) - - Falls back to EXECUTOR_MODE=local env var (deprecated) -- Docker mode (default): FastAPI server for container deployment +Supports three startup paths: +- Local sidecar mode (default): local app IPC over a sidecar socket, no Backend +- Local Backend mode: socket IPC plus WebSocket executor when WEGENT_BACKEND_URL is configured +- Docker mode: FastAPI server when EXECUTOR_MODE=docker is explicit CLI options: - --version, -v: Print version and exit @@ -24,12 +23,12 @@ import argparse import asyncio -import contextlib import logging import multiprocessing import os import sys from pathlib import Path +from typing import Any def _handle_upgrade_flag(args: argparse.Namespace) -> int: @@ -101,16 +100,6 @@ def _parse_args() -> argparse.Namespace: type=str, help="Path to config file (default: ~/.wegent-executor/device-config.json)", ) - parser.add_argument( - "--app-ipc", - action="store_true", - help="Serve newline-delimited app IPC on stdin/stdout", - ) - parser.add_argument( - "--no-backend", - action="store_true", - help="Disable the Backend WebSocket channel when app IPC is enabled", - ) parser.add_argument( "-h", "--help", action="store_true", help="Show this help message and exit" ) @@ -120,59 +109,171 @@ def _parse_args() -> argparse.Namespace: return args -def _configure_app_ipc_stdio() -> None: - """Keep stdout reserved for app IPC JSON messages.""" - os.environ["WEGENT_LOG_TO_STDERR"] = "1" +def _load_device_config_for_mode(config_path: str | None): + from executor.config.device_config import load_device_config - loggers = [logging.getLogger()] - for candidate in logging.Logger.manager.loggerDict.values(): - if isinstance(candidate, logging.Logger): - loggers.append(candidate) + return load_device_config(config_path) - for current_logger in loggers: - for handler in current_logger.handlers: - _redirect_stream_handler(handler) - listener = getattr(current_logger, "_queue_listener", None) - for handler in getattr(listener, "handlers", []) or []: - _redirect_stream_handler(handler) +def _configure_local_file_logging() -> None: + """Write local sidecar executor logs to the standard local executor log file.""" + import executor.config.config as config + from shared.logger import configure_file_logging + + log_dir = Path(config.WEGENT_EXECUTOR_LOG_DIR).expanduser() + log_file = log_dir / config.WEGENT_EXECUTOR_LOG_FILE + max_bytes = config.WEGENT_EXECUTOR_LOG_MAX_SIZE * 1024 * 1024 + backup_count = config.WEGENT_EXECUTOR_LOG_BACKUP_COUNT + log_level = ( + logging.DEBUG + if os.environ.get("LOG_LEVEL", "").upper() == "DEBUG" + else logging.INFO + ) + + configure_file_logging( + str(log_file), + max_bytes=max_bytes, + backup_count=backup_count, + level=log_level, + ) + logger.info("File logging enabled: %s", log_file) + + +def _read_existing_device_config(config_path: str | None) -> dict[str, Any]: + from executor.config.device_config import _get_default_config_path + + path = Path(config_path) if config_path else _get_default_config_path() + if not path.exists(): + return {} -def _redirect_stream_handler(handler: logging.Handler) -> None: - if not isinstance(handler, logging.StreamHandler): - return try: - handler.setStream(sys.stderr) + import json + + with open(path, "r", encoding="utf-8") as file: + data = json.load(file) except Exception: - handler.stream = sys.stderr + return {} + return data if isinstance(data, dict) else {} -def _load_device_config_for_mode(config_path: str | None, *, quiet_stdout: bool): - from executor.config.device_config import load_device_config - if quiet_stdout: - with contextlib.redirect_stdout(sys.stderr): - return load_device_config(config_path) - return load_device_config(config_path) +def _configured_backend_url(config_path: str | None) -> str: + env_url = os.environ.get("WEGENT_BACKEND_URL", "").strip() + if env_url: + return env_url + + connection = _read_existing_device_config(config_path).get("connection", {}) + if not isinstance(connection, dict): + return "" + backend_url = connection.get("backend_url") + return backend_url.strip() if isinstance(backend_url, str) else "" + + +def _configured_executor_mode(config_path: str | None) -> str: + env_mode = os.environ.get("EXECUTOR_MODE", "").strip().lower() + if env_mode: + return env_mode + + mode = _read_existing_device_config(config_path).get("mode") + return mode.strip().lower() if isinstance(mode, str) else "" + + +def _should_run_docker_server(config_path: str | None) -> bool: + return _configured_executor_mode(config_path) == "docker" + +def _should_run_local_mode(config_path: str | None) -> bool: + return not _should_run_docker_server(config_path) -async def _run_local_runner_with_app_ipc(runner, app_ipc_server) -> None: - """Run Backend LocalRunner and app IPC in one executor process.""" + +def _has_backend_connection(device_config: Any) -> bool: + return bool(getattr(device_config.connection, "backend_url", "").strip()) + + +def _should_connect_backend(device_config: Any) -> bool: + """Connect Backend when a remote address is configured.""" + return _has_backend_connection(device_config) + + +async def _run_local_runner_with_app_ipc( + runner, + app_ipc_server, +) -> None: + """Run Backend LocalRunner and local app IPC socket in one executor process.""" runtime_work_handler = getattr(runner, "runtime_work_handler", None) if runtime_work_handler is not None: await runtime_work_handler.start_codex_watcher() runner_task = asyncio.create_task(runner.start()) + runner_task.add_done_callback(_log_runner_task_result) await asyncio.sleep(0) try: - await app_ipc_server.serve() + await app_ipc_server.serve_forever() finally: app_ipc_server.stop() + await app_ipc_server.wait_closed() runner_task.cancel() await asyncio.gather(runner_task, return_exceptions=True) + if runtime_work_handler is not None: await runtime_work_handler.stop_codex_watcher() +def _log_runner_task_result(task: asyncio.Task) -> None: + if task.cancelled(): + return + try: + task.result() + except Exception: + logger.exception("Local backend runner stopped unexpectedly") + + +async def _run_backend_runner(device_config: Any, app_event_emitter: Any) -> None: + """Start the optional Backend runner without blocking local app IPC.""" + try: + from executor.modes.local.runner import LocalRunner + + runner = LocalRunner( + device_config=device_config, + app_event_emitter=app_event_emitter, + ) + await runner.start() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Optional Backend runner failed") + + +async def _run_app_ipc_sidecar_with_backend( + device_config: Any, + app_ipc_server: Any, +) -> None: + """Run app IPC immediately and attach Backend connectivity in the background.""" + from executor.modes.local.app_ipc import _attach_runtime_work_handler + + if not await app_ipc_server.start(): + return + + runtime_handler_task = asyncio.create_task( + _attach_runtime_work_handler(app_ipc_server) + ) + + backend_task = asyncio.create_task( + _run_backend_runner(device_config, app_ipc_server.emit_event) + ) + backend_task.add_done_callback(_log_runner_task_result) + + try: + await app_ipc_server.wait_serving() + finally: + app_ipc_server.stop() + await app_ipc_server.wait_closed() + backend_task.cancel() + runtime_handler_task.cancel() + await asyncio.gather(backend_task, return_exceptions=True) + await asyncio.gather(runtime_handler_task, return_exceptions=True) + + def main() -> None: """ Main function for running the executor. @@ -182,15 +283,13 @@ def main() -> None: 2. ~/.wegent-executor/device-config.json (default path) 3. EXECUTOR_MODE environment variable (deprecated, for backward compatibility) - In local mode, starts the WebSocket-based local runner. - In Docker mode (default), starts the FastAPI server. + Without a Backend URL, starts the local app IPC sidecar socket. + With a Backend URL, starts the WebSocket-based local runner. + In explicit Docker mode, starts the FastAPI server. """ # Parse arguments first args = _parse_args() - if args.app_ipc: - _configure_app_ipc_stdio() - # Handle version flag first (before any heavy initialization) if args.version: from executor.version import get_version @@ -202,43 +301,42 @@ def main() -> None: if args.upgrade: sys.exit(_handle_upgrade_flag(args)) - from executor.config.device_config import ( - get_config_path_from_args, - should_use_local_mode, - ) + from executor.config.device_config import get_config_path_from_args # Get config path from command line arguments config_path = get_config_path_from_args() # Determine if we should run in local mode - if args.app_ipc or should_use_local_mode(config_path): + if _should_run_local_mode(config_path): # Local mode: Run WebSocket-based executor # Load full configuration for local mode try: - device_config = _load_device_config_for_mode( - config_path, - quiet_stdout=args.app_ipc, - ) + device_config = _load_device_config_for_mode(config_path) # Sync device config values to global config for modules that read # from config directly. device_config already has env overrides applied. from executor.config.config import sync_device_config sync_device_config(device_config) + _configure_local_file_logging() import executor.config.config as config - if args.app_ipc and args.no_backend: - from executor.modes.local.app_ipc import run_app_ipc + backend_enabled = _should_connect_backend(device_config) + + if not backend_enabled: + from executor.modes.local.app_ipc import run_app_ipc_sidecar - logger.info("Starting executor in APP IPC mode without Backend") + logger.info( + "Starting executor in local app IPC sidecar mode without Backend" + ) asyncio.run( - run_app_ipc(device_id=device_config.device_id or "local-device") + run_app_ipc_sidecar( + device_id=device_config.device_id or "local-device" + ) ) return - from executor.modes.local.runner import LocalRunner - logger.info("Starting executor in LOCAL mode") logger.info(f"Device ID: {device_config.device_id}") logger.info(f"Device Name: {device_config.device_name}") @@ -247,20 +345,17 @@ def main() -> None: f"Auth Token: {'***' if config.WEGENT_AUTH_TOKEN else 'NOT SET'}" ) - runner = LocalRunner(device_config=device_config) - if args.app_ipc: - from executor.modes.local.app_ipc import AppIpcServer + from executor.modes.local.app_ipc import AppIpcServer - app_ipc_server = AppIpcServer( - device_id=device_config.device_id or "local-device", - runtime_work_handler=runner.runtime_work_handler, - command_handler=runner.command_handler, - ) - runner.set_app_event_emitter(app_ipc_server.emit_event) - logger.info("Starting executor with Backend and APP IPC channels") - asyncio.run(_run_local_runner_with_app_ipc(runner, app_ipc_server)) - else: - asyncio.run(runner.start()) + app_ipc_server = AppIpcServer( + device_id=device_config.device_id or "local-device", + ) + logger.info( + "Starting app IPC sidecar socket with optional Backend runner in background" + ) + asyncio.run( + _run_app_ipc_sidecar_with_backend(device_config, app_ipc_server) + ) except FileNotFoundError as e: logger.error(f"Configuration error: {e}") sys.exit(1) @@ -268,7 +363,14 @@ def main() -> None: logger.exception(f"Failed to start local mode: {e}") sys.exit(1) else: - # Docker mode (default): Run FastAPI server + if not _should_run_docker_server(config_path): + logger.error( + "Executor mode is not configured for Docker. Set EXECUTOR_MODE=docker " + "to start the FastAPI executor server." + ) + sys.exit(1) + + # Docker mode: Run FastAPI server # Import FastAPI dependencies only in Docker mode import uvicorn diff --git a/executor/modes/local/app_ipc.py b/executor/modes/local/app_ipc.py index 867619ff6a..c78f6bc2d3 100644 --- a/executor/modes/local/app_ipc.py +++ b/executor/modes/local/app_ipc.py @@ -2,24 +2,28 @@ # # SPDX-License-Identifier: Apache-2.0 -"""Newline-delimited JSON-RPC bridge between WeWork and the local executor.""" +"""Local app IPC socket server for WeWork and the local executor sidecar.""" from __future__ import annotations import asyncio +import contextlib import json +import os import shlex -import sys from dataclasses import dataclass -from typing import Any, TextIO +from pathlib import Path +from typing import Any from executor.modes.local.command_handler import CommandHandler -from executor.runtime_work.rpc_handler import RuntimeWorkRpcHandler +from executor.version import get_version from shared.logger import setup_logger logger = setup_logger("local_app_ipc") DEFAULT_DEVICE_ID = "local-device" +DEFAULT_SOCKET_NAME = "app-ipc.sock" +DEFAULT_LOCK_NAME = "app-ipc.lock" DEFAULT_TIMEOUT_SECONDS = 60 DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024 @@ -64,47 +68,104 @@ class LocalAppCommandDefinition: } +def app_ipc_socket_path() -> Path: + """Return the local executor app IPC socket path.""" + override = os.environ.get("WEGENT_EXECUTOR_APP_IPC_SOCKET", "").strip() + if override: + return Path(override).expanduser() + + from executor.config.config import WEGENT_EXECUTOR_HOME + + return Path(WEGENT_EXECUTOR_HOME).expanduser() / DEFAULT_SOCKET_NAME + + class AppIpcServer: - """Serve app-originated JSON-RPC requests over stdin/stdout.""" + """Serve app-originated JSON-RPC requests over the local sidecar socket.""" def __init__( self, *, - input_stream: TextIO | None = None, - output: TextIO | None = None, + socket_path: str | Path | None = None, runtime_work_handler: Any | None = None, command_handler: CommandHandler | None = None, device_id: str = DEFAULT_DEVICE_ID, ) -> None: - self.input_stream = input_stream or sys.stdin - self.output = output or sys.stdout + self.socket_path = Path(socket_path) if socket_path else app_ipc_socket_path() self.runtime_work_handler = runtime_work_handler self.command_handler = command_handler or CommandHandler() self.device_id = device_id or DEFAULT_DEVICE_ID - self._write_lock = asyncio.Lock() + self._server: asyncio.AbstractServer | None = None + self._clients: set[asyncio.StreamWriter] = set() + self._clients_lock = asyncio.Lock() + self._lock_file: Any | None = None + self._socket_stat: os.stat_result | None = None + self._runtime_handler_ready = asyncio.Event() + if self.runtime_work_handler is not None: + self._runtime_handler_ready.set() self._running = False - async def serve(self) -> None: - """Read request lines until stdin closes.""" - self._running = True - await self.emit_event( - "executor.ready", - {"device_id": self.device_id, "ready": True}, - ) - while self._running: - line = await asyncio.to_thread(self.input_stream.readline) - if line == "": - break - await self.handle_line(line) + def set_runtime_work_handler(self, runtime_work_handler: Any) -> None: + """Attach the runtime work handler after socket startup.""" + self.runtime_work_handler = runtime_work_handler + self._runtime_handler_ready.set() + + async def start(self) -> bool: + """Start listening on the sidecar socket. + + Returns False when another executor already owns the singleton lock. + """ + if not self._acquire_sidecar_lock(): + logger.info("Local app IPC sidecar socket is already running") + return False + + try: + self._running = True + self._prepare_socket_path() + self._server = await asyncio.start_unix_server( + self._handle_client, + path=str(self.socket_path), + ) + os.chmod(self.socket_path, 0o600) + with contextlib.suppress(OSError): + self._socket_stat = self.socket_path.stat() + logger.info( + "Local app IPC sidecar socket listening on %s", self.socket_path + ) + return True + except Exception: + self.stop() + raise + + async def serve_forever(self) -> None: + """Listen on the sidecar socket until cancelled or stopped.""" + if not await self.start(): + return + await self.wait_serving() + + async def wait_serving(self) -> None: + """Wait while the socket server accepts clients.""" + if self._server is None: + return + async with self._server: + await self._server.serve_forever() def stop(self) -> None: - """Stop the request loop after the current line is processed.""" + """Stop accepting socket clients.""" self._running = False + if self._server is not None: + self._server.close() + self._remove_socket_path() + self._release_sidecar_lock() - async def handle_line(self, line: str) -> None: + async def wait_closed(self) -> None: + """Wait for the socket server to close.""" + if self._server is not None: + await self._server.wait_closed() + + async def handle_line(self, line: str) -> dict[str, Any] | None: """Parse and dispatch one newline-delimited app IPC message.""" if not line.strip(): - return + return None request_id: str | None = None try: @@ -127,21 +188,14 @@ async def handle_line(self, line: str) -> None: raise AppIpcError("invalid_request", "Request params must be an object") result = await self.dispatch(method.strip(), params) - await self._write( - { - "type": "response", - "id": request_id, - "ok": True, - "result": result, - } - ) + return self._response_message(request_id, result) except AppIpcError as exc: - await self._write_error(request_id, exc.code, exc.message) + return self._error_message(request_id, exc.code, exc.message) except json.JSONDecodeError as exc: - await self._write_error(request_id, "invalid_json", str(exc)) + return self._error_message(request_id, "invalid_json", str(exc)) except Exception as exc: logger.exception("Unhandled app IPC request error") - await self._write_error(request_id, "internal_error", str(exc)) + return self._error_message(request_id, "internal_error", str(exc)) async def dispatch(self, method: str, params: dict[str, Any]) -> Any: """Dispatch one app IPC request to runtime work or device command handlers.""" @@ -152,6 +206,16 @@ async def dispatch(self, method: str, params: dict[str, Any]) -> Any: method = "runtime.tasks.send" if method.startswith("runtime."): + if self.runtime_work_handler is None: + try: + await asyncio.wait_for( + self._runtime_handler_ready.wait(), timeout=30 + ) + except asyncio.TimeoutError as exc: + raise AppIpcError( + "runtime_unavailable", + "Runtime work handler is not available", + ) from exc if self.runtime_work_handler is None: raise AppIpcError( "runtime_unavailable", @@ -164,16 +228,187 @@ async def dispatch(self, method: str, params: dict[str, Any]) -> Any: raise AppIpcError("unsupported_method", f"Unsupported app IPC method: {method}") async def emit_event(self, event: str, payload: dict[str, Any]) -> None: - """Emit one app IPC event line.""" + """Broadcast one app IPC event to every connected client.""" + message = self._event_message(event, payload) + await self._broadcast(message) + + def _prepare_socket_path(self) -> None: + self.socket_path.parent.mkdir(parents=True, exist_ok=True) + self._remove_socket_path(force=True) + + def _lock_path(self) -> Path: + return self.socket_path.with_name(DEFAULT_LOCK_NAME) + + def _acquire_sidecar_lock(self) -> bool: + """Acquire the local executor singleton lock for this user home.""" + self.socket_path.parent.mkdir(parents=True, exist_ok=True) + lock_path = self._lock_path() + lock_file = open(lock_path, "a+", encoding="utf-8") + try: + if os.name != "nt": + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + else: + import msvcrt + + msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1) + except OSError: + lock_file.close() + return False + + lock_file.seek(0) + lock_file.truncate() + lock_file.write(str(os.getpid())) + lock_file.flush() + self._lock_file = lock_file + return True + + def _release_sidecar_lock(self) -> None: + lock_file = self._lock_file + self._lock_file = None + if lock_file is None: + return + try: + if os.name != "nt": + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + else: + import msvcrt + + msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) + except OSError: + pass + finally: + lock_file.close() + + def _remove_socket_path(self, *, force: bool = False) -> None: + expected_stat = self._socket_stat + if not force and expected_stat is None: + return + try: + current_stat = self.socket_path.stat() + if ( + not force + and expected_stat is not None + and ( + current_stat.st_ino != expected_stat.st_ino + or current_stat.st_dev != expected_stat.st_dev + ) + ): + return + self.socket_path.unlink() + except FileNotFoundError: + return + except OSError as exc: + logger.warning( + "Failed to remove app IPC socket %s: %s", self.socket_path, exc + ) + finally: + if not force: + self._socket_stat = None + + async def _handle_client( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + async with self._clients_lock: + self._clients.add(writer) + + peer = writer.get_extra_info("peername") + logger.info("Local app IPC client connected: %s", peer or "local") + try: + await self._write_to_client( + writer, + self._event_message( + "executor.ready", + { + "device_id": self.device_id, + "ready": True, + "version": get_version(), + }, + ), + ) + while self._running: + line = await reader.readline() + if not line: + break + response = await self.handle_line( + line.decode("utf-8", errors="replace") + ) + if response is not None: + await self._write_to_client(writer, response) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning("Local app IPC client disconnected with error: %s", exc) + finally: + async with self._clients_lock: + self._clients.discard(writer) + writer.close() + with contextlib.suppress(Exception): + await writer.wait_closed() + + async def _broadcast(self, message: dict[str, Any]) -> None: + async with self._clients_lock: + clients = list(self._clients) + + stale_clients: list[asyncio.StreamWriter] = [] + for writer in clients: + try: + await self._write_to_client(writer, message) + except Exception as exc: + logger.warning("Failed to write app IPC event to client: %s", exc) + stale_clients.append(writer) + + if stale_clients: + async with self._clients_lock: + for writer in stale_clients: + self._clients.discard(writer) + + async def _write_to_client( + self, + writer: asyncio.StreamWriter, + message: dict[str, Any], + ) -> None: + line = json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n" + writer.write(line.encode("utf-8")) + await writer.drain() + + def _response_message(self, request_id: str, result: Any) -> dict[str, Any]: + return { + "type": "response", + "id": request_id, + "ok": True, + "result": result, + } + + def _error_message( + self, + request_id: str | None, + code: str, + message: str, + ) -> dict[str, Any]: + return { + "type": "response", + "id": request_id, + "ok": False, + "error": { + "code": code, + "message": message, + }, + } + + def _event_message(self, event: str, payload: dict[str, Any]) -> dict[str, Any]: normalized_payload = dict(payload) normalized_payload.setdefault("device_id", self.device_id) - await self._write( - { - "type": "event", - "event": event, - "payload": normalized_payload, - } - ) + return { + "type": "event", + "event": event, + "payload": normalized_payload, + } async def _handle_device_command(self, params: dict[str, Any]) -> dict[str, Any]: command_key = params.get("command_key") @@ -214,30 +449,6 @@ def _request_id(self, message: dict[str, Any]) -> str: raise AppIpcError("invalid_request", "Request id is required") return request_id - async def _write_error( - self, - request_id: str | None, - code: str, - message: str, - ) -> None: - await self._write( - { - "type": "response", - "id": request_id, - "ok": False, - "error": { - "code": code, - "message": message, - }, - } - ) - - async def _write(self, message: dict[str, Any]) -> None: - line = json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n" - async with self._write_lock: - self.output.write(line) - self.output.flush() - def _build_argv(self, command: str, args: list[str]) -> list[str]: argv = shlex.split(command) if not argv: @@ -350,16 +561,32 @@ def _json_result(self, result: dict[str, Any]) -> dict[str, Any]: return processed -async def run_app_ipc(device_id: str = DEFAULT_DEVICE_ID) -> None: - """Run the standalone app IPC server without a Backend channel.""" +async def run_app_ipc_sidecar(device_id: str = DEFAULT_DEVICE_ID) -> None: + """Run the app IPC socket server without a Backend channel.""" server = AppIpcServer(device_id=device_id) + if not await server.start(): + return + + runtime_handler_task = asyncio.create_task(_attach_runtime_work_handler(server)) + try: + await server.wait_serving() + finally: + server.stop() + await server.wait_closed() + runtime_handler_task.cancel() + await asyncio.gather(runtime_handler_task, return_exceptions=True) + + +async def _attach_runtime_work_handler(server: AppIpcServer) -> None: + """Create and run the runtime work handler after the socket is available.""" + from executor.runtime_work.rpc_handler import RuntimeWorkRpcHandler + runtime_work_handler = RuntimeWorkRpcHandler( responses_event_emitter=server.emit_event, ) - server.runtime_work_handler = runtime_work_handler - - await runtime_work_handler.start_codex_watcher() + server.set_runtime_work_handler(runtime_work_handler) try: - await server.serve() + await runtime_work_handler.start_codex_watcher() + await asyncio.Event().wait() finally: await runtime_work_handler.stop_codex_watcher() diff --git a/executor/modes/local/runner.py b/executor/modes/local/runner.py index 30d38b790b..947090556b 100644 --- a/executor/modes/local/runner.py +++ b/executor/modes/local/runner.py @@ -783,6 +783,10 @@ def _setup_file_logging(self) -> None: max_bytes = config.WEGENT_EXECUTOR_LOG_MAX_SIZE * 1024 * 1024 backup_count = config.WEGENT_EXECUTOR_LOG_BACKUP_COUNT + if os.environ.get("WEGENT_LOG_FILE_PATH") == log_file: + logger.info("File logging already enabled: %s", log_file) + return + file_handler = RotatingFileHandler( log_file, maxBytes=max_bytes, @@ -811,6 +815,10 @@ def _setup_file_logging(self) -> None: "websocket_client", "local_heartbeat", "local_handlers", + "local_app_ipc", + "local_command_handler", + "runtime_work_rpc_handler", + "codex_session_discovery", "task_executor", "executor.config.config_loader", # Agent and download loggers diff --git a/executor/scripts/local_executor_install.ps1 b/executor/scripts/local_executor_install.ps1 index f4293f8aaf..6b799555f0 100644 --- a/executor/scripts/local_executor_install.ps1 +++ b/executor/scripts/local_executor_install.ps1 @@ -393,14 +393,12 @@ function Show-Usage { Write-Host "" Write-Host "To run wegent-executor, use PowerShell:" Write-Host "" - Write-Host ' $env:EXECUTOR_MODE="local"' -ForegroundColor Yellow Write-Host ' $env:WEGENT_BACKEND_URL=""' -ForegroundColor Yellow Write-Host ' $env:WEGENT_AUTH_TOKEN=""' -ForegroundColor Yellow Write-Host " & `"$InstallDir\$BinaryName`"" -ForegroundColor Yellow Write-Host "" Write-Host "Or use Command Prompt (cmd):" -ForegroundColor Blue Write-Host "" - Write-Host " set EXECUTOR_MODE=local" -ForegroundColor Yellow Write-Host " set WEGENT_BACKEND_URL=" -ForegroundColor Yellow Write-Host " set WEGENT_AUTH_TOKEN=" -ForegroundColor Yellow Write-Host " wegent-executor" -ForegroundColor Yellow diff --git a/executor/scripts/local_executor_install.sh b/executor/scripts/local_executor_install.sh index 2310a4659f..0d4fa4af95 100755 --- a/executor/scripts/local_executor_install.sh +++ b/executor/scripts/local_executor_install.sh @@ -593,7 +593,6 @@ print_usage_instructions() { echo "" echo "To run wegent-executor, use the following command:" echo "" - echo -e "${YELLOW}EXECUTOR_MODE=local \\\\${NC}" echo -e "${YELLOW}WEGENT_BACKEND_URL= \\\\${NC}" echo -e "${YELLOW}WEGENT_AUTH_TOKEN= \\\\${NC}" echo -e "${YELLOW}${INSTALL_DIR}/${BINARY_NAME}${NC}" diff --git a/executor/tests/scripts/test_local_sh.py b/executor/tests/scripts/test_local_sh.py index 7ee14774c2..ec0e228fd0 100644 --- a/executor/tests/scripts/test_local_sh.py +++ b/executor/tests/scripts/test_local_sh.py @@ -18,10 +18,11 @@ def test_local_sh_allows_device_config_connection_values(): assert "overrides device-config.json" in script -def test_local_sh_does_not_override_existing_device_config_mode(): +def test_local_sh_does_not_set_executor_mode_for_local_defaults(): script = (Path(__file__).resolve().parents[2] / "local.sh").read_text( encoding="utf-8" ) assert 'export EXECUTOR_MODE="${EXECUTOR_MODE:-local}"' not in script - assert 'if [[ ! -f "$DEVICE_CONFIG_PATH" && -z "${EXECUTOR_MODE:-}" ]]' in script + assert "export EXECUTOR_MODE=local" not in script + assert "DEVICE_CONFIG_PATH" not in script diff --git a/executor/tests/test_local_app_ipc.py b/executor/tests/test_local_app_ipc.py index d4ca9a8046..5ac525c00d 100644 --- a/executor/tests/test_local_app_ipc.py +++ b/executor/tests/test_local_app_ipc.py @@ -2,24 +2,20 @@ # # SPDX-License-Identifier: Apache-2.0 -"""Tests for the local app IPC stdio bridge.""" +"""Tests for the local app IPC sidecar socket.""" import asyncio -import io import json +import os import sys from unittest.mock import patch import pytest -from executor.modes.local.app_ipc import AppIpcServer +from executor.modes.local.app_ipc import AppIpcServer, app_ipc_socket_path from executor.modes.local.runner import LocalRunner -def _json_lines(output: io.StringIO) -> list[dict]: - return [json.loads(line) for line in output.getvalue().splitlines()] - - @pytest.mark.asyncio async def test_app_ipc_routes_runtime_rpc_request(): class RuntimeHandler: @@ -30,13 +26,11 @@ async def handle_runtime_rpc(self, data): } return {"success": True, "workspaces": []} - output = io.StringIO() server = AppIpcServer( - output=output, runtime_work_handler=RuntimeHandler(), ) - await server.handle_line( + response = await server.handle_line( json.dumps( { "type": "request", @@ -47,37 +41,32 @@ async def handle_runtime_rpc(self, data): ) ) - assert _json_lines(output) == [ - { - "type": "response", - "id": "req-1", - "ok": True, - "result": {"success": True, "workspaces": []}, - } - ] + assert response == { + "type": "response", + "id": "req-1", + "ok": True, + "result": {"success": True, "workspaces": []}, + } @pytest.mark.asyncio async def test_app_ipc_emits_runtime_events_with_device_id(): - output = io.StringIO() - server = AppIpcServer(output=output, device_id="device-1") + server = AppIpcServer(device_id="device-1") - await server.emit_event( + event = server._event_message( "response.output_text.delta", {"local_task_id": "task-1", "data": {"delta": "hi"}}, ) - assert _json_lines(output) == [ - { - "type": "event", - "event": "response.output_text.delta", - "payload": { - "device_id": "device-1", - "local_task_id": "task-1", - "data": {"delta": "hi"}, - }, - } - ] + assert event == { + "type": "event", + "event": "response.output_text.delta", + "payload": { + "device_id": "device-1", + "local_task_id": "task-1", + "data": {"delta": "hi"}, + }, + } @pytest.mark.asyncio @@ -100,13 +89,11 @@ async def handle_execute_command(self, data): } command_handler = CommandHandler() - output = io.StringIO() server = AppIpcServer( - output=output, command_handler=command_handler, ) - await server.handle_line( + response = await server.handle_line( json.dumps( { "type": "request", @@ -130,15 +117,15 @@ async def handle_execute_command(self, data): "timeout_seconds": 10, "max_output_bytes": 4096, } - assert _json_lines(output)[0]["result"]["stdout"] == ["src"] + assert response is not None + assert response["result"]["stdout"] == ["src"] @pytest.mark.asyncio async def test_app_ipc_unknown_method_returns_protocol_error(): - output = io.StringIO() - server = AppIpcServer(output=output) + server = AppIpcServer() - await server.handle_line( + response = await server.handle_line( json.dumps( { "type": "request", @@ -149,19 +136,85 @@ async def test_app_ipc_unknown_method_returns_protocol_error(): ) ) - response = _json_lines(output)[0] + assert response is not None assert response["ok"] is False assert response["error"]["code"] == "unsupported_method" -def test_executor_cli_parses_app_ipc_flags(): +def test_executor_cli_has_no_app_ipc_transport_flags(): from executor.main import _parse_args - with patch.object(sys, "argv", ["main.py", "--app-ipc", "--no-backend"]): + with patch.object(sys, "argv", ["main.py"]): args = _parse_args() - assert args.app_ipc is True - assert args.no_backend is True + assert not hasattr(args, "app_ipc") + assert not hasattr(args, "no_backend") + + +def test_executor_defaults_to_local_sidecar_without_backend_config(tmp_path): + from executor.main import ( + _should_run_docker_server, + _should_run_local_mode, + ) + + config_path = tmp_path / "missing-device-config.json" + with patch.dict(os.environ, {}, clear=True): + assert _should_run_docker_server(str(config_path)) is False + assert _should_run_local_mode(str(config_path)) is True + + +def test_executor_uses_local_sidecar_when_backend_url_is_configured(tmp_path): + from executor.main import _should_run_local_mode + + config_path = tmp_path / "device-config.json" + config_path.write_text( + json.dumps( + { + "mode": "local", + "connection": { + "backend_url": "https://wegent.example.com", + "auth_token": "token", + }, + } + ), + encoding="utf-8", + ) + + with patch.dict(os.environ, {}, clear=True): + assert _should_run_local_mode(str(config_path)) is True + + +def test_executor_backend_url_env_selects_local_sidecar_without_config(tmp_path): + from executor.main import _should_run_local_mode + + config_path = tmp_path / "missing-device-config.json" + with patch.dict( + os.environ, + {"WEGENT_BACKEND_URL": "https://wegent.example.com"}, + clear=True, + ): + assert _should_run_local_mode(str(config_path)) is True + + +def test_executor_docker_mode_disables_default_local_sidecar(tmp_path): + from executor.main import ( + _should_run_docker_server, + _should_run_local_mode, + ) + + config_path = tmp_path / "device-config.json" + config_path.write_text(json.dumps({"mode": "docker"}), encoding="utf-8") + + with patch.dict(os.environ, {}, clear=True): + assert _should_run_docker_server(str(config_path)) is True + assert _should_run_local_mode(str(config_path)) is False + + +def test_app_ipc_socket_path_can_be_overridden(tmp_path): + socket_path = tmp_path / "executor.sock" + + with patch.dict(os.environ, {"WEGENT_EXECUTOR_APP_IPC_SOCKET": str(socket_path)}): + assert app_ipc_socket_path() == socket_path @pytest.mark.asyncio @@ -191,7 +244,7 @@ async def emit_app(event, payload): @pytest.mark.asyncio -async def test_app_ipc_runner_cancels_local_runner_when_app_channel_closes(): +async def test_app_ipc_runner_cancels_local_runner_when_socket_server_stops(): from executor.main import _run_local_runner_with_app_ipc events = [] @@ -209,9 +262,18 @@ class Server: def stop(self): events.append("server:stop") - async def serve(self): - events.append("server:serve") + async def wait_closed(self): + events.append("server:closed") + + async def serve_forever(self): + events.append("server:serve_forever") await _run_local_runner_with_app_ipc(Runner(), Server()) - assert events == ["runner:start", "server:serve", "server:stop", "runner:cancelled"] + assert events == [ + "runner:start", + "server:serve_forever", + "server:stop", + "server:closed", + "runner:cancelled", + ] diff --git a/frontend/e2e/fixtures/claudecode-executor/Dockerfile b/frontend/e2e/fixtures/claudecode-executor/Dockerfile index a859073fa0..c4782e2315 100644 --- a/frontend/e2e/fixtures/claudecode-executor/Dockerfile +++ b/frontend/e2e/fixtures/claudecode-executor/Dockerfile @@ -20,5 +20,6 @@ ENV NODE_PATH=/usr/lib/node_modules ENV PYTHONPATH=/workspace/src ENV PYTHONUNBUFFERED=1 ENV PORT=10001 +ENV EXECUTOR_MODE=docker CMD ["/app/executor"] diff --git a/packages/chat-core/src/socket/authenticatedSocketClient.test.ts b/packages/chat-core/src/socket/authenticatedSocketClient.test.ts index 938a93bb77..2f9dd0ff9a 100644 --- a/packages/chat-core/src/socket/authenticatedSocketClient.test.ts +++ b/packages/chat-core/src/socket/authenticatedSocketClient.test.ts @@ -87,6 +87,27 @@ describe('createAuthenticatedSocketClient', () => { expect(rawSocket.socket.connect).toHaveBeenCalledTimes(1) }) + test('merges custom socket auth metadata with token auth', async () => { + const rawSocket = createMockSocket() + mockIo.mockReturnValue(rawSocket.socket) + const client = createAuthenticatedSocketClient({ + socketBaseUrl: () => 'http://socket', + path: '/socket.io', + namespace: '/chat', + getToken: () => 'token', + auth: { client_origin: 'wework' }, + }) + + await client.connect() + + expect(mockIo).toHaveBeenCalledWith( + 'http://socket/chat', + expect.objectContaining({ + auth: { client_origin: 'wework', token: 'token' }, + }) + ) + }) + test('recreates a fresh authenticated socket after connect_error', async () => { vi.useFakeTimers() const firstSocket = createMockSocket() diff --git a/packages/chat-core/src/socket/authenticatedSocketClient.ts b/packages/chat-core/src/socket/authenticatedSocketClient.ts index d468e98859..504ae6123e 100644 --- a/packages/chat-core/src/socket/authenticatedSocketClient.ts +++ b/packages/chat-core/src/socket/authenticatedSocketClient.ts @@ -46,6 +46,7 @@ export interface AuthenticatedSocketClientOptions { authErrorEvent?: string onAuthError?: (error: unknown) => void isAuthError?: (error: Error) => boolean + auth?: Record logger?: Pick } @@ -259,7 +260,7 @@ class AuthenticatedSocketClientImpl implements AuthenticatedSocketClient { const socket = io(joinNamespace(socketBaseUrl, this.options.namespace), { path: this.options.path, - auth: { token }, + auth: { ...this.options.auth, token }, autoConnect: false, reconnection: false, transports: this.options.transports, diff --git a/shared/logger.py b/shared/logger.py index fe49f084d4..f646ed8871 100644 --- a/shared/logger.py +++ b/shared/logger.py @@ -17,7 +17,7 @@ import multiprocessing import os import sys -from logging.handlers import QueueHandler, QueueListener +from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler from typing import Optional @@ -73,6 +73,96 @@ def emit(self, record): pass +_FILE_HANDLER: Optional[RotatingFileHandler] = None +_FILE_HANDLER_PATH: Optional[str] = None + + +def _get_int_env(name: str, default: int) -> int: + value = os.environ.get(name) + if value is None: + return default + try: + return int(value) + except ValueError: + return default + + +def _logger_has_handler(logger: logging.Logger, handler: logging.Handler) -> bool: + return any(existing is handler for existing in logger.handlers) + + +def _file_log_handler( + *, + level: int, + format: str, + datefmt: str, + include_request_id: bool, +) -> Optional[RotatingFileHandler]: + """Return the shared rotating file handler when file logging is configured.""" + global _FILE_HANDLER, _FILE_HANDLER_PATH + + log_file = os.environ.get("WEGENT_LOG_FILE_PATH", "").strip() + if not log_file: + return None + + if _FILE_HANDLER is not None and _FILE_HANDLER_PATH != log_file: + try: + _FILE_HANDLER.close() + finally: + _FILE_HANDLER = None + _FILE_HANDLER_PATH = None + + if _FILE_HANDLER is None: + os.makedirs(os.path.dirname(log_file), exist_ok=True) + max_bytes = _get_int_env("WEGENT_LOG_FILE_MAX_BYTES", 10 * 1024 * 1024) + backup_count = _get_int_env("WEGENT_LOG_FILE_BACKUP_COUNT", 5) + handler = RotatingFileHandler( + log_file, + maxBytes=max_bytes, + backupCount=backup_count, + encoding="utf-8", + ) + handler.setFormatter(logging.Formatter(format, datefmt)) + if include_request_id: + handler.addFilter(RequestIdFilter()) + _FILE_HANDLER = handler + _FILE_HANDLER_PATH = log_file + + _FILE_HANDLER.setLevel(level) + return _FILE_HANDLER + + +def configure_file_logging( + log_file: str, + *, + max_bytes: int = 10 * 1024 * 1024, + backup_count: int = 5, + level: int = logging.INFO, +) -> None: + """Enable shared rotating file logging for existing and future loggers.""" + os.environ["WEGENT_LOG_FILE_PATH"] = log_file + os.environ["WEGENT_LOG_FILE_MAX_BYTES"] = str(max_bytes) + os.environ["WEGENT_LOG_FILE_BACKUP_COUNT"] = str(backup_count) + + handler = _file_log_handler( + level=level, + format="%(asctime)s - [%(request_id)s] - [%(name)s] - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + include_request_id=True, + ) + if handler is None: + return + + loggers = [logging.getLogger()] + for candidate in logging.Logger.manager.loggerDict.values(): + if isinstance(candidate, logging.Logger): + loggers.append(candidate) + + for current_logger in loggers: + if current_logger.handlers and not _logger_has_handler(current_logger, handler): + current_logger.addHandler(handler) + + def _stop_queue_listener_safely(listener: QueueListener) -> None: """Stop a QueueListener once, ignoring duplicate shutdown calls.""" if getattr(listener, "_thread", None) is None: @@ -128,8 +218,15 @@ def setup_logger( # Prevent adding duplicate handlers if logger.handlers: - # Logger already configured, just update level and return logger.setLevel(level) + file_handler = _file_log_handler( + level=level, + format=format, + datefmt=datefmt, + include_request_id=include_request_id, + ) + if file_handler is not None and not _logger_has_handler(logger, file_handler): + logger.addHandler(file_handler) return logger # Set logger level @@ -186,4 +283,13 @@ def setup_logger( logger.addHandler(console_handler) + file_handler = _file_log_handler( + level=level, + format=format, + datefmt=datefmt, + include_request_id=include_request_id, + ) + if file_handler is not None and not _logger_has_handler(logger, file_handler): + logger.addHandler(file_handler) + return logger diff --git a/wework/scripts/dev-mac-app.sh b/wework/scripts/dev-mac-app.sh index 724a9b18c5..6bf43131f9 100755 --- a/wework/scripts/dev-mac-app.sh +++ b/wework/scripts/dev-mac-app.sh @@ -103,6 +103,10 @@ fi export VITE_API_PROXY_TARGET="${VITE_API_PROXY_TARGET:-http://$LOCAL_IP:$BACKEND_PORT}" export VITE_SOCKET_PROXY_TARGET="${VITE_SOCKET_PROXY_TARGET:-${WEGENT_SOCKET_URL:-$VITE_API_PROXY_TARGET}}" +if [ -z "${WEWORK_EXECUTOR_SIDECAR:-}" ] && [ -x "$PROJECT_DIR/executor/dist/wegent-executor" ]; then + export WEWORK_EXECUTOR_SIDECAR="$PROJECT_DIR/executor/dist/wegent-executor" +fi + TAURI_DEV_CONFIG="$(mktemp -t wework-tauri-dev.XXXXXX.json)" trap 'rm -f "$TAURI_DEV_CONFIG"' EXIT @@ -124,6 +128,7 @@ echo "Starting WeWork mac app" echo " WEWORK_PORT=$WEWORK_PORT" echo " VITE_API_PROXY_TARGET=$VITE_API_PROXY_TARGET" echo " VITE_SOCKET_PROXY_TARGET=$VITE_SOCKET_PROXY_TARGET" +echo " WEWORK_EXECUTOR_SIDECAR=${WEWORK_EXECUTOR_SIDECAR:-}" if [ "${WEWORK_DRY_RUN:-}" = "1" ]; then echo " TAURI_DEV_CONFIG=$TAURI_DEV_CONFIG" diff --git a/wework/src-tauri/capabilities/default.json b/wework/src-tauri/capabilities/default.json index 62785b9b55..69373cf6f9 100644 --- a/wework/src-tauri/capabilities/default.json +++ b/wework/src-tauri/capabilities/default.json @@ -16,7 +16,7 @@ { "name": "binaries/wegent-executor", "sidecar": true, - "args": ["--app-ipc", "--no-backend"] + "args": [] } ] }, diff --git a/wework/src-tauri/src/lib.rs b/wework/src-tauri/src/lib.rs index 344fa799bb..7f88106207 100644 --- a/wework/src-tauri/src/lib.rs +++ b/wework/src-tauri/src/lib.rs @@ -1,6 +1,8 @@ mod local_executor; mod local_terminal; +use tauri::Manager; + fn normalized_non_empty(value: String) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { @@ -263,6 +265,17 @@ pub fn run() { .plugin(tauri_plugin_shell::init()) .manage(local_executor::LocalExecutorState::default()) .manage(local_terminal::LocalTerminalState::default()) + .on_window_event(|window, event| { + if matches!( + event, + tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed + ) { + let state = window + .app_handle() + .state::(); + local_executor::shutdown_local_executor(&state); + } + }) .setup(|app| { #[cfg(desktop)] if app diff --git a/wework/src-tauri/src/local_executor.rs b/wework/src-tauri/src/local_executor.rs index 21caced5da..9f6e264faa 100644 --- a/wework/src-tauri/src/local_executor.rs +++ b/wework/src-tauri/src/local_executor.rs @@ -1,33 +1,78 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write}; +#[cfg(unix)] +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; -use tauri::async_runtime::{channel, Sender}; -use tauri::{Emitter, State}; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread; +use std::time::Duration; +use tauri::{async_runtime::Mutex as AsyncMutex, Emitter, State}; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::ShellExt; const LOCAL_EXECUTOR_EVENT: &str = "local-executor:event"; const LOCAL_EXECUTOR_SIDECAR: &str = "binaries/wegent-executor"; +const LOCAL_EXECUTOR_SIDECAR_ENV: &str = "WEWORK_EXECUTOR_SIDECAR"; +const LOCAL_EXECUTOR_SOCKET_ENV: &str = "WEGENT_EXECUTOR_APP_IPC_SOCKET"; const LOCAL_EXECUTOR_DEVICE_ID: &str = "local-device"; +const LOCAL_EXECUTOR_SOCKET_NAME: &str = "app-ipc.sock"; +const LOCAL_EXECUTOR_CONNECT_RETRIES: usize = 120; +const LOCAL_EXECUTOR_CONNECT_RETRY_MS: u64 = 250; -type PendingSender = Sender>; +type PendingSender = mpsc::Sender>; type SharedExecutorInner = Arc>; pub struct LocalExecutorState { inner: SharedExecutorInner, next_id: AtomicU64, + start_lock: AsyncMutex<()>, } #[derive(Default)] struct LocalExecutorInner { - child: Option, + child: Option, pending: HashMap, running: bool, ready: bool, device_id: Option, + version: Option, error: Option, + generation: u64, + #[cfg(unix)] + stream: Option, +} + +enum LocalExecutorChild { + Tauri(CommandChild), + Process(Child), +} + +impl LocalExecutorChild { + fn is_running(&mut self) -> bool { + match self { + LocalExecutorChild::Tauri(_) => true, + LocalExecutorChild::Process(child) => match child.try_wait() { + Ok(Some(_)) => false, + Ok(None) => true, + Err(_) => false, + }, + } + } + + fn kill(self) { + match self { + LocalExecutorChild::Tauri(child) => { + let _ = child.kill(); + } + LocalExecutorChild::Process(mut child) => { + let _ = child.kill(); + } + } + } } impl Default for LocalExecutorState { @@ -35,10 +80,28 @@ impl Default for LocalExecutorState { Self { inner: Arc::new(Mutex::new(LocalExecutorInner::default())), next_id: AtomicU64::new(1), + start_lock: AsyncMutex::new(()), } } } +pub fn shutdown_local_executor(state: &LocalExecutorState) { + let child = state.inner.lock().ok().and_then(|mut inner| { + inner.running = false; + inner.ready = false; + inner.generation = inner.generation.saturating_add(1); + clear_connected_stream(&mut inner); + inner.error = Some("Local executor stopped".to_string()); + inner.child.take() + }); + + if let Some(child) = child { + child.kill(); + } + + fail_pending_requests_inner(&state.inner, "Local executor stopped".to_string()); +} + #[derive(Debug, Deserialize)] #[serde(tag = "type")] pub enum ExecutorLine { @@ -80,6 +143,7 @@ pub struct LocalExecutorStatus { ready: bool, #[serde(rename = "deviceId")] device_id: Option, + version: Option, error: Option, } @@ -92,11 +156,58 @@ fn next_request_id(state: &LocalExecutorState) -> String { format!("local-req-{id}") } +fn app_ipc_socket_path() -> Result { + if let Ok(path) = std::env::var(LOCAL_EXECUTOR_SOCKET_ENV) { + let trimmed = path.trim(); + if !trimmed.is_empty() { + return Ok(PathBuf::from(trimmed)); + } + } + + if let Ok(path) = std::env::var("WEGENT_EXECUTOR_HOME") { + let trimmed = path.trim(); + if !trimmed.is_empty() { + return Ok(PathBuf::from(trimmed).join(LOCAL_EXECUTOR_SOCKET_NAME)); + } + } + + let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?; + Ok(PathBuf::from(home) + .join(".wegent-executor") + .join(LOCAL_EXECUTOR_SOCKET_NAME)) +} + +fn configured_sidecar_path() -> Option { + std::env::var_os(LOCAL_EXECUTOR_SIDECAR_ENV) + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) +} + +fn has_connected_stream(inner: &LocalExecutorInner) -> bool { + #[cfg(unix)] + { + inner.stream.is_some() + } + #[cfg(not(unix))] + { + let _ = inner; + false + } +} + +fn clear_connected_stream(inner: &mut LocalExecutorInner) { + #[cfg(unix)] + { + inner.stream = None; + } +} + fn status_from_inner(inner: &LocalExecutorInner) -> LocalExecutorStatus { LocalExecutorStatus { running: inner.running, ready: inner.ready, device_id: inner.device_id.clone(), + version: inner.version.clone(), error: inner.error.clone(), } } @@ -122,7 +233,7 @@ fn response_error(response: ExecutorResponse) -> String { .unwrap_or_else(|| "Local executor request failed".to_string()) } -async fn resolve_response_inner(inner: &SharedExecutorInner, response: ExecutorResponse) { +fn resolve_response_inner(inner: &SharedExecutorInner, response: ExecutorResponse) { let sender = inner .lock() .ok() @@ -134,18 +245,42 @@ async fn resolve_response_inner(inner: &SharedExecutorInner, response: ExecutorR } else { Err(response_error(response)) }; - let _ = sender.send(result).await; + let _ = sender.send(result); } } -async fn fail_pending_requests(state: &LocalExecutorState, message: String) { - fail_pending_requests_inner(&state.inner, message).await; +fn fail_pending_requests(state: &LocalExecutorState, message: String) { + fail_pending_requests_inner(&state.inner, message); +} + +fn fail_pending_requests_inner(inner: &SharedExecutorInner, message: String) { + let pending = inner + .lock() + .map(|mut inner| { + inner + .pending + .drain() + .map(|(_, sender)| sender) + .collect::>() + }) + .unwrap_or_default(); + + for sender in pending { + let _ = sender.send(Err(message.clone())); + } } -async fn fail_pending_requests_inner(inner: &SharedExecutorInner, message: String) { +fn fail_pending_requests_for_generation( + inner: &SharedExecutorInner, + generation: u64, + message: String, +) { let pending = inner .lock() .map(|mut inner| { + if inner.generation != generation { + return Vec::new(); + } inner .pending .drain() @@ -155,7 +290,7 @@ async fn fail_pending_requests_inner(inner: &SharedExecutorInner, message: Strin .unwrap_or_default(); for sender in pending { - let _ = sender.send(Err(message.clone())).await; + let _ = sender.send(Err(message.clone())); } } @@ -167,12 +302,71 @@ fn set_executor_error_inner(inner: &SharedExecutorInner, error: String) { if let Ok(mut inner) = inner.lock() { inner.running = false; inner.ready = false; - inner.child = None; + clear_connected_stream(&mut inner); + inner.error = Some(error); + } +} + +fn set_executor_error_for_generation(inner: &SharedExecutorInner, generation: u64, error: String) { + if let Ok(mut inner) = inner.lock() { + if inner.generation != generation { + return; + } + inner.running = false; + inner.ready = false; + clear_connected_stream(&mut inner); inner.error = Some(error); } } -async fn handle_executor_line_inner( +fn mark_child_terminated_inner(inner: &SharedExecutorInner, message: String) { + if let Ok(mut inner) = inner.lock() { + inner.child = None; + inner.running = false; + inner.ready = false; + clear_connected_stream(&mut inner); + inner.error = Some(message); + } +} + +fn update_ready_event_inner(inner: &SharedExecutorInner, event: &ExecutorEvent) { + if event.event != "executor.ready" { + return; + } + + let ready = event + .payload + .get("ready") + .and_then(Value::as_bool) + .unwrap_or(true); + let device_id = event + .payload + .get("device_id") + .or_else(|| event.payload.get("deviceId")) + .and_then(Value::as_str) + .map(str::to_string); + let version = event + .payload + .get("version") + .and_then(Value::as_str) + .map(str::to_string); + + if let Ok(mut inner) = inner.lock() { + inner.running = true; + inner.ready = ready; + if device_id.is_some() { + inner.device_id = device_id; + } + if version.is_some() { + inner.version = version; + } + if ready { + inner.error = None; + } + } +} + +fn handle_executor_line_inner( app: &tauri::AppHandle, inner: &SharedExecutorInner, line: &str, @@ -183,9 +377,10 @@ async fn handle_executor_line_inner( match parse_executor_line(line)? { ExecutorLine::Response(response) => { - resolve_response_inner(inner, response).await; + resolve_response_inner(inner, response); } ExecutorLine::Event(event) => { + update_ready_event_inner(inner, &event); app.emit(LOCAL_EXECUTOR_EVENT, event) .map_err(|error| error.to_string())?; } @@ -194,90 +389,222 @@ async fn handle_executor_line_inner( Ok(()) } -fn drain_complete_lines(buffer: &mut String, chunk: &str) -> Vec { - buffer.push_str(chunk); - let mut lines = Vec::new(); +#[cfg(unix)] +fn connect_sidecar_socket() -> Result { + let path = app_ipc_socket_path()?; + UnixStream::connect(&path) + .map_err(|error| format!("Failed to connect local executor socket {path:?}: {error}")) +} - while let Some(index) = buffer.find('\n') { - let mut line = buffer.drain(..=index).collect::(); - if line.ends_with('\n') { - line.pop(); +#[cfg(unix)] +fn attach_connected_stream( + app: tauri::AppHandle, + state: &LocalExecutorState, + stream: UnixStream, +) -> Result<(), String> { + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .map_err(|error| format!("Failed to configure local executor socket timeout: {error}"))?; + let mut reader = BufReader::new(stream); + let mut ready_line = String::new(); + reader + .read_line(&mut ready_line) + .map_err(|error| format!("Failed to read local executor ready event: {error}"))?; + if ready_line.trim().is_empty() { + return Err("Local executor did not send a ready event".to_string()); + } + reader + .get_ref() + .set_read_timeout(None) + .map_err(|error| format!("Failed to clear local executor socket timeout: {error}"))?; + let writer = reader + .get_ref() + .try_clone() + .map_err(|error| format!("Failed to clone local executor socket: {error}"))?; + let generation = { + let mut inner = state + .inner + .lock() + .map_err(|_| "Failed to lock local executor state".to_string())?; + inner.generation = inner.generation.saturating_add(1); + inner.stream = Some(writer); + inner.running = true; + inner.ready = false; + inner.device_id = Some( + inner + .device_id + .clone() + .unwrap_or_else(|| LOCAL_EXECUTOR_DEVICE_ID.to_string()), + ); + inner.error = None; + inner.generation + }; + handle_executor_line_inner(&app, &state.inner, &ready_line)?; + { + let inner = state + .inner + .lock() + .map_err(|_| "Failed to lock local executor state".to_string())?; + if !inner.ready { + return Err("Local executor did not report ready".to_string()); } - if line.ends_with('\r') { - line.pop(); + } + + let state_handle = state.inner.clone(); + thread::spawn(move || { + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => break, + Ok(_) => { + if let Err(error) = handle_executor_line_inner(&app, &state_handle, &line) { + log::warn!("Failed to handle local executor socket line: {error}"); + } + } + Err(error) => { + log::warn!("Local executor socket read failed: {error}"); + break; + } + } + } + + let message = "Local executor socket disconnected".to_string(); + set_executor_error_for_generation(&state_handle, generation, message.clone()); + fail_pending_requests_for_generation(&state_handle, generation, message); + }); + + Ok(()) +} + +#[cfg(unix)] +fn write_request_line(inner: &mut LocalExecutorInner, line: &str) -> Result<(), String> { + let Some(stream) = inner.stream.as_mut() else { + return Err("Local executor socket is not connected".to_string()); + }; + + stream + .write_all(line.as_bytes()) + .and_then(|_| stream.flush()) + .map_err(|error| format!("Failed to write local executor request: {error}")) +} + +#[cfg(not(unix))] +fn write_request_line(_inner: &mut LocalExecutorInner, _line: &str) -> Result<(), String> { + Err("Local executor socket IPC is not available on this platform".to_string()) +} + +fn drain_process_output(prefix: &'static str, output: impl std::io::Read + Send + 'static) { + thread::spawn(move || { + let reader = BufReader::new(output); + for line in reader.lines().map_while(Result::ok) { + let trimmed = line.trim(); + if !trimmed.is_empty() { + log::info!("{prefix}: {trimmed}"); + } } - lines.push(line); + }); +} + +fn spawn_configured_sidecar(path: PathBuf) -> Result { + if !path.exists() { + return Err(format!( + "Configured local executor sidecar does not exist: {}", + path.display() + )); + } + + let mut child = Command::new(&path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| { + format!( + "Failed to start local executor sidecar {}: {error}", + path.display() + ) + })?; + + if let Some(stdout) = child.stdout.take() { + drain_process_output("Local executor stdout", stdout); + } + if let Some(stderr) = child.stderr.take() { + drain_process_output("Local executor stderr", stderr); } - lines + Ok(LocalExecutorChild::Process(child)) } -async fn start_executor_if_needed( +async fn spawn_sidecar_if_needed( app: tauri::AppHandle, state: &LocalExecutorState, ) -> Result<(), String> { { - let inner = state + let mut inner = state .inner .lock() .map_err(|_| "Failed to lock local executor state".to_string())?; - if inner.running && inner.child.is_some() { - return Ok(()); + if let Some(child) = inner.child.as_mut() { + if child.is_running() { + return Ok(()); + } + inner.child = None; } } + if let Some(path) = configured_sidecar_path() { + let child = spawn_configured_sidecar(path)?; + let mut inner = state + .inner + .lock() + .map_err(|_| "Failed to lock local executor state".to_string())?; + inner.child = Some(child); + inner.running = true; + inner.ready = false; + inner.error = None; + return Ok(()); + } + let sidecar = app .shell() .sidecar(LOCAL_EXECUTOR_SIDECAR) - .map_err(|error| error.to_string())? - .args(["--app-ipc", "--no-backend"]); - let (mut rx, child) = sidecar.spawn().map_err(|error| error.to_string())?; + .map_err(|error| { + format!("Failed to resolve local executor sidecar {LOCAL_EXECUTOR_SIDECAR}: {error}") + })?; + let (mut rx, child) = sidecar.spawn().map_err(|error| { + format!("Failed to start local executor sidecar {LOCAL_EXECUTOR_SIDECAR}: {error}") + })?; { let mut inner = state .inner .lock() .map_err(|_| "Failed to lock local executor state".to_string())?; - inner.child = Some(child); + inner.child = Some(LocalExecutorChild::Tauri(child)); inner.running = true; - inner.ready = true; - inner.device_id = Some(LOCAL_EXECUTOR_DEVICE_ID.to_string()); + inner.ready = false; inner.error = None; } - let app_handle = app.clone(); let state_handle = state.inner.clone(); tauri::async_runtime::spawn(async move { - let mut stdout_buffer = String::new(); - while let Some(event) = rx.recv().await { match event { CommandEvent::Stdout(bytes) => { let text = String::from_utf8_lossy(&bytes); - for line in drain_complete_lines(&mut stdout_buffer, &text) { - if let Err(error) = - handle_executor_line_inner(&app_handle, &state_handle, &line).await - { - log::warn!("Failed to handle local executor line: {error}"); - } + if !text.trim().is_empty() { + log::info!("Local executor stdout: {}", text.trim()); } } CommandEvent::Stderr(bytes) => { let text = String::from_utf8_lossy(&bytes); - log::warn!("Local executor stderr: {}", text.trim()); + if !text.trim().is_empty() { + log::warn!("Local executor stderr: {}", text.trim()); + } } CommandEvent::Terminated(payload) => { - if !stdout_buffer.trim().is_empty() { - if let Err(error) = - handle_executor_line_inner(&app_handle, &state_handle, &stdout_buffer) - .await - { - log::warn!("Failed to handle local executor line: {error}"); - } - } let message = format!("Local executor exited: {payload:?}"); - set_executor_error_inner(&state_handle, message.clone()); - fail_pending_requests_inner(&state_handle, message).await; + mark_child_terminated_inner(&state_handle, message.clone()); + fail_pending_requests_inner(&state_handle, message); break; } _ => {} @@ -288,21 +615,96 @@ async fn start_executor_if_needed( Ok(()) } -async fn restart_executor(app: tauri::AppHandle, state: &LocalExecutorState) -> Result<(), String> { - let old_child = state +async fn retry_connect_delay() { + let _ = tauri::async_runtime::spawn_blocking(|| { + thread::sleep(Duration::from_millis(LOCAL_EXECUTOR_CONNECT_RETRY_MS)); + }) + .await; +} + +#[cfg(unix)] +async fn start_executor_if_needed_unlocked( + app: tauri::AppHandle, + state: &LocalExecutorState, +) -> Result<(), String> { + { + let inner = state + .inner + .lock() + .map_err(|_| "Failed to lock local executor state".to_string())?; + if inner.running && has_connected_stream(&inner) { + return Ok(()); + } + } + + let mut last_error = match connect_sidecar_socket() { + Ok(stream) => return attach_connected_stream(app, state, stream), + Err(error) => error, + }; + + spawn_sidecar_if_needed(app.clone(), state).await?; + + for _ in 0..LOCAL_EXECUTOR_CONNECT_RETRIES { + match connect_sidecar_socket() { + Ok(stream) => return attach_connected_stream(app.clone(), state, stream), + Err(error) => { + last_error = error; + retry_connect_delay().await; + } + } + } + + let message = if last_error.is_empty() { + "Failed to connect local executor socket".to_string() + } else { + last_error + }; + set_executor_error(state, message.clone()); + fail_pending_requests(state, message.clone()); + Err(message) +} + +#[cfg(not(unix))] +async fn start_executor_if_needed_unlocked( + _app: tauri::AppHandle, + state: &LocalExecutorState, +) -> Result<(), String> { + let message = "Local executor socket IPC is not available on this platform".to_string(); + set_executor_error(state, message.clone()); + Err(message) +} + +async fn start_executor_if_needed( + app: tauri::AppHandle, + state: &LocalExecutorState, +) -> Result<(), String> { + let _guard = state.start_lock.lock().await; + start_executor_if_needed_unlocked(app, state).await +} + +async fn restart_executor_unlocked( + app: tauri::AppHandle, + state: &LocalExecutorState, +) -> Result<(), String> { + let mut old_child = state .inner .lock() .map_err(|_| "Failed to lock local executor state".to_string())? .child .take(); - if let Some(child) = old_child { - let _ = child.kill(); + if let Some(child) = old_child.take() { + child.kill(); } set_executor_error(state, "Local executor restarting".to_string()); - fail_pending_requests(state, "Local executor restarting".to_string()).await; - start_executor_if_needed(app, state).await + fail_pending_requests(state, "Local executor restarting".to_string()); + start_executor_if_needed_unlocked(app, state).await +} + +async fn restart_executor(app: tauri::AppHandle, state: &LocalExecutorState) -> Result<(), String> { + let _guard = state.start_lock.lock().await; + restart_executor_unlocked(app, state).await } async fn send_executor_request( @@ -313,7 +715,7 @@ async fn send_executor_request( start_executor_if_needed(app, state).await?; let request_id = next_request_id(state); - let (sender, mut receiver) = channel::>(1); + let (sender, receiver) = mpsc::channel::>(); let message = json!({ "type": "request", "id": request_id, @@ -331,20 +733,19 @@ async fn send_executor_request( .lock() .map_err(|_| "Failed to lock local executor state".to_string())?; inner.pending.insert(request_id.clone(), sender); - let Some(child) = inner.child.as_mut() else { + if let Err(error) = write_request_line(&mut inner, &line) { inner.pending.remove(&request_id); - return Err("Local executor is not running".to_string()); - }; - if let Err(error) = child.write(line.as_bytes()) { - inner.pending.remove(&request_id); - return Err(format!("Failed to write local executor request: {error}")); + return Err(error); } } - receiver - .recv() - .await - .unwrap_or_else(|| Err("Local executor disconnected".to_string())) + tauri::async_runtime::spawn_blocking(move || { + receiver + .recv() + .unwrap_or_else(|_| Err("Local executor disconnected".to_string())) + }) + .await + .map_err(|error| error.to_string())? } #[tauri::command] @@ -385,6 +786,23 @@ pub async fn local_executor_request( #[cfg(test)] mod tests { use super::*; + use std::ffi::OsString; + use std::sync::{Mutex as TestMutex, MutexGuard, OnceLock}; + + fn env_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| TestMutex::new(())) + .lock() + .expect("env lock should be available") + } + + fn restore_env(key: &str, value: Option) { + if let Some(value) = value { + std::env::set_var(key, value); + } else { + std::env::remove_var(key); + } + } #[test] fn parses_success_response_line() { @@ -407,20 +825,49 @@ mod tests { } #[test] - fn drains_complete_stdout_lines() { - let mut buffer = String::new(); - - assert!(drain_complete_lines(&mut buffer, r#"{"type":"event""#).is_empty()); - let lines = drain_complete_lines(&mut buffer, r#","event":"ready","payload":{}}"#); - assert!(lines.is_empty()); - let lines = drain_complete_lines( - &mut buffer, - "\n{\"type\":\"response\",\"id\":\"req-1\",\"ok\":true}\npartial", - ); + fn app_ipc_socket_path_uses_override() { + let _guard = env_lock(); + let previous_socket = std::env::var_os("WEGENT_EXECUTOR_APP_IPC_SOCKET"); + std::env::set_var("WEGENT_EXECUTOR_APP_IPC_SOCKET", "/tmp/wegent-test.sock"); + let path = app_ipc_socket_path().expect("socket path should resolve"); + restore_env("WEGENT_EXECUTOR_APP_IPC_SOCKET", previous_socket); + + assert_eq!(path, PathBuf::from("/tmp/wegent-test.sock")); + } + + #[test] + fn app_ipc_socket_path_uses_executor_home() { + let _guard = env_lock(); + let previous_socket = std::env::var_os("WEGENT_EXECUTOR_APP_IPC_SOCKET"); + let previous_home = std::env::var_os("WEGENT_EXECUTOR_HOME"); + std::env::remove_var("WEGENT_EXECUTOR_APP_IPC_SOCKET"); + std::env::set_var("WEGENT_EXECUTOR_HOME", "/tmp/wegent-home"); + let path = app_ipc_socket_path().expect("socket path should resolve"); + restore_env("WEGENT_EXECUTOR_APP_IPC_SOCKET", previous_socket); + restore_env("WEGENT_EXECUTOR_HOME", previous_home); + + assert_eq!(path, PathBuf::from("/tmp/wegent-home/app-ipc.sock")); + } + + #[test] + fn ready_event_updates_status_device_id() { + let inner = Arc::new(Mutex::new(LocalExecutorInner::default())); + let event = ExecutorEvent { + event: "executor.ready".to_string(), + payload: json!({ + "device_id": "configured-device", + "ready": true, + "version": "1.9.0", + }), + }; + + update_ready_event_inner(&inner, &event); - assert_eq!(lines.len(), 2); - assert_eq!(lines[0], r#"{"type":"event","event":"ready","payload":{}}"#); - assert_eq!(lines[1], r#"{"type":"response","id":"req-1","ok":true}"#); - assert_eq!(buffer, "partial"); + let status = inner.lock().expect("state should lock"); + assert!(status.running); + assert!(status.ready); + assert_eq!(status.device_id.as_deref(), Some("configured-device")); + assert_eq!(status.version.as_deref(), Some("1.9.0")); + assert_eq!(status.error, None); } } diff --git a/wework/src/App.apps.test.tsx b/wework/src/App.apps.test.tsx index 593024949c..3e1a5f846b 100644 --- a/wework/src/App.apps.test.tsx +++ b/wework/src/App.apps.test.tsx @@ -26,6 +26,12 @@ vi.mock('@/features/workbench/WorkbenchProvider', () => ({ WorkbenchProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })) +vi.mock('@/tauri/localExecutor', () => ({ + ensureLocalExecutorStarted: vi + .fn() + .mockResolvedValue({ running: true, ready: true, deviceId: 'local-device' }), +})) + vi.mock('@/pages/WorkbenchPage', () => ({ WorkbenchPage: () =>
WeWork 工作台
, })) @@ -100,7 +106,7 @@ describe('App center route', () => { render() - await userEvent.click(screen.getByTestId('chrome-tab-apps')) + await userEvent.click(await screen.findByTestId('chrome-tab-apps')) await waitFor(() => expect(window.location.pathname).toBe('/apps')) expect(screen.getByTestId('apps-page')).toBeInTheDocument() diff --git a/wework/src/App.plugins.test.tsx b/wework/src/App.plugins.test.tsx index 2f4b4e3eb9..8743603693 100644 --- a/wework/src/App.plugins.test.tsx +++ b/wework/src/App.plugins.test.tsx @@ -5,6 +5,12 @@ import type { WorkbenchContextValue } from '@/features/workbench/WorkbenchProvid import './i18n' import App from './App' +vi.mock('@/tauri/localExecutor', () => ({ + ensureLocalExecutorStarted: vi + .fn() + .mockResolvedValue({ running: true, ready: true, deviceId: 'local-device' }), +})) + const mockViewport = vi.hoisted(() => ({ isMobile: false, })) diff --git a/wework/src/App.tsx b/wework/src/App.tsx index f5d467aa6e..fb0d6f94f4 100644 --- a/wework/src/App.tsx +++ b/wework/src/App.tsx @@ -16,6 +16,7 @@ import { useChromeTabs } from '@/components/topnav/useChromeTabs' import { isTauriRuntime } from '@/lib/runtime-environment' import { AppUpdateProvider } from '@/features/app-update/AppUpdateProvider' import { AppUpdateTitlebarButton } from '@/components/topnav/AppUpdateTitlebarButton' +import { LocalRuntimeInitializer } from '@/features/local-runtime/LocalRuntimeInitializer' function useCurrentPath() { const [path, setPath] = useState(stripAppBasePath(window.location.pathname)) @@ -91,18 +92,20 @@ function AppShell() { } return ( -
- {isTauri && ( - } - /> - )} -
- + +
+ {isTauri && ( + } + /> + )} +
+ +
-
+ ) } diff --git a/wework/src/api/local/localChatStream.ts b/wework/src/api/local/localChatStream.ts index 3ee00511e7..691007d4fd 100644 --- a/wework/src/api/local/localChatStream.ts +++ b/wework/src/api/local/localChatStream.ts @@ -1,91 +1,13 @@ import type { ChatCancelAck, ChatCancelPayload, ChatGuideAck, ChatGuidePayload } from '@/types/api' import type { ChatStreamHandlers } from '@/stream/chatStream' import type { LocalExecutorEvent } from '@/tauri/localExecutor' +import { createResponseApiStreamState, emitResponseApiEvent } from '@/stream/responseApiStream' interface LocalChatStreamDeps { subscribe: (handler: (event: LocalExecutorEvent) => void) => Promise<() => void> request: (method: string, params?: Record) => Promise } -function asRecord(value: unknown): Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? (value as Record) - : {} -} - -function stringField(record: Record, key: string): string | undefined { - const value = record[key] - return typeof value === 'string' ? value : undefined -} - -function numberField(record: Record, key: string): number { - const value = record[key] - return typeof value === 'number' ? value : 0 -} - -function eventBase(payload: Record) { - return { - task_id: numberField(payload, 'task_id'), - subtask_id: numberField(payload, 'subtask_id'), - device_id: stringField(payload, 'device_id'), - local_task_id: stringField(payload, 'local_task_id'), - } -} - -function eventResult(payload: Record): Record { - return asRecord(payload.data) -} - -function eventContent(payload: Record): string { - const result = eventResult(payload) - return ( - stringField(result, 'delta') ?? - stringField(result, 'value') ?? - stringField(result, 'output_text') ?? - '' - ) -} - -function emitResponseEvent(handlers: ChatStreamHandlers, event: LocalExecutorEvent): void { - const payload = asRecord(event.payload) - const base = eventBase(payload) - - if (event.event === 'response.created' || event.event === 'response.in_progress') { - handlers.onChatStart?.({ - ...base, - shell_type: stringField(payload, 'runtime'), - }) - return - } - - if (event.event === 'response.output_text.delta') { - handlers.onChatChunk?.({ - ...base, - content: eventContent(payload), - offset: numberField(payload, 'offset'), - result: eventResult(payload), - }) - return - } - - if (event.event === 'response.completed') { - handlers.onChatDone?.({ - ...base, - offset: numberField(payload, 'offset'), - result: eventResult(payload), - }) - return - } - - if (event.event === 'response.incomplete' || event.event === 'error') { - handlers.onChatError?.({ - ...base, - error: stringField(payload, 'error') ?? (eventContent(payload) || 'Local executor error'), - type: stringField(payload, 'type') ?? event.event, - }) - } -} - export function createLocalChatStream(deps: LocalChatStreamDeps) { return { sendGuidance(payload: ChatGuidePayload): Promise { @@ -103,18 +25,21 @@ export function createLocalChatStream(deps: LocalChatStreamDeps) { subscribe(handlers: ChatStreamHandlers): () => void { let cleanup: (() => void) | null = null let active = true - - void deps.subscribe(event => { - if (active) { - emitResponseEvent(handlers, event) - } - }).then(unlisten => { - if (active) { - cleanup = unlisten - return - } - unlisten() - }) + const state = createResponseApiStreamState() + + void deps + .subscribe(event => { + if (active) { + emitResponseApiEvent(handlers, event.event, event.payload, state) + } + }) + .then(unlisten => { + if (active) { + cleanup = unlisten + return + } + unlisten() + }) return () => { active = false diff --git a/wework/src/api/local/localServices.test.ts b/wework/src/api/local/localServices.test.ts index 8af7c3b882..4598e36839 100644 --- a/wework/src/api/local/localServices.test.ts +++ b/wework/src/api/local/localServices.test.ts @@ -39,6 +39,7 @@ describe('createLocalAppServices', () => { status: 'online', device_type: 'local', executor_version: '1.9.0', + bind_shell: 'claudecode', }), ]) await expect(services.userApi?.updateCurrentUser({ preferences: {} })).resolves.toEqual( @@ -52,6 +53,26 @@ describe('createLocalAppServices', () => { expect(request).toHaveBeenCalledWith('runtime.tasks.list', {}) }) + test('keeps local device visible when executor startup fails', async () => { + const services = createLocalAppServices({ + ensure: vi.fn().mockRejectedValue(new Error('sidecar missing')), + request: vi.fn(), + subscribe: vi.fn(), + }) + + await expect(services.deviceApi.listDevices()).resolves.toEqual([ + expect.objectContaining({ + device_id: 'local-device', + name: 'Local Executor', + status: 'offline', + device_type: 'local', + executor_version: '1.8.5', + bind_shell: 'claudecode', + error: 'sidecar missing', + }), + ]) + }) + test('routes runtime task creation and device commands through app ipc', async () => { const request = vi.fn().mockImplementation(async (method: string) => { if (method === 'runtime.tasks.create') { @@ -69,7 +90,7 @@ describe('createLocalAppServices', () => { return {} }) const services = createLocalAppServices({ - ensure: vi.fn().mockResolvedValue({ running: true, ready: true, deviceId: 'local-device' }), + ensure: vi.fn().mockResolvedValue({ running: true, ready: true, deviceId: 'device-uuid' }), request, subscribe: vi.fn(), }) @@ -101,7 +122,7 @@ describe('createLocalAppServices', () => { expect(request).toHaveBeenCalledWith('runtime.tasks.create', { teamId: 0, - deviceId: 'local-device', + deviceId: 'device-uuid', workspacePath: '/Users/me/project', localTaskId: 'task-1', runtime: 'codex', @@ -147,7 +168,7 @@ describe('createLocalAppServices', () => { path: '/Users/me/project', }, }, - device_id: 'local-device', + device_id: 'device-uuid', execution_target_type: 'local', workspace_source: 'local_path', project_workspace_path: '/Users/me/project', @@ -158,7 +179,7 @@ describe('createLocalAppServices', () => { }), }) expect(request).toHaveBeenCalledWith('device.execute_command', { - deviceId: 'local-device', + deviceId: 'device-uuid', command_key: 'home_dir', timeout_seconds: 10, }) @@ -167,7 +188,7 @@ describe('createLocalAppServices', () => { test('rejects local runtime task creation without a workspace path', async () => { const request = vi.fn() const services = createLocalAppServices({ - ensure: vi.fn().mockResolvedValue({ running: true, ready: true, deviceId: 'local-device' }), + ensure: vi.fn().mockResolvedValue({ running: true, ready: true, deviceId: 'device-uuid' }), request, subscribe: vi.fn(), }) @@ -183,6 +204,25 @@ describe('createLocalAppServices', () => { expect(request).not.toHaveBeenCalled() }) + test('normalizes legacy local runtime task addresses to the ready device id', async () => { + const request = vi.fn().mockResolvedValue({ accepted: true }) + const services = createLocalAppServices({ + ensure: vi.fn().mockResolvedValue({ running: true, ready: true, deviceId: 'device-uuid' }), + request, + subscribe: vi.fn(), + }) + + await services.runtimeWorkApi?.sendRuntimeMessage({ + address: { deviceId: 'local-device', localTaskId: 'task-1' }, + message: 'continue', + }) + + expect(request).toHaveBeenCalledWith('runtime.tasks.send', { + address: { deviceId: 'device-uuid', localTaskId: 'task-1' }, + message: 'continue', + }) + }) + test('adapts executor runtime workspace list to workbench shape', async () => { const request = vi.fn().mockResolvedValue({ success: true, @@ -216,7 +256,7 @@ describe('createLocalAppServices', () => { ], }) const services = createLocalAppServices({ - ensure: vi.fn().mockResolvedValue({ running: true, ready: true, deviceId: 'local-device' }), + ensure: vi.fn().mockResolvedValue({ running: true, ready: true, deviceId: 'device-uuid' }), request, subscribe: vi.fn(), }) @@ -231,7 +271,7 @@ describe('createLocalAppServices', () => { }, deviceWorkspaces: [ expect.objectContaining({ - deviceId: 'local-device', + deviceId: 'device-uuid', workspacePath: '/Users/me/project', workspaceKind: 'workspace', workspaceSource: 'local', @@ -248,7 +288,7 @@ describe('createLocalAppServices', () => { ], chats: [ expect.objectContaining({ - deviceId: 'local-device', + deviceId: 'device-uuid', workspacePath: '/Users/me/chat', workspaceKind: 'chat', localTasks: [ diff --git a/wework/src/api/local/localServices.ts b/wework/src/api/local/localServices.ts index bddd95691e..6220064fe3 100644 --- a/wework/src/api/local/localServices.ts +++ b/wework/src/api/local/localServices.ts @@ -19,6 +19,7 @@ import { type LocalExecutorEvent, type LocalExecutorStatus, } from '@/tauri/localExecutor' +import { WEWORK_MIN_EXECUTOR_VERSION } from '@/lib/device-capabilities' import { createLocalChatStream } from './localChatStream' import { LOCAL_USER } from './localSession' @@ -82,14 +83,30 @@ function localDeviceFromStatus(status: LocalExecutorStatus): DeviceInfo { capabilities: ['runtime-work', 'device-commands'], slot_used: 0, slot_max: 5, - executor_version: (status as LocalExecutorStatus & { version?: string }).version ?? null, + executor_version: + (status as LocalExecutorStatus & { version?: string }).version ?? WEWORK_MIN_EXECUTOR_VERSION, latest_version: null, update_available: false, - bind_shell: 'openclaw', + error: status.error ?? null, + bind_shell: 'claudecode', runtime_transfer_host: null, } } +function localDeviceIdFromStatus(status: LocalExecutorStatus | null | undefined): string { + return status?.deviceId?.trim() || LOCAL_DEVICE_ID +} + +function localExecutorErrorStatus(error: unknown): LocalExecutorStatus { + return { + running: false, + ready: false, + deviceId: LOCAL_DEVICE_ID, + error: + error instanceof Error ? error.message : String(error || 'Local executor is unavailable'), + } +} + function commandText(response: DeviceCommandResponse): string { const output = Array.isArray(response.stdout) ? response.stdout.join('\n') @@ -204,7 +221,10 @@ function isNonEmptyString(value: string | null): value is string { return Boolean(value) } -function createLocalExecutionRequest(data: RuntimeTaskCreateRequest): Record { +function createLocalExecutionRequest( + data: RuntimeTaskCreateRequest, + localDeviceId: string +): Record { const workspacePath = requiredRuntimeWorkspacePath(data) const [taskId, subtaskId] = createRuntimeExecutionIds(data) const title = runtimeTaskTitle(data) @@ -260,7 +280,7 @@ function createLocalExecutionRequest(data: RuntimeTaskCreateRequest): Record { +function createLocalRuntimeTaskPayload( + data: RuntimeTaskCreateRequest, + localDeviceId: string +): Record { const workspacePath = requiredRuntimeWorkspacePath(data) return { ...data, - deviceId: data.deviceId ?? LOCAL_DEVICE_ID, + deviceId: localDeviceId, workspacePath, title: runtimeTaskTitle(data), - executionRequest: createLocalExecutionRequest(data), + executionRequest: createLocalExecutionRequest( + { ...data, deviceId: localDeviceId }, + localDeviceId + ), } as unknown as Record } -function adaptRuntimeWorkListResponse(response: unknown): RuntimeWorkListResponse { +function normalizeRuntimeWorkDeviceId( + runtimeWork: RuntimeWorkListResponse, + localDeviceId: string +): RuntimeWorkListResponse { + const normalizeWorkspace = (workspace: RuntimeDeviceWorkspace): RuntimeDeviceWorkspace => ({ + ...workspace, + deviceId: localDeviceId, + deviceName: workspace.deviceName || 'Local Executor', + deviceStatus: workspace.deviceStatus ?? 'online', + available: workspace.available !== false, + }) + + return { + ...runtimeWork, + projects: runtimeWork.projects.map(project => ({ + ...project, + deviceWorkspaces: project.deviceWorkspaces.map(normalizeWorkspace), + })), + chats: runtimeWork.chats.map(normalizeWorkspace), + } +} + +function normalizeLocalDeviceRecord>( + data: T, + localDeviceId: string +): T { + const next: Record = { ...data } + + if ('deviceId' in next) next.deviceId = localDeviceId + if ('device_id' in next) next.device_id = localDeviceId + + const address = recordValue(next.address) + if (Object.keys(address).length > 0) { + next.address = { + ...address, + deviceId: localDeviceId, + ...('device_id' in address ? { device_id: localDeviceId } : {}), + } + } + + if (Array.isArray(next.addresses)) { + next.addresses = next.addresses.map(addressItem => { + const addressRecord = recordValue(addressItem) + if (Object.keys(addressRecord).length === 0) return addressItem + return { + ...addressRecord, + deviceId: localDeviceId, + ...('device_id' in addressRecord ? { device_id: localDeviceId } : {}), + } + }) + } + + return next as T +} + +function adaptRuntimeWorkListResponse( + response: unknown, + localDeviceId: string +): RuntimeWorkListResponse { const record = recordValue(response) if (Array.isArray(record.projects) && Array.isArray(record.chats)) { - return response as RuntimeWorkListResponse + return normalizeRuntimeWorkDeviceId(response as RuntimeWorkListResponse, localDeviceId) } const workspaces = Array.isArray(record.workspaces) ? record.workspaces : [] @@ -334,8 +418,7 @@ function adaptRuntimeWorkListResponse(response: unknown): RuntimeWorkListRespons const deviceWorkspace: RuntimeDeviceWorkspace = { id: null, projectId: null, - deviceId: - stringValue(workspace.deviceId) ?? stringValue(workspace.device_id) ?? LOCAL_DEVICE_ID, + deviceId: localDeviceId, deviceName: 'Local Executor', deviceStatus: 'online', available: true, @@ -369,41 +452,51 @@ function adaptRuntimeWorkListResponse(response: unknown): RuntimeWorkListRespons } function createRuntimeWorkApi( - request: (method: string, params?: Record) => Promise + request: (method: string, params?: Record) => Promise, + getLocalDeviceId: () => Promise ) { + const normalizeRequest = async >(data: T): Promise => + normalizeLocalDeviceRecord(data, await getLocalDeviceId()) + + const requestWithLocalDevice = async ( + method: string, + data: Record + ): Promise => request(method, await normalizeRequest(data)) + return { async listRuntimeWork(): Promise { - return adaptRuntimeWorkListResponse(await request('runtime.tasks.list', {})) + const localDeviceId = await getLocalDeviceId() + return adaptRuntimeWorkListResponse(await request('runtime.tasks.list', {}), localDeviceId) }, upsertDeviceWorkspace() { return cloudConnectionRequired('upsertDeviceWorkspace') }, prepareDeviceWorkspace(data: Record) { - return request('runtime.workspaces.prepare', data) + return requestWithLocalDevice('runtime.workspaces.prepare', data) }, deleteDeviceWorkspace(data: Record) { - return request('runtime.workspaces.delete', data) + return requestWithLocalDevice('runtime.workspaces.delete', data) }, getRuntimeTranscript(data: Record) { - return request('runtime.tasks.transcript', data) + return requestWithLocalDevice('runtime.tasks.transcript', data) }, searchRuntimeWork(data: Record) { - return request('runtime.tasks.search', data) + return requestWithLocalDevice('runtime.tasks.search', data) }, revertRuntimeFileChanges(data: Record) { - return request('runtime.tasks.revert_file_changes', data) + return requestWithLocalDevice('runtime.tasks.revert_file_changes', data) }, sendRuntimeMessage(data: Record) { - return request('runtime.tasks.send', data) + return requestWithLocalDevice('runtime.tasks.send', data) }, openRuntimeWorkspace(data: Record) { - return request('runtime.workspaces.open', data) + return requestWithLocalDevice('runtime.workspaces.open', data) }, renameRuntimeWorkspace(data: Record) { - return request('runtime.workspaces.rename', data) + return requestWithLocalDevice('runtime.workspaces.rename', data) }, removeRuntimeWorkspace(data: Record) { - return request('runtime.workspaces.remove', data) + return requestWithLocalDevice('runtime.workspaces.remove', data) }, bindRuntimeTaskImSessions() { return cloudConnectionRequired('bindRuntimeTaskImSessions') @@ -424,54 +517,61 @@ function createRuntimeWorkApi( return Promise.resolve({ address, subscribed: false, sessionKeys: [] }) }, archiveRuntimeTask(data: RuntimeTaskAddress) { - return request('runtime.tasks.archive', data as unknown as Record) + return requestWithLocalDevice( + 'runtime.tasks.archive', + data as unknown as Record + ) }, renameRuntimeTask(data: Record) { - return request('runtime.tasks.rename', data) + return requestWithLocalDevice('runtime.tasks.rename', data) }, listArchivedConversations(data: Record = {}) { - return request('runtime.archived_conversations.list', data) + return requestWithLocalDevice('runtime.archived_conversations.list', data) }, archiveConversation(data: RuntimeTaskAddress) { - return request('runtime.tasks.archive', data as unknown as Record) + return requestWithLocalDevice( + 'runtime.tasks.archive', + data as unknown as Record + ) }, archiveProjectConversations(data: Record) { - return request('runtime.archived_conversations.archive_project', data) + return requestWithLocalDevice('runtime.archived_conversations.archive_project', data) }, archiveAllConversations() { return request('runtime.archived_conversations.archive_all', {}) }, unarchiveConversation(data: RuntimeTaskAddress) { - return request( + return requestWithLocalDevice( 'runtime.archived_conversations.unarchive', data as unknown as Record ) }, deleteArchivedConversation(data: RuntimeTaskAddress) { - return request( + return requestWithLocalDevice( 'runtime.archived_conversations.delete', data as unknown as Record ) }, deleteArchivedConversationsBulk(data: Record) { - return request('runtime.archived_conversations.delete_bulk', data) + return requestWithLocalDevice('runtime.archived_conversations.delete_bulk', data) }, cancelRuntimeTask(data: RuntimeTaskAddress) { - return request('runtime.tasks.cancel', data as unknown as Record) + return requestWithLocalDevice( + 'runtime.tasks.cancel', + data as unknown as Record + ) }, async createRuntimeTask(data: RuntimeTaskCreateRequest) { - const payload = createLocalRuntimeTaskPayload(data) - const response = await request>( - 'runtime.tasks.create', - payload - ) + const localDeviceId = await getLocalDeviceId() + const payload = createLocalRuntimeTaskPayload(data, localDeviceId) + const response = await request>('runtime.tasks.create', payload) return { ...response, - deviceId: stringValue(response.deviceId) ?? data.deviceId ?? LOCAL_DEVICE_ID, + deviceId: localDeviceId, } }, forkRuntimeTask(data: Record) { - return request('runtime.tasks.import_fork', data) + return requestWithLocalDevice('runtime.tasks.import_fork', data) }, } } @@ -480,8 +580,26 @@ export function createLocalAppServices(deps: LocalAppServicesDeps = {}): Workben const ensure = deps.ensure ?? ensureLocalExecutorStarted const request = deps.request ?? requestLocalExecutor const subscribe = deps.subscribe ?? subscribeLocalExecutorEvents + let lastStatus: LocalExecutorStatus | null = null + let ensurePromise: Promise | null = null + + const ensureStatus = async () => { + if (!ensurePromise) { + ensurePromise = ensure() + .then(status => { + lastStatus = status + return status + }) + .finally(() => { + ensurePromise = null + }) + } + return ensurePromise + } - const executeCommand = ( + const getLocalDeviceId = async () => localDeviceIdFromStatus(await ensureStatus()) + + const executeCommand = async ( deviceId: string, data: { command_key: string @@ -492,7 +610,11 @@ export function createLocalAppServices(deps: LocalAppServicesDeps = {}): Workben timeout_seconds?: number max_output_bytes?: number } - ) => request('device.execute_command', { deviceId, ...data }) + ) => + request( + 'device.execute_command', + normalizeLocalDeviceRecord({ deviceId, ...data }, await getLocalDeviceId()) + ) return { teamApi: { @@ -518,7 +640,17 @@ export function createLocalAppServices(deps: LocalAppServicesDeps = {}): Workben revertTurnFileChanges: () => cloudConnectionRequired('revertTurnFileChanges'), }, deviceApi: { - listDevices: async () => [localDeviceFromStatus(await ensure())], + async listDevices() { + try { + return [localDeviceFromStatus(await ensureStatus())] + } catch (error) { + const fallback = { + ...localExecutorErrorStatus(error), + deviceId: localDeviceIdFromStatus(lastStatus), + } + return [localDeviceFromStatus(fallback)] + } + }, async getHomeDirectory(deviceId: string) { const response = await executeCommand(deviceId, { command_key: 'home_dir', @@ -568,7 +700,7 @@ export function createLocalAppServices(deps: LocalAppServicesDeps = {}): Workben return commandSkills(response) }, }, - runtimeWorkApi: createRuntimeWorkApi(request), + runtimeWorkApi: createRuntimeWorkApi(request, getLocalDeviceId), userApi: { updateCurrentUser: async (data: { preferences?: User['preferences'] }) => ({ ...LOCAL_USER, diff --git a/wework/src/components/chat/MessageList.test.tsx b/wework/src/components/chat/MessageList.test.tsx index 25fb68e9a1..f9cc93add1 100644 --- a/wework/src/components/chat/MessageList.test.tsx +++ b/wework/src/components/chat/MessageList.test.tsx @@ -8,6 +8,7 @@ import '@/i18n' const tauriCoreMock = vi.hoisted(() => ({ convertFileSrc: vi.fn((path: string) => `asset://localhost/${path.replace(/^\/+/, '')}`), + isTauri: vi.fn(() => false), })) vi.mock('@tauri-apps/api/core', () => tauriCoreMock) @@ -163,6 +164,7 @@ describe('MessageList', () => { tauriCoreMock.convertFileSrc = vi.fn( (path: string) => `asset://localhost/${path.replace(/^\/+/, '')}` ) + tauriCoreMock.isTauri = vi.fn(() => false) localStorage.clear() URL.createObjectURL = originalCreateObjectUrl URL.revokeObjectURL = originalRevokeObjectUrl diff --git a/wework/src/config/runtime.test.ts b/wework/src/config/runtime.test.ts index 0e5190a340..446cfa186f 100644 --- a/wework/src/config/runtime.test.ts +++ b/wework/src/config/runtime.test.ts @@ -4,18 +4,17 @@ import { getRuntimeConfig } from './runtime' describe('getRuntimeConfig', () => { afterEach(() => { delete window.__WEWORK_RUNTIME_CONFIG__ - delete (globalThis as typeof globalThis & { isTauri?: boolean }).isTauri vi.unstubAllEnvs() }) test('reads cloud device scaling wiki URL from wework frontend config', () => { vi.stubEnv( 'VITE_CLOUD_DEVICE_SCALING_WIKI_URL', - 'https://wiki.example.com/cloud-device-scaling', + 'https://wiki.example.com/cloud-device-scaling' ) expect(getRuntimeConfig().cloudDeviceScalingWikiUrl).toBe( - 'https://wiki.example.com/cloud-device-scaling', + 'https://wiki.example.com/cloud-device-scaling' ) }) @@ -27,6 +26,7 @@ describe('getRuntimeConfig', () => { apiBaseUrl: 'http://runtime.example.com/api', socketBaseUrl: 'http://runtime.example.com', socketPath: '/socket.io', + runtimeMode: 'backend', loginMode: 'oidc', } @@ -35,6 +35,7 @@ describe('getRuntimeConfig', () => { expect(config.apiBaseUrl).toBe('http://runtime.example.com/api') expect(config.socketBaseUrl).toBe('http://runtime.example.com') expect(config.socketPath).toBe('/socket.io') + expect(config.runtimeMode).toBe('backend') expect(config.loginMode).toBe('oidc') }) @@ -47,14 +48,7 @@ describe('getRuntimeConfig', () => { expect(getRuntimeConfig().loginMode).toBe('all') }) - test('defaults to backend mode in browser development', () => { - expect(getRuntimeConfig().runtimeMode).toBe('backend') - }) - - test('defaults to local-first mode inside tauri runtime', () => { - const globalWithTauri = globalThis as typeof globalThis & { isTauri?: boolean } - globalWithTauri.isTauri = true - + test('defaults to local-first mode without explicit runtime config', () => { expect(getRuntimeConfig().runtimeMode).toBe('local-first') }) @@ -72,12 +66,18 @@ describe('getRuntimeConfig', () => { expect(getRuntimeConfig().runtimeMode).toBe('local-first') }) + test('uses backend mode from build-time environment', () => { + vi.stubEnv('VITE_WEWORK_RUNTIME_MODE', 'backend') + + expect(getRuntimeConfig().runtimeMode).toBe('backend') + }) + test('ignores invalid runtime modes', () => { vi.stubEnv('VITE_WEWORK_RUNTIME_MODE', 'invalid-mode') window.__WEWORK_RUNTIME_CONFIG__ = { runtimeMode: 'invalid-runtime-mode' as never, } - expect(getRuntimeConfig().runtimeMode).toBe('backend') + expect(getRuntimeConfig().runtimeMode).toBe('local-first') }) }) diff --git a/wework/src/config/runtime.ts b/wework/src/config/runtime.ts index a1bf342a93..9e929011a5 100644 --- a/wework/src/config/runtime.ts +++ b/wework/src/config/runtime.ts @@ -1,5 +1,3 @@ -import { isTauri } from '@tauri-apps/api/core' - export type RuntimeMode = 'local-first' | 'backend' export interface RuntimeConfig { @@ -35,7 +33,7 @@ function runtimeOverrides(): RuntimeConfigOverrides { function runtimeString( overrides: RuntimeConfigOverrides, - key: keyof RuntimeConfig, + key: keyof RuntimeConfig ): string | undefined { const value = overrides[key] return typeof value === 'string' && value.length > 0 ? value : undefined @@ -60,11 +58,7 @@ function resolveRuntimeMode(overrides: RuntimeConfigOverrides): RuntimeMode { return envValue } - if (isTauri()) { - return 'local-first' - } - - return 'backend' + return 'local-first' } function resolveLoginMode(overrides: RuntimeConfigOverrides): RuntimeConfig['loginMode'] { @@ -123,7 +117,7 @@ export function getRuntimeConfig(): RuntimeConfig { const appBasePath = normalizeBasePath( runtimeString(overrides, 'appBasePath') || import.meta.env.VITE_APP_BASE_PATH || - import.meta.env.BASE_URL, + import.meta.env.BASE_URL ) const apiBaseUrl = runtimeString(overrides, 'apiBaseUrl') || diff --git a/wework/src/features/auth/AuthProvider.test.tsx b/wework/src/features/auth/AuthProvider.test.tsx index c8e047ccd2..fc19058d8c 100644 --- a/wework/src/features/auth/AuthProvider.test.tsx +++ b/wework/src/features/auth/AuthProvider.test.tsx @@ -21,6 +21,12 @@ function Probe() { ) } +function useBackendRuntime() { + window.__WEWORK_RUNTIME_CONFIG__ = { + runtimeMode: 'backend', + } +} + describe('AuthProvider', () => { beforeEach(() => { localStorage.clear() @@ -30,6 +36,7 @@ describe('AuthProvider', () => { }) test('loads current user when token is valid', async () => { + useBackendRuntime() setToken(createJwt(Math.floor(Date.now() / 1000) + 3600)) const authApi = { getCurrentUser: vi.fn().mockResolvedValue({ id: 1, user_name: 'alice', email: 'a@b.c' }), @@ -50,6 +57,7 @@ describe('AuthProvider', () => { }) test('redirects protected routes to login when admin password setup is required', async () => { + useBackendRuntime() setToken(createJwt(Math.floor(Date.now() / 1000) + 3600)) window.history.pushState({}, '', '/') const authApi = { @@ -78,6 +86,7 @@ describe('AuthProvider', () => { }) test('redirects protected routes to login when token is missing', async () => { + useBackendRuntime() render( diff --git a/wework/src/features/local-runtime/LocalRuntimeInitializer.test.tsx b/wework/src/features/local-runtime/LocalRuntimeInitializer.test.tsx new file mode 100644 index 0000000000..725a28f822 --- /dev/null +++ b/wework/src/features/local-runtime/LocalRuntimeInitializer.test.tsx @@ -0,0 +1,76 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import '@/i18n' +import { ensureLocalExecutorStarted } from '@/tauri/localExecutor' +import { LocalRuntimeInitializer } from './LocalRuntimeInitializer' + +vi.mock('@/tauri/localExecutor', () => ({ + ensureLocalExecutorStarted: vi.fn(), +})) + +const ensureMock = vi.mocked(ensureLocalExecutorStarted) + +function enableTauri() { + Object.defineProperty(window, '__TAURI_INTERNALS__', { + configurable: true, + value: {}, + }) +} + +describe('LocalRuntimeInitializer', () => { + beforeEach(() => { + enableTauri() + ensureMock.mockReset() + }) + + test('holds the app on the initialization screen until executor is ready', async () => { + ensureMock.mockResolvedValue({ running: true, ready: true, deviceId: 'local-device' }) + + render( + +
Main app
+
+ ) + + expect(screen.getByTestId('local-runtime-initializer')).toBeInTheDocument() + expect(screen.queryByTestId('main-app')).not.toBeInTheDocument() + + expect(await screen.findByTestId('main-app')).toBeInTheDocument() + expect(screen.queryByTestId('local-runtime-initializer')).not.toBeInTheDocument() + }) + + test('shows startup error and retries initialization', async () => { + ensureMock + .mockRejectedValueOnce(new Error('socket unavailable')) + .mockResolvedValueOnce({ running: true, ready: true, deviceId: 'local-device' }) + + render( + +
Main app
+
+ ) + + expect(await screen.findByTestId('local-runtime-error')).toHaveTextContent( + 'socket unavailable' + ) + expect(screen.getByText('~/.wegent-executor/logs/executor.log')).toBeInTheDocument() + + await userEvent.click(screen.getByTestId('local-runtime-retry-button')) + + await waitFor(() => expect(screen.getByTestId('main-app')).toBeInTheDocument()) + }) + + test('does not block non-tauri runtimes', () => { + delete (window as typeof window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ + + render( + +
Main app
+
+ ) + + expect(screen.getByTestId('main-app')).toBeInTheDocument() + expect(ensureMock).not.toHaveBeenCalled() + }) +}) diff --git a/wework/src/features/local-runtime/LocalRuntimeInitializer.tsx b/wework/src/features/local-runtime/LocalRuntimeInitializer.tsx new file mode 100644 index 0000000000..ff0e408ce9 --- /dev/null +++ b/wework/src/features/local-runtime/LocalRuntimeInitializer.tsx @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import { AlertCircle, Loader2, RefreshCw, TerminalSquare } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { getRuntimeConfig } from '@/config/runtime' +import { useTranslation } from '@/hooks/useTranslation' +import { isTauriRuntime } from '@/lib/runtime-environment' +import { ensureLocalExecutorStarted, type LocalExecutorStatus } from '@/tauri/localExecutor' + +const LOCAL_EXECUTOR_LOG_PATH = '~/.wegent-executor/logs/executor.log' + +type LocalRuntimePhase = 'starting' | 'ready' | 'failed' + +interface LocalRuntimeInitializerProps { + children: ReactNode +} + +interface LocalRuntimeState { + phase: LocalRuntimePhase + error: string | null +} + +function shouldInitializeLocalRuntime(): boolean { + return getRuntimeConfig().runtimeMode === 'local-first' && isTauriRuntime() +} + +function localRuntimeError(status: LocalExecutorStatus): string | null { + if (status.error) return status.error + if (!status.running) return 'Local executor is not running' + if (status.ready === false) return 'Local executor is not ready' + return null +} + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : String(error || fallback) +} + +async function resolveLocalRuntimeState(fallbackError: string): Promise { + try { + const status = await ensureLocalExecutorStarted() + const statusError = localRuntimeError(status) + if (statusError) { + throw new Error(statusError) + } + return { phase: 'ready', error: null } + } catch (error) { + return { + phase: 'failed', + error: errorMessage(error, fallbackError), + } + } +} + +export function LocalRuntimeInitializer({ children }: LocalRuntimeInitializerProps) { + const { t } = useTranslation('localRuntime') + const enabled = useMemo(() => shouldInitializeLocalRuntime(), []) + const fallbackError = t('fallback_error') + const [state, setState] = useState(() => ({ + phase: enabled ? 'starting' : 'ready', + error: null, + })) + + const retryInitialize = useCallback(async () => { + if (!enabled) return + setState({ phase: 'starting', error: null }) + setState(await resolveLocalRuntimeState(fallbackError)) + }, [enabled, fallbackError]) + + useEffect(() => { + if (!enabled) return undefined + let cancelled = false + void resolveLocalRuntimeState(fallbackError).then(nextState => { + if (!cancelled) { + setState(nextState) + } + }) + return () => { + cancelled = true + } + }, [enabled, fallbackError]) + + if (state.phase === 'ready') { + return <>{children} + } + + const failed = state.phase === 'failed' + + return ( +
+
+
+ {failed ? ( + + ) : ( + + )} +
+

+ {failed ? t('failed_title') : t('starting_title')} +

+

+ {failed ? t('failed_description') : t('starting_description')} +

+ + {failed ? ( +
+ {state.error && ( +

+ {state.error} +

+ )} +
+ {t('log_label')}: + + {LOCAL_EXECUTOR_LOG_PATH} + +
+
+ ) : ( +
+ + {t('starting_title')} +
+ )} + + {failed && ( + + )} +
+
+ ) +} diff --git a/wework/src/features/workbench/WorkbenchProvider.test.tsx b/wework/src/features/workbench/WorkbenchProvider.test.tsx index cbd94c5258..fd3e19f635 100644 --- a/wework/src/features/workbench/WorkbenchProvider.test.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.test.tsx @@ -258,8 +258,6 @@ function createWorkbenchServices(overrides: Partial = {}): Wo }, runtimeWorkApi: createRuntimeWorkApiMock(), chatStream: { - sendGuidance: vi.fn(), - cancelStream: vi.fn(), subscribe: vi.fn(() => vi.fn()), }, } as unknown as WorkbenchServices @@ -1743,8 +1741,6 @@ describe('WorkbenchProvider runtime tasks', () => { runtimeWorkApi: runtimeWorkApi as WorkbenchServices['runtimeWorkApi'], chatStream: { subscribe, - sendGuidance: vi.fn(), - cancelStream: vi.fn(), } as unknown as WorkbenchServices['chatStream'], }) @@ -2078,20 +2074,20 @@ describe('WorkbenchProvider runtime tasks', () => { expect(screen.getByTestId('queued-messages')).toHaveTextContent('') }) - test('sends queued runtime messages as guidance for the active stream', async () => { + test('pauses the active local task before sending queued guidance', async () => { let streamHandlers: ChatStreamHandlers = {} const subscribe = vi.fn((handlers: ChatStreamHandlers) => { streamHandlers = handlers return vi.fn() }) - const sendGuidance = vi.fn().mockResolvedValue({ - success: true, - guidance_id: 'guidance-runtime-1', - }) const sendRuntimeMessage = vi.fn().mockResolvedValue({ accepted: true, localTaskId: 'runtime-a', }) + const cancelRuntimeTask = vi.fn().mockResolvedValue({ + accepted: true, + localTaskId: 'runtime-a', + }) const runtimeWorkApi = createRuntimeWorkApiMock({ listRuntimeWork: vi.fn().mockResolvedValue( createRuntimeWork({ @@ -2131,13 +2127,12 @@ describe('WorkbenchProvider runtime tasks', () => { messages: [{ id: 'runtime-a:user:1', role: 'user', content: 'first message' }], }), sendRuntimeMessage, + cancelRuntimeTask, }) const services = createWorkbenchServices({ runtimeWorkApi: runtimeWorkApi as WorkbenchServices['runtimeWorkApi'], chatStream: { subscribe, - sendGuidance, - cancelStream: vi.fn(), } as unknown as WorkbenchServices['chatStream'], }) @@ -2166,17 +2161,23 @@ describe('WorkbenchProvider runtime tasks', () => { await userEvent.click(screen.getByText('send follow-up')) await userEvent.click(screen.getByText('guide first queued')) - await waitFor(() => expect(sendGuidance).toHaveBeenCalledTimes(1)) - expect(sendGuidance).toHaveBeenCalledWith({ - task_id: 77, - subtask_id: 101, - team_id: 2, + await waitFor(() => expect(cancelRuntimeTask).toHaveBeenCalledTimes(1)) + expect(cancelRuntimeTask).toHaveBeenCalledWith({ + deviceId: 'device-1', + workspacePath: '/workspace/project-alpha', + localTaskId: 'runtime-a', + }) + await waitFor(() => expect(sendRuntimeMessage).toHaveBeenCalledTimes(1)) + expect(sendRuntimeMessage).toHaveBeenCalledWith({ + address: { + deviceId: 'device-1', + workspacePath: '/workspace/project-alpha', + localTaskId: 'runtime-a', + }, message: '继续修', - guidance: '继续修', - client_guidance_id: expect.stringMatching(/^guidance-101-/), }) expect(screen.getByTestId('queued-messages')).toHaveTextContent('') - expect(screen.getByTestId('guidance-messages')).toHaveTextContent('queued:继续修') + expect(screen.getByTestId('guidance-messages')).toHaveTextContent('') }) test('pauses the active local task before sending queued guidance without DB task context', async () => { @@ -2185,10 +2186,6 @@ describe('WorkbenchProvider runtime tasks', () => { streamHandlers = handlers return vi.fn() }) - const sendGuidance = vi.fn().mockResolvedValue({ - success: true, - guidance_id: 'guidance-runtime-1', - }) const sendRuntimeMessage = vi.fn().mockResolvedValue({ accepted: true, localTaskId: 'runtime-a', @@ -2242,8 +2239,6 @@ describe('WorkbenchProvider runtime tasks', () => { runtimeWorkApi: runtimeWorkApi as WorkbenchServices['runtimeWorkApi'], chatStream: { subscribe, - sendGuidance, - cancelStream: vi.fn(), } as unknown as WorkbenchServices['chatStream'], }) @@ -2271,7 +2266,6 @@ describe('WorkbenchProvider runtime tasks', () => { await userEvent.click(screen.getByText('send follow-up')) await userEvent.click(screen.getByText('guide first queued')) - expect(sendGuidance).not.toHaveBeenCalled() await waitFor(() => expect(cancelRuntimeTask).toHaveBeenCalledTimes(1)) expect(cancelRuntimeTask).toHaveBeenCalledWith({ deviceId: 'device-1', @@ -2422,19 +2416,29 @@ describe('WorkbenchProvider runtime tasks', () => { ) await act(async () => { - streamHandlers.onChatMessage?.({ + streamHandlers.onChatStart?.({ + subtask_id: 101, + shell_type: 'Codex', + device_id: 'device-1', + local_task_id: 'runtime-a', + }) + streamHandlers.onChatDone?.({ subtask_id: 101, - role: 'assistant', - content: 'stale runtime a output', - created_at: '2026-06-20T00:00:00.000Z', + offset: 0, + result: { value: 'stale runtime a output' }, device_id: 'device-1', local_task_id: 'runtime-a', }) - streamHandlers.onChatMessage?.({ + streamHandlers.onChatStart?.({ subtask_id: 102, - role: 'assistant', - content: 'current runtime b output', - created_at: '2026-06-20T00:00:01.000Z', + shell_type: 'Codex', + device_id: 'device-1', + local_task_id: 'runtime-b', + }) + streamHandlers.onChatDone?.({ + subtask_id: 102, + offset: 0, + result: { value: 'current runtime b output' }, device_id: 'device-1', local_task_id: 'runtime-b', }) diff --git a/wework/src/features/workbench/WorkbenchProvider.tsx b/wework/src/features/workbench/WorkbenchProvider.tsx index 7c43ac0ff7..0452afd527 100644 --- a/wework/src/features/workbench/WorkbenchProvider.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.tsx @@ -49,7 +49,6 @@ import type { Attachment, BindRuntimeTaskIMSessionsResponse, ChatBlock, - ChatMessagePayload, ChatSendPayload, CreateProjectRequest, CreateGitWorkspaceProjectRequest, @@ -416,6 +415,7 @@ function createBackendServices(): WorkbenchServices { path: socketPath, namespace: '/chat', getToken, + auth: { client_origin: WEWORK_CLIENT_ORIGIN }, logger: console, }) @@ -499,20 +499,32 @@ function resolveRuntimeTaskRouteAddress( ...runtimeWork.projects.flatMap(projectWork => projectWork.deviceWorkspaces), ...runtimeWork.chats, ] + let fallbackAddress: RuntimeTaskAddress | null = null + let hasMultipleFallbacks = false for (const workspace of workspaces) { - if (workspace.deviceId !== route.deviceId) continue - const task = workspace.localTasks.find(item => item.localTaskId === route.localTaskId) if (!task) continue + if (workspace.deviceId !== route.deviceId) { + if (fallbackAddress) { + hasMultipleFallbacks = true + } else { + fallbackAddress = { + deviceId: workspace.deviceId, + localTaskId: task.localTaskId, + } + } + continue + } + return { deviceId: workspace.deviceId, localTaskId: task.localTaskId, } } - return null + return hasMultipleFallbacks ? null : fallbackAddress } function isCurrentLocalTaskEvent( @@ -727,27 +739,6 @@ function runtimeMessagesToWorkbenchMessages( return messages.map(message => runtimeMessageToWorkbenchMessage(address, message)) } -function chatMessageToWorkbenchMessage(payload: ChatMessagePayload): WorkbenchMessage { - const role = payload.role.toLowerCase() === 'user' ? 'user' : 'assistant' - const source = - role === 'user' && payload.source?.source === 'im' - ? ({ ...payload.source, source: 'im' } as MessageSource) - : undefined - const messageKey = payload.message_id ?? payload.subtask_id - const localPrefix = payload.local_task_id ? `runtime-${payload.local_task_id}-` : '' - return { - id: `${localPrefix}message-${messageKey}`, - taskId: payload.task_id, - subtaskId: payload.subtask_id, - role, - content: payload.content, - status: 'done', - attachments: payload.attachments, - source, - createdAt: payload.created_at, - } -} - function findFileChangesBySubtaskId( messages: WorkbenchMessage[], subtaskId: number @@ -1386,13 +1377,6 @@ export function WorkbenchProvider({ children, user, services }: WorkbenchProvide onDeviceStatus: handleDeviceStatus, onDeviceSlotUpdate: refreshDevicesAfterEvent, onDeviceUpgradeStatus: handleDeviceUpgradeStatus, - onChatMessage: payload => { - if (!isCurrentLocalTaskEvent(currentRuntimeTaskRef.current, payload)) return - dispatchMessages({ - type: 'user_added', - message: chatMessageToWorkbenchMessage(payload), - }) - }, onChatStart: payload => { if (!isCurrentLocalTaskEvent(currentRuntimeTaskRef.current, payload)) return setIsAwaitingAssistantStart(false) @@ -1460,34 +1444,6 @@ export function WorkbenchProvider({ children, user, services }: WorkbenchProvide }, }) }, - onGuidanceQueued: payload => { - setGuidanceMessages(items => - items.map(item => - item.id === payload.guidance_id || item.id === payload.client_guidance_id - ? { - ...item, - id: payload.guidance_id, - content: payload.message ?? payload.content ?? item.content, - status: 'queued', - } - : item - ) - ) - }, - onGuidanceApplied: payload => { - setGuidanceMessages(items => - items.filter( - item => item.id !== payload.guidance_id && item.id !== payload.client_guidance_id - ) - ) - }, - onGuidanceExpired: payload => { - setGuidanceMessages(items => - items.map(item => - payload.guidance_ids.includes(item.id) ? { ...item, status: 'expired' } : item - ) - ) - }, }) }, [refreshWorkLists, refreshDevices, resolvedServices, setDeviceUpgradeState]) @@ -1968,7 +1924,9 @@ export function WorkbenchProvider({ children, user, services }: WorkbenchProvide if (addresses.length === 0) return - const responses = await Promise.all(addresses.map(address => runtimeWorkApi.archiveConversation(address))) + const responses = await Promise.all( + addresses.map(address => runtimeWorkApi.archiveConversation(address)) + ) const failedResponse = responses.find(response => !response.accepted) if (failedResponse) { dispatch({ @@ -3076,15 +3034,19 @@ export function WorkbenchProvider({ children, user, services }: WorkbenchProvide ) const pauseCurrentResponse = useCallback(async () => { - if (!activeAssistantMessage?.subtaskId) return + if (!activeAssistantMessage?.subtaskId || !state.currentRuntimeTask) return + const runtimeWorkApi = resolvedServices.runtimeWorkApi + if (!runtimeWorkApi) { + dispatch({ + type: 'error_set', + error: 'Runtime API 不可用', + }) + return + } - const ack = await resolvedServices.chatStream.cancelStream({ - subtask_id: activeAssistantMessage.subtaskId, - partial_content: activeAssistantMessage.content, - shell_type: activeAssistantMessage.shellType, - }) + const ack = await runtimeWorkApi.cancelRuntimeTask(state.currentRuntimeTask) - if (ack.error || ack.success === false) { + if (!ack.accepted) { dispatch({ type: 'error_set', error: normalizeGuidanceError(ack.error ?? '取消当前回复失败'), @@ -3097,155 +3059,97 @@ export function WorkbenchProvider({ children, user, services }: WorkbenchProvide subtaskId: activeAssistantMessage.subtaskId, content: activeAssistantMessage.content, }) - }, [activeAssistantMessage, resolvedServices.chatStream]) + }, [activeAssistantMessage, resolvedServices.runtimeWorkApi, state.currentRuntimeTask]) const sendQueuedAsGuidance = useCallback( async (id: string) => { const queuedMessage = queuedSends.find(item => item.id === id) if (!queuedMessage || queuedMessage.status === 'sending') return - const taskId = activeAssistantMessage?.taskId - const subtaskId = activeAssistantMessage?.subtaskId - const teamId = state.defaultTeam?.id - if (!taskId || !subtaskId || !teamId) { - const runtimeAddress = queuedMessage.runtimeAddress ?? state.currentRuntimeTask - if (runtimeAddress) { - const runtimeWorkApi = resolvedServices.runtimeWorkApi - if (!runtimeWorkApi) { - setQueuedSends(items => - items.map(item => - item.id === id ? { ...item, status: 'failed', error: 'Runtime API 不可用' } : item - ) - ) - return - } - - setQueuedSends(items => - items.map(item => - item.id === id - ? { - ...item, - status: 'sending', - error: undefined, - notice: '正在暂停当前回复并发送', - } - : item - ) + const runtimeAddress = queuedMessage.runtimeAddress ?? state.currentRuntimeTask + if (!runtimeAddress) { + setQueuedSends(items => + items.map(item => + item.id === id ? { ...item, status: 'failed', error: '当前回复缺少引导上下文' } : item ) + ) + return + } - try { - const cancelResponse = await runtimeWorkApi.cancelRuntimeTask(runtimeAddress) - if (!cancelResponse.accepted) { - throw new Error(cancelResponse.error || '暂停当前回复失败') - } - - if (activeAssistantMessage?.subtaskId) { - dispatchMessages({ - type: 'assistant_done', - subtaskId: activeAssistantMessage.subtaskId, - content: activeAssistantMessage.content, - }) - } - - const runtimeSendRequest: RuntimeSendRequest = { - address: runtimeAddress, - message: queuedMessage.content, - } - if (queuedMessage.attachments && queuedMessage.attachments.length > 0) { - runtimeSendRequest.attachmentIds = queuedMessage.attachments.map( - attachment => attachment.id - ) - } - setIsAwaitingAssistantStart(true) - dispatchMessages({ - type: 'user_added', - message: { - id: `runtime-guidance-${Date.now()}`, - role: 'user', - content: queuedMessage.content, - attachments: queuedMessage.attachments, - status: 'done', - createdAt: new Date().toISOString(), - }, - }) - const sendResponse = await runtimeWorkApi.sendRuntimeMessage(runtimeSendRequest) - if (!sendResponse.accepted) { - throw new Error(sendResponse.error || '发送失败') - } - setQueuedSends(items => items.filter(item => item.id !== id)) - await refreshWorkLists() - } catch (error) { - setIsAwaitingAssistantStart(false) - setQueuedSends(items => - items.map(item => - item.id === id - ? { - ...item, - status: 'failed', - notice: undefined, - error: error instanceof Error ? error.message : '引导发送失败', - } - : item - ) - ) - } - return - } - + const runtimeWorkApi = resolvedServices.runtimeWorkApi + if (!runtimeWorkApi) { setQueuedSends(items => items.map(item => - item.id === id ? { ...item, status: 'failed', error: '当前回复缺少引导上下文' } : item + item.id === id ? { ...item, status: 'failed', error: 'Runtime API 不可用' } : item ) ) return } - const guidanceId = `guidance-${subtaskId}-${Date.now()}` - setQueuedSends(items => items.filter(item => item.id !== id)) - setGuidanceMessages(items => [ - ...items, - { - id: guidanceId, - content: queuedMessage.content, - status: 'sending', - createdAt: new Date().toISOString(), - }, - ]) + setQueuedSends(items => + items.map(item => + item.id === id + ? { + ...item, + status: 'sending', + error: undefined, + notice: '正在暂停当前回复并发送', + } + : item + ) + ) try { - const ack = await resolvedServices.chatStream.sendGuidance({ - task_id: taskId, - subtask_id: subtaskId, - team_id: teamId, - message: queuedMessage.content, - guidance: queuedMessage.content, - client_guidance_id: guidanceId, - }) + const cancelResponse = await runtimeWorkApi.cancelRuntimeTask(runtimeAddress) + if (!cancelResponse.accepted) { + throw new Error(cancelResponse.error || '暂停当前回复失败') + } - if (ack.error || ack.success === false) { - throw new Error(normalizeGuidanceError(ack.error)) + if (activeAssistantMessage?.subtaskId) { + dispatchMessages({ + type: 'assistant_done', + subtaskId: activeAssistantMessage.subtaskId, + content: activeAssistantMessage.content, + }) } - setGuidanceMessages(items => - items.map(item => - item.id === guidanceId - ? { - ...item, - id: ack.guidance_id ?? item.id, - status: 'queued', - } - : item + const runtimeSendRequest: RuntimeSendRequest = { + address: runtimeAddress, + message: queuedMessage.content, + } + if (queuedMessage.attachments && queuedMessage.attachments.length > 0) { + runtimeSendRequest.attachmentIds = queuedMessage.attachments.map( + attachment => attachment.id ) - ) + } + setIsAwaitingAssistantStart(true) + dispatchMessages({ + type: 'user_added', + message: { + id: `runtime-guidance-${Date.now()}`, + role: 'user', + content: queuedMessage.content, + attachments: queuedMessage.attachments, + status: 'done', + createdAt: new Date().toISOString(), + }, + }) + const sendResponse = await runtimeWorkApi.sendRuntimeMessage(runtimeSendRequest) + if (!sendResponse.accepted) { + throw new Error(sendResponse.error || '发送失败') + } + setQueuedSends(items => items.filter(item => item.id !== id)) + await refreshWorkLists() } catch (error) { - setGuidanceMessages(items => + setIsAwaitingAssistantStart(false) + setQueuedSends(items => items.map(item => - item.id === guidanceId + item.id === id ? { ...item, status: 'failed', - error: - error instanceof Error ? normalizeGuidanceError(error.message) : '引导发送失败', + notice: undefined, + error: error instanceof Error ? error.message : '引导发送失败', } : item ) @@ -3256,10 +3160,8 @@ export function WorkbenchProvider({ children, user, services }: WorkbenchProvide activeAssistantMessage, queuedSends, refreshWorkLists, - resolvedServices.chatStream, resolvedServices.runtimeWorkApi, state.currentRuntimeTask, - state.defaultTeam?.id, ] ) diff --git a/wework/src/features/workbench/workbenchReducer.test.ts b/wework/src/features/workbench/workbenchReducer.test.ts index 84ccb0e693..7a02432b5e 100644 --- a/wework/src/features/workbench/workbenchReducer.test.ts +++ b/wework/src/features/workbench/workbenchReducer.test.ts @@ -208,6 +208,55 @@ describe('workbenchReducer', () => { expect(refreshed.devices).toEqual([device]) }) + test('reconciles legacy local-device current task to the refreshed ready device id', () => { + const state = { + ...initialWorkbenchState, + currentRuntimeTask: { + deviceId: 'local-device', + localTaskId: 'task-1', + }, + } + + const refreshed = workbenchReducer(state, { + type: 'lists_refreshed', + projects: [], + devices: [ + { + id: 1, + device_id: 'device-uuid', + name: 'Local Executor', + status: 'online' as const, + is_default: true, + }, + ], + runtimeWork: { + projects: [], + chats: [ + { + deviceId: 'device-uuid', + workspacePath: '/Users/me/chat', + available: true, + mapped: true, + localTasks: [ + { + localTaskId: 'task-1', + workspacePath: '/Users/me/chat', + title: 'Chat', + runtime: 'codex', + }, + ], + }, + ], + totalLocalTasks: 1, + }, + }) + + expect(refreshed.currentRuntimeTask).toEqual({ + deviceId: 'device-uuid', + localTaskId: 'task-1', + }) + }) + test('updates cached device and runtime workspace status from websocket events', () => { const state = workbenchReducer(initialWorkbenchState, { type: 'lists_refreshed', diff --git a/wework/src/features/workbench/workbenchReducer.ts b/wework/src/features/workbench/workbenchReducer.ts index ec5217cb89..881140f69e 100644 --- a/wework/src/features/workbench/workbenchReducer.ts +++ b/wework/src/features/workbench/workbenchReducer.ts @@ -128,6 +128,50 @@ function updateRuntimeWorkDeviceStatus( } } +function findRuntimeTaskAddressByLocalTaskId( + runtimeWork: RuntimeWorkListResponse | null | undefined, + localTaskId: string +): RuntimeTaskAddress | null { + if (!runtimeWork) return null + + let match: RuntimeTaskAddress | null = null + const workspaces = [ + ...runtimeWork.projects.flatMap(project => project.deviceWorkspaces), + ...runtimeWork.chats, + ] + + for (const workspace of workspaces) { + if (!workspace.localTasks.some(task => task.localTaskId === localTaskId)) continue + + const address = { + deviceId: workspace.deviceId, + localTaskId, + } + if (match && match.deviceId !== address.deviceId) { + return null + } + match = address + } + + return match +} + +function reconcileCurrentRuntimeTaskAddress( + currentRuntimeTask: RuntimeTaskAddress | null, + devices: DeviceInfo[], + runtimeWork: RuntimeWorkListResponse | null | undefined +): RuntimeTaskAddress | null { + if (!currentRuntimeTask) return null + if (devices.some(device => device.device_id === currentRuntimeTask.deviceId)) { + return currentRuntimeTask + } + + return ( + findRuntimeTaskAddressByLocalTaskId(runtimeWork, currentRuntimeTask.localTaskId) ?? + currentRuntimeTask + ) +} + function runtimeWorkspaceFromMapping( mapping: DeviceWorkspaceResponse, devices: DeviceInfo[] @@ -215,14 +259,21 @@ function upsertPreparedRuntimeWorkspace( export function workbenchReducer(state: WorkbenchState, action: WorkbenchAction): WorkbenchState { switch (action.type) { - case 'bootstrapped': + case 'bootstrapped': { + const devices = keepDevicesOnTransientEmpty(state.devices, action.devices) + const runtimeWork = action.runtimeWork === undefined ? state.runtimeWork : action.runtimeWork return { ...state, user: action.user, defaultTeam: action.defaultTeam, projects: action.projects, - devices: keepDevicesOnTransientEmpty(state.devices, action.devices), - runtimeWork: action.runtimeWork === undefined ? state.runtimeWork : action.runtimeWork, + devices, + runtimeWork, + currentRuntimeTask: reconcileCurrentRuntimeTaskAddress( + state.currentRuntimeTask, + devices, + runtimeWork + ), currentProject: action.currentProject === undefined ? state.currentProject : action.currentProject, standaloneDeviceId: @@ -236,12 +287,20 @@ export function workbenchReducer(state: WorkbenchState, action: WorkbenchAction) isBootstrapping: false, error: null, } + } case 'lists_refreshed': { + const devices = keepDevicesOnTransientEmpty(state.devices, action.devices) + const runtimeWork = action.runtimeWork === undefined ? state.runtimeWork : action.runtimeWork const refreshedState = { ...state, projects: action.projects, - devices: keepDevicesOnTransientEmpty(state.devices, action.devices), - runtimeWork: action.runtimeWork === undefined ? state.runtimeWork : action.runtimeWork, + devices, + runtimeWork, + currentRuntimeTask: reconcileCurrentRuntimeTaskAddress( + state.currentRuntimeTask, + devices, + runtimeWork + ), currentProject: state.currentProject ? (action.projects.find(project => project.id === state.currentProject?.id) ?? null) : null, @@ -256,10 +315,16 @@ export function workbenchReducer(state: WorkbenchState, action: WorkbenchAction) } return refreshedState } - case 'devices_refreshed': + case 'devices_refreshed': { + const devices = keepDevicesOnTransientEmpty(state.devices, action.devices) return { ...state, - devices: keepDevicesOnTransientEmpty(state.devices, action.devices), + devices, + currentRuntimeTask: reconcileCurrentRuntimeTaskAddress( + state.currentRuntimeTask, + devices, + state.runtimeWork + ), standaloneDeviceId: action.standaloneDeviceId === undefined ? state.standaloneDeviceId @@ -269,6 +334,7 @@ export function workbenchReducer(state: WorkbenchState, action: WorkbenchAction) ? state.standaloneWorkspacePath : action.standaloneWorkspacePath, } + } case 'device_status_changed': return { ...state, diff --git a/wework/src/i18n/index.ts b/wework/src/i18n/index.ts index 04fc3bc540..28be0e19be 100644 --- a/wework/src/i18n/index.ts +++ b/wework/src/i18n/index.ts @@ -5,14 +5,16 @@ import enCommon from './locales/en/common.json' import zhCommon from './locales/zh-CN/common.json' import enChat from './locales/en/chat.json' import zhChat from './locales/zh-CN/chat.json' +import enLocalRuntime from './locales/en/localRuntime.json' +import zhLocalRuntime from './locales/zh-CN/localRuntime.json' i18n .use(LanguageDetector) .use(initReactI18next) .init({ resources: { - en: { chat: enChat, common: enCommon }, - 'zh-CN': { chat: zhChat, common: zhCommon }, + en: { chat: enChat, common: enCommon, localRuntime: enLocalRuntime }, + 'zh-CN': { chat: zhChat, common: zhCommon, localRuntime: zhLocalRuntime }, }, lng: 'zh-CN', fallbackLng: 'zh-CN', diff --git a/wework/src/i18n/locales/en/localRuntime.json b/wework/src/i18n/locales/en/localRuntime.json new file mode 100644 index 0000000000..a3ea19b1cb --- /dev/null +++ b/wework/src/i18n/locales/en/localRuntime.json @@ -0,0 +1,9 @@ +{ + "starting_title": "Starting local executor", + "starting_description": "Connecting to the local daemon. First startup may take a few seconds.", + "failed_title": "Local executor failed to start", + "failed_description": "Check whether the executor can start correctly, or inspect the log and retry.", + "log_label": "Log file", + "retry": "Retry", + "fallback_error": "Local executor is unavailable" +} diff --git a/wework/src/i18n/locales/zh-CN/localRuntime.json b/wework/src/i18n/locales/zh-CN/localRuntime.json new file mode 100644 index 0000000000..4313e481c1 --- /dev/null +++ b/wework/src/i18n/locales/zh-CN/localRuntime.json @@ -0,0 +1,9 @@ +{ + "starting_title": "正在启动本地执行器", + "starting_description": "正在连接本机 daemon。首次启动可能需要几秒。", + "failed_title": "本地执行器启动失败", + "failed_description": "请检查 executor 是否能正常启动,或查看日志后重试。", + "log_label": "日志位置", + "retry": "重试", + "fallback_error": "本地执行器不可用" +} diff --git a/wework/src/pages/LoginPage.test.tsx b/wework/src/pages/LoginPage.test.tsx index 3e81e97a75..85d208adca 100644 --- a/wework/src/pages/LoginPage.test.tsx +++ b/wework/src/pages/LoginPage.test.tsx @@ -23,6 +23,9 @@ describe('LoginPage', () => { beforeEach(() => { localStorage.clear() sessionStorage.clear() + window.__WEWORK_RUNTIME_CONFIG__ = { + runtimeMode: 'backend', + } window.history.pushState({}, '', '/login') }) diff --git a/wework/src/stream/chatStream.test.ts b/wework/src/stream/chatStream.test.ts index c48f3af7b6..4105682e17 100644 --- a/wework/src/stream/chatStream.test.ts +++ b/wework/src/stream/chatStream.test.ts @@ -2,30 +2,61 @@ import { describe, expect, test, vi } from 'vitest' import { createChatStream } from './chatStream' describe('createChatStream', () => { - test('cancels active stream through chat:cancel', async () => { - const emit = vi.fn((_event, _payload, ack) => ack({ success: true })) - const socket = { emit, on: vi.fn(), off: vi.fn(), connected: true } + test('maps backend response api text events to chat chunks', () => { + const listeners = new Map void>() + const socket = { + emit: vi.fn(), + on: vi.fn((event: string, handler: (payload: unknown) => void) => { + listeners.set(event, handler) + }), + off: vi.fn(), + connected: true, + } + const onChatChunk = vi.fn() const stream = createChatStream(socket) - const result = await stream.cancelStream({ - subtask_id: 9, - partial_content: 'partial', - shell_type: 'ClaudeCode', + stream.subscribe({ onChatChunk }) + listeners.get('response.output_text.delta')?.({ + task_id: 0, + subtask_id: 202, + device_id: 'device-1', + local_task_id: 'codex-1', + runtime: 'codex', + data: { delta: 'hello' }, + }) + + expect(onChatChunk).toHaveBeenCalledWith({ + task_id: 0, + subtask_id: 202, + device_id: 'device-1', + local_task_id: 'codex-1', + content: 'hello', + offset: 0, + result: { delta: 'hello' }, }) + }) + + test('does not subscribe to legacy chat stream events', () => { + const socket = { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + connected: true, + } + const onChatChunk = vi.fn() + const stream = createChatStream(socket) + + stream.subscribe({ onChatChunk }) - expect(result).toEqual({ success: true }) - expect(emit).toHaveBeenCalledWith( - 'chat:cancel', - { - subtask_id: 9, - partial_content: 'partial', - shell_type: 'ClaudeCode', - }, - expect.any(Function) - ) + expect(socket.on).not.toHaveBeenCalledWith('chat:start', expect.any(Function)) + expect(socket.on).not.toHaveBeenCalledWith('chat:chunk', expect.any(Function)) + expect(socket.on).not.toHaveBeenCalledWith('chat:done', expect.any(Function)) + expect(socket.on).not.toHaveBeenCalledWith('chat:error', expect.any(Function)) + expect(socket.on).not.toHaveBeenCalledWith('chat:block_created', expect.any(Function)) + expect(socket.on).not.toHaveBeenCalledWith('chat:block_updated', expect.any(Function)) }) - test('registers and unregisters streaming handlers', () => { + test('registers and unregisters response api and device handlers', () => { const socket = { emit: vi.fn(), on: vi.fn(), off: vi.fn(), connected: true } const stream = createChatStream(socket) const handlers = { @@ -37,17 +68,11 @@ describe('createChatStream', () => { const cleanup = stream.subscribe(handlers) cleanup() - expect(socket.on).toHaveBeenCalledWith('chat:chunk', handlers.onChatChunk) + expect(socket.on).toHaveBeenCalledWith('response.output_text.delta', expect.any(Function)) expect(socket.on).toHaveBeenCalledWith('device:slot_update', handlers.onDeviceSlotUpdate) - expect(socket.on).toHaveBeenCalledWith( - 'device:upgrade_status', - handlers.onDeviceUpgradeStatus - ) - expect(socket.off).toHaveBeenCalledWith('chat:chunk', handlers.onChatChunk) + expect(socket.on).toHaveBeenCalledWith('device:upgrade_status', handlers.onDeviceUpgradeStatus) + expect(socket.off).toHaveBeenCalledWith('response.output_text.delta', expect.any(Function)) expect(socket.off).toHaveBeenCalledWith('device:slot_update', handlers.onDeviceSlotUpdate) - expect(socket.off).toHaveBeenCalledWith( - 'device:upgrade_status', - handlers.onDeviceUpgradeStatus - ) + expect(socket.off).toHaveBeenCalledWith('device:upgrade_status', handlers.onDeviceUpgradeStatus) }) }) diff --git a/wework/src/stream/chatStream.ts b/wework/src/stream/chatStream.ts index 7cdd3db76d..ed13b90e60 100644 --- a/wework/src/stream/chatStream.ts +++ b/wework/src/stream/chatStream.ts @@ -1,35 +1,28 @@ import type { ChatBlockCreatedPayload, ChatBlockUpdatedPayload, - ChatCancelAck, - ChatCancelPayload, ChatChunkPayload, ChatDonePayload, ChatErrorPayload, - ChatGuideAck, - ChatGuidePayload, - ChatGuidanceAppliedPayload, - ChatGuidanceExpiredPayload, - ChatGuidanceQueuedPayload, - ChatMessagePayload, ChatStartPayload, } from '@/types/api' import type { DeviceSlotUpdatePayload, DeviceUpgradeStatusPayload } from '@/types/device-events' import type { SocketClientSocket } from '@wegent/chat-core' +import { + createResponseApiStreamState, + emitResponseApiEvent, + RESPONSE_API_STREAM_EVENTS, +} from './responseApiStream' export type WorkbenchSocket = SocketClientSocket export interface ChatStreamHandlers { - onChatMessage?: (payload: ChatMessagePayload) => void onChatStart?: (payload: ChatStartPayload) => void onChatChunk?: (payload: ChatChunkPayload) => void onChatDone?: (payload: ChatDonePayload) => void onChatError?: (payload: ChatErrorPayload) => void onBlockCreated?: (payload: ChatBlockCreatedPayload) => void onBlockUpdated?: (payload: ChatBlockUpdatedPayload) => void - onGuidanceQueued?: (payload: ChatGuidanceQueuedPayload) => void - onGuidanceApplied?: (payload: ChatGuidanceAppliedPayload) => void - onGuidanceExpired?: (payload: ChatGuidanceExpiredPayload) => void onDeviceOnline?: (payload: unknown) => void onDeviceOffline?: (payload: unknown) => void onDeviceStatus?: (payload: unknown) => void @@ -37,69 +30,20 @@ export interface ChatStreamHandlers { onDeviceUpgradeStatus?: (payload: DeviceUpgradeStatusPayload) => void } -const SEND_TIMEOUT_MS = 30_000 - -function normalizeGuideAck(response: ChatGuideAck | undefined): ChatGuideAck { - if (!response) { - return { success: false, error: '引导发送失败' } - } - if (response.error) { - return { ...response, success: false } - } - return { ...response, success: response.success ?? true } -} - export function createChatStream( socket: Pick ) { return { - sendGuidance(payload: ChatGuidePayload): Promise { - return new Promise(resolve => { - if (!socket.connected) { - resolve({ success: false, error: '连接未建立,请刷新页面重试' }) - return + subscribe(handlers: ChatStreamHandlers): () => void { + const responseState = createResponseApiStreamState() + const responseHandlers = RESPONSE_API_STREAM_EVENTS.map(eventName => { + const handler = (payload: unknown) => { + emitResponseApiEvent(handlers, eventName, payload, responseState) } - - const timer = setTimeout(() => { - resolve({ success: false, error: '引导发送超时,请重试' }) - }, SEND_TIMEOUT_MS) - - socket.emit('chat:guide', payload, (response: ChatGuideAck) => { - clearTimeout(timer) - resolve(normalizeGuideAck(response)) - }) + socket.on(eventName, handler) + return { eventName, handler } }) - }, - cancelStream(payload: ChatCancelPayload): Promise { - return new Promise(resolve => { - if (!socket.connected) { - resolve({ success: false, error: '连接未建立,请刷新页面重试' }) - return - } - socket.emit('chat:cancel', payload, (response: ChatCancelAck) => { - if (!response) { - resolve({ success: false, error: '取消失败' }) - return - } - resolve({ - ...response, - success: response.error ? false : (response.success ?? true), - }) - }) - }) - }, - subscribe(handlers: ChatStreamHandlers): () => void { - if (handlers.onChatMessage) socket.on('chat:message', handlers.onChatMessage) - if (handlers.onChatStart) socket.on('chat:start', handlers.onChatStart) - if (handlers.onChatChunk) socket.on('chat:chunk', handlers.onChatChunk) - if (handlers.onChatDone) socket.on('chat:done', handlers.onChatDone) - if (handlers.onChatError) socket.on('chat:error', handlers.onChatError) - if (handlers.onBlockCreated) socket.on('chat:block_created', handlers.onBlockCreated) - if (handlers.onBlockUpdated) socket.on('chat:block_updated', handlers.onBlockUpdated) - if (handlers.onGuidanceQueued) socket.on('chat:guidance_queued', handlers.onGuidanceQueued) - if (handlers.onGuidanceApplied) socket.on('chat:guidance_applied', handlers.onGuidanceApplied) - if (handlers.onGuidanceExpired) socket.on('chat:guidance_expired', handlers.onGuidanceExpired) if (handlers.onDeviceOnline) socket.on('device:online', handlers.onDeviceOnline) if (handlers.onDeviceOffline) socket.on('device:offline', handlers.onDeviceOffline) if (handlers.onDeviceStatus) socket.on('device:status', handlers.onDeviceStatus) @@ -111,18 +55,9 @@ export function createChatStream( } return () => { - if (handlers.onChatMessage) socket.off('chat:message', handlers.onChatMessage) - if (handlers.onChatStart) socket.off('chat:start', handlers.onChatStart) - if (handlers.onChatChunk) socket.off('chat:chunk', handlers.onChatChunk) - if (handlers.onChatDone) socket.off('chat:done', handlers.onChatDone) - if (handlers.onChatError) socket.off('chat:error', handlers.onChatError) - if (handlers.onBlockCreated) socket.off('chat:block_created', handlers.onBlockCreated) - if (handlers.onBlockUpdated) socket.off('chat:block_updated', handlers.onBlockUpdated) - if (handlers.onGuidanceQueued) socket.off('chat:guidance_queued', handlers.onGuidanceQueued) - if (handlers.onGuidanceApplied) - socket.off('chat:guidance_applied', handlers.onGuidanceApplied) - if (handlers.onGuidanceExpired) - socket.off('chat:guidance_expired', handlers.onGuidanceExpired) + responseHandlers.forEach(({ eventName, handler }) => { + socket.off(eventName, handler) + }) if (handlers.onDeviceOnline) socket.off('device:online', handlers.onDeviceOnline) if (handlers.onDeviceOffline) socket.off('device:offline', handlers.onDeviceOffline) if (handlers.onDeviceStatus) socket.off('device:status', handlers.onDeviceStatus) diff --git a/wework/src/stream/responseApiStream.ts b/wework/src/stream/responseApiStream.ts new file mode 100644 index 0000000000..60d54cc9c3 --- /dev/null +++ b/wework/src/stream/responseApiStream.ts @@ -0,0 +1,482 @@ +import type { ChatBlock } from '@/types/api' +import type { ChatStreamHandlers } from './chatStream' + +export const RESPONSE_API_STREAM_EVENTS = [ + 'response.created', + 'response.in_progress', + 'response.completed', + 'response.failed', + 'response.incomplete', + 'response.reasoning_summary_part.added', + 'response.reasoning_summary_text.delta', + 'response.output_item.added', + 'response.output_item.done', + 'response.content_part.added', + 'response.content_part.done', + 'response.output_text.delta', + 'response.output_text.annotation.added', + 'response.output_text.done', + 'response.refusal.delta', + 'response.refusal.done', + 'response.function_call_arguments.delta', + 'response.function_call_arguments.done', + 'response.file_search_call.in_progress', + 'response.file_search_call.searching', + 'response.file_search_call.completed', + 'response.web_search_call.in_progress', + 'response.web_search_call.searching', + 'response.web_search_call.completed', + 'response.mcp_list_tools.in_progress', + 'response.mcp_list_tools.completed', + 'response.mcp_list_tools.failed', + 'response.mcp_call.in_progress', + 'response.mcp_call_arguments.delta', + 'response.mcp_call_arguments.done', + 'response.mcp_call.completed', + 'response.mcp_call.failed', + 'image_generation.partial_image', + 'response.block.created', + 'response.block.updated', + 'response.status.updated', + 'error', +] as const + +export interface ResponseApiStreamState { + toolContexts: Map }> +} + +export function createResponseApiStreamState(): ResponseApiStreamState { + return { toolContexts: new Map() } +} + +function asRecord(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : {} +} + +function stringField(record: Record, key: string): string | undefined { + const value = record[key] + return typeof value === 'string' ? value : undefined +} + +function numberField(record: Record, key: string): number { + const value = record[key] + return typeof value === 'number' ? value : 0 +} + +function recordField(record: Record, key: string): Record { + return asRecord(record[key]) +} + +function eventBase(payload: Record) { + return { + task_id: numberField(payload, 'task_id'), + subtask_id: numberField(payload, 'subtask_id'), + device_id: stringField(payload, 'device_id'), + local_task_id: stringField(payload, 'local_task_id'), + } +} + +function eventResult(payload: Record): Record { + return asRecord(payload.data) +} + +function parseRecord(value: unknown): Record | undefined { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as Record + } + if (typeof value !== 'string' || !value.trim()) return undefined + + try { + const parsed = JSON.parse(value) + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : undefined + } catch { + return undefined + } +} + +function eventContent(payload: Record): string { + const result = eventResult(payload) + return ( + stringField(result, 'delta') ?? + stringField(result, 'value') ?? + stringField(result, 'output_text') ?? + '' + ) +} + +function completedContent(data: Record): string | undefined { + const explicit = stringField(data, 'value') ?? stringField(data, 'output_text') + if (explicit) return explicit + + const response = recordField(data, 'response') + const output = response.output + if (!Array.isArray(output)) return undefined + + const parts = output.flatMap(item => { + const itemRecord = asRecord(item) + const content = itemRecord.content + if (!Array.isArray(content)) return [] + return content.flatMap(part => { + const partRecord = asRecord(part) + if (partRecord.type !== 'output_text') return [] + const text = stringField(partRecord, 'text') + return text ? [text] : [] + }) + }) + + return parts.join('') +} + +function completedResult(data: Record): Record { + const result: Record = { ...data } + const content = completedContent(data) + if (content !== undefined) { + result.value = content + } + + const response = recordField(data, 'response') + const fileChanges = response.file_changes ?? response.fileChanges ?? data.file_changes + if (typeof fileChanges === 'object' && fileChanges !== null && !Array.isArray(fileChanges)) { + result.file_changes = fileChanges + } + const blocks = response.blocks ?? data.blocks + if (Array.isArray(blocks)) { + result.blocks = blocks + } + + return result +} + +function reasoningContent(eventName: string, data: Record): string { + if (eventName === 'response.reasoning_summary_text.delta') { + return stringField(data, 'delta') ?? '' + } + + const part = recordField(data, 'part') + return part.type === 'reasoning' ? (stringField(part, 'text') ?? '') : '' +} + +function callIdFromItem(item: Record): string | undefined { + return stringField(item, 'call_id') ?? stringField(item, 'callId') ?? stringField(item, 'id') +} + +function callIdFromData(data: Record): string | undefined { + return stringField(data, 'call_id') ?? stringField(data, 'callId') ?? stringField(data, 'item_id') +} + +function normalizeToolName(item: Record): string { + const itemType = stringField(item, 'type') + const rawName = + stringField(item, 'name') ?? + stringField(item, 'server_label') ?? + (itemType === 'shell_call' ? 'bash' : undefined) ?? + (itemType === 'mcp_call' ? 'mcp' : 'unknown') + if (rawName === 'exec_command') return 'bash' + if (rawName === 'exec') return 'bash' + return rawName +} + +function normalizeToolInput(input: Record | undefined): Record { + if (!input) return {} + const normalized = { ...input } + if ('cmd' in normalized && !('command' in normalized)) { + normalized.command = normalized.cmd + delete normalized.cmd + } + if ('workdir' in normalized && !('cwd' in normalized)) { + normalized.cwd = normalized.workdir + delete normalized.workdir + } + return normalized +} + +function toolInputFrom( + data: Record, + item: Record +): Record { + const argumentsSummary = parseRecord(data.arguments_summary) + if (argumentsSummary) return normalizeToolInput(argumentsSummary) + + const itemInput = parseRecord(item.input) + if (itemInput) return normalizeToolInput(itemInput) + + const itemArguments = parseRecord(item.arguments) + if (itemArguments) return normalizeToolInput(itemArguments) + + const dataArguments = parseRecord(data.arguments) + if (dataArguments) return normalizeToolInput(dataArguments) + + const action = recordField(item, 'action') + const commands = action.commands + if (Array.isArray(commands)) { + const command = commands + .filter((value): value is string => typeof value === 'string') + .join('\n') + return command ? { command } : {} + } + + return {} +} + +function responseToolItem(data: Record): Record { + return recordField(data, 'item') +} + +function isToolItem(item: Record): boolean { + return ['function_call', 'mcp_call', 'shell_call'].includes(stringField(item, 'type') ?? '') +} + +function toolStatusFromItem( + item: Record, + fallback: ChatBlock['status'] +): ChatBlock['status'] { + const status = stringField(item, 'status') + return status === 'error' || status === 'failed' ? 'error' : fallback +} + +function emitBlockCreated( + handlers: ChatStreamHandlers, + base: ReturnType, + data: Record, + state: ResponseApiStreamState +): void { + const item = responseToolItem(data) + if (!isToolItem(item)) return + + const callId = callIdFromItem(item) + if (!callId) return + + const toolInput = toolInputFrom(data, item) + const toolName = normalizeToolName(item) + state.toolContexts.set(callId, { name: toolName, input: toolInput }) + + handlers.onBlockCreated?.({ + ...base, + block: { + id: callId, + type: 'tool', + tool_use_id: callId, + tool_name: toolName, + tool_input: toolInput, + status: data.argument_status === 'streaming' ? 'generating_arguments' : 'pending', + timestamp: Date.now(), + }, + }) +} + +function emitBlockUpdated( + handlers: ChatStreamHandlers, + base: ReturnType, + blockId: string | undefined, + updates: { + status?: ChatBlock['status'] | 'running' + tool_input?: Record + tool_output?: unknown + content?: string + } +): void { + if (!blockId) return + handlers.onBlockUpdated?.({ + ...base, + block_id: blockId, + ...updates, + }) +} + +function emitToolArgumentsUpdate( + handlers: ChatStreamHandlers, + base: ReturnType, + data: Record, + state: ResponseApiStreamState, + status: ChatBlock['status'] +): void { + const callId = callIdFromData(data) + if (!callId) return + + const toolInput = toolInputFrom(data, {}) + const previous = state.toolContexts.get(callId) ?? {} + const nextInput = Object.keys(toolInput).length > 0 ? toolInput : previous.input + state.toolContexts.set(callId, { ...previous, input: nextInput }) + + emitBlockUpdated(handlers, base, callId, { + status, + ...(nextInput && { tool_input: nextInput }), + }) +} + +function emitToolDone( + handlers: ChatStreamHandlers, + base: ReturnType, + data: Record, + state: ResponseApiStreamState +): void { + const item = responseToolItem(data) + const callId = callIdFromItem(item) ?? callIdFromData(data) + if (!callId) return + + const previous = state.toolContexts.get(callId) + const toolInput = toolInputFrom(data, item) + const nextInput = Object.keys(toolInput).length > 0 ? toolInput : previous?.input + const toolOutput = item.output ?? data.output ?? data.failure_reason + const status = toolStatusFromItem(item, data.failure_reason ? 'error' : 'done') + + emitBlockUpdated(handlers, base, callId, { + status, + ...(nextInput && { tool_input: nextInput }), + ...(toolOutput !== undefined && { tool_output: toolOutput }), + }) + + state.toolContexts.delete(callId) +} + +function emitResponseBlockCreated( + handlers: ChatStreamHandlers, + base: ReturnType, + data: Record +): void { + const block = data.block + if (typeof block !== 'object' || block === null || Array.isArray(block)) return + handlers.onBlockCreated?.({ + ...base, + block: block as ChatBlock, + }) +} + +function emitResponseBlockUpdated( + handlers: ChatStreamHandlers, + base: ReturnType, + data: Record +): void { + const updates = recordField(data, 'updates') + const toolInput = parseRecord(updates.tool_input) + emitBlockUpdated(handlers, base, stringField(data, 'block_id'), { + ...(typeof updates.content === 'string' && { content: updates.content }), + ...(typeof updates.tool_output !== 'undefined' && { tool_output: updates.tool_output }), + ...(toolInput && { tool_input: toolInput }), + ...(typeof updates.status === 'string' && { + status: updates.status as ChatBlock['status'], + }), + }) +} + +function errorMessage(payload: Record, data: Record): string { + const error = data.error + if (typeof error === 'string') return error + const errorRecord = asRecord(error) + return ( + stringField(payload, 'error') ?? + stringField(data, 'message') ?? + stringField(errorRecord, 'message') ?? + eventContent(payload) ?? + 'Response stream error' + ) +} + +export function emitResponseApiEvent( + handlers: ChatStreamHandlers, + eventName: string, + rawPayload: unknown, + state: ResponseApiStreamState +): void { + const payload = asRecord(rawPayload) + const base = eventBase(payload) + const data = eventResult(payload) + + if (eventName === 'response.created' || eventName === 'response.in_progress') { + handlers.onChatStart?.({ + ...base, + shell_type: stringField(payload, 'runtime'), + }) + return + } + + if (eventName === 'response.output_text.delta' || eventName === 'response.refusal.delta') { + handlers.onChatChunk?.({ + ...base, + content: eventContent(payload), + offset: numberField(payload, 'offset'), + result: eventResult(payload), + }) + return + } + + if ( + eventName === 'response.reasoning_summary_text.delta' || + eventName === 'response.reasoning_summary_part.added' + ) { + const content = reasoningContent(eventName, data) + if (!content) return + handlers.onChatChunk?.({ + ...base, + content: '', + offset: numberField(payload, 'offset'), + result: { reasoning_chunk: content }, + }) + return + } + + if (eventName === 'response.block.created') { + emitResponseBlockCreated(handlers, base, data) + return + } + + if (eventName === 'response.block.updated') { + emitResponseBlockUpdated(handlers, base, data) + return + } + + if (eventName === 'response.output_item.added') { + emitBlockCreated(handlers, base, data, state) + return + } + + if ( + eventName === 'response.function_call_arguments.delta' || + eventName === 'response.mcp_call_arguments.delta' + ) { + emitToolArgumentsUpdate(handlers, base, data, state, 'generating_arguments') + return + } + + if ( + eventName === 'response.function_call_arguments.done' || + eventName === 'response.mcp_call_arguments.done' + ) { + emitToolArgumentsUpdate(handlers, base, data, state, 'pending') + return + } + + if ( + eventName === 'response.output_item.done' || + eventName === 'response.mcp_call.completed' || + eventName === 'response.mcp_call.failed' + ) { + emitToolDone(handlers, base, data, state) + return + } + + if (eventName === 'response.completed') { + handlers.onChatDone?.({ + ...base, + offset: numberField(payload, 'offset'), + result: completedResult(data), + }) + return + } + + if ( + eventName === 'response.incomplete' || + eventName === 'response.failed' || + eventName === 'error' + ) { + handlers.onChatError?.({ + ...base, + error: errorMessage(payload, data), + type: stringField(payload, 'type') ?? eventName, + }) + } +} diff --git a/wework/src/tauri/localExecutor.ts b/wework/src/tauri/localExecutor.ts index 17f15def9a..bdfcbacd42 100644 --- a/wework/src/tauri/localExecutor.ts +++ b/wework/src/tauri/localExecutor.ts @@ -14,6 +14,7 @@ export interface LocalExecutorStatus { running: boolean ready?: boolean deviceId?: string + version?: string error?: string } diff --git a/wework/src/types/api.ts b/wework/src/types/api.ts index 3d72b4026d..263751f3b9 100644 --- a/wework/src/types/api.ts +++ b/wework/src/types/api.ts @@ -107,6 +107,7 @@ export interface DeviceInfo { executor_version?: string | null latest_version?: string | null update_available?: boolean + error?: string | null bind_shell?: 'claudecode' | 'openclaw' | string client_ip?: string | null runtime_transfer_host?: string | null From c0d418f979a66b92dfd7f752de6aae19aa2d91a6 Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 25 Jun 2026 19:54:41 +0800 Subject: [PATCH 16/17] fix: use local mode in app --- wework/src/api/http.ts | 7 +-- .../src/features/auth/AuthProvider.test.tsx | 35 +++++++++++++++ wework/src/features/auth/AuthProvider.tsx | 3 +- .../workbench/WorkbenchProvider.test.tsx | 13 ++++++ .../features/workbench/WorkbenchProvider.tsx | 3 +- wework/src/lib/local-terminal.test.ts | 1 + wework/src/lib/runtime-environment.test.ts | 44 +++++++++++++++++++ wework/src/lib/runtime-environment.ts | 11 ++++- 8 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 wework/src/lib/runtime-environment.test.ts diff --git a/wework/src/api/http.ts b/wework/src/api/http.ts index 81a919a290..1fcdcceb03 100644 --- a/wework/src/api/http.ts +++ b/wework/src/api/http.ts @@ -1,17 +1,14 @@ import { fetch as tauriFetch } from '@tauri-apps/plugin-http' import { removeToken } from './auth' import { redirectToLogin } from '@/features/auth/redirect' +import { isTauriRuntime } from '@/lib/runtime-environment' // In a packaged Tauri app the WebView calls the API cross-origin, which // triggers CORS preflight. Routing through the Tauri (Rust) HTTP client // bypasses the WebView same-origin policy entirely. Outside Tauri (browser // dev server via Vite proxy, vitest) fall back to the global fetch. export function shouldUseTauriFetch(): boolean { - return ( - import.meta.env.MODE !== 'test' && - typeof window !== 'undefined' && - '__TAURI_INTERNALS__' in window - ) + return import.meta.env.MODE !== 'test' && isTauriRuntime() } function requestUrl(baseUrl: string, endpoint: string): string { diff --git a/wework/src/features/auth/AuthProvider.test.tsx b/wework/src/features/auth/AuthProvider.test.tsx index fc19058d8c..9558b0decb 100644 --- a/wework/src/features/auth/AuthProvider.test.tsx +++ b/wework/src/features/auth/AuthProvider.test.tsx @@ -27,11 +27,23 @@ function useBackendRuntime() { } } +function setTauriRuntime() { + Object.defineProperty(window, '__TAURI_INTERNALS__', { + value: {}, + configurable: true, + }) +} + +function clearTauriRuntime() { + delete (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ +} + describe('AuthProvider', () => { beforeEach(() => { localStorage.clear() sessionStorage.clear() delete window.__WEWORK_RUNTIME_CONFIG__ + clearTauriRuntime() window.history.pushState({}, '', '/') }) @@ -98,6 +110,7 @@ describe('AuthProvider', () => { }) test('creates local user without redirect or backend calls in local-first mode', async () => { + setTauriRuntime() window.__WEWORK_RUNTIME_CONFIG__ = { runtimeMode: 'local-first', } @@ -123,4 +136,26 @@ describe('AuthProvider', () => { expect(authApi.getCurrentUser).not.toHaveBeenCalled() expect(authApi.getCurrentUserWithoutAuthRedirect).not.toHaveBeenCalled() }) + + test('uses backend auth in browser even when runtime mode defaults local-first', async () => { + setToken(createJwt(Math.floor(Date.now() / 1000) + 3600)) + const authApi = { + getCurrentUser: vi.fn().mockResolvedValue({ id: 1, user_name: 'admin', email: 'a@b.c' }), + getCurrentUserWithoutAuthRedirect: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + loginWithOidcToken: vi.fn(), + setupAdminPassword: vi.fn(), + } + + render( + + + + ) + + await waitFor(() => expect(screen.getByTestId('auth-probe')).toHaveTextContent('admin:ready')) + expect(authApi.getCurrentUser).toHaveBeenCalledTimes(1) + expect(window.location.pathname).toBe('/') + }) }) diff --git a/wework/src/features/auth/AuthProvider.tsx b/wework/src/features/auth/AuthProvider.tsx index 72470536af..be62839816 100644 --- a/wework/src/features/auth/AuthProvider.tsx +++ b/wework/src/features/auth/AuthProvider.tsx @@ -4,6 +4,7 @@ import { createAuthApi, isAuthenticated, removeToken, type LoginRequest } from ' import { ApiError, createHttpClient } from '@/api/http' import { getLocalUser } from '@/api/local/localSession' import { getRuntimeConfig, stripAppBasePath } from '@/config/runtime' +import { isTauriRuntime } from '@/lib/runtime-environment' import type { User } from '@/types/api' import { getAdminUsernameFromSetupError, @@ -26,7 +27,7 @@ function createDefaultAuthApi(): AuthApi { } function isLocalFirstRuntime(): boolean { - return getRuntimeConfig().runtimeMode === 'local-first' + return getRuntimeConfig().runtimeMode === 'local-first' && isTauriRuntime() } function isAuthRoute(pathname: string) { diff --git a/wework/src/features/workbench/WorkbenchProvider.test.tsx b/wework/src/features/workbench/WorkbenchProvider.test.tsx index fd3e19f635..ffc9a4a7da 100644 --- a/wework/src/features/workbench/WorkbenchProvider.test.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.test.tsx @@ -31,6 +31,17 @@ vi.mock('@/tauri/localExecutor', () => ({ subscribeLocalExecutorEvents: localExecutorMocks.subscribeLocalExecutorEvents, })) +function setTauriRuntime() { + Object.defineProperty(window, '__TAURI_INTERNALS__', { + value: {}, + configurable: true, + }) +} + +function clearTauriRuntime() { + delete (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ +} + function deferred() { let resolve!: (value: T) => void let reject!: (error: unknown) => void @@ -599,6 +610,7 @@ function RuntimeTaskSkillsProbe() { describe('WorkbenchProvider runtime tasks', () => { beforeEach(() => { delete window.__WEWORK_RUNTIME_CONFIG__ + clearTauriRuntime() window.history.pushState({}, '', '/') localStorage.clear() sessionStorage.clear() @@ -618,6 +630,7 @@ describe('WorkbenchProvider runtime tasks', () => { }) test('bootstraps with local app services in local-first runtime mode', async () => { + setTauriRuntime() window.__WEWORK_RUNTIME_CONFIG__ = { runtimeMode: 'local-first', } diff --git a/wework/src/features/workbench/WorkbenchProvider.tsx b/wework/src/features/workbench/WorkbenchProvider.tsx index 0452afd527..f8209dcfed 100644 --- a/wework/src/features/workbench/WorkbenchProvider.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.tsx @@ -38,6 +38,7 @@ import { getModelCompatibilityFamily } from '@/lib/model-ui' import { buildRuntimeTaskRoute, navigateTo, parseRuntimeTaskRoute } from '@/lib/navigation' import type { RuntimeTaskRoute } from '@/lib/navigation' import { supportsGitWorktreeExecution } from '@/lib/projectClassification' +import { isTauriRuntime } from '@/lib/runtime-environment' import { runtimeProjectUiId } from '@/lib/runtime-project' import { findWorkbenchDevice, @@ -437,7 +438,7 @@ function createBackendServices(): WorkbenchServices { function createDefaultServices(): WorkbenchServices { const { runtimeMode } = getRuntimeConfig() - if (runtimeMode === 'local-first') { + if (runtimeMode === 'local-first' && isTauriRuntime()) { return createLocalAppServices() } diff --git a/wework/src/lib/local-terminal.test.ts b/wework/src/lib/local-terminal.test.ts index 7c753ec6c6..6ed442fdb1 100644 --- a/wework/src/lib/local-terminal.test.ts +++ b/wework/src/lib/local-terminal.test.ts @@ -14,6 +14,7 @@ import { } from './local-terminal' vi.mock('@tauri-apps/api/core', () => ({ + isTauri: vi.fn(() => false), invoke: vi.fn(), })) diff --git a/wework/src/lib/runtime-environment.test.ts b/wework/src/lib/runtime-environment.test.ts new file mode 100644 index 0000000000..23950acc7e --- /dev/null +++ b/wework/src/lib/runtime-environment.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, test } from 'vitest' +import { isTauriRuntime } from './runtime-environment' + +function setGlobalIsTauri(value: boolean) { + Object.defineProperty(globalThis, 'isTauri', { + configurable: true, + value, + }) +} + +function setTauriInternals() { + Object.defineProperty(window, '__TAURI_INTERNALS__', { + configurable: true, + value: {}, + }) +} + +function clearTauriRuntime() { + delete (globalThis as typeof globalThis & { isTauri?: boolean }).isTauri + delete (window as typeof window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ + delete (window as typeof window & { __TAURI__?: unknown }).__TAURI__ +} + +describe('isTauriRuntime', () => { + afterEach(() => { + clearTauriRuntime() + }) + + test('uses the Tauri v2 runtime marker', () => { + setGlobalIsTauri(true) + + expect(isTauriRuntime()).toBe(true) + }) + + test('keeps supporting the legacy Tauri global', () => { + setTauriInternals() + + expect(isTauriRuntime()).toBe(true) + }) + + test('returns false in a regular browser runtime', () => { + expect(isTauriRuntime()).toBe(false) + }) +}) diff --git a/wework/src/lib/runtime-environment.ts b/wework/src/lib/runtime-environment.ts index 29115f6bc3..6ecb9b4426 100644 --- a/wework/src/lib/runtime-environment.ts +++ b/wework/src/lib/runtime-environment.ts @@ -1,7 +1,16 @@ +import { isTauri as isTauriApiRuntime } from '@tauri-apps/api/core' + +function hasTauriGlobal(): boolean { + return ( + typeof window !== 'undefined' && + ('__TAURI_INTERNALS__' in window || '__TAURI__' in window) + ) +} + export function isTauriRuntime(): boolean { if (typeof window === 'undefined') { return false } - return '__TAURI_INTERNALS__' in window || '__TAURI__' in window + return isTauriApiRuntime() || hasTauriGlobal() } From 0701c3adb8bfdea5aca89b089dc7f0d13642b75f Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 25 Jun 2026 20:14:20 +0800 Subject: [PATCH 17/17] feat: support local dev --- executor/runtime_work/rpc_handler.py | 51 +++-- executor/scripts/dev_sidecar.py | 201 ++++++++++++++++++ .../runtime_work/test_local_task_store.py | 54 +++++ .../tests/scripts/test_wework_dev_mac_app.py | 29 +++ wework/scripts/dev-executor-sidecar.sh | 25 +++ wework/scripts/dev-mac-app.sh | 10 +- 6 files changed, 347 insertions(+), 23 deletions(-) create mode 100755 executor/scripts/dev_sidecar.py create mode 100644 executor/tests/scripts/test_wework_dev_mac_app.py create mode 100755 wework/scripts/dev-executor-sidecar.sh diff --git a/executor/runtime_work/rpc_handler.py b/executor/runtime_work/rpc_handler.py index 1c58511359..1cb0d35651 100644 --- a/executor/runtime_work/rpc_handler.py +++ b/executor/runtime_work/rpc_handler.py @@ -2121,19 +2121,40 @@ def _attach_sdk_codex_thread( self._remember_sdk_codex_task(updated) return updated - def _load_payload_task( - self, - payload: dict[str, Any], - *, - discovered_tasks: Optional[list[LocalTaskRecord]] = None, - ) -> LocalTaskRecord: + def _payload_address(self, payload: dict[str, Any]) -> dict[str, Any]: + address = payload.get("address") + if isinstance(address, dict): + return address + return {} + + def _payload_local_task_id(self, payload: dict[str, Any]) -> str: local_task_id = payload.get("localTaskId") + if not isinstance(local_task_id, str) or not local_task_id.strip(): + address = self._payload_address(payload) + local_task_id = address.get("localTaskId") or address.get("local_task_id") if not isinstance(local_task_id, str) or not local_task_id.strip(): raise ValueError("localTaskId is required") + return local_task_id.strip() + def _payload_workspace_path(self, payload: dict[str, Any]) -> Optional[str]: workspace_path = payload.get("workspacePath") + if workspace_path is None: + address = self._payload_address(payload) + workspace_path = address.get("workspacePath") or address.get( + "workspace_path" + ) if workspace_path is not None and not isinstance(workspace_path, str): raise ValueError("workspacePath must be a string") + return workspace_path + + def _load_payload_task( + self, + payload: dict[str, Any], + *, + discovered_tasks: Optional[list[LocalTaskRecord]] = None, + ) -> LocalTaskRecord: + local_task_id = self._payload_local_task_id(payload) + workspace_path = self._payload_workspace_path(payload) try: task = self.store.get_task(local_task_id, workspace_path=workspace_path) @@ -2160,13 +2181,8 @@ def _load_payload_task( raise KeyError(f"Local task not found: {local_task_id}") def _load_payload_transcript_task(self, payload: dict[str, Any]) -> LocalTaskRecord: - local_task_id = payload.get("localTaskId") - if not isinstance(local_task_id, str) or not local_task_id.strip(): - raise ValueError("localTaskId is required") - - workspace_path = payload.get("workspacePath") - if workspace_path is not None and not isinstance(workspace_path, str): - raise ValueError("workspacePath must be a string") + local_task_id = self._payload_local_task_id(payload) + workspace_path = self._payload_workspace_path(payload) sdk_task = self._find_sdk_codex_task( local_task_id, @@ -2541,13 +2557,8 @@ def _find_discovered_task( return None def _load_archived_payload_task(self, payload: dict[str, Any]) -> LocalTaskRecord: - local_task_id = payload.get("localTaskId") - if not isinstance(local_task_id, str) or not local_task_id.strip(): - raise ValueError("localTaskId is required") - - workspace_path = payload.get("workspacePath") - if workspace_path is not None and not isinstance(workspace_path, str): - raise ValueError("workspacePath must be a string") + local_task_id = self._payload_local_task_id(payload) + workspace_path = self._payload_workspace_path(payload) codex_project_index = self._codex_project_index() diff --git a/executor/scripts/dev_sidecar.py b/executor/scripts/dev_sidecar.py new file mode 100755 index 0000000000..911a40fb5c --- /dev/null +++ b/executor/scripts/dev_sidecar.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python + +# SPDX-FileCopyrightText: 2026 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Run the local executor from source and restart it when source files change.""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import threading +import time +from pathlib import Path +from typing import Iterable + +from watchdog.events import FileSystemEvent, FileSystemEventHandler +from watchdog.observers import Observer + + +EXECUTOR_DIR = Path(__file__).resolve().parents[1] +PROJECT_DIR = EXECUTOR_DIR.parent +DEFAULT_RELOAD_EXTENSIONS = {".py", ".toml", ".yaml", ".yml", ".json"} +IGNORED_PATH_PARTS = { + ".git", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".venv", + "__pycache__", + "build", + "dist", + "node_modules", + "venv", +} + + +def log(message: str) -> None: + print(f"[wegent-executor-dev] {message}", flush=True) + + +def prepend_pythonpath(env: dict[str, str]) -> dict[str, str]: + current = env.get("PYTHONPATH", "") + paths = [str(PROJECT_DIR)] + if current: + paths.append(current) + env["PYTHONPATH"] = os.pathsep.join(paths) + return env + + +def reload_extensions() -> set[str]: + raw = os.environ.get("WEGENT_EXECUTOR_RELOAD_EXTENSIONS", "") + if not raw.strip(): + return DEFAULT_RELOAD_EXTENSIONS + return {item.strip() for item in raw.split(",") if item.strip()} + + +def reload_dirs() -> list[Path]: + raw = os.environ.get("WEGENT_EXECUTOR_RELOAD_DIRS", "") + if raw.strip(): + return [Path(item).expanduser().resolve() for item in raw.split(os.pathsep) if item] + return [EXECUTOR_DIR, PROJECT_DIR / "shared"] + + +def debounce_seconds() -> float: + raw = os.environ.get("WEGENT_EXECUTOR_RELOAD_DEBOUNCE_SECONDS", "0.5") + try: + return max(0.1, float(raw)) + except ValueError: + return 0.5 + + +def should_reload_path(path: Path, extensions: set[str]) -> bool: + if any(part in IGNORED_PATH_PARTS for part in path.parts): + return False + return path.suffix in extensions + + +class ReloadEventHandler(FileSystemEventHandler): + def __init__(self, supervisor: "DevSidecarSupervisor", extensions: set[str]) -> None: + self.supervisor = supervisor + self.extensions = extensions + + def on_any_event(self, event: FileSystemEvent) -> None: + if event.is_directory: + return + paths = [Path(event.src_path)] + dest_path = getattr(event, "dest_path", "") + if dest_path: + paths.append(Path(dest_path)) + for path in paths: + if should_reload_path(path, self.extensions): + self.supervisor.request_reload(path) + return + + +class DevSidecarSupervisor: + def __init__(self, args: Iterable[str]) -> None: + self.args = list(args) + self.child: subprocess.Popen[bytes] | None = None + self.lock = threading.Lock() + self.reload_requested = threading.Event() + self.stop_requested = threading.Event() + self.last_reload_at = 0.0 + self.last_reload_path: Path | None = None + self.debounce = debounce_seconds() + + def command(self) -> list[str]: + return [sys.executable, str(EXECUTOR_DIR / "main.py"), *self.args] + + def child_env(self) -> dict[str, str]: + return prepend_pythonpath(os.environ.copy()) + + def start_child(self) -> None: + command = self.command() + log(f"starting executor from source: {' '.join(command)}") + self.child = subprocess.Popen(command, cwd=EXECUTOR_DIR, env=self.child_env()) + + def stop_child(self) -> int: + child = self.child + self.child = None + if child is None: + return 0 + if child.poll() is not None: + return child.returncode or 0 + + child.terminate() + try: + return child.wait(timeout=5) + except subprocess.TimeoutExpired: + child.kill() + return child.wait() + + def request_reload(self, path: Path) -> None: + now = time.monotonic() + with self.lock: + self.last_reload_path = path + if now - self.last_reload_at < self.debounce: + return + self.last_reload_at = now + self.reload_requested.set() + + def request_stop(self, signum: int, _frame: object) -> None: + log(f"received signal {signum}, stopping") + self.stop_requested.set() + self.reload_requested.set() + + def run(self) -> int: + self.start_child() + while not self.stop_requested.is_set(): + child = self.child + if child is None: + return 1 + + if child.poll() is not None: + return child.returncode or 0 + + if not self.reload_requested.wait(timeout=0.2): + continue + self.reload_requested.clear() + + if self.stop_requested.is_set(): + break + + path = self.last_reload_path + log(f"source changed, restarting executor: {path}") + self.stop_child() + self.start_child() + + return self.stop_child() + + +def start_observer(supervisor: DevSidecarSupervisor) -> Observer: + observer = Observer() + handler = ReloadEventHandler(supervisor, reload_extensions()) + for directory in reload_dirs(): + if directory.exists(): + observer.schedule(handler, str(directory), recursive=True) + log(f"watching {directory}") + observer.start() + return observer + + +def main() -> int: + supervisor = DevSidecarSupervisor(sys.argv[1:]) + signal.signal(signal.SIGTERM, supervisor.request_stop) + signal.signal(signal.SIGINT, supervisor.request_stop) + + observer = start_observer(supervisor) + try: + return supervisor.run() + finally: + observer.stop() + observer.join(timeout=5) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/executor/tests/runtime_work/test_local_task_store.py b/executor/tests/runtime_work/test_local_task_store.py index d7a88d3be4..7682ea1c91 100644 --- a/executor/tests/runtime_work/test_local_task_store.py +++ b/executor/tests/runtime_work/test_local_task_store.py @@ -1401,6 +1401,60 @@ async def stream_message(self, thread_id, message, *, cwd=None, emitter): assert thread_id not in handler._running_sdk_task_ids +@pytest.mark.asyncio +async def test_runtime_work_handler_send_accepts_runtime_task_address_payload( + tmp_path, +): + from executor.runtime_work.local_task_store import LocalTaskStore + from executor.runtime_work.rpc_handler import RuntimeWorkRpcHandler + + class FakeCodexStreamDiscovery: + def __init__(self): + self.streamed_messages = [] + self.finished = asyncio.Event() + + def discover(self): + return [_sdk_codex_record(thread_id)] + + async def stream_message(self, thread_id, message, *, cwd=None, emitter): + self.streamed_messages.append((thread_id, message, cwd)) + await emitter.start(shell_type="Codex") + await emitter.text_delta("done") + await emitter.done("done") + self.finished.set() + + thread_id = "019ee7f6-456a-78a1-96b1-66451afc310e" + discovery = FakeCodexStreamDiscovery() + handler = RuntimeWorkRpcHandler( + store=LocalTaskStore(tmp_path / "index.json"), + adapters={"codex": SimpleNamespace()}, + codex_discovery=discovery, + responses_event_emitter=lambda _event, _payload: None, + ) + + result = await handler.handle_runtime_rpc( + { + "method": "runtime.tasks.send", + "payload": { + "address": { + "deviceId": "local-device", + "workspacePath": "/repo/Wegent", + "localTaskId": thread_id, + }, + "message": "continue from WeWork", + }, + } + ) + + assert result["success"] is True + assert result["accepted"] is True + await asyncio.wait_for(discovery.finished.wait(), timeout=1) + await asyncio.gather(*handler._running_sdk_tasks) + assert discovery.streamed_messages == [ + (thread_id, "continue from WeWork", "/repo/Wegent") + ] + + @pytest.mark.asyncio async def test_runtime_work_handler_keeps_codex_followup_attachments_in_transcript( tmp_path, diff --git a/executor/tests/scripts/test_wework_dev_mac_app.py b/executor/tests/scripts/test_wework_dev_mac_app.py new file mode 100644 index 0000000000..3cf3428503 --- /dev/null +++ b/executor/tests/scripts/test_wework_dev_mac_app.py @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: 2026 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +WEWORK_SCRIPTS_DIR = PROJECT_ROOT / "wework" / "scripts" + + +def test_dev_mac_app_defaults_to_source_reload_executor_sidecar(): + script = (WEWORK_SCRIPTS_DIR / "dev-mac-app.sh").read_text(encoding="utf-8") + + assert 'WEWORK_EXECUTOR_SIDECAR="$WEWORK_DIR/scripts/dev-executor-sidecar.sh"' in script + assert "executor/dist/wegent-executor" not in script + + +def test_dev_executor_sidecar_runs_executor_source_with_reload(): + wrapper = WEWORK_SCRIPTS_DIR / "dev-executor-sidecar.sh" + supervisor = PROJECT_ROOT / "executor" / "scripts" / "dev_sidecar.py" + + assert wrapper.exists() + assert supervisor.exists() + assert "scripts/dev_sidecar.py" in wrapper.read_text(encoding="utf-8") + + supervisor_source = supervisor.read_text(encoding="utf-8") + assert "watchdog.observers" in supervisor_source + assert "main.py" in supervisor_source diff --git a/wework/scripts/dev-executor-sidecar.sh b/wework/scripts/dev-executor-sidecar.sh new file mode 100755 index 0000000000..43425653d6 --- /dev/null +++ b/wework/scripts/dev-executor-sidecar.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WEWORK_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +PROJECT_DIR="$(cd "$WEWORK_DIR/.." && pwd)" +EXECUTOR_DIR="$PROJECT_DIR/executor" + +export PYTHONPATH="$PROJECT_DIR${PYTHONPATH:+:$PYTHONPATH}" + +cd "$EXECUTOR_DIR" + +if [ "${WEGENT_EXECUTOR_DEV_RELOAD:-1}" = "0" ]; then + if [ -x "$EXECUTOR_DIR/.venv/bin/python" ]; then + exec "$EXECUTOR_DIR/.venv/bin/python" main.py "$@" + fi + exec uv run python main.py "$@" +fi + +if [ -x "$EXECUTOR_DIR/.venv/bin/python" ]; then + exec "$EXECUTOR_DIR/.venv/bin/python" scripts/dev_sidecar.py "$@" +fi + +exec uv run python scripts/dev_sidecar.py "$@" diff --git a/wework/scripts/dev-mac-app.sh b/wework/scripts/dev-mac-app.sh index 6bf43131f9..fe22d142f0 100755 --- a/wework/scripts/dev-mac-app.sh +++ b/wework/scripts/dev-mac-app.sh @@ -20,6 +20,10 @@ Environment: WEWORK_PORT Default dev server port when --port is not provided. WEWORK_HOST Host IP used to build backend proxy targets. BACKEND_PORT Backend port used when proxy targets are not set. + WEWORK_EXECUTOR_SIDECAR + Executor sidecar path. Defaults to source reload sidecar. + WEGENT_EXECUTOR_DEV_RELOAD + Set to 0 to run executor source once without reload. Examples: bash wework/scripts/dev-mac-app.sh --port 9130 @@ -102,10 +106,10 @@ fi export VITE_API_PROXY_TARGET="${VITE_API_PROXY_TARGET:-http://$LOCAL_IP:$BACKEND_PORT}" export VITE_SOCKET_PROXY_TARGET="${VITE_SOCKET_PROXY_TARGET:-${WEGENT_SOCKET_URL:-$VITE_API_PROXY_TARGET}}" - -if [ -z "${WEWORK_EXECUTOR_SIDECAR:-}" ] && [ -x "$PROJECT_DIR/executor/dist/wegent-executor" ]; then - export WEWORK_EXECUTOR_SIDECAR="$PROJECT_DIR/executor/dist/wegent-executor" +if [ -z "${WEWORK_EXECUTOR_SIDECAR:-}" ]; then + WEWORK_EXECUTOR_SIDECAR="$WEWORK_DIR/scripts/dev-executor-sidecar.sh" fi +export WEWORK_EXECUTOR_SIDECAR TAURI_DEV_CONFIG="$(mktemp -t wework-tauri-dev.XXXXXX.json)" trap 'rm -f "$TAURI_DEV_CONFIG"' EXIT