Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
652 changes: 652 additions & 0 deletions docs/plans/2026-08-15-wework-local-cloud-project-space-decoupling.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions wework/e2e/desktop/checkpoints.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const DESKTOP_CHECKPOINTS = [
'telemetry-consent',
'automation-lifecycle',
'project-automation',
'offline-local-project-space',
'plugin-auto-update',
'model-routing',
'permission-modes',
Expand Down
2 changes: 1 addition & 1 deletion wework/e2e/desktop/modules/workspace-flows.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,7 @@ async function verifyWorkspaceTabIsolation(control) {
'workspace-tabs-isolation-05-detached-window.png'
)

const sourceStorageKey = 'wework.workspaceTabs.v2:main'
const sourceStorageKey = 'wework.workspaceTabs.v3:main'
const sourceTabRemovalStartedAt = Date.now()
let sourceTabs = []
while (Date.now() - sourceTabRemovalStartedAt < DEFAULT_STEP_TIMEOUT_MS) {
Expand Down
2 changes: 2 additions & 0 deletions wework/e2e/desktop/run-checkpoints.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ const CHECKPOINT_SCENARIO_MODULES = {
'runtime-task-queue': './scenarios/runtime-task-queue.scenario.mjs',
'split-workbench': './scenarios/split-workbench.scenario.mjs',
'project-automation': './scenarios/project-automation.scenario.mjs',
'offline-local-project-space': './scenarios/offline-local-project-space.scenario.mjs',
}
const SCENARIO_ONLY_CHECKPOINTS = new Set([
'change-request-status',
'claude-runtime',
'local-file-preview',
'local-harness',
'offline-local-project-space',
'runtime-task-queue',
'split-workbench',
'temporary-chat',
Expand Down
140 changes: 140 additions & 0 deletions wework/e2e/desktop/scenarios/offline-local-project-space.scenario.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict'

const PROJECT_NAME = '离线本地项目空间'
const TASK_NAME = '离线本地任务'
const UPDATED_TASK_NAME = '离线本地任务(已更新)'

function json(response, status, body) {
response.writeHead(status, { 'content-type': 'application/json' })
response.end(JSON.stringify(body))
}

export function createDesktopScenario({ uiTimeoutMs }) {
let cloudProjectListFailures = 0
const cloudDetailRequests = []

return {
async handleHttp(request, response, url) {
if (request.method === 'GET' && url.pathname === '/api/v1/cloud-projects') {
cloudProjectListFailures += 1
json(response, 503, { detail: 'Desktop E2E cloud project service is unavailable' })
return true
}
if (url.pathname.startsWith('/api/v1/cloud-projects/')) {
cloudDetailRequests.push(`${request.method} ${url.pathname}`)
}
return false
},

async verify(control) {
await control.command('waitFor', '[data-testid="workspace-tab-add"]', {
timeoutMs: uiTimeoutMs,
})
await control.command('click', '[data-testid="workspace-tab-add"]')
await control.command('waitFor', '[data-testid="workspace-tab-add-menu"]', {
timeoutMs: uiTimeoutMs,
})
await control.command('click', '[data-testid="workspace-tab-add-board"]')
await control.command('waitFor', '[data-testid="cloud-todo-workspace"]', {
timeoutMs: uiTimeoutMs,
})
await control.command('waitFor', '[data-testid="cloud-project-add"]', {
timeoutMs: uiTimeoutMs,
})

await control.command('click', '[data-testid="cloud-project-add"]')
await control.command('waitFor', '[data-testid="cloud-project-name"]', {
timeoutMs: uiTimeoutMs,
})
await control.command('fill', '[data-testid="cloud-project-name"]', {
value: PROJECT_NAME,
})
await control.command('click', '[data-testid="cloud-project-location-local"]')
await control.command('click', '[data-testid="cloud-project-task-provider-local"]')
await control.command('clickWhenEnabled', '[data-testid="cloud-project-create-confirm"]', {
timeoutMs: uiTimeoutMs,
})

await control.command('waitFor', '[data-testid="cloud-todo-column-inbox"]', {
timeoutMs: uiTimeoutMs,
})
await control.command('click', '[data-testid="cloud-todo-add"]')
await control.command('waitFor', '[data-testid="cloud-todo-create-panel"]', {
timeoutMs: uiTimeoutMs,
})
await control.command('fill', '[data-testid="cloud-todo-title"]', {
value: TASK_NAME,
})
await control.command('clickWhenEnabled', '[data-testid="cloud-todo-create-confirm"]', {
timeoutMs: uiTimeoutMs,
})
await control.command('waitFor', '[data-testid^="cloud-todo-card-"]', {
text: TASK_NAME,
timeoutMs: uiTimeoutMs,
})
const boardSnapshot = JSON.parse(await control.command('snapshot', 'body'))
const taskCardTestId = boardSnapshot.testIds.find(
testId =>
testId.startsWith('cloud-todo-card-') &&
![
'cloud-todo-card-add-child-',
'cloud-todo-card-assignee-',
'cloud-todo-card-archive-',
'cloud-todo-card-drop-',
'cloud-todo-card-menu-',
'cloud-todo-card-more-',
].some(prefix => testId.startsWith(prefix))
)
assert.ok(taskCardTestId, 'The newly created local task card was not present in the board')
await control.command('click', `[data-testid="${taskCardTestId}"]`)
await control.command('waitFor', '[data-testid="cloud-todo-detail"]', {
timeoutMs: uiTimeoutMs,
})
await control.command('fill', '[data-testid="cloud-todo-detail-title"]', {
value: UPDATED_TASK_NAME,
})
await control.command('clickWhenEnabled', '[data-testid="cloud-todo-save"]', {
timeoutMs: uiTimeoutMs,
})
await control.command('click', '[data-testid="cloud-todo-detail-close"]')
await control.command('waitFor', `[data-testid="${taskCardTestId}"]`, {
text: UPDATED_TASK_NAME,
timeoutMs: uiTimeoutMs,
})
await control.command('click', '[data-testid="cloud-project-files-view"]')
await control.command('waitFor', '[data-testid="cloud-files-upload"]', {
timeoutMs: uiTimeoutMs,
})
await control.command('click', '[data-testid="cloud-project-manage-view"]')
await control.command('waitFor', '[data-testid="cloud-project-members-toggle"]', {
timeoutMs: uiTimeoutMs,
})
await control.command('click', '[data-testid="cloud-project-automation-view"]')
await control.command('waitFor', '[data-testid="project-automation-view"]', {
timeoutMs: uiTimeoutMs,
})
await control.command('click', '[data-testid^="workspace-tab-select-task-"]')
await control.command('waitFor', '[data-testid="automation-button"]', {
timeoutMs: uiTimeoutMs,
})
await control.command('click', '[data-testid="automation-button"]')
await control.command('waitFor', '[data-testid="create-automation-button"]', {
timeoutMs: uiTimeoutMs,
})

assert.ok(
cloudProjectListFailures > 0,
'The scenario did not exercise an unavailable cloud project list'
)
assert.deepEqual(
cloudDetailRequests,
[],
`Local project details unexpectedly called cloud APIs: ${cloudDetailRequests.join(', ')}`
)
},

diagnostics() {
return { cloudDetailRequests, cloudProjectListFailures }
},
}
}
24 changes: 20 additions & 4 deletions wework/src/api/backend/backendServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,14 @@ export function createBackendWorkbenchServices(
getToken: resolveToken,
})

const teamApi = createTeamApi(client)
const modelApi = createModelApi(client)
const projectChatAgentApi = createProjectChatAgentApi(client)
const projectAutomationApi = createProjectAutomationApi(client)

return {
teamApi: createTeamApi(client),
modelApi: createModelApi(client),
teamApi,
modelApi,
skillApi: createSkillApi(client),
projectApi,
gitApi: createGitApi(client),
Expand All @@ -83,6 +88,17 @@ export function createBackendWorkbenchServices(
cloud: deliveryApi,
defaultLocation: 'cloud',
},
projectSpaceDetailServices: {
cloud: {
deliveryApi,
projectChatClient,
projectChatAgentApi,
projectAutomationApi,
deviceApi,
modelApi,
teamApi,
},
},
imSessionApi: createImSessionApi(client),
runtimeWorkApi,
attachmentApi: createAttachmentApi({
Expand All @@ -100,8 +116,8 @@ export function createBackendWorkbenchServices(
userApi: createUserApi(client),
socketClient,
projectChatClient,
projectChatAgentApi: createProjectChatAgentApi(client),
projectAutomationApi: createProjectAutomationApi(client),
projectChatAgentApi,
projectAutomationApi,
workspaceSessionApi: {
startProjectTerminal: projectApi.startTerminalSession,
startProjectCodeServer: projectApi.startCodeServerSession,
Expand Down
46 changes: 46 additions & 0 deletions wework/src/api/hybrid/hybridServices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1447,6 +1447,52 @@ describe('createHybridWorkbenchServices', () => {
expect(services.workspaceSessionApi).toBe(mocks.cloudWorkspaceSessionApi)
})

it('returns local automations without waiting for an unresponsive cloud executor', async () => {
mocks.cloudListDevices.mockResolvedValue([
{
device_id: 'cloud-device',
device_type: 'cloud',
status: 'online',
},
])
mocks.localAutomationApi.listAutomations.mockResolvedValue({
items: [
{
id: 'local-automation',
source: 'local',
version: 1,
name: 'Local automation',
description: '',
prompt: 'Run locally',
schedule: { type: 'interval', value: 1, unit: 'hours' },
timezone: 'UTC',
enabled: true,
conversationMode: 'independent',
notificationPolicy: 'all_runs',
taskRequest: { deviceId: 'local-device' },
createdAt: '2026-08-15T00:00:00Z',
updatedAt: '2026-08-15T00:00:00Z',
},
],
})
mocks.cloudRuntimeIpcRequest.mockImplementation(method =>
method === 'runtime.automations.list'
? new Promise(() => undefined)
: Promise.resolve({ items: [] })
)
const services = createServices()
await services.cloudBackgroundApi?.listDevices?.()

const response = await services.automationApi?.listAutomations()

expect(response?.items.map(item => item.id)).toEqual(['local-automation'])
expect(mocks.cloudRuntimeIpcRequest).toHaveBeenCalledWith(
'runtime.automations.list',
{},
'cloud-device'
)
})

it('routes cloud automations to the selected remote executor', async () => {
mocks.cloudRuntimeIpcRequest.mockImplementation(async (method, params) => {
if (method === 'runtime.automations.create') {
Expand Down
58 changes: 37 additions & 21 deletions wework/src/api/hybrid/hybridServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { WorkbenchServices } from '@/features/workbench/workbenchServices'
import {
notifyWorkbenchCloudArchivesChanged,
notifyWorkbenchCloudSearchResults,
notifyWorkbenchAutomationsChanged,
notifyWorkbenchModelsChanged,
} from '@/features/workbench/workbenchCloudDataEvents'
import { requestCloudModelCatalogSync } from '@/features/model-settings/cloudModelCatalogSyncRequest'
Expand Down Expand Up @@ -336,6 +337,8 @@ export function createHybridWorkbenchServices(
})
const cloudRuntimeApis = new Map<string, NonNullable<WorkbenchServices['runtimeWorkApi']>>()
const cloudAutomationApis = new Map<string, NonNullable<WorkbenchServices['automationApi']>>()
const rememberedCloudAutomations = new Map<string, Automation[]>()
const cloudAutomationRequests = new Map<string, Promise<void>>()
const automationDevices = new Map<string, string>()
const localDeviceIds = new Set<string>([LOCAL_DEVICE_ID])
const localRuntimeInstanceIds = new Set<string>()
Expand Down Expand Up @@ -1008,6 +1011,29 @@ export function createHybridWorkbenchServices(
const rememberAutomationRoutes = (deviceId: string, automations: Automation[]) => {
automations.forEach(automation => automationDevices.set(automation.id, deviceId))
}
const refreshCloudAutomationsInBackground = () => {
rememberedCloudDevices.filter(isUsableDevice).forEach(device => {
const deviceId = device.device_id
if (cloudAutomationRequests.has(deviceId)) return
const request = automationApiForDevice(deviceId)
.listAutomations()
.then(response => {
rememberAutomationRoutes(deviceId, response.items)
rememberedCloudAutomations.set(deviceId, response.items)
notifyWorkbenchAutomationsChanged()
})
.catch(error => {
console.warn('[Wework] Failed to refresh cloud automations in background', {
deviceId,
error,
})
})
.finally(() => {
cloudAutomationRequests.delete(deviceId)
})
cloudAutomationRequests.set(deviceId, request)
})
}
const automationMutationDeviceId = (data: { taskRequest?: RuntimeTaskCreateRequest }) => {
const deviceId = data.taskRequest?.deviceId?.trim()
if (!deviceId) throw new Error('Automation target device is required')
Expand All @@ -1023,17 +1049,12 @@ export function createHybridWorkbenchServices(
}
const automationApi: NonNullable<WorkbenchServices['automationApi']> = {
async listAutomations() {
const cloudDeviceIds = rememberedCloudDevices
.filter(device => isUsableDevice(device))
.map(device => device.device_id)
const deviceIds = [LOCAL_DEVICE_ID, ...cloudDeviceIds]
const responses = await Promise.all(
deviceIds.map(deviceId => automationApiForDevice(deviceId).listAutomations())
)
responses.forEach((response, index) =>
rememberAutomationRoutes(deviceIds[index], response.items)
)
return { items: responses.flatMap(response => response.items) }
const localResponse = await localServices.automationApi!.listAutomations()
rememberAutomationRoutes(LOCAL_DEVICE_ID, localResponse.items)
refreshCloudAutomationsInBackground()
return {
items: [...localResponse.items, ...Array.from(rememberedCloudAutomations.values()).flat()],
}
},
async getAutomation(automationId) {
const deviceId = await automationDeviceId(automationId)
Expand Down Expand Up @@ -1077,16 +1098,7 @@ export function createHybridWorkbenchServices(
const deviceId = await automationDeviceId(automationId)
return automationApiForDevice(deviceId).listAutomationRuns(automationId)
}
const deviceIds = [
LOCAL_DEVICE_ID,
...rememberedCloudDevices
.filter(device => isUsableDevice(device))
.map(device => device.device_id),
]
const responses = await Promise.all(
deviceIds.map(deviceId => automationApiForDevice(deviceId).listAutomationRuns())
)
return { items: responses.flatMap(response => response.items) }
return localServices.automationApi!.listAutomationRuns()
},
}

Expand Down Expand Up @@ -1149,6 +1161,10 @@ export function createHybridWorkbenchServices(
cloud: cloudProjectSpaceApi,
defaultLocation: 'cloud',
},
projectSpaceDetailServices: {
local: localServices.projectSpaceDetailServices?.local,
cloud: cloudServices.projectSpaceDetailServices?.cloud,
},
teamApi: {
// Wegent Teams are backend CRDs. The local service only exposes the
// synthetic id=0 workbench Team, which is valid as the local default but
Expand Down
Loading
Loading