From a9944b5366b6c8dad6a93d57e74e475ff8221e60 Mon Sep 17 00:00:00 2001 From: src-opn Date: Thu, 23 Apr 2026 17:39:05 -0700 Subject: [PATCH 1/2] feat(server-v2): restore cloud desktop compatibility --- .../server-v2/src/adapters/remote-openwork.ts | 14 +- apps/server-v2/src/app.test.ts | 146 ++++ .../server-v2/src/context/app-dependencies.ts | 11 +- apps/server-v2/src/contract.test.ts | 29 + apps/server-v2/src/files.test.ts | 141 ++++ apps/server-v2/src/routes/cloud.ts | 363 +++++++++ apps/server-v2/src/routes/index.ts | 2 + apps/server-v2/src/routes/route-paths.ts | 29 + apps/server-v2/src/routes/system.ts | 85 ++- apps/server-v2/src/schemas/cloud.ts | 195 +++++ apps/server-v2/src/schemas/system.ts | 11 + apps/server-v2/src/services/cloud-service.ts | 703 ++++++++++++++++++ .../config-materialization-service.ts | 99 ++- prds/server-v2-plan/update-0423/plan.md | 154 ++++ prds/server-v2-plan/update-0423/task-list.md | 114 +++ 15 files changed, 2092 insertions(+), 4 deletions(-) create mode 100644 apps/server-v2/src/routes/cloud.ts create mode 100644 apps/server-v2/src/schemas/cloud.ts create mode 100644 apps/server-v2/src/services/cloud-service.ts create mode 100644 prds/server-v2-plan/update-0423/plan.md create mode 100644 prds/server-v2-plan/update-0423/task-list.md diff --git a/apps/server-v2/src/adapters/remote-openwork.ts b/apps/server-v2/src/adapters/remote-openwork.ts index 0a72cd2d3..2538d5e84 100644 --- a/apps/server-v2/src/adapters/remote-openwork.ts +++ b/apps/server-v2/src/adapters/remote-openwork.ts @@ -43,6 +43,18 @@ function normalizeBaseUrl(value: string) { return value.replace(/\/+$/, ""); } +function sanitizeProxyResponse(response: Response) { + const headers = new Headers(response.headers); + headers.delete("content-encoding"); + headers.delete("transfer-encoding"); + headers.delete("content-length"); + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); +} + function unwrapEnvelope(payload: unknown): T { if (payload && typeof payload === "object" && "ok" in (payload as Record)) { const record = payload as Record; @@ -154,5 +166,5 @@ export async function requestRemoteOpenworkRaw(input: { throw new RouteError(502, "bad_gateway", text.trim() || `Remote OpenWork request failed with status ${response.status}.`); } - return response; + return sanitizeProxyResponse(response); } diff --git a/apps/server-v2/src/app.test.ts b/apps/server-v2/src/app.test.ts index 8bf663bdf..fc28e188b 100644 --- a/apps/server-v2/src/app.test.ts +++ b/apps/server-v2/src/app.test.ts @@ -119,6 +119,21 @@ test("openapi route is generated from the live Hono app", async () => { expect(document.paths["/system/opencode/health"].get.operationId).toBe("getSystemOpencodeHealth"); expect(document.paths["/system/runtime/versions"].get.operationId).toBe("getSystemRuntimeVersions"); expect(document.paths["/system/runtime/upgrade"].post.operationId).toBe("postSystemRuntimeUpgrade"); + expect(document.paths["/v1/app-version"].get.operationId).toBe("getV1AppVersion"); + expect(document.paths["/v1/llm-providers"].get.operationId).toBe("getV1LlmProviders"); + expect(document.paths["/v1/llm-providers/{llmProviderId}/connect"].get.operationId).toBe("getV1LlmProvidersByLlmProviderIdConnect"); + expect(document.paths["/system/cloud/bootstrap"].get.operationId).toBe("getSystemCloudBootstrap"); + expect(document.paths["/dev/log"].get.operationId).toBe("getDevLog"); + expect(document.paths["/dev/log"].post.operationId).toBe("postDevLog"); + expect(document.paths["/v1/me"].get.operationId).toBe("getV1Me"); + expect(document.paths["/v1/me/orgs"].get.operationId).toBe("getV1MeOrgs"); + expect(document.paths["/v1/me/desktop-config"].get.operationId).toBe("getV1MeDesktopConfig"); + expect(document.paths["/v1/auth/desktop-handoff/exchange"].post.operationId).toBe("postV1AuthDesktopHandoffExchange"); + expect(document.paths["/api/auth/organization/set-active"].post.operationId).toBe("postApiAuthOrganizationSetActive"); + expect(document.paths["/workspaces/{workspaceId}/cloud/llm-providers/state"].get.operationId).toBe("getWorkspacesByWorkspaceIdCloudLlmProvidersState"); + expect(document.paths["/workspaces/{workspaceId}/cloud/llm-providers/sync"].post.operationId).toBe("postWorkspacesByWorkspaceIdCloudLlmProvidersSync"); + expect(document.paths["/workspaces/{workspaceId}/cloud/llm-providers/{cloudProviderId}"].put.operationId).toBe("putWorkspacesByWorkspaceIdCloudLlmProvidersByCloudProviderId"); + expect(document.paths["/workspaces/{workspaceId}/config/disabled-providers"].patch.operationId).toBe("patchWorkspacesByWorkspaceIdConfigDisabledProviders"); expect(document.paths["/system/servers/connect"].post.operationId).toBe("postSystemServersConnect"); expect(document.paths["/workspaces"].get.operationId).toBe("getWorkspaces"); expect(document.paths["/workspaces/local"].post.operationId).toBe("postWorkspacesLocal"); @@ -132,6 +147,137 @@ test("openapi route is generated from the live Hono app", async () => { expect(document.paths["/workspaces/{workspaceId}/events"].get.operationId).toBe("getWorkspacesByWorkspaceIdEvents"); }); +test("cloud compatibility routes proxy and persist cloud state through server-v2", async () => { + const originalFetch = globalThis.fetch; + const { app, dependencies } = createTestApp(); + + dependencies.services.managed.upsertCloudSignin({ + auth: { authToken: "cloud-token" }, + cloudBaseUrl: "https://app.openworklabs.com", + metadata: null, + orgId: null, + userId: null, + }); + + globalThis.fetch = (async (input) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url); + if (url.pathname === "/api/den/v1/app-version") { + return new Response(JSON.stringify({ latestAppVersion: "0.11.212", minAppVersion: "0.11.207" }), { + headers: { "Content-Type": "application/json" }, + }); + } + if (url.pathname === "/api/den/v1/me") { + return new Response(JSON.stringify({ + user: { id: "usr_1", email: "omar@example.com", name: "Omar" }, + session: { id: "ses_1" }, + }), { + headers: { "Content-Type": "application/json" }, + }); + } + if (url.pathname === "/api/den/v1/me/orgs") { + return new Response(JSON.stringify({ + orgs: [ + { id: "org_1", name: "Alpha", slug: "alpha", role: "owner", isActive: true }, + { id: "org_2", name: "Beta", slug: "beta", role: "member", isActive: false }, + ], + activeOrgId: "org_1", + activeOrgSlug: "alpha", + }), { + headers: { "Content-Type": "application/json" }, + }); + } + if (url.pathname === "/api/den/v1/me/desktop-config") { + return new Response(JSON.stringify({ + allowedDesktopVersions: ["0.11.212"], + blockZenModel: true, + disallowNonCloudModels: true, + }), { + headers: { "Content-Type": "application/json" }, + }); + } + if (url.pathname === "/api/auth/organization/set-active") { + return new Response(JSON.stringify({ ok: true }), { + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("Not found", { status: 404 }); + }) as typeof fetch; + + try { + const [versionResponse, meResponse, orgsResponse, desktopConfigResponse] = await Promise.all([ + app.request("http://openwork.local/v1/app-version"), + app.request("http://openwork.local/v1/me"), + app.request("http://openwork.local/v1/me/orgs"), + app.request("http://openwork.local/v1/me/desktop-config"), + ]); + + expect(versionResponse.status).toBe(200); + expect(await versionResponse.json()).toMatchObject({ latestAppVersion: "0.11.212", minAppVersion: "0.11.207" }); + expect(meResponse.status).toBe(200); + expect(await meResponse.json()).toMatchObject({ user: { id: "usr_1", email: "omar@example.com" } }); + expect(orgsResponse.status).toBe(200); + expect(await orgsResponse.json()).toMatchObject({ activeOrgId: "org_1", activeOrgSlug: "alpha" }); + expect(desktopConfigResponse.status).toBe(200); + expect(await desktopConfigResponse.json()).toMatchObject({ blockZenModel: true, disallowNonCloudModels: true }); + + const setActiveResponse = await app.request("http://openwork.local/api/auth/organization/set-active", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ organizationId: "org_2" }), + }); + + expect(setActiveResponse.status).toBe(200); + expect(await setActiveResponse.json()).toMatchObject({ ok: true, activeOrgId: "org_1", activeOrgSlug: "alpha" }); + expect(dependencies.persistence.repositories.cloudSignin.getPrimary()?.metadata).toMatchObject({ + activeOrgName: "Alpha", + activeOrgSlug: "alpha", + validatedUser: { id: "usr_1", email: "omar@example.com", name: "Omar" }, + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("cloud bootstrap and dev log routes expose the remaining compatibility surfaces", async () => { + const originalDevLog = process.env.OPENWORK_DEV_LOG_FILE; + const { app } = createTestApp(); + const tempLogPath = `/tmp/openwork-server-v2-dev-log-${Math.random().toString(16).slice(2)}.jsonl`; + process.env.OPENWORK_DEV_LOG_FILE = tempLogPath; + + try { + const bootstrapResponse = await app.request("http://openwork.local/system/cloud/bootstrap"); + const bootstrapBody = await bootstrapResponse.json(); + expect(bootstrapResponse.status).toBe(200); + expect(bootstrapBody.data).toMatchObject({ + apiBaseUrl: expect.any(String), + baseUrl: expect.any(String), + requireSignin: false, + }); + + const probeResponse = await app.request("http://openwork.local/dev/log"); + const probeBody = await probeResponse.json(); + expect(probeResponse.status).toBe(200); + expect(probeBody).toMatchObject({ ok: true, path: tempLogPath }); + + const appendResponse = await app.request("http://openwork.local/dev/log", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify([{ scope: "test", value: 1 }]), + }); + const appendBody = await appendResponse.json(); + expect(appendResponse.status).toBe(200); + expect(appendBody).toMatchObject({ ok: true, count: 1 }); + } finally { + if (originalDevLog === undefined) { + delete process.env.OPENWORK_DEV_LOG_FILE; + } else { + process.env.OPENWORK_DEV_LOG_FILE = originalDevLog; + } + } +}); + test("runtime routes expose the initial server-owned status surfaces", async () => { const { app } = createTestApp(); diff --git a/apps/server-v2/src/context/app-dependencies.ts b/apps/server-v2/src/context/app-dependencies.ts index 0f6c33eb3..d698d1889 100644 --- a/apps/server-v2/src/context/app-dependencies.ts +++ b/apps/server-v2/src/context/app-dependencies.ts @@ -1,5 +1,6 @@ import { createAuthService, type AuthService } from "../services/auth-service.js"; import { createCapabilitiesService, type CapabilitiesService } from "../services/capabilities-service.js"; +import { createCloudService, type CloudService } from "../services/cloud-service.js"; import { createConfigMaterializationService, type ConfigMaterializationService } from "../services/config-materialization-service.js"; import { createManagedResourceService, type ManagedResourceService } from "../services/managed-resource-service.js"; import { createProcessInfoAdapter, type ProcessInfoAdapter } from "../adapters/process-info.js"; @@ -23,9 +24,10 @@ export type AppDependencies = { environment: string; persistence: ServerPersistence; processInfo: ProcessInfoAdapter; - services: { + services: { auth: AuthService; capabilities: CapabilitiesService; + cloud: CloudService; config: ConfigMaterializationService; files: WorkspaceFileService; managed: ManagedResourceService; @@ -141,6 +143,12 @@ export function createAppDependencies(overrides: CreateAppDependenciesOverrides serverId: persistence.registry.localServerId, workingDirectory: persistence.workingDirectory, }); + const cloud = createCloudService({ + config, + repositories: persistence.repositories, + serverId: persistence.registry.localServerId, + version, + }); const sessions = createWorkspaceSessionService({ repositories: persistence.repositories, runtime, @@ -179,6 +187,7 @@ export function createAppDependencies(overrides: CreateAppDependenciesOverrides services: { auth, capabilities, + cloud, config, files, managed, diff --git a/apps/server-v2/src/contract.test.ts b/apps/server-v2/src/contract.test.ts index 63bd0754a..2d0112269 100644 --- a/apps/server-v2/src/contract.test.ts +++ b/apps/server-v2/src/contract.test.ts @@ -30,6 +30,20 @@ test("openapi generation writes the committed server-v2 contract", async () => { const openApiContents = await Bun.file(path.join(packageDir, "openapi/openapi.json")).text(); expect(openApiContents).toContain('"/system/health"'); + expect(openApiContents).toContain('"/v1/app-version"'); + expect(openApiContents).toContain('"/v1/llm-providers"'); + expect(openApiContents).toContain('"/v1/llm-providers/{llmProviderId}/connect"'); + expect(openApiContents).toContain('"/system/cloud/bootstrap"'); + expect(openApiContents).toContain('"/dev/log"'); + expect(openApiContents).toContain('"/v1/me"'); + expect(openApiContents).toContain('"/v1/me/orgs"'); + expect(openApiContents).toContain('"/v1/me/desktop-config"'); + expect(openApiContents).toContain('"/v1/auth/desktop-handoff/exchange"'); + expect(openApiContents).toContain('"/api/auth/organization/set-active"'); + expect(openApiContents).toContain('"/workspaces/{workspaceId}/cloud/llm-providers/state"'); + expect(openApiContents).toContain('"/workspaces/{workspaceId}/cloud/llm-providers/sync"'); + expect(openApiContents).toContain('"/workspaces/{workspaceId}/cloud/llm-providers/{cloudProviderId}"'); + expect(openApiContents).toContain('"/workspaces/{workspaceId}/config/disabled-providers"'); expect(openApiContents).toContain('"getSystemHealth"'); expect(openApiContents).toContain('"/system/status"'); expect(openApiContents).toContain('"/system/cloud-signin"'); @@ -50,6 +64,21 @@ test("sdk generation succeeds from the server-v2 openapi document", async () => const sdkIndex = await Bun.file(path.join(repoDir, "packages/openwork-server-sdk/generated/index.ts")).text(); expect(sdkIndex).toContain("getSystemHealth"); + expect(sdkIndex).toContain("getV1AppVersion"); + expect(sdkIndex).toContain("getV1LlmProviders"); + expect(sdkIndex).toContain("getV1LlmProvidersByLlmProviderIdConnect"); + expect(sdkIndex).toContain("getSystemCloudBootstrap"); + expect(sdkIndex).toContain("getDevLog"); + expect(sdkIndex).toContain("postDevLog"); + expect(sdkIndex).toContain("getV1Me"); + expect(sdkIndex).toContain("getV1MeOrgs"); + expect(sdkIndex).toContain("getV1MeDesktopConfig"); + expect(sdkIndex).toContain("postV1AuthDesktopHandoffExchange"); + expect(sdkIndex).toContain("postApiAuthOrganizationSetActive"); + expect(sdkIndex).toContain("getWorkspacesByWorkspaceIdCloudLlmProvidersState"); + expect(sdkIndex).toContain("postWorkspacesByWorkspaceIdCloudLlmProvidersSync"); + expect(sdkIndex).toContain("putWorkspacesByWorkspaceIdCloudLlmProvidersByCloudProviderId"); + expect(sdkIndex).toContain("patchWorkspacesByWorkspaceIdConfigDisabledProviders"); expect(sdkIndex).toContain("getSystemStatus"); expect(sdkIndex).toContain("getSystemCloudSignin"); expect(sdkIndex).toContain("getSystemManagedMcps"); diff --git a/apps/server-v2/src/files.test.ts b/apps/server-v2/src/files.test.ts index 2dd2a71de..dd9ac7233 100644 --- a/apps/server-v2/src/files.test.ts +++ b/apps/server-v2/src/files.test.ts @@ -348,3 +348,144 @@ test("reconciliation absorbs recognized managed items from local workspace files expect(snapshot.effective.opencode.plugin).toContain("demo-plugin"); expect((snapshot.effective.opencode.provider as any).openai.options.apiKey).toBe("redacted"); }); + +test("workspace cloud provider routes persist imports, disabled providers, and sync state through server-v2", async () => { + const originalFetch = globalThis.fetch; + const { app, dependencies, root } = createTestApp(); + const workspaceRoot = path.join(root, "workspace-cloud"); + + const createResponse = await app.request("http://openwork.local/workspaces/local", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ folderPath: workspaceRoot, name: "Cloud", preset: "starter" }), + }); + const created = await createResponse.json(); + const workspaceId = created.data.id as string; + + dependencies.services.managed.upsertCloudSignin({ + auth: { authToken: "cloud-token" }, + cloudBaseUrl: "https://app.openworklabs.com", + metadata: null, + orgId: "org_1", + userId: "usr_1", + }); + + let includeSecondProvider = false; + globalThis.fetch = (async (input) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url); + if (url.pathname === "/api/den/v1/llm-providers") { + return new Response(JSON.stringify({ + llmProviders: [ + { + id: "lp_1", + source: "models_dev", + providerId: "openai-cloud", + name: "OpenAI Cloud", + providerConfig: { env: ["OPENAI_API_KEY"], options: { baseUrl: "https://api.openai.com/v1" } }, + hasApiKey: true, + models: [{ id: "gpt-4.1", name: "GPT-4.1", config: { reasoning: { supported: true } }, createdAt: null }], + createdAt: null, + updatedAt: "2026-04-23T00:00:00.000Z", + }, + ...(includeSecondProvider + ? [{ + id: "lp_2", + source: "custom", + providerId: "anthropic-cloud", + name: "Anthropic Cloud", + providerConfig: { env: ["ANTHROPIC_API_KEY"] }, + hasApiKey: true, + models: [{ id: "claude-sonnet", name: "Claude Sonnet", config: {}, createdAt: null }], + createdAt: null, + updatedAt: "2026-04-23T01:00:00.000Z", + }] + : []), + ], + }), { headers: { "Content-Type": "application/json" } }); + } + if (url.pathname === "/api/den/v1/llm-providers/lp_1/connect") { + return new Response(JSON.stringify({ + llmProvider: { + id: "lp_1", + source: "models_dev", + providerId: "openai-cloud", + name: "OpenAI Cloud", + providerConfig: { env: ["OPENAI_API_KEY"], options: { baseUrl: "https://api.openai.com/v1" } }, + hasApiKey: true, + apiKey: "sk-openai", + models: [{ id: "gpt-4.1", name: "GPT-4.1", config: { reasoning: { supported: true } }, createdAt: null }], + createdAt: null, + updatedAt: "2026-04-23T00:00:00.000Z", + }, + }), { headers: { "Content-Type": "application/json" } }); + } + if (url.pathname === "/api/den/v1/llm-providers/lp_2/connect") { + return new Response(JSON.stringify({ + llmProvider: { + id: "lp_2", + source: "custom", + providerId: "anthropic-cloud", + name: "Anthropic Cloud", + providerConfig: { env: ["ANTHROPIC_API_KEY"] }, + hasApiKey: true, + apiKey: "sk-anthropic", + models: [{ id: "claude-sonnet", name: "Claude Sonnet", config: {}, createdAt: null }], + createdAt: null, + updatedAt: "2026-04-23T01:00:00.000Z", + }, + }), { headers: { "Content-Type": "application/json" } }); + } + return new Response("Not found", { status: 404 }); + }) as typeof fetch; + + try { + const listResponse = await app.request("http://openwork.local/v1/llm-providers"); + const listBody = await listResponse.json(); + expect(listResponse.status).toBe(200); + expect(listBody.llmProviders).toHaveLength(1); + + const importResponse = await app.request(`http://openwork.local/workspaces/${workspaceId}/cloud/llm-providers/lp_1`, { + method: "PUT", + }); + const importBody = await importResponse.json(); + expect(importResponse.status).toBe(200); + expect(importBody.data.importedProviders.lp_1.providerId).toBe("openai-cloud"); + expect((importBody.data.snapshot.effective.opencode.provider as any)["openai-cloud"].models["gpt-4.1"].name).toBe("GPT-4.1"); + expect(dependencies.persistence.repositories.providerConfigs.getById(`provider_${workspaceId}_openai-cloud`)?.auth).toMatchObject({ key: "sk-openai", type: "api" }); + + const disabledResponse = await app.request(`http://openwork.local/workspaces/${workspaceId}/config/disabled-providers`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ disabledProviders: ["demo-provider", "openai-cloud"] }), + }); + const disabledBody = await disabledResponse.json(); + expect(disabledResponse.status).toBe(200); + expect(disabledBody.data.disabledProviders).toEqual(["demo-provider", "openai-cloud"]); + + includeSecondProvider = true; + const syncResponse = await app.request(`http://openwork.local/workspaces/${workspaceId}/cloud/llm-providers/sync`, { + method: "POST", + }); + const syncBody = await syncResponse.json(); + expect(syncResponse.status).toBe(200); + expect(syncBody.data.added).toContain("lp_2"); + expect(syncBody.data.importedProviders.lp_2.providerId).toBe("anthropic-cloud"); + expect(syncBody.data.disabledProviders).toEqual(["demo-provider"]); + + const stateResponse = await app.request(`http://openwork.local/workspaces/${workspaceId}/cloud/llm-providers/state`); + const stateBody = await stateResponse.json(); + expect(stateResponse.status).toBe(200); + expect(stateBody.data.importedProviders.lp_1.providerId).toBe("openai-cloud"); + expect(stateBody.data.importedProviders.lp_2.providerId).toBe("anthropic-cloud"); + + const removeResponse = await app.request(`http://openwork.local/workspaces/${workspaceId}/cloud/llm-providers/lp_1`, { + method: "DELETE", + }); + const removeBody = await removeResponse.json(); + expect(removeResponse.status).toBe(200); + expect(removeBody.data.importedProviders.lp_1).toBeUndefined(); + expect((removeBody.data.snapshot.effective.opencode.provider as any)["openai-cloud"]).toBeUndefined(); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/apps/server-v2/src/routes/cloud.ts b/apps/server-v2/src/routes/cloud.ts new file mode 100644 index 000000000..3d6c0f1f6 --- /dev/null +++ b/apps/server-v2/src/routes/cloud.ts @@ -0,0 +1,363 @@ +import type { Hono } from "hono"; +import { describeRoute } from "hono-openapi"; +import type { Context } from "hono"; +import { ZodError } from "zod"; +import { getRequestContext, type AppBindings } from "../context/request-context.js"; +import { buildSuccessResponse, RouteError } from "../http.js"; +import { jsonResponse } from "../openapi.js"; +import { + cloudAppVersionResponseSchema, + cloudCompatErrorSchema, + cloudDesktopConfigSchema, + cloudDesktopHandoffExchangeRequestSchema, + cloudDesktopHandoffExchangeResponseSchema, + cloudLlmProviderConnectionResponseSchema, + cloudLlmProviderListResponseSchema, + cloudMeResponseSchema, + cloudOrganizationsResponseSchema, + cloudSetActiveOrganizationRequestSchema, + cloudSetActiveOrganizationResponseSchema, + workspaceCloudProviderMutationResponseSchema, + workspaceCloudProviderStateResponseSchema, + workspaceCloudProviderSyncResponseSchema, + workspaceDisabledProvidersWriteSchema, +} from "../schemas/cloud.js"; +import { CloudProxyError } from "../services/cloud-service.js"; +import { routePaths } from "./route-paths.js"; + +function parseJsonBody(schema: { parse(input: unknown): T }, request: Request) { + return request.json().then((body) => schema.parse(body)); +} + +function compatErrorPayload(error: { code: string; details?: unknown; message: string }) { + return { + details: error.details, + error: error.code, + message: error.message, + }; +} + +function respondWithCompatError(c: Context, error: unknown) { + if (error instanceof CloudProxyError) { + const payload = error.payload; + if (payload && typeof payload === "object") { + return c.json(payload, error.status as any); + } + + return c.json(compatErrorPayload({ code: "bad_gateway", message: error.message }), error.status as any); + } + + if (error instanceof RouteError) { + return c.json(compatErrorPayload({ code: error.code, details: error.details, message: error.message }), error.status as any); + } + + if (error instanceof ZodError) { + return c.json(compatErrorPayload({ + code: "invalid_request", + details: error.issues.map((issue) => ({ + message: issue.message, + path: issue.path, + })), + message: "Request validation failed.", + }), 400); + } + + throw error; +} + +export function registerCloudRoutes(app: Hono) { + app.get( + routePaths.v1.appVersion, + describeRoute({ + tags: ["Cloud"], + summary: "Get cloud app version metadata", + description: "Returns the current cloud-controlled desktop version metadata through Server V2.", + responses: { + 200: jsonResponse("Cloud app version metadata returned successfully.", cloudAppVersionResponseSchema), + 500: jsonResponse("The server failed to return app version metadata.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + return c.json(await getRequestContext(c).services.cloud.getAppVersionMetadata()); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + + app.post( + routePaths.v1.auth.desktopHandoffExchange, + describeRoute({ + tags: ["Cloud"], + summary: "Exchange a desktop handoff grant", + description: "Exchanges a cloud desktop handoff grant and persists the resulting cloud signin state in Server V2.", + responses: { + 200: jsonResponse("Desktop handoff exchanged successfully.", cloudDesktopHandoffExchangeResponseSchema), + 400: jsonResponse("The desktop handoff request body was invalid.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to exchange the handoff grant.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + const body = await parseJsonBody(cloudDesktopHandoffExchangeRequestSchema, c.req.raw); + return c.json(await getRequestContext(c).services.cloud.exchangeDesktopHandoff(body.grant)); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + + app.get( + routePaths.v1.llmProviders, + describeRoute({ + tags: ["Cloud"], + summary: "List cloud LLM providers", + description: "Returns the cloud LLM providers visible to the active organization through Server V2.", + responses: { + 200: jsonResponse("Cloud LLM providers returned successfully.", cloudLlmProviderListResponseSchema), + 401: jsonResponse("Cloud signin is required to list LLM providers.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to list cloud LLM providers.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + return c.json({ llmProviders: await getRequestContext(c).services.cloud.listLlmProviders() }); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + + app.get( + routePaths.v1.llmProviderConnect(), + describeRoute({ + tags: ["Cloud"], + summary: "Get a cloud LLM provider connect payload", + description: "Returns one cloud LLM provider with its concrete connection details through Server V2.", + responses: { + 200: jsonResponse("Cloud LLM provider connection payload returned successfully.", cloudLlmProviderConnectionResponseSchema), + 401: jsonResponse("Cloud signin is required to read the provider connect payload.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to read the cloud provider connect payload.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + const llmProviderId = c.req.param("llmProviderId") ?? ""; + return c.json({ llmProvider: await getRequestContext(c).services.cloud.getLlmProviderConnection(llmProviderId) }); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + + app.get( + routePaths.v1.me, + describeRoute({ + tags: ["Cloud"], + summary: "Get the current cloud user", + description: "Returns the current cloud user and session using the server-owned cloud signin state.", + responses: { + 200: jsonResponse("Current cloud user returned successfully.", cloudMeResponseSchema), + 401: jsonResponse("Cloud signin is required to read the current user.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to read the current cloud user.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + return c.json(await getRequestContext(c).services.cloud.getSession()); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + + app.get( + routePaths.v1.meOrgs, + describeRoute({ + tags: ["Cloud"], + summary: "List current cloud organizations", + description: "Returns the current cloud organizations and active organization using the server-owned cloud signin state.", + responses: { + 200: jsonResponse("Current cloud organizations returned successfully.", cloudOrganizationsResponseSchema), + 401: jsonResponse("Cloud signin is required to read organizations.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to read cloud organizations.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + return c.json(await getRequestContext(c).services.cloud.getOrganizations()); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + + app.get( + routePaths.v1.meDesktopConfig, + describeRoute({ + tags: ["Cloud"], + summary: "Get the current cloud desktop config", + description: "Returns org-scoped desktop restrictions and allowed desktop versions through Server V2.", + responses: { + 200: jsonResponse("Current cloud desktop config returned successfully.", cloudDesktopConfigSchema), + 401: jsonResponse("Cloud signin is required to read desktop config.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to read the current cloud desktop config.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + return c.json(await getRequestContext(c).services.cloud.getDesktopConfig()); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + + app.post( + routePaths.api.auth.organizationSetActive, + describeRoute({ + tags: ["Cloud"], + summary: "Set the active cloud organization", + description: "Sets the active cloud organization through Server V2 and persists the resulting active-org metadata.", + responses: { + 200: jsonResponse("Active cloud organization updated successfully.", cloudSetActiveOrganizationResponseSchema), + 400: jsonResponse("The active organization request body was invalid.", cloudCompatErrorSchema), + 401: jsonResponse("Cloud signin is required to change organizations.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to change the active organization.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + const body = await parseJsonBody(cloudSetActiveOrganizationRequestSchema, c.req.raw); + return c.json(await getRequestContext(c).services.cloud.setActiveOrganization(body)); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + + app.get( + routePaths.workspaces.cloud.providerState(), + describeRoute({ + tags: ["Cloud"], + summary: "Read workspace cloud provider state", + description: "Returns the server-owned imported cloud provider state and disabled provider list for one workspace.", + responses: { + 200: jsonResponse("Workspace cloud provider state returned successfully.", workspaceCloudProviderStateResponseSchema), + 401: jsonResponse("Authentication is required to read workspace cloud provider state.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to read workspace cloud provider state.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + const requestContext = getRequestContext(c); + requestContext.services.auth.requireVisibleRead(requestContext.actor); + const workspaceId = c.req.param("workspaceId") ?? ""; + return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.cloud.getWorkspaceCloudProviderState(workspaceId))); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + + app.patch( + routePaths.workspaces.configDisabledProviders(), + describeRoute({ + tags: ["Cloud"], + summary: "Set workspace disabled providers", + description: "Persists the workspace disabled provider list through Server V2 instead of app-local config mutation.", + responses: { + 200: jsonResponse("Workspace disabled providers updated successfully.", workspaceCloudProviderMutationResponseSchema), + 400: jsonResponse("The disabled provider payload was invalid.", cloudCompatErrorSchema), + 401: jsonResponse("Authentication is required to update disabled providers.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to update disabled providers.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + const requestContext = getRequestContext(c); + requestContext.services.auth.requireVisibleRead(requestContext.actor); + const body = await parseJsonBody(workspaceDisabledProvidersWriteSchema, c.req.raw); + const workspaceId = c.req.param("workspaceId") ?? ""; + return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.cloud.setWorkspaceDisabledProviders(workspaceId, body.disabledProviders))); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + + app.put( + routePaths.workspaces.cloud.providerImport(), + describeRoute({ + tags: ["Cloud"], + summary: "Import one cloud LLM provider into a workspace", + description: "Creates or updates one workspace-scoped cloud-managed provider config and persists its import state through Server V2.", + responses: { + 200: jsonResponse("Workspace cloud provider imported successfully.", workspaceCloudProviderMutationResponseSchema), + 401: jsonResponse("Authentication is required to import a cloud provider.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to import the cloud provider.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + const requestContext = getRequestContext(c); + requestContext.services.auth.requireVisibleRead(requestContext.actor); + const workspaceId = c.req.param("workspaceId") ?? ""; + const cloudProviderId = c.req.param("cloudProviderId") ?? ""; + return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.cloud.importWorkspaceCloudProvider(workspaceId, cloudProviderId))); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + + app.delete( + routePaths.workspaces.cloud.providerImport(), + describeRoute({ + tags: ["Cloud"], + summary: "Remove one imported cloud LLM provider from a workspace", + description: "Removes one workspace-scoped cloud-managed provider config and clears its persisted import state through Server V2.", + responses: { + 200: jsonResponse("Workspace cloud provider removed successfully.", workspaceCloudProviderMutationResponseSchema), + 401: jsonResponse("Authentication is required to remove a cloud provider.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to remove the cloud provider.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + const requestContext = getRequestContext(c); + requestContext.services.auth.requireVisibleRead(requestContext.actor); + const workspaceId = c.req.param("workspaceId") ?? ""; + const cloudProviderId = c.req.param("cloudProviderId") ?? ""; + return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.cloud.removeWorkspaceCloudProvider(workspaceId, cloudProviderId))); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + + app.post( + routePaths.workspaces.cloud.providerSync(), + describeRoute({ + tags: ["Cloud"], + summary: "Sync workspace cloud LLM providers", + description: "Reconciles workspace cloud-managed provider config with the currently visible cloud organization provider catalog through Server V2.", + responses: { + 200: jsonResponse("Workspace cloud providers synced successfully.", workspaceCloudProviderSyncResponseSchema), + 401: jsonResponse("Authentication is required to sync cloud providers.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to sync cloud providers.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + const requestContext = getRequestContext(c); + requestContext.services.auth.requireVisibleRead(requestContext.actor); + const workspaceId = c.req.param("workspaceId") ?? ""; + return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.cloud.syncWorkspaceCloudProviders(workspaceId))); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); +} diff --git a/apps/server-v2/src/routes/index.ts b/apps/server-v2/src/routes/index.ts index 9bfc6bcae..ecbcbaaf5 100644 --- a/apps/server-v2/src/routes/index.ts +++ b/apps/server-v2/src/routes/index.ts @@ -1,6 +1,7 @@ import type { Hono } from "hono"; import type { AppDependencies } from "../context/app-dependencies.js"; import type { AppBindings } from "../context/request-context.js"; +import { registerCloudRoutes } from "./cloud.js"; import { registerFileRoutes } from "./files.js"; import { registerManagedRoutes } from "./managed.js"; import { registerRuntimeRoutes } from "./runtime.js"; @@ -10,6 +11,7 @@ import { registerWorkspaceRoutes } from "./workspaces.js"; export function registerRoutes(app: Hono, dependencies: AppDependencies) { registerSystemRoutes(app, dependencies); + registerCloudRoutes(app); registerRuntimeRoutes(app); registerWorkspaceRoutes(app); registerFileRoutes(app); diff --git a/apps/server-v2/src/routes/route-paths.ts b/apps/server-v2/src/routes/route-paths.ts index 9ad267c40..0929dd3c3 100644 --- a/apps/server-v2/src/routes/route-paths.ts +++ b/apps/server-v2/src/routes/route-paths.ts @@ -1,10 +1,12 @@ const WORKSPACE_ID_PARAMETER = ":workspaceId"; export const routeNamespaces = { + dev: "/dev", root: "/", openapi: "/openapi.json", system: "/system", workspaces: "/workspaces", + v1: "/v1", } as const; export function workspaceRoutePath(workspaceId: string = WORKSPACE_ID_PARAMETER) { @@ -44,10 +46,19 @@ export const workspaceResourcePattern = workspaceRoutePath(); export const routePaths = { root: routeNamespaces.root, openapiDocument: routeNamespaces.openapi, + api: { + auth: { + organizationSetActive: "/api/auth/organization/set-active", + }, + }, + dev: { + log: `${routeNamespaces.dev}/log`, + }, system: { base: routeNamespaces.system, capabilities: `${routeNamespaces.system}/capabilities`, cloudSignin: `${routeNamespaces.system}/cloud-signin`, + cloudBootstrap: `${routeNamespaces.system}/cloud/bootstrap`, health: `${routeNamespaces.system}/health`, managed: { item: (kind: string, itemId: string = ":itemId") => `${routeNamespaces.system}/managed/${kind}/${itemId}`, @@ -76,6 +87,17 @@ export const routePaths = { versions: `${routeNamespaces.system}/runtime/versions`, }, }, + v1: { + appVersion: `${routeNamespaces.v1}/app-version`, + auth: { + desktopHandoffExchange: `${routeNamespaces.v1}/auth/desktop-handoff/exchange`, + }, + llmProviderConnect: (llmProviderId: string = ":llmProviderId") => `${routeNamespaces.v1}/llm-providers/${llmProviderId}/connect`, + llmProviders: `${routeNamespaces.v1}/llm-providers`, + me: `${routeNamespaces.v1}/me`, + meDesktopConfig: `${routeNamespaces.v1}/me/desktop-config`, + meOrgs: `${routeNamespaces.v1}/me/orgs`, + }, workspaces: { base: routeNamespaces.workspaces, createLocal: `${routeNamespaces.workspaces}/local`, @@ -90,6 +112,13 @@ export const routePaths = { `${workspaceRoutePath(workspaceId)}/artifacts/${artifactId}`, }, config: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/config`, + cloud: { + providerImport: (cloudProviderId: string = ":cloudProviderId", workspaceId: string = WORKSPACE_ID_PARAMETER) => + `${workspaceRoutePath(workspaceId)}/cloud/llm-providers/${cloudProviderId}`, + providerState: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/cloud/llm-providers/state`, + providerSync: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/cloud/llm-providers/sync`, + }, + configDisabledProviders: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/config/disabled-providers`, engineReload: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/engine/reload`, export: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/export`, fileSessions: { diff --git a/apps/server-v2/src/routes/system.ts b/apps/server-v2/src/routes/system.ts index d70a8ab4c..8e68d1db4 100644 --- a/apps/server-v2/src/routes/system.ts +++ b/apps/server-v2/src/routes/system.ts @@ -1,3 +1,5 @@ +import { appendFile, mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; import type { Hono } from "hono"; import { describeRoute, openAPIRouteHandler } from "hono-openapi"; import { HTTPException } from "hono/http-exception"; @@ -13,7 +15,8 @@ import { serverInventoryListResponseSchema, systemStatusResponseSchema, } from "../schemas/registry.js"; -import { healthResponseSchema, metadataResponseSchema, openApiDocumentSchema, rootInfoResponseSchema } from "../schemas/system.js"; +import { cloudBootstrapConfigResponseSchema } from "../schemas/cloud.js"; +import { devLogStatusSchema, devLogWriteResponseSchema, healthResponseSchema, metadataResponseSchema, openApiDocumentSchema, rootInfoResponseSchema } from "../schemas/system.js"; import { routePaths } from "./route-paths.js"; type ServerV2App = Hono; @@ -96,6 +99,10 @@ function createOpenApiDocumentation(version: string) { name: "System", description: "Server-level operational routes and contract metadata.", }, + { + name: "Cloud", + description: "Server-owned cloud compatibility routes and persisted cloud signin state.", + }, { name: "Workspaces", description: "Workspace-first resources will live under /workspaces/:workspaceId.", @@ -128,6 +135,11 @@ function createOpenApiDocumentation(version: string) { }; } +function resolveDevLogPath(): string | null { + const raw = (process.env.OPENWORK_DEV_LOG_FILE ?? "").trim(); + return raw.length > 0 ? raw : null; +} + export function registerSystemRoutes(app: ServerV2App, dependencies: AppDependencies) { app.get( routePaths.root, @@ -212,6 +224,22 @@ export function registerSystemRoutes(app: ServerV2App, dependencies: AppDependen }, ); + app.get( + routePaths.system.cloudBootstrap, + describeRoute({ + tags: ["Cloud"], + summary: "Get cloud bootstrap config", + description: "Returns the server-owned cloud bootstrap config that desktop-hosted clients can use for base URL and forced-signin decisions.", + responses: withCommonErrorResponses({ + 200: jsonResponse("Cloud bootstrap config returned successfully.", cloudBootstrapConfigResponseSchema), + }), + }), + (c) => { + const requestContext = getRequestContext(c); + return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.cloud.getBootstrapConfig())); + }, + ); + app.get( routePaths.system.servers, describeRoute({ @@ -316,4 +344,59 @@ export function registerSystemRoutes(app: ServerV2App, dependencies: AppDependen }, }), ); + + app.get( + routePaths.dev.log, + describeRoute({ + tags: ["System"], + summary: "Probe the dev log sink", + description: "Returns whether the optional Server V2 dev log sink is enabled for the current process.", + responses: withCommonErrorResponses({ + 200: jsonResponse("Dev log sink status returned successfully.", devLogStatusSchema), + }), + }), + (c) => { + const target = resolveDevLogPath(); + if (!target) { + return c.json({ ok: false, reason: "dev_log_disabled" }); + } + return c.json({ ok: true, path: target }); + }, + ); + + app.post( + routePaths.dev.log, + describeRoute({ + tags: ["System"], + summary: "Append entries to the dev log sink", + description: "Appends JSON log entries to the optional Server V2 dev log file for local debugging and automation tooling.", + responses: withCommonErrorResponses({ + 200: jsonResponse("Dev log entries appended successfully.", devLogWriteResponseSchema), + }, { includeInvalidRequest: true }), + }), + async (c) => { + const target = resolveDevLogPath(); + if (!target) { + return c.json({ ok: false, reason: "dev_log_disabled" }); + } + + let payload: unknown = null; + try { + payload = await c.req.json(); + } catch { + return c.json({ error: "invalid_request", message: "Request body must be valid JSON." }, 400); + } + + const entries = Array.isArray(payload) ? payload : [payload]; + await mkdir(dirname(target), { recursive: true }); + const lines = entries.map((entry) => { + if (entry && typeof entry === "object" && !Array.isArray(entry)) { + return JSON.stringify({ at: new Date().toISOString(), ...(entry as Record) }); + } + return JSON.stringify({ at: new Date().toISOString(), value: entry }); + }).join("\n"); + await appendFile(target, `${lines}\n`, "utf8"); + return c.json({ ok: true, count: entries.length }); + }, + ); } diff --git a/apps/server-v2/src/schemas/cloud.ts b/apps/server-v2/src/schemas/cloud.ts new file mode 100644 index 000000000..da874d825 --- /dev/null +++ b/apps/server-v2/src/schemas/cloud.ts @@ -0,0 +1,195 @@ +import { z } from "zod"; +import { successResponseSchema } from "./common.js"; + +const nullableString = z.string().nullable(); + +export const cloudCompatErrorSchema = z.object({ + error: z.string().min(1), + message: z.string().min(1), + details: z.unknown().optional(), +}).meta({ ref: "OpenWorkServerV2CloudCompatError" }); + +export const cloudUserSchema = z.object({ + id: z.string().min(1), + email: z.string().min(1), + name: nullableString.optional(), +}).passthrough().meta({ ref: "OpenWorkServerV2CloudUser" }); + +export const cloudSessionSchema = z.object({}).passthrough().meta({ ref: "OpenWorkServerV2CloudSession" }); + +export const cloudMeResponseSchema = z.object({ + user: cloudUserSchema, + session: cloudSessionSchema, +}).passthrough().meta({ ref: "OpenWorkServerV2CloudMeResponse" }); + +export const cloudAppVersionResponseSchema = z.object({ + minAppVersion: z.string(), + latestAppVersion: z.string().min(1), +}).meta({ ref: "OpenWorkServerV2CloudAppVersionResponse" }); + +export const cloudDesktopConfigSchema = z.object({ + disallowNonCloudModels: z.boolean().optional(), + blockZenModel: z.boolean().optional(), + blockMultipleWorkspaces: z.boolean().optional(), + allowedDesktopVersions: z.array(z.string().trim().min(1).max(32)).optional(), +}).meta({ ref: "OpenWorkServerV2CloudDesktopConfig" }); + +export const cloudDesktopHandoffExchangeRequestSchema = z.object({ + grant: z.string().trim().min(1), +}).meta({ ref: "OpenWorkServerV2CloudDesktopHandoffExchangeRequest" }); + +export const cloudDesktopHandoffExchangeResponseSchema = z.object({ + user: cloudUserSchema.nullable(), + token: nullableString, +}).passthrough().meta({ ref: "OpenWorkServerV2CloudDesktopHandoffExchangeResponse" }); + +export const cloudOrganizationSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1).optional(), + slug: z.string().min(1).optional(), + role: z.enum(["owner", "admin", "member"]).optional(), + isActive: z.boolean().optional(), +}).passthrough().meta({ ref: "OpenWorkServerV2CloudOrganization" }); + +export const cloudOrganizationsResponseSchema = z.object({ + orgs: z.array(cloudOrganizationSchema), + activeOrgId: nullableString, + activeOrgSlug: nullableString, +}).passthrough().meta({ ref: "OpenWorkServerV2CloudOrganizationsResponse" }); + +export const cloudSetActiveOrganizationRequestSchema = z.object({ + organizationId: nullableString.optional(), + organizationSlug: nullableString.optional(), +}).refine( + (value) => Boolean(value.organizationId?.trim() || value.organizationSlug?.trim()), + { + error: "organizationId or organizationSlug is required.", + path: ["organizationId"], + }, +).meta({ ref: "OpenWorkServerV2CloudSetActiveOrganizationRequest" }); + +export const cloudSetActiveOrganizationResponseSchema = z.object({ + ok: z.literal(true), + activeOrgId: nullableString, + activeOrgSlug: nullableString, +}).meta({ ref: "OpenWorkServerV2CloudSetActiveOrganizationResponse" }); + +export const cloudBootstrapConfigSchema = z.object({ + apiBaseUrl: z.string().min(1), + baseUrl: z.string().min(1), + requireSignin: z.boolean(), +}).meta({ ref: "OpenWorkServerV2CloudBootstrapConfig" }); + +export const cloudBootstrapConfigResponseSchema = successResponseSchema( + "OpenWorkServerV2CloudBootstrapConfigResponse", + cloudBootstrapConfigSchema, +); + +export const cloudLlmProviderModelSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + config: z.record(z.string(), z.unknown()), + createdAt: nullableString.optional(), +}).meta({ ref: "OpenWorkServerV2CloudLlmProviderModel" }); + +export const cloudLlmProviderSchema = z.object({ + id: z.string().min(1), + source: z.enum(["models_dev", "custom"]), + providerId: z.string().min(1), + name: z.string().min(1), + providerConfig: z.record(z.string(), z.unknown()), + hasApiKey: z.boolean(), + models: z.array(cloudLlmProviderModelSchema), + createdAt: nullableString, + updatedAt: nullableString, +}).meta({ ref: "OpenWorkServerV2CloudLlmProvider" }); + +export const cloudLlmProviderConnectionSchema = cloudLlmProviderSchema.extend({ + apiKey: nullableString, +}).meta({ ref: "OpenWorkServerV2CloudLlmProviderConnection" }); + +export const cloudLlmProviderListResponseSchema = z.object({ + llmProviders: z.array(cloudLlmProviderSchema), +}).meta({ ref: "OpenWorkServerV2CloudLlmProviderListResponse" }); + +export const cloudLlmProviderConnectionResponseSchema = z.object({ + llmProvider: cloudLlmProviderConnectionSchema, +}).meta({ ref: "OpenWorkServerV2CloudLlmProviderConnectionResponse" }); + +export const workspaceImportedCloudProviderSchema = z.object({ + cloudProviderId: z.string().min(1), + providerId: z.string().min(1), + sourceProviderId: z.string().min(1), + name: z.string().min(1), + source: nullableString, + updatedAt: nullableString, + modelIds: z.array(z.string().min(1)), + importedAt: z.number().int().nonnegative().nullable(), +}).meta({ ref: "OpenWorkServerV2WorkspaceImportedCloudProvider" }); + +export const workspaceCloudProviderStateDataSchema = z.object({ + disabledProviders: z.array(z.string().min(1)), + importedProviders: z.record(z.string(), workspaceImportedCloudProviderSchema), +}).meta({ ref: "OpenWorkServerV2WorkspaceCloudProviderStateData" }); + +export const workspaceCloudProviderStateResponseSchema = successResponseSchema( + "OpenWorkServerV2WorkspaceCloudProviderStateResponse", + workspaceCloudProviderStateDataSchema, +); + +export const workspaceCloudProviderMutationDataSchema = z.object({ + disabledProviders: z.array(z.string().min(1)), + importedProviders: z.record(z.string(), workspaceImportedCloudProviderSchema), + snapshot: z.object({ + effective: z.object({ + opencode: z.record(z.string(), z.unknown()), + openwork: z.record(z.string(), z.unknown()), + }), + stored: z.object({ + opencode: z.record(z.string(), z.unknown()), + openwork: z.record(z.string(), z.unknown()), + }), + materialized: z.object({ + compatibilityOpencodePath: nullableString, + compatibilityOpenworkPath: nullableString, + configDir: nullableString, + configOpencodePath: nullableString, + configOpenworkPath: nullableString, + }), + updatedAt: z.string(), + workspaceId: z.string().min(1), + }), +}).meta({ ref: "OpenWorkServerV2WorkspaceCloudProviderMutationData" }); + +export const workspaceCloudProviderMutationResponseSchema = successResponseSchema( + "OpenWorkServerV2WorkspaceCloudProviderMutationResponse", + workspaceCloudProviderMutationDataSchema, +); + +export const workspaceCloudProviderImportRequestSchema = z.object({ + cloudProviderId: z.string().trim().min(1).optional(), +}).meta({ ref: "OpenWorkServerV2WorkspaceCloudProviderImportRequest" }); + +export const workspaceDisabledProvidersWriteSchema = z.object({ + disabledProviders: z.array(z.string().trim().min(1)), +}).meta({ ref: "OpenWorkServerV2WorkspaceDisabledProvidersWrite" }); + +export const workspaceCloudProviderSyncDataSchema = workspaceCloudProviderMutationDataSchema.extend({ + added: z.array(z.string().min(1)), + removed: z.array(z.string().min(1)), + updated: z.array(z.string().min(1)), +}).meta({ ref: "OpenWorkServerV2WorkspaceCloudProviderSyncData" }); + +export const workspaceCloudProviderSyncResponseSchema = successResponseSchema( + "OpenWorkServerV2WorkspaceCloudProviderSyncResponse", + workspaceCloudProviderSyncDataSchema, +); + +export type CloudAppVersionResponse = z.infer; +export type CloudDesktopConfig = z.infer; +export type CloudDesktopHandoffExchangeResponse = z.infer; +export type CloudLlmProvider = z.infer; +export type CloudLlmProviderConnection = z.infer; +export type CloudMeResponse = z.infer; +export type CloudOrganizationsResponse = z.infer; +export type WorkspaceImportedCloudProvider = z.infer; diff --git a/apps/server-v2/src/schemas/system.ts b/apps/server-v2/src/schemas/system.ts index 16620ee04..9ad9f6119 100644 --- a/apps/server-v2/src/schemas/system.ts +++ b/apps/server-v2/src/schemas/system.ts @@ -131,3 +131,14 @@ export const openApiDocumentSchema = z.object({ paths: z.record(z.string(), z.unknown()), components: z.object({}).passthrough().optional(), }).passthrough().meta({ ref: "OpenWorkServerV2OpenApiDocument" }); + +export const devLogStatusSchema = z.object({ + ok: z.boolean(), + path: z.string().optional(), + reason: z.string().optional(), +}).meta({ ref: "OpenWorkServerV2DevLogStatus" }); + +export const devLogWriteResponseSchema = z.object({ + count: z.number().int().nonnegative(), + ok: z.literal(true), +}).meta({ ref: "OpenWorkServerV2DevLogWriteResponse" }); diff --git a/apps/server-v2/src/services/cloud-service.ts b/apps/server-v2/src/services/cloud-service.ts new file mode 100644 index 000000000..8b9484cad --- /dev/null +++ b/apps/server-v2/src/services/cloud-service.ts @@ -0,0 +1,703 @@ +import type { ConfigMaterializationService } from "./config-materialization-service.js"; +import type { ServerPersistence } from "../database/persistence.js"; +import type { JsonObject } from "../database/types.js"; +import { RouteError } from "../http.js"; +import { + cloudAppVersionResponseSchema, + cloudDesktopConfigSchema, + cloudDesktopHandoffExchangeResponseSchema, + cloudLlmProviderConnectionResponseSchema, + cloudLlmProviderListResponseSchema, + cloudMeResponseSchema, + cloudOrganizationsResponseSchema, + type CloudAppVersionResponse, + type CloudDesktopConfig, + type CloudDesktopHandoffExchangeResponse, + type CloudLlmProvider, + type CloudLlmProviderConnection, + type CloudMeResponse, + type CloudOrganizationsResponse, + type WorkspaceImportedCloudProvider, +} from "../schemas/cloud.js"; + +type CloudBaseUrls = { + apiBaseUrl: string; + baseUrl: string; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function normalizeUrl(input: string | null | undefined): string | null { + const value = (input ?? "").trim(); + if (!value) { + return null; + } + + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return null; + } + return url.toString().replace(/\/+$/, ""); + } catch { + return null; + } +} + +function isTruthy(value: string | undefined) { + if (!value) { + return false; + } + + return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase()); +} + +function isWebAppHost(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase(); + + if ( + normalized === "localhost" + || normalized === "0.0.0.0" + || normalized === "::1" + || normalized === "[::1]" + || /^127(?:\.\d{1,3}){3}$/.test(normalized) + ) { + return true; + } + + const ipv4Match = normalized.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (ipv4Match) { + const [first, second, third, fourth] = ipv4Match.slice(1).map(Number); + const octets = [first, second, third, fourth]; + if (octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)) { + if ( + first === 10 + || first === 127 + || (first === 172 && second >= 16 && second <= 31) + || (first === 192 && second === 168) + || (first === 169 && second === 254) + || (first === 100 && second >= 64 && second <= 127) + ) { + return true; + } + } + } + + return normalized === "app.openworklabs.com" || normalized === "app.openwork.software" || normalized.startsWith("app."); +} + +function ensureDenApiBasePath(input: string): string { + const normalized = normalizeUrl(input); + if (!normalized) { + return input; + } + + try { + const url = new URL(normalized); + const pathname = url.pathname.replace(/\/+$/, ""); + if (pathname.toLowerCase().endsWith("/api/den")) { + return normalized; + } + url.pathname = `${pathname}/api/den`.replace(/\/+/g, "/"); + return url.toString().replace(/\/+$/, ""); + } catch { + return normalized; + } +} + +function deriveDenApiBaseUrl(input: string): string { + const normalized = normalizeUrl(input) ?? "https://app.openworklabs.com"; + + try { + const url = new URL(normalized); + const pathname = url.pathname.replace(/\/+$/, ""); + if (pathname.toLowerCase().endsWith("/api/den")) { + return normalized; + } + if (isWebAppHost(url.hostname)) { + return ensureDenApiBasePath(normalized); + } + } catch { + return normalized; + } + + return normalized; +} + +function normalizeVersion(value: string | undefined, fallback = "") { + const trimmed = (value ?? "").trim().replace(/^v/i, ""); + return trimmed || fallback; +} + +function nowIso() { + return new Date().toISOString(); +} + +export class CloudProxyError extends Error { + constructor( + readonly status: number, + readonly payload: unknown, + message: string, + ) { + super(message); + this.name = "CloudProxyError"; + } +} + +export type CloudService = ReturnType; + +export function createCloudService(input: { + config: ConfigMaterializationService; + repositories: ServerPersistence["repositories"]; + serverId: string; + version: string; +}) { + const defaultCloudBaseUrl = normalizeUrl(process.env.OPENWORK_SERVER_V2_CLOUD_BASE_URL) ?? "https://app.openworklabs.com"; + const configuredApiBaseUrl = normalizeUrl(process.env.OPENWORK_SERVER_V2_CLOUD_API_BASE_URL); + const defaultRequireSignin = isTruthy(process.env.OPENWORK_SERVER_V2_CLOUD_REQUIRE_SIGNIN); + const fallbackLatestAppVersion = normalizeVersion(process.env.OPENWORK_SERVER_V2_LATEST_APP_VERSION, input.version); + const fallbackMinAppVersion = normalizeVersion(process.env.OPENWORK_SERVER_V2_MIN_APP_VERSION, fallbackLatestAppVersion); + + function getPrimarySignin() { + return input.repositories.cloudSignin.getPrimary(); + } + + function resolveCloudBaseUrls(): CloudBaseUrls { + const record = getPrimarySignin(); + const baseUrl = normalizeUrl(record?.cloudBaseUrl) ?? defaultCloudBaseUrl; + return { + baseUrl, + apiBaseUrl: configuredApiBaseUrl ?? deriveDenApiBaseUrl(baseUrl), + }; + } + + function resolveRequestUrl(path: string) { + const baseUrls = resolveCloudBaseUrls(); + const baseUrl = path.startsWith("/api/") ? baseUrls.baseUrl : baseUrls.apiBaseUrl; + return `${baseUrl}${path}`; + } + + function readToken() { + const auth = getPrimarySignin()?.auth; + if (!isRecord(auth)) { + return null; + } + + const authToken = typeof auth.authToken === "string" ? auth.authToken.trim() : ""; + const token = typeof auth.token === "string" ? auth.token.trim() : ""; + return authToken || token || null; + } + + function requireToken() { + const token = readToken(); + if (!token) { + throw new RouteError(401, "unauthorized", "Cloud signin is not configured."); + } + return token; + } + + function updateSigninRecord(updater: (current: ReturnType) => Parameters[0] | null) { + const current = getPrimarySignin(); + const next = updater(current); + if (!next) { + return current; + } + + return input.repositories.cloudSignin.upsert(next); + } + + async function requestCloud(path: string, options: { + body?: unknown; + method?: string; + token?: string | null; + } = {}) { + const headers = new Headers({ Accept: "application/json" }); + if (options.token) { + headers.set("Authorization", `Bearer ${options.token}`); + } + if (options.body !== undefined) { + headers.set("Content-Type", "application/json"); + } + + let response: Response; + try { + response = await fetch(resolveRequestUrl(path), { + method: options.method ?? "GET", + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + } catch (error) { + throw new RouteError(502, "bad_gateway", error instanceof Error ? error.message : "Cloud request failed."); + } + + const text = await response.text(); + let payload: unknown = null; + if (text) { + try { + payload = JSON.parse(text); + } catch { + payload = { message: text }; + } + } + + if (!response.ok) { + const message = isRecord(payload) && typeof payload.message === "string" + ? payload.message + : `Cloud request failed with ${response.status}.`; + throw new CloudProxyError(response.status, payload, message); + } + + return payload; + } + + function persistSessionMetadata(payload: CloudMeResponse) { + const current = getPrimarySignin(); + if (!current) { + return; + } + + updateSigninRecord(() => ({ + ...current, + lastValidatedAt: nowIso(), + metadata: { + ...(current.metadata ?? {}), + session: payload.session, + validatedUser: payload.user, + }, + userId: payload.user.id, + })); + } + + function persistOrganizationMetadata(payload: CloudOrganizationsResponse) { + const current = getPrimarySignin(); + if (!current) { + return; + } + + const activeOrg = payload.orgs.find((org) => org.id === payload.activeOrgId) + ?? payload.orgs.find((org) => org.slug === payload.activeOrgSlug) + ?? null; + + updateSigninRecord(() => ({ + ...current, + metadata: { + ...(current.metadata ?? {}), + activeOrgName: typeof activeOrg?.name === "string" ? activeOrg.name : null, + activeOrgSlug: payload.activeOrgSlug, + organizations: payload.orgs, + }, + orgId: payload.activeOrgId, + })); + } + + function getStringList(value: unknown) { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + : [] as string[]; + } + + function getCloudProviderEnv(config: Record) { + return getStringList(config.env); + } + + function buildCloudProviderConfig(provider: CloudLlmProviderConnection): JsonObject { + const models = Object.fromEntries( + provider.models.map((model) => { + const next: Record = { + id: model.id, + name: model.name, + }; + const raw = model.config; + for (const key of [ + "family", + "release_date", + "attachment", + "reasoning", + "temperature", + "tool_call", + "interleaved", + "cost", + "limit", + "modalities", + "status", + "options", + "headers", + "provider", + "variants", + ] as const) { + const value = raw[key]; + if (value !== undefined) { + next[key] = value; + } + } + return [model.id, next] as const; + }), + ); + + const next: JsonObject = { + id: provider.providerId, + name: provider.name, + env: getCloudProviderEnv(provider.providerConfig), + models, + }; + + if (typeof provider.providerConfig.npm === "string" && provider.providerConfig.npm.trim()) { + next.npm = provider.providerConfig.npm; + } + if (typeof provider.providerConfig.api === "string" && provider.providerConfig.api.trim()) { + next.api = provider.providerConfig.api; + } + if (provider.providerConfig.options && typeof provider.providerConfig.options === "object" && !Array.isArray(provider.providerConfig.options)) { + next.options = provider.providerConfig.options as Record; + } + if (Array.isArray(provider.providerConfig.whitelist)) { + next.whitelist = getStringList(provider.providerConfig.whitelist); + } + if (Array.isArray(provider.providerConfig.blacklist)) { + next.blacklist = getStringList(provider.providerConfig.blacklist); + } + + return next; + } + + function readImportedProviders(openwork: JsonObject): Record { + const cloudImports = isRecord(openwork.cloudImports) ? openwork.cloudImports : {}; + const providers = isRecord(cloudImports.providers) ? cloudImports.providers : {}; + return Object.fromEntries( + Object.entries(providers) + .map(([key, value]) => { + if (!isRecord(value)) { + return null; + } + const cloudProviderId = typeof value.cloudProviderId === "string" ? value.cloudProviderId.trim() : key.trim(); + const providerId = typeof value.providerId === "string" ? value.providerId.trim() : ""; + const sourceProviderId = typeof value.sourceProviderId === "string" ? value.sourceProviderId.trim() : providerId; + const name = typeof value.name === "string" ? value.name.trim() : providerId || cloudProviderId; + if (!cloudProviderId || !providerId || !sourceProviderId || !name) { + return null; + } + return [cloudProviderId, { + cloudProviderId, + providerId, + sourceProviderId, + name, + source: typeof value.source === "string" ? value.source.trim() || null : null, + updatedAt: typeof value.updatedAt === "string" ? value.updatedAt.trim() || null : null, + modelIds: getStringList(value.modelIds).sort(), + importedAt: typeof value.importedAt === "number" && Number.isFinite(value.importedAt) ? value.importedAt : null, + } satisfies WorkspaceImportedCloudProvider] as const; + }) + .filter((entry): entry is readonly [string, WorkspaceImportedCloudProvider] => Boolean(entry)), + ); + } + + function buildImportedProviderRecord(provider: CloudLlmProviderConnection): WorkspaceImportedCloudProvider { + return { + cloudProviderId: provider.id, + importedAt: Date.now(), + modelIds: provider.models.map((model) => model.id.trim()).filter(Boolean).sort(), + name: provider.name, + providerId: provider.providerId, + source: provider.source, + sourceProviderId: provider.providerId, + updatedAt: provider.updatedAt, + }; + } + + async function getWorkspaceProviderState(workspaceId: string) { + const snapshot = await input.config.getWorkspaceConfigSnapshot(workspaceId); + const disabledProviders = getStringList(snapshot.stored.opencode.disabled_providers); + const importedProviders = readImportedProviders(snapshot.stored.openwork); + return { + disabledProviders, + importedProviders, + snapshot, + }; + } + + async function writeImportedProviders(workspaceId: string, nextProviders: Record) { + return input.config.updateWorkspaceOpenworkConfig(workspaceId, (current) => { + const cloudImports = isRecord(current.cloudImports) ? { ...current.cloudImports } : {}; + cloudImports.providers = nextProviders; + return { + ...current, + cloudImports, + }; + }); + } + + function assertCloudImportSafe(workspaceId: string, provider: CloudLlmProviderConnection, importedProviders: Record) { + const existingImported = Object.values(importedProviders).find((entry) => entry.providerId === provider.providerId); + if (existingImported && existingImported.cloudProviderId !== provider.id) { + throw new RouteError( + 409, + "conflict", + `${provider.providerId} is already imported from ${existingImported.name}. Remove it before importing a different cloud provider.`, + ); + } + + const assigned = input.config.listWorkspaceProviderConfigs(workspaceId); + const conflicting = assigned.find((item) => (item.key ?? item.displayName) === provider.providerId); + if (conflicting && conflicting.cloudItemId !== provider.id && !existingImported) { + throw new RouteError( + 409, + "conflict", + `${provider.providerId} already exists in this workspace. Remove the existing provider config before importing the cloud-managed version.`, + ); + } + } + + return { + getBootstrapConfig() { + const baseUrls = resolveCloudBaseUrls(); + return { + apiBaseUrl: baseUrls.apiBaseUrl, + baseUrl: baseUrls.baseUrl, + requireSignin: defaultRequireSignin, + }; + }, + + async exchangeDesktopHandoff(grant: string): Promise { + const payload = await requestCloud("/v1/auth/desktop-handoff/exchange", { + method: "POST", + body: { grant }, + }); + const parsed = cloudDesktopHandoffExchangeResponseSchema.parse(payload); + if (parsed.token) { + const current = getPrimarySignin(); + const baseUrls = resolveCloudBaseUrls(); + input.repositories.cloudSignin.upsert({ + auth: { authToken: parsed.token }, + cloudBaseUrl: baseUrls.baseUrl, + id: current?.id ?? `cloud_${input.serverId}`, + lastValidatedAt: parsed.user ? nowIso() : null, + metadata: { + ...(current?.metadata ?? {}), + validatedUser: parsed.user, + }, + orgId: current?.orgId ?? null, + serverId: input.serverId, + userId: parsed.user?.id ?? current?.userId ?? null, + }); + } + return parsed; + }, + + async getSession(): Promise { + const payload = await requestCloud("/v1/me", { + token: requireToken(), + }); + const parsed = cloudMeResponseSchema.parse(payload); + persistSessionMetadata(parsed); + return parsed; + }, + + async getOrganizations(): Promise { + const payload = await requestCloud("/v1/me/orgs", { + token: requireToken(), + }); + const parsed = cloudOrganizationsResponseSchema.parse(payload); + persistOrganizationMetadata(parsed); + return parsed; + }, + + async setActiveOrganization(inputValue: { organizationId?: string | null; organizationSlug?: string | null }) { + await requestCloud("/api/auth/organization/set-active", { + method: "POST", + token: requireToken(), + body: { + organizationId: inputValue.organizationId?.trim() || undefined, + organizationSlug: inputValue.organizationSlug?.trim() || undefined, + }, + }); + + const orgs = await this.getOrganizations(); + return { + activeOrgId: orgs.activeOrgId, + activeOrgSlug: orgs.activeOrgSlug, + ok: true as const, + }; + }, + + async getDesktopConfig(): Promise { + const payload = await requestCloud("/v1/me/desktop-config", { + token: requireToken(), + }); + return cloudDesktopConfigSchema.parse(payload); + }, + + async listLlmProviders(): Promise { + const payload = await requestCloud("/v1/llm-providers", { + token: requireToken(), + }); + return cloudLlmProviderListResponseSchema.parse(payload).llmProviders; + }, + + async getLlmProviderConnection(cloudProviderId: string): Promise { + const payload = await requestCloud(`/v1/llm-providers/${encodeURIComponent(cloudProviderId)}/connect`, { + token: requireToken(), + }); + return cloudLlmProviderConnectionResponseSchema.parse(payload).llmProvider; + }, + + async getAppVersionMetadata(): Promise { + try { + const payload = await requestCloud("/v1/app-version"); + return cloudAppVersionResponseSchema.parse(payload); + } catch (error) { + if (fallbackLatestAppVersion) { + return { + latestAppVersion: fallbackLatestAppVersion, + minAppVersion: fallbackMinAppVersion, + }; + } + + throw error; + } + }, + + async getWorkspaceCloudProviderState(workspaceId: string) { + const state = await getWorkspaceProviderState(workspaceId); + return { + disabledProviders: state.disabledProviders, + importedProviders: state.importedProviders, + }; + }, + + async setWorkspaceDisabledProviders(workspaceId: string, providerIds: string[]) { + const snapshot = input.config.setWorkspaceDisabledProviders(workspaceId, providerIds); + const state = await getWorkspaceProviderState(workspaceId); + return { + disabledProviders: state.disabledProviders, + importedProviders: state.importedProviders, + snapshot, + }; + }, + + async importWorkspaceCloudProvider(workspaceId: string, cloudProviderId: string) { + const state = await getWorkspaceProviderState(workspaceId); + const provider = await this.getLlmProviderConnection(cloudProviderId); + assertCloudImportSafe(workspaceId, provider, state.importedProviders); + + const existingImported = state.importedProviders[cloudProviderId] ?? null; + if (!provider.apiKey && getCloudProviderEnv(provider.providerConfig).length > 0) { + throw new RouteError(400, "invalid_request", `${provider.name} does not have a stored organization credential yet.`); + } + + if (existingImported?.providerId && existingImported.providerId !== provider.providerId) { + input.config.removeWorkspaceProviderConfig(workspaceId, existingImported.providerId); + } + + const snapshot = input.config.upsertWorkspaceProviderConfig(workspaceId, { + auth: provider.apiKey ? { key: provider.apiKey, type: "api" } : null, + cloudItemId: provider.id, + config: buildCloudProviderConfig(provider), + displayName: provider.name, + key: provider.providerId, + metadata: { + importedVia: "cloud_sync", + modelIds: provider.models.map((model) => model.id), + sourceProviderId: provider.providerId, + workspaceId, + }, + source: "cloud_synced", + }); + + const nextImportedProviders = { + ...state.importedProviders, + [provider.id]: buildImportedProviderRecord(provider), + }; + await writeImportedProviders(workspaceId, nextImportedProviders); + const nextDisabledProviders = state.disabledProviders.filter((id) => id !== provider.providerId && id !== existingImported?.providerId); + input.config.setWorkspaceDisabledProviders(workspaceId, nextDisabledProviders); + const nextState = await getWorkspaceProviderState(workspaceId); + return { + disabledProviders: nextState.disabledProviders, + importedProviders: nextState.importedProviders, + snapshot, + }; + }, + + async removeWorkspaceCloudProvider(workspaceId: string, cloudProviderId: string) { + const state = await getWorkspaceProviderState(workspaceId); + const imported = state.importedProviders[cloudProviderId]; + if (!imported) { + throw new RouteError(404, "not_found", `Cloud provider not imported: ${cloudProviderId}`); + } + + const snapshot = input.config.removeWorkspaceProviderConfig(workspaceId, imported.providerId); + const nextImportedProviders = { ...state.importedProviders }; + delete nextImportedProviders[cloudProviderId]; + await writeImportedProviders(workspaceId, nextImportedProviders); + input.config.setWorkspaceDisabledProviders(workspaceId, state.disabledProviders.filter((id) => id !== imported.providerId)); + const nextState = await getWorkspaceProviderState(workspaceId); + return { + disabledProviders: nextState.disabledProviders, + importedProviders: nextState.importedProviders, + snapshot, + }; + }, + + async syncWorkspaceCloudProviders(workspaceId: string) { + const currentState = await getWorkspaceProviderState(workspaceId); + const providers = await this.listLlmProviders(); + const currentById = new Map(providers.map((provider) => [provider.id, provider] as const)); + + const added: string[] = []; + const removed: string[] = []; + const updated: string[] = []; + + for (const cloudProviderId of Object.keys(currentState.importedProviders)) { + if (!currentById.has(cloudProviderId)) { + await this.removeWorkspaceCloudProvider(workspaceId, cloudProviderId); + removed.push(cloudProviderId); + } + } + + let latestState = await getWorkspaceProviderState(workspaceId); + for (const provider of providers) { + const imported = latestState.importedProviders[provider.id] ?? null; + const modelIds = provider.models.map((model) => model.id.trim()).filter(Boolean).sort(); + const changed = !imported + || imported.providerId !== provider.providerId + || imported.sourceProviderId !== provider.providerId + || imported.updatedAt !== provider.updatedAt + || imported.name !== provider.name + || imported.source !== provider.source + || imported.modelIds.join("\n") !== modelIds.join("\n"); + + if (!changed) { + continue; + } + + await this.importWorkspaceCloudProvider(workspaceId, provider.id); + if (imported) { + updated.push(provider.id); + } else { + added.push(provider.id); + } + latestState = await getWorkspaceProviderState(workspaceId); + } + + const importedProviderIds = new Set( + Object.values(latestState.importedProviders).map((entry) => entry.providerId), + ); + const nextDisabledProviders = latestState.disabledProviders.filter((id) => !importedProviderIds.has(id)); + if (nextDisabledProviders.length !== latestState.disabledProviders.length) { + input.config.setWorkspaceDisabledProviders(workspaceId, nextDisabledProviders); + latestState = await getWorkspaceProviderState(workspaceId); + } + + return { + added, + disabledProviders: latestState.disabledProviders, + importedProviders: latestState.importedProviders, + removed, + snapshot: latestState.snapshot, + updated, + }; + }, + }; +} diff --git a/apps/server-v2/src/services/config-materialization-service.ts b/apps/server-v2/src/services/config-materialization-service.ts index 124c0f4ed..092d53bfc 100644 --- a/apps/server-v2/src/services/config-materialization-service.ts +++ b/apps/server-v2/src/services/config-materialization-service.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { HTTPException } from "hono/http-exception"; import type { ServerRepositories } from "../database/repositories.js"; -import type { JsonObject, ManagedConfigRecord, WorkspaceRecord } from "../database/types.js"; +import type { JsonObject, ManagedConfigRecord, ManagedSource, WorkspaceRecord } from "../database/types.js"; import type { ServerWorkingDirectory } from "../database/working-directory.js"; import { ensureWorkspaceConfigDir } from "../database/working-directory.js"; import { RouteError } from "../http.js"; @@ -603,6 +603,16 @@ export function createConfigMaterializationService(input: { }; } + function persistWorkspaceConfigState(workspace: WorkspaceRecord, next: { openwork: JsonObject; opencode: JsonObject }) { + const canonical = canonicalizeWorkspaceConfigState(workspace, next); + input.repositories.workspaceConfigState.upsert({ + openwork: canonical.openwork, + opencode: canonical.opencode, + workspaceId: workspace.id, + }); + return materializeWorkspaceSnapshot(workspace.id); + } + function materializeSkills(workspace: WorkspaceRecord) { const skills = listAssignedSkills(workspace.id); const roots = workspaceSkillRoots(workspace); @@ -754,6 +764,93 @@ export function createConfigMaterializationService(input: { return materializeWorkspaceSnapshot(workspaceId); }, + updateWorkspaceOpenworkConfig(workspaceId: string, updater: (current: JsonObject) => JsonObject) { + const workspace = getWorkspaceOrThrow(workspaceId); + ensureWorkspaceLocal(workspace); + const current = ensureWorkspaceConfigState(workspace); + return persistWorkspaceConfigState(workspace, { + openwork: asObject(updater(asObject(current.openwork))), + opencode: current.opencode, + }); + }, + + listWorkspaceProviderConfigs(workspaceId: string) { + const workspace = getWorkspaceOrThrow(workspaceId); + ensureWorkspaceLocal(workspace); + return listAssignedRecords(workspace.id, "workspaceProviderConfigs", "providerConfigs"); + }, + + setWorkspaceDisabledProviders(workspaceId: string, providerIds: string[]) { + const workspace = getWorkspaceOrThrow(workspaceId); + ensureWorkspaceLocal(workspace); + const current = ensureWorkspaceConfigState(workspace); + const nextOpencode = asObject(current.opencode); + const nextDisabledProviders = normalizeStringArray(providerIds); + if (nextDisabledProviders.length > 0) { + nextOpencode.disabled_providers = nextDisabledProviders; + } else { + delete nextOpencode.disabled_providers; + } + return persistWorkspaceConfigState(workspace, { + openwork: current.openwork, + opencode: nextOpencode, + }); + }, + + upsertWorkspaceProviderConfig(workspaceId: string, inputValue: { + auth?: JsonObject | null; + cloudItemId?: string | null; + config: JsonObject; + displayName: string; + key: string; + metadata?: JsonObject | null; + source?: ManagedSource; + }) { + const workspace = getWorkspaceOrThrow(workspaceId); + ensureWorkspaceLocal(workspace); + ensureWorkspaceConfigState(workspace); + + const key = inputValue.key.trim(); + if (!key) { + throw new RouteError(400, "invalid_request", "Provider key is required."); + } + + const existing = listAssignedRecords(workspace.id, "workspaceProviderConfigs", "providerConfigs"); + const upserted = input.repositories.providerConfigs.upsert({ + auth: inputValue.auth ?? null, + cloudItemId: inputValue.cloudItemId ?? null, + config: inputValue.config, + displayName: inputValue.displayName.trim() || key, + id: `provider_${workspace.id}_${key}`, + key, + metadata: inputValue.metadata ?? null, + source: inputValue.source ?? "imported", + }); + const nextIds = dedupeAssignments([ + ...existing + .filter((item) => (item.key ?? item.displayName) !== key) + .map((item) => item.id), + upserted.id, + ]); + input.repositories.workspaceProviderConfigs.replaceAssignments(workspace.id, nextIds); + return materializeWorkspaceSnapshot(workspace.id); + }, + + removeWorkspaceProviderConfig(workspaceId: string, providerId: string) { + const workspace = getWorkspaceOrThrow(workspaceId); + ensureWorkspaceLocal(workspace); + ensureWorkspaceConfigState(workspace); + const key = providerId.trim(); + const existing = listAssignedRecords(workspace.id, "workspaceProviderConfigs", "providerConfigs"); + const nextRecords = existing.filter((item) => (item.key ?? item.displayName) !== key); + const removed = existing.find((item) => (item.key ?? item.displayName) === key) ?? null; + input.repositories.workspaceProviderConfigs.replaceAssignments(workspace.id, dedupeAssignments(nextRecords.map((item) => item.id))); + if (removed) { + input.repositories.providerConfigs.deleteById(removed.id); + } + return materializeWorkspaceSnapshot(workspace.id); + }, + async readRawOpencodeConfig(workspaceId: string, scope: "global" | "project") { const workspace = getWorkspaceOrThrow(workspaceId); if (workspace.kind === "remote") { diff --git a/prds/server-v2-plan/update-0423/plan.md b/prds/server-v2-plan/update-0423/plan.md new file mode 100644 index 000000000..b75d51d17 --- /dev/null +++ b/prds/server-v2-plan/update-0423/plan.md @@ -0,0 +1,154 @@ +# Server V2 Catch-Up Plan + +## Date + +2026-04-23 + +## Scope + +- Baseline start: `12900a0` (`server-v2` merge). This is the starting snapshot, so it is not treated as a catch-up commit. +- Cutoff: `2e440d4` (desktop React cutover). This commit is included because it landed before the pause ended and it contains a small amount of real server/app behavior in addition to framework-port churn. +- Reviewed range: `12900a0..2e440d4`. +- Result: 14 commits in the range touched `apps/app` or `apps/server`. +- Result: 5 of those 14 are version-only package bumps and can be ignored for migration work. +- Result: 9 commits contain behavior or contract changes that matter to `server-v2`, plus one framework cutover commit with a few important non-UI carry-forwards. + +## What Server V2 Needs To Catch Up First + +1. Cloud bootstrap, sign-in, and active-org semantics. + The post-merge app now assumes persisted desktop bootstrap config, optional forced sign-in, Better Auth active-org switching, and org-scoped config fetches. +2. Desktop restriction and update-gating contracts. + The app now enforces org restrictions and desktop update eligibility from cloud config, not just local client heuristics. +3. Cloud provider provisioning and sync behavior. + Provider import/sync now assumes periodic reconciliation with cloud, and provider identity is keyed by cloud provider ID rather than provider-family ID. +4. Transport and server behavior from the React cutoff. + The React port itself is not the migration target, but `apps/server/src/server.ts` picked up proxy-response sanitization and a dev log sink, and the app started depending on better streaming transport behavior. +5. App-side SDK shape changes. + New provider/model metadata assumptions landed after the `server-v2` start and should be honored by the replacement path. + +## Contracts And Behaviors To Preserve + +- `GET /v1/app-version` with at least `latestAppVersion`, and compatibility with `minAppVersion`. +- `GET /v1/me` for authenticated session restore. +- `GET /v1/me/orgs` returning active-org information. +- `POST /api/auth/organization/set-active` to switch active org server-side. +- `GET /v1/me/desktop-config` for org-scoped desktop restrictions and update controls. +- `POST /v1/auth/desktop-handoff/exchange` for desktop sign-in handoff. +- Active-org-scoped routes now fetched without org identifiers in the URL, including workers, templates, skills, skill hubs, and LLM providers. +- Cloud provider sync keyed by cloud provider ID, with `sourceProviderId` preserved separately. +- Proxy behavior that strips stale transport headers from already-decoded upstream responses. +- Optional `GET /dev/log` and `POST /dev/log` dev-log behavior if we want to keep current debugging tooling. + +## Commit Inventory + +### `7bb7e524` `chore(deps): pin opencode CLI + SDK to v1.4.9 (#1471)` + +- Touches: `apps/app` +- What changed: no user-facing feature, but the app adapted to new SDK/provider metadata shapes. Reasoning moved under `model.capabilities.reasoning`, provider config handling became more direct, and session todo typing was adjusted. +- Important files: `apps/app/package.json`, `apps/app/src/app/context/model-config.ts`, `apps/app/src/app/context/session.ts`, `apps/app/src/app/lib/model-behavior.ts`, `apps/app/src/app/utils/providers.ts` +- Server-v2 action: adapt. Any replacement server path that feeds provider/model metadata into the app should match the newer SDK shape, especially `capabilities.reasoning`. + +### `85ab73bc` `feat(den): gate desktop updates by supported version (#1476)` + +- Touches: `apps/app` +- What changed: desktop updates are now hidden unless cloud version metadata says the offered update is supported. The app fetches `/v1/app-version` and only shows a Tauri update when the available version is less than or equal to `latestAppVersion`. +- Important files: `apps/app/src/app/lib/den.ts`, `apps/app/src/app/system-state.ts` +- Server-v2 action: port. `server-v2` should preserve the `/v1/app-version` contract and fail-closed behavior for unsupported or unknown versions. + +### `ac41d58b` `feat(den): use Better Auth active org context (#1485)` + +- Touches: `apps/app` +- What changed: org-aware cloud flows stopped passing org identifiers through resource URLs and started relying on server-side active-org context instead. The org picker now performs a real server-side org switch, and follow-up calls fetch workers, templates, skills, hubs, and LLM providers from active-org routes. +- Important files: `apps/app/src/app/components/den-settings-panel.tsx`, `apps/app/src/app/lib/den.ts`, `apps/app/src/app/workspace/create-workspace-modal.tsx` +- Server-v2 action: port. The replacement path should preserve active-org switching via `POST /api/auth/organization/set-active`, `GET /v1/me/orgs`, and active-org-scoped resource routes such as `/v1/workers`, `/v1/templates`, `/v1/skills`, `/v1/skill-hubs`, and `/v1/llm-providers`. + +### `da9a4f24` `feat(desktop): persist desktop bootstrap and org restrictions (#1479)` + +- Touches: `apps/app` +- What changed: this is the biggest post-start change for cloud-connected desktop behavior. It added persistent desktop bootstrap config, optional forced sign-in, org-scoped desktop-config fetches, active-org restore/sync during auth hydration, and tighter handling of cloud base URLs and API base URLs. +- Important files: `apps/app/src/app/app.tsx`, `apps/app/src/app/cloud/den-auth-provider.tsx`, `apps/app/src/app/cloud/desktop-config-provider.tsx`, `apps/app/src/app/cloud/forced-signin-page.tsx`, `apps/app/src/app/cloud/den-signin-surface.tsx`, `apps/app/src/app/lib/den.ts`, `apps/app/src/app/lib/den-session-events.ts`, `apps/app/src/app/lib/tauri.ts`, `apps/app/src/app/system-state.ts`, `apps/app/src/app/components/den-settings-panel.tsx`, `apps/app/src/app/workspace/create-workspace-modal.tsx`, `apps/app/src/app/pages/skills.tsx`, `apps/app/src/app/entry.tsx`, `apps/app/src/index.tsx`, `apps/app/src/app/types.ts` +- Server-v2 action: port. Preserve bootstrap compatibility around `baseUrl`, `apiBaseUrl`, `requireSignin`, desktop handoff exchange, authenticated restore, active-org sync, and `GET /v1/me/desktop-config`. + +### `3ac290fa` `chore: bump version to 0.11.208` + +- Touches: `apps/app`, `apps/server` +- What changed: version bump only. +- Important files: `apps/app/package.json`, `apps/server/package.json` +- Server-v2 action: ignore. + +### `f0e4f6db` `chore: bump version to 0.11.209` + +- Touches: `apps/app`, `apps/server` +- What changed: version bump only. +- Important files: `apps/app/package.json`, `apps/server/package.json` +- Server-v2 action: ignore. + +### `872c2176` `chore: bump version to 0.11.210` + +- Touches: `apps/app`, `apps/server` +- What changed: version bump only. +- Important files: `apps/app/package.json`, `apps/server/package.json` +- Server-v2 action: ignore. + +### `9462b41c` `feat(app): enforce desktop restriction policies (#1505)` + +- Touches: `apps/app` +- What changed: the client now actively enforces org desktop restrictions. That includes hiding blocked provider/model paths, preventing forbidden provider-auth flows, reconciling invalid existing selections to allowed fallbacks, and persisting provider disconnects into workspace `opencode.jsonc` through `disabled_providers`. +- Important files: `apps/app/src/app/app.tsx`, `apps/app/src/app/cloud/desktop-app-restrictions.ts`, `apps/app/src/app/cloud/desktop-config-provider.tsx`, `apps/app/src/app/components/restriction-notice-modal.tsx`, `apps/app/src/app/context/model-config.ts`, `apps/app/src/app/context/providers/provider-auth-modal.tsx`, `apps/app/src/app/context/providers/store.ts`, `apps/app/src/app/lib/den.ts` +- Server-v2 action: port and adapt. Preserve the desktop-config restriction contract, especially fields such as `blockZenModel`, `disallowNonCloudModels`, and any related org restriction fields. Also make sure the replacement path can read and write workspace config in a way that keeps `disabled_providers` synchronized. + +### `aa8f39e3` `feat(app): auto-sync cloud providers (#1509)` + +- Touches: `apps/app` +- What changed: the app now automatically reconciles workspace providers against the active cloud org on sign-in, on app or workspace changes, every 5 minutes, and when Cloud settings is opened. Sync removes stale providers, re-imports changed ones, and adds newly available cloud-managed providers. +- Important files: `apps/app/src/app/app.tsx`, `apps/app/src/app/cloud/sync/constants.ts`, `apps/app/src/app/components/den-settings-panel.tsx`, `apps/app/src/app/context/providers/store.ts`, `apps/app/src/app/pages/settings.tsx`, `apps/app/src/app/shell/settings-shell.tsx` +- Server-v2 action: port. The new path should preserve provider reconciliation behavior and the sync triggers, or provide an adapter that makes the app observe the same provider lifecycle. + +### `022b68a8` `feat(app): key cloud providers by cloud id (#1510)` + +- Touches: `apps/app` +- What changed: imported cloud providers stopped being keyed by provider-family IDs like `openai` and started being keyed by the cloud provider's own stable ID. The app now preserves both the cloud-managed provider ID and the `sourceProviderId` used for family-specific logic. +- Important files: `apps/app/src/app/cloud/import-state.ts`, `apps/app/src/app/components/den-settings-panel.tsx`, `apps/app/src/app/components/model-picker-modal.tsx`, `apps/app/src/app/components/provider-icon.tsx`, `apps/app/src/app/context/model-config.ts`, `apps/app/src/app/context/providers/provider-auth-modal.tsx`, `apps/app/src/app/context/providers/store.ts`, `apps/app/src/app/lib/model-behavior.ts`, `apps/app/src/app/pages/session.tsx`, `apps/app/src/app/pages/settings.tsx` +- Server-v2 action: port. Do not keep assuming cloud-managed providers are keyed by vendor family. The replacement path should preserve the separation between cloud provider ID, local managed provider key, and `sourceProviderId`. + +### `ccdb46d1` `chore: bump version to 0.11.211` + +- Touches: `apps/app`, `apps/server` +- What changed: version bump only. +- Important files: `apps/app/package.json`, `apps/server/package.json` +- Server-v2 action: ignore. + +### `e97a11d4` `chore: bump version to 0.11.212` + +- Touches: `apps/app`, `apps/server` +- What changed: version bump only. +- Important files: `apps/app/package.json`, `apps/server/package.json` +- Server-v2 action: ignore. + +### `daff81be` `feat(app): gate desktop updates by org config (#1512)` + +- Touches: `apps/app` +- What changed: desktop updates now wait for cloud auth hydration and then additionally require the active org's desktop config to allow that exact version through `allowedDesktopVersions`. If the org config cannot be fetched, the update is suppressed instead of shown. +- Important files: `apps/app/src/app/app.tsx`, `apps/app/src/app/cloud/den-auth-provider.tsx`, `apps/app/src/app/cloud/desktop-app-restrictions.ts`, `apps/app/src/app/lib/den.ts`, `apps/app/src/app/system-state.ts` +- Server-v2 action: port. Preserve org-scoped desktop config with `allowedDesktopVersions`, plus the existing app-version metadata check. + +### `2e440d4` `Task/react port cutover react only workspace fixes (#1470)` + +- Touches: `apps/app`, `apps/server` +- What changed: most of this commit is Solid-to-React port churn and should not be copied as part of `server-v2` catch-up. The parts that do matter are: proxy response sanitization in the server so browser clients do not choke on decoded-but-still-gzipped-looking responses, optional `/dev/log` probe and append endpoints for local debugging, app transport changes that route streaming endpoints through native `fetch` instead of the Tauri HTTP plugin, better desktop boot publication of the real engine base URL, and a few session/workspace flows that now assume server-backed rename/delete/create behavior works correctly. +- Important files: `apps/server/src/server.ts`, `apps/app/src/app/lib/opencode.ts`, `apps/app/src/app/lib/openwork-server.ts`, `apps/app/src/react-app/shell/debug-logger.ts`, `apps/app/src/react-app/shell/desktop-runtime-boot.ts`, `apps/app/src/react-app/shell/session-route.tsx`, `apps/app/src/react-app/**` +- Server-v2 action: adapt. Preserve proxy header sanitization, streaming-endpoint expectations, and optionally the `/dev/log` behavior if we want to keep current debug tooling. Ignore the React framework port itself. + +## Practical Catch-Up Order + +1. Replicate cloud bootstrap, forced-signin, active-org, and desktop-config contracts first. +2. Replicate desktop restriction enforcement and update gating next, because those now shape core app startup and provider/model availability. +3. Replicate cloud provider sync and cloud-ID-based provider identity before porting more provider UX, otherwise the migrated app will drift from cloud state. +4. Carry over the small but important transport and proxy fixes from `2e440d4` so the migrated app does not regress in browser or desktop-hosted mode. +5. Treat the version bumps as noise and the React cutover as a source of targeted behavioral deltas, not as a migration blueprint. + +## Notes + +- `apps/server` changed meaningfully in this range only in `2e440d4`; the other `apps/server` touches are version bumps. +- The strongest evidence for missing `server-v2` catch-up is around desktop bootstrap, org-scoped restrictions, update gating, and cloud-managed LLM provider identity and sync. +- Subagents were used to inspect the relevant commits in parallel and collapse the results into this catch-up list. diff --git a/prds/server-v2-plan/update-0423/task-list.md b/prds/server-v2-plan/update-0423/task-list.md new file mode 100644 index 000000000..699da2330 --- /dev/null +++ b/prds/server-v2-plan/update-0423/task-list.md @@ -0,0 +1,114 @@ +# Server V2 Catch-Up Task List + +## Goal + +Bring `apps/server-v2` up to parity with the post-`12900a0` features documented in `plan.md`, while keeping new behavior owned by the server instead of re-adding it in the desktop app. + +## Implementation Rules + +- Build new behavior in `apps/server-v2/**`, not in `apps/server/**` and not as app-only logic. +- The app should consume typed `server-v2` routes and schemas. It should not become the source of truth for org restrictions, update gating, cloud provider reconciliation, or active-org state. +- If a behavior needs local persistence or workspace mutation, expose it through `server-v2` APIs instead of Tauri-only helpers. +- If a behavior already exists in the legacy app, treat that app code as a reference implementation, not the final ownership layer. + +## Task 1: Add Cloud Bootstrap And Auth Foundations + +- Build `server-v2` routes and schemas for desktop bootstrap and sign-in handoff. +- Implement support for `baseUrl`, `apiBaseUrl`, and `requireSignin` in server-owned bootstrap/config responses. +- Implement authenticated restore flows behind `GET /v1/me` and `POST /v1/auth/desktop-handoff/exchange`. +- Put route definitions, response schemas, and service logic in dedicated `server-v2` modules such as `routes/cloud.ts`, `schemas/cloud.ts`, and `services/cloud-auth.ts`. +- Definition of done: the app can boot against `server-v2`, restore a signed-in session, and determine whether forced sign-in is required without needing new app-side policy logic. + +## Task 2: Add Better Auth Active-Org Semantics + +- Implement `POST /api/auth/organization/set-active` in `server-v2`. +- Implement `GET /v1/me/orgs` so the app can fetch `activeOrgId` and `activeOrgSlug` from the server. +- Move all org-sensitive cloud reads to active-org-aware server routes rather than app-constructed org-specific URLs. +- Implement server-owned active-org resolution in the request layer so downstream handlers automatically scope to the active org. +- Definition of done: workers, templates, skills, skill hubs, and LLM providers can all be fetched from active-org-aware routes without the app manually threading org IDs everywhere. + +## Task 3: Add Desktop Config And Restriction Contracts + +- Implement `GET /v1/me/desktop-config` in `server-v2`. +- Define a single server-v2 schema for desktop restrictions, including `blockZenModel`, `disallowNonCloudModels`, `allowedDesktopVersions`, and any other org-scoped restriction fields the app already expects. +- Put restriction evaluation and payload shaping in a service module rather than mixing it into handlers. +- Make the server response stable enough that the desktop app only needs to render and enforce what the server declares. +- Definition of done: the app can fetch one org-scoped config payload from `server-v2` and use it as the source of truth for restriction and update policy decisions. + +## Task 4: Add Version Gating To The Server + +- Implement `GET /v1/app-version` in `server-v2`. +- Preserve compatibility with `latestAppVersion` and `minAppVersion`. +- Keep the endpoint fail-closed from the client's perspective by returning trustworthy metadata instead of relying on the app to infer support. +- Put version comparison and release-policy logic in a server service such as `services/version-policy.ts`. +- Definition of done: the app can decide whether a desktop update is eligible using only server-supplied metadata and org desktop config. + +## Task 5: Add Cloud LLM Provider Catalog Endpoints + +- Implement active-org-scoped LLM provider routes in `server-v2`, including list and connect surfaces. +- Preserve the identity split between cloud provider ID and `sourceProviderId`. +- Make the server response shape explicit enough that the app does not need to guess provider family from local state. +- Match the newer metadata shape expected by the app, including provider/model capabilities used by model config and reasoning behavior. +- Definition of done: the app can fetch the provider catalog from `server-v2` and treat cloud provider IDs as the stable identity key. + +## Task 6: Add Server-Owned Cloud Provider Reconciliation + +- Move provider import and reconciliation logic behind `server-v2` operations instead of leaving it as app-owned diff logic. +- Add server routes or commands for listing available org providers, importing them into a workspace, updating changed provider records, and removing stale ones. +- Keep reconciliation logic keyed by cloud provider ID, not by vendor-family IDs like `openai`. +- Implement the behavior so the desktop app can trigger sync, but the server decides what add, update, and remove operations are required. +- Definition of done: a workspace can be reconciled against the active org by calling `server-v2`, and the app only needs to request the sync and refresh rendered state. + +## Task 7: Add Workspace Config Mutation For Provider Restrictions + +- Implement `server-v2` endpoints for reading and mutating workspace config related to provider enablement, including `disabled_providers`. +- Keep config read and write behavior server-owned so cloud restrictions and disconnect flows do not depend on Tauri-only filesystem mutation. +- Reuse the broader `server-v2` rule that workspace-scoped mutations belong to the server. +- Put JSONC parsing and mutation helpers in dedicated config modules instead of route handlers. +- Definition of done: provider disconnects, restriction reconciliation, and future provider policy changes can all persist through `server-v2` APIs. + +## Task 8: Carry Over Transport And Proxy Fixes + +- Port proxy response sanitization into `server-v2` so upstream decoded responses do not leak stale `content-encoding`, `content-length`, or `transfer-encoding` headers. +- Preserve streaming-friendly behavior for SSE-style routes that the app consumes. +- Decide whether `server-v2` should also own the `/dev/log` debugging contract. If yes, implement the same probe and append behavior behind a dev-only guard. +- Keep these behaviors in shared proxy and transport helpers so they apply consistently across `server-v2` upstream integrations. +- Definition of done: browser and desktop-hosted clients can call proxied and streaming endpoints through `server-v2` without decode failures or transport regressions. + +## Task 9: Expose Typed Schemas And SDK Surfaces + +- Add or update `server-v2` schemas for every new route above. +- Regenerate the OpenAPI output and typed SDK after the routes are added. +- Keep the app cutover limited to consuming the generated client and removing legacy-path calls. Do not move policy logic into the app during the cutover. +- Definition of done: every catch-up behavior has a typed `server-v2` contract and can be consumed from one app-side client layer. + +## Task 10: Validate The Whole Flow End To End + +- Verify forced sign-in boot. +- Verify active-org switching and org-scoped resource reads. +- Verify desktop-config fetch and restriction enforcement inputs. +- Verify app-version gating and org-allowed desktop versions. +- Verify cloud provider fetch and reconcile behavior using cloud IDs. +- Verify workspace config persistence for disabled providers. +- Verify proxy and streaming behavior through `server-v2`. +- Definition of done: the desktop app can exercise all catch-up flows against `server-v2` without depending on legacy server behavior for the same feature. + +## Recommended Build Order + +1. Task 1 +2. Task 2 +3. Task 3 +4. Task 4 +5. Task 5 +6. Task 6 +7. Task 7 +8. Task 8 +9. Task 9 +10. Task 10 + +## Explicit Non-Goals + +- Do not port the React framework migration itself into this plan. +- Do not add new app-only restriction logic as a shortcut. +- Do not keep using legacy server routes as the long-term source of truth once the `server-v2` equivalents exist. +- Do not treat version-only package bumps as implementation work. From a1cc95062eaea04d60b5dce6f7a4e9f320089b9b Mon Sep 17 00:00:00 2001 From: src-opn Date: Fri, 24 Apr 2026 13:55:44 -0700 Subject: [PATCH 2/2] feat(server-v2): proxy remaining cloud org routes --- apps/server-v2/src/app.test.ts | 97 ++++++++- apps/server-v2/src/routes/cloud.ts | 211 +++++++++++++++++++ apps/server-v2/src/routes/route-paths.ts | 7 + apps/server-v2/src/schemas/cloud.ts | 97 +++++++++ apps/server-v2/src/services/cloud-service.ts | 97 +++++++++ 5 files changed, 507 insertions(+), 2 deletions(-) diff --git a/apps/server-v2/src/app.test.ts b/apps/server-v2/src/app.test.ts index fc28e188b..cf7110985 100644 --- a/apps/server-v2/src/app.test.ts +++ b/apps/server-v2/src/app.test.ts @@ -120,6 +120,15 @@ test("openapi route is generated from the live Hono app", async () => { expect(document.paths["/system/runtime/versions"].get.operationId).toBe("getSystemRuntimeVersions"); expect(document.paths["/system/runtime/upgrade"].post.operationId).toBe("postSystemRuntimeUpgrade"); expect(document.paths["/v1/app-version"].get.operationId).toBe("getV1AppVersion"); + expect(document.paths["/v1/workers"].get.operationId).toBe("getV1Workers"); + expect(document.paths["/v1/workers/{workerId}/tokens"].post.operationId).toBe("postV1WorkersByWorkerIdTokens"); + expect(document.paths["/v1/templates"].get.operationId).toBe("getV1Templates"); + expect(document.paths["/v1/templates"].post.operationId).toBe("postV1Templates"); + expect(document.paths["/v1/templates/{templateId}"].delete.operationId).toBe("deleteV1TemplatesByTemplateId"); + expect(document.paths["/v1/skills"].get.operationId).toBe("getV1Skills"); + expect(document.paths["/v1/skills"].post.operationId).toBe("postV1Skills"); + expect(document.paths["/v1/skill-hubs"].get.operationId).toBe("getV1SkillHubs"); + expect(document.paths["/v1/skill-hubs/{skillHubId}/skills"].post.operationId).toBe("postV1SkillHubsBySkillHubIdSkills"); expect(document.paths["/v1/llm-providers"].get.operationId).toBe("getV1LlmProviders"); expect(document.paths["/v1/llm-providers/{llmProviderId}/connect"].get.operationId).toBe("getV1LlmProvidersByLlmProviderIdConnect"); expect(document.paths["/system/cloud/bootstrap"].get.operationId).toBe("getSystemCloudBootstrap"); @@ -159,8 +168,9 @@ test("cloud compatibility routes proxy and persist cloud state through server-v2 userId: null, }); - globalThis.fetch = (async (input) => { + globalThis.fetch = (async (input, init) => { const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url); + const method = init?.method ?? (typeof input === "string" || input instanceof URL ? "GET" : input.method ?? "GET"); if (url.pathname === "/api/den/v1/app-version") { return new Response(JSON.stringify({ latestAppVersion: "0.11.212", minAppVersion: "0.11.207" }), { headers: { "Content-Type": "application/json" }, @@ -195,6 +205,46 @@ test("cloud compatibility routes proxy and persist cloud state through server-v2 headers: { "Content-Type": "application/json" }, }); } + if (url.pathname === "/api/den/v1/workers") { + return new Response(JSON.stringify({ + workers: [{ id: "worker_1", name: "Worker One", status: "ready", instance: { url: "https://worker.example.com", provider: "daytona" }, isMine: true, createdAt: "2026-04-23T00:00:00.000Z" }], + }), { headers: { "Content-Type": "application/json" } }); + } + if (url.pathname === "/api/den/v1/workers/worker_1/tokens") { + return new Response(JSON.stringify({ + tokens: { client: "client-token", owner: "owner-token", host: "host-token" }, + connect: { openworkUrl: "https://worker.example.com", workspaceId: "ws_remote_1" }, + }), { headers: { "Content-Type": "application/json" } }); + } + if (url.pathname === "/api/den/v1/templates" && method === "GET") { + return new Response(JSON.stringify({ + templates: [{ id: "tpl_1", organizationId: "org_1", name: "Starter", templateData: { preset: "starter" }, createdAt: null, updatedAt: null, creator: null }], + }), { headers: { "Content-Type": "application/json" } }); + } + if (url.pathname === "/api/den/v1/templates" && method === "POST") { + return new Response(JSON.stringify({ + template: { id: "tpl_2", organizationId: "org_1", name: "Created", templateData: { preset: "new" }, createdAt: null, updatedAt: null, creator: null }, + }), { headers: { "Content-Type": "application/json" } }); + } + if (url.pathname === "/api/den/v1/templates/tpl_2") { + return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" } }); + } + if (url.pathname === "/api/den/v1/skills" && method === "GET") { + return new Response(JSON.stringify({ + skills: [{ id: "skill_1", title: "Org Skill", description: null, skillText: "hello", shared: "org", updatedAt: null }], + }), { headers: { "Content-Type": "application/json" } }); + } + if (url.pathname === "/api/den/v1/skills" && method === "POST") { + return new Response(JSON.stringify({ id: "skill_2" }), { headers: { "Content-Type": "application/json" } }); + } + if (url.pathname === "/api/den/v1/skill-hubs") { + return new Response(JSON.stringify({ + skillHubs: [{ id: "hub_1", name: "Hub One", skills: [{ id: "skill_1", title: "Org Skill", description: null, skillText: "hello", shared: "org", updatedAt: null }] }], + }), { headers: { "Content-Type": "application/json" } }); + } + if (url.pathname === "/api/den/v1/skill-hubs/hub_1/skills") { + return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" } }); + } if (url.pathname === "/api/auth/organization/set-active") { return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" }, @@ -204,11 +254,16 @@ test("cloud compatibility routes proxy and persist cloud state through server-v2 }) as typeof fetch; try { - const [versionResponse, meResponse, orgsResponse, desktopConfigResponse] = await Promise.all([ + const [versionResponse, meResponse, orgsResponse, desktopConfigResponse, workersResponse, workerTokensResponse, templatesResponse, skillsResponse, skillHubsResponse] = await Promise.all([ app.request("http://openwork.local/v1/app-version"), app.request("http://openwork.local/v1/me"), app.request("http://openwork.local/v1/me/orgs"), app.request("http://openwork.local/v1/me/desktop-config"), + app.request("http://openwork.local/v1/workers?limit=20"), + app.request("http://openwork.local/v1/workers/worker_1/tokens", { method: "POST" }), + app.request("http://openwork.local/v1/templates"), + app.request("http://openwork.local/v1/skills"), + app.request("http://openwork.local/v1/skill-hubs"), ]); expect(versionResponse.status).toBe(200); @@ -219,6 +274,44 @@ test("cloud compatibility routes proxy and persist cloud state through server-v2 expect(await orgsResponse.json()).toMatchObject({ activeOrgId: "org_1", activeOrgSlug: "alpha" }); expect(desktopConfigResponse.status).toBe(200); expect(await desktopConfigResponse.json()).toMatchObject({ blockZenModel: true, disallowNonCloudModels: true }); + expect(workersResponse.status).toBe(200); + expect(await workersResponse.json()).toMatchObject({ workers: [{ workerId: "worker_1", workerName: "Worker One" }] }); + expect(workerTokensResponse.status).toBe(200); + expect(await workerTokensResponse.json()).toMatchObject({ tokens: { client: "client-token" } }); + expect(templatesResponse.status).toBe(200); + expect(await templatesResponse.json()).toMatchObject({ templates: [{ id: "tpl_1", name: "Starter" }] }); + expect(skillsResponse.status).toBe(200); + expect(await skillsResponse.json()).toMatchObject({ skills: [{ id: "skill_1", title: "Org Skill" }] }); + expect(skillHubsResponse.status).toBe(200); + expect(await skillHubsResponse.json()).toMatchObject({ skillHubs: [{ id: "hub_1", name: "Hub One" }] }); + + const createTemplateResponse = await app.request("http://openwork.local/v1/templates", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Created", templateData: { preset: "new" } }), + }); + expect(createTemplateResponse.status).toBe(200); + expect(await createTemplateResponse.json()).toMatchObject({ template: { id: "tpl_2", name: "Created" } }); + + const deleteTemplateResponse = await app.request("http://openwork.local/v1/templates/tpl_2", { method: "DELETE" }); + expect(deleteTemplateResponse.status).toBe(200); + expect(await deleteTemplateResponse.json()).toMatchObject({ ok: true }); + + const createSkillResponse = await app.request("http://openwork.local/v1/skills", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ skillText: "hello", shared: "org" }), + }); + expect(createSkillResponse.status).toBe(200); + expect(await createSkillResponse.json()).toMatchObject({ id: "skill_2" }); + + const addSkillToHubResponse = await app.request("http://openwork.local/v1/skill-hubs/hub_1/skills", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ skillId: "skill_2" }), + }); + expect(addSkillToHubResponse.status).toBe(200); + expect(await addSkillToHubResponse.json()).toMatchObject({ ok: true }); const setActiveResponse = await app.request("http://openwork.local/api/auth/organization/set-active", { method: "POST", diff --git a/apps/server-v2/src/routes/cloud.ts b/apps/server-v2/src/routes/cloud.ts index 3d6c0f1f6..6cd574c72 100644 --- a/apps/server-v2/src/routes/cloud.ts +++ b/apps/server-v2/src/routes/cloud.ts @@ -14,9 +14,20 @@ import { cloudLlmProviderConnectionResponseSchema, cloudLlmProviderListResponseSchema, cloudMeResponseSchema, + cloudOrgSkillCreateRequestSchema, + cloudOrgSkillCreateResponseSchema, + cloudOrgSkillHubAddSkillRequestSchema, + cloudOrgSkillHubAddSkillResponseSchema, + cloudOrgSkillHubListResponseSchema, + cloudOrgSkillListResponseSchema, cloudOrganizationsResponseSchema, cloudSetActiveOrganizationRequestSchema, cloudSetActiveOrganizationResponseSchema, + cloudTemplateCreateRequestSchema, + cloudTemplateListResponseSchema, + cloudTemplateResponseSchema, + cloudWorkerListResponseSchema, + cloudWorkerTokensResponseSchema, workspaceCloudProviderMutationResponseSchema, workspaceCloudProviderStateResponseSchema, workspaceCloudProviderSyncResponseSchema, @@ -151,6 +162,206 @@ export function registerCloudRoutes(app: Hono) { }, ); + app.get( + routePaths.v1.workers, + describeRoute({ + tags: ["Cloud"], + summary: "List cloud workers", + description: "Returns the active-organization worker list through Server V2.", + responses: { + 200: jsonResponse("Cloud workers returned successfully.", cloudWorkerListResponseSchema), + 401: jsonResponse("Cloud signin is required to list workers.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to list cloud workers.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + const limit = Number.parseInt(new URL(c.req.url).searchParams.get("limit") ?? "20", 10); + return c.json(await getRequestContext(c).services.cloud.listWorkers(Number.isFinite(limit) ? limit : 20)); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + + app.post( + routePaths.v1.workerTokens(), + describeRoute({ + tags: ["Cloud"], + summary: "Get cloud worker tokens", + description: "Returns connect tokens for one active-organization worker through Server V2.", + responses: { + 200: jsonResponse("Cloud worker tokens returned successfully.", cloudWorkerTokensResponseSchema), + 401: jsonResponse("Cloud signin is required to read worker tokens.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to read cloud worker tokens.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + const workerId = c.req.param("workerId") ?? ""; + return c.json(await getRequestContext(c).services.cloud.getWorkerTokens(workerId)); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + + app.get( + routePaths.v1.templates, + describeRoute({ + tags: ["Cloud"], + summary: "List cloud templates", + description: "Returns the active-organization templates through Server V2.", + responses: { + 200: jsonResponse("Cloud templates returned successfully.", cloudTemplateListResponseSchema), + 401: jsonResponse("Cloud signin is required to list templates.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to list cloud templates.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + return c.json(await getRequestContext(c).services.cloud.listTemplates()); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + + app.post( + routePaths.v1.templates, + describeRoute({ + tags: ["Cloud"], + summary: "Create a cloud template", + description: "Creates an active-organization template through Server V2.", + responses: { + 200: jsonResponse("Cloud template created successfully.", cloudTemplateResponseSchema), + 400: jsonResponse("The template creation payload was invalid.", cloudCompatErrorSchema), + 401: jsonResponse("Cloud signin is required to create templates.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to create the cloud template.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + const body = await parseJsonBody(cloudTemplateCreateRequestSchema, c.req.raw); + return c.json(await getRequestContext(c).services.cloud.createTemplate(body)); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + + app.delete( + routePaths.v1.templateById(), + describeRoute({ + tags: ["Cloud"], + summary: "Delete a cloud template", + description: "Deletes one active-organization template through Server V2.", + responses: { + 200: jsonResponse("Cloud template deleted successfully.", cloudCompatErrorSchema), + 401: jsonResponse("Cloud signin is required to delete templates.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to delete the cloud template.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + const templateId = c.req.param("templateId") ?? ""; + await getRequestContext(c).services.cloud.deleteTemplate(templateId); + return c.json({ ok: true }); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + + app.get( + routePaths.v1.skills, + describeRoute({ + tags: ["Cloud"], + summary: "List cloud skills", + description: "Returns the active-organization shared skill list through Server V2.", + responses: { + 200: jsonResponse("Cloud skills returned successfully.", cloudOrgSkillListResponseSchema), + 401: jsonResponse("Cloud signin is required to list skills.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to list cloud skills.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + return c.json(await getRequestContext(c).services.cloud.listOrgSkills()); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + + app.post( + routePaths.v1.skills, + describeRoute({ + tags: ["Cloud"], + summary: "Create a cloud skill", + description: "Creates an active-organization skill through Server V2.", + responses: { + 200: jsonResponse("Cloud skill created successfully.", cloudOrgSkillCreateResponseSchema), + 400: jsonResponse("The skill creation payload was invalid.", cloudCompatErrorSchema), + 401: jsonResponse("Cloud signin is required to create skills.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to create the cloud skill.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + const body = await parseJsonBody(cloudOrgSkillCreateRequestSchema, c.req.raw); + return c.json(await getRequestContext(c).services.cloud.createOrgSkill(body)); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + + app.get( + routePaths.v1.skillHubs, + describeRoute({ + tags: ["Cloud"], + summary: "List cloud skill hubs", + description: "Returns the active-organization skill hubs through Server V2.", + responses: { + 200: jsonResponse("Cloud skill hubs returned successfully.", cloudOrgSkillHubListResponseSchema), + 401: jsonResponse("Cloud signin is required to list skill hubs.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to list cloud skill hubs.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + return c.json(await getRequestContext(c).services.cloud.listOrgSkillHubs()); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + + app.post( + routePaths.v1.skillHubAddSkill(), + describeRoute({ + tags: ["Cloud"], + summary: "Add a skill to a cloud skill hub", + description: "Adds an active-organization skill to one skill hub through Server V2.", + responses: { + 200: jsonResponse("Cloud skill added to hub successfully.", cloudOrgSkillHubAddSkillResponseSchema), + 400: jsonResponse("The hub skill payload was invalid.", cloudCompatErrorSchema), + 401: jsonResponse("Cloud signin is required to mutate skill hubs.", cloudCompatErrorSchema), + 500: jsonResponse("The server failed to add the cloud skill to the hub.", cloudCompatErrorSchema), + }, + }), + async (c) => { + try { + const skillHubId = c.req.param("skillHubId") ?? ""; + const body = await parseJsonBody(cloudOrgSkillHubAddSkillRequestSchema, c.req.raw); + return c.json(await getRequestContext(c).services.cloud.addOrgSkillToHub(skillHubId, body.skillId)); + } catch (error) { + return respondWithCompatError(c, error); + } + }, + ); + app.get( routePaths.v1.me, describeRoute({ diff --git a/apps/server-v2/src/routes/route-paths.ts b/apps/server-v2/src/routes/route-paths.ts index 0929dd3c3..711c99d87 100644 --- a/apps/server-v2/src/routes/route-paths.ts +++ b/apps/server-v2/src/routes/route-paths.ts @@ -97,6 +97,13 @@ export const routePaths = { me: `${routeNamespaces.v1}/me`, meDesktopConfig: `${routeNamespaces.v1}/me/desktop-config`, meOrgs: `${routeNamespaces.v1}/me/orgs`, + skillHubAddSkill: (skillHubId: string = ":skillHubId") => `${routeNamespaces.v1}/skill-hubs/${skillHubId}/skills`, + skillHubs: `${routeNamespaces.v1}/skill-hubs`, + skills: `${routeNamespaces.v1}/skills`, + templateById: (templateId: string = ":templateId") => `${routeNamespaces.v1}/templates/${templateId}`, + templates: `${routeNamespaces.v1}/templates`, + workerTokens: (workerId: string = ":workerId") => `${routeNamespaces.v1}/workers/${workerId}/tokens`, + workers: `${routeNamespaces.v1}/workers`, }, workspaces: { base: routeNamespaces.workspaces, diff --git a/apps/server-v2/src/schemas/cloud.ts b/apps/server-v2/src/schemas/cloud.ts index da874d825..162ce811c 100644 --- a/apps/server-v2/src/schemas/cloud.ts +++ b/apps/server-v2/src/schemas/cloud.ts @@ -116,6 +116,103 @@ export const cloudLlmProviderConnectionResponseSchema = z.object({ llmProvider: cloudLlmProviderConnectionSchema, }).meta({ ref: "OpenWorkServerV2CloudLlmProviderConnectionResponse" }); +export const cloudWorkerSummarySchema = z.object({ + workerId: z.string().min(1), + workerName: z.string().min(1), + status: z.string().min(1), + instanceUrl: nullableString, + provider: nullableString, + isMine: z.boolean(), + createdAt: nullableString, +}).meta({ ref: "OpenWorkServerV2CloudWorkerSummary" }); + +export const cloudWorkerListResponseSchema = z.object({ + workers: z.array(cloudWorkerSummarySchema), +}).meta({ ref: "OpenWorkServerV2CloudWorkerListResponse" }); + +export const cloudWorkerTokensResponseSchema = z.object({ + tokens: z.object({ + client: nullableString, + owner: nullableString, + host: nullableString, + }).passthrough(), + connect: z.object({ + openworkUrl: nullableString.optional(), + workspaceId: nullableString.optional(), + }).passthrough().optional(), +}).passthrough().meta({ ref: "OpenWorkServerV2CloudWorkerTokensResponse" }); + +export const cloudTemplateCreatorSchema = z.object({ + memberId: z.string().min(1), + role: z.enum(["owner", "admin", "member"]), + userId: z.string().min(1), + name: nullableString, + email: nullableString, + image: nullableString, +}).meta({ ref: "OpenWorkServerV2CloudTemplateCreator" }); + +export const cloudTemplateSchema = z.object({ + id: z.string().min(1), + organizationId: z.string().min(1), + name: z.string().min(1), + templateData: z.unknown(), + createdAt: nullableString, + updatedAt: nullableString, + creator: cloudTemplateCreatorSchema.nullable(), +}).meta({ ref: "OpenWorkServerV2CloudTemplate" }); + +export const cloudTemplateListResponseSchema = z.object({ + templates: z.array(cloudTemplateSchema), +}).meta({ ref: "OpenWorkServerV2CloudTemplateListResponse" }); + +export const cloudTemplateResponseSchema = z.object({ + template: cloudTemplateSchema, +}).meta({ ref: "OpenWorkServerV2CloudTemplateResponse" }); + +export const cloudTemplateCreateRequestSchema = z.object({ + name: z.string().trim().min(1), + templateData: z.unknown(), +}).meta({ ref: "OpenWorkServerV2CloudTemplateCreateRequest" }); + +export const cloudOrgSkillSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + description: nullableString, + skillText: z.string().min(1), + hubName: nullableString.optional(), + shared: z.enum(["org", "public"]).nullable(), + updatedAt: nullableString, +}).meta({ ref: "OpenWorkServerV2CloudOrgSkill" }); + +export const cloudOrgSkillListResponseSchema = z.object({ + skills: z.array(cloudOrgSkillSchema), +}).meta({ ref: "OpenWorkServerV2CloudOrgSkillListResponse" }); + +export const cloudOrgSkillHubSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + skills: z.array(cloudOrgSkillSchema), +}).meta({ ref: "OpenWorkServerV2CloudOrgSkillHub" }); + +export const cloudOrgSkillHubListResponseSchema = z.object({ + skillHubs: z.array(cloudOrgSkillHubSchema), +}).meta({ ref: "OpenWorkServerV2CloudOrgSkillHubListResponse" }); + +export const cloudOrgSkillCreateRequestSchema = z.object({ + skillText: z.string().min(1), + shared: z.enum(["org", "public"]).nullable().optional(), +}).meta({ ref: "OpenWorkServerV2CloudOrgSkillCreateRequest" }); + +export const cloudOrgSkillCreateResponseSchema = z.object({ + id: z.string().min(1), +}).passthrough().meta({ ref: "OpenWorkServerV2CloudOrgSkillCreateResponse" }); + +export const cloudOrgSkillHubAddSkillRequestSchema = z.object({ + skillId: z.string().trim().min(1), +}).meta({ ref: "OpenWorkServerV2CloudOrgSkillHubAddSkillRequest" }); + +export const cloudOrgSkillHubAddSkillResponseSchema = z.object({}).passthrough().meta({ ref: "OpenWorkServerV2CloudOrgSkillHubAddSkillResponse" }); + export const workspaceImportedCloudProviderSchema = z.object({ cloudProviderId: z.string().min(1), providerId: z.string().min(1), diff --git a/apps/server-v2/src/services/cloud-service.ts b/apps/server-v2/src/services/cloud-service.ts index 8b9484cad..e5ba04720 100644 --- a/apps/server-v2/src/services/cloud-service.ts +++ b/apps/server-v2/src/services/cloud-service.ts @@ -9,7 +9,14 @@ import { cloudLlmProviderConnectionResponseSchema, cloudLlmProviderListResponseSchema, cloudMeResponseSchema, + cloudOrgSkillCreateResponseSchema, + cloudOrgSkillHubListResponseSchema, + cloudOrgSkillListResponseSchema, cloudOrganizationsResponseSchema, + cloudTemplateListResponseSchema, + cloudTemplateResponseSchema, + cloudWorkerListResponseSchema, + cloudWorkerTokensResponseSchema, type CloudAppVersionResponse, type CloudDesktopConfig, type CloudDesktopHandoffExchangeResponse, @@ -542,6 +549,96 @@ export function createCloudService(input: { return cloudLlmProviderConnectionResponseSchema.parse(payload).llmProvider; }, + async listWorkers(limit = 20) { + const query = new URLSearchParams(); + query.set("limit", String(limit)); + const payload = await requestCloud(`/v1/workers?${query.toString()}`, { + token: requireToken(), + }); + const parsed = isRecord(payload) && Array.isArray(payload.workers) + ? { + workers: payload.workers.map((entry) => { + const record = isRecord(entry) ? entry : {}; + const instance = isRecord(record.instance) ? record.instance : null; + return { + workerId: typeof record.id === "string" ? record.id : "", + workerName: typeof record.name === "string" ? record.name : "", + status: typeof record.status === "string" ? record.status : "unknown", + instanceUrl: instance && typeof instance.url === "string" ? instance.url : null, + provider: instance && typeof instance.provider === "string" ? instance.provider : null, + isMine: Boolean(record.isMine), + createdAt: typeof record.createdAt === "string" ? record.createdAt : null, + }; + }), + } + : payload; + return cloudWorkerListResponseSchema.parse(parsed); + }, + + async getWorkerTokens(workerId: string) { + const payload = await requestCloud(`/v1/workers/${encodeURIComponent(workerId)}/tokens`, { + body: {}, + method: "POST", + token: requireToken(), + }); + return cloudWorkerTokensResponseSchema.parse(payload); + }, + + async listTemplates() { + const payload = await requestCloud("/v1/templates", { + token: requireToken(), + }); + return cloudTemplateListResponseSchema.parse(payload); + }, + + async createTemplate(inputValue: { name: string; templateData: unknown }) { + const payload = await requestCloud("/v1/templates", { + body: inputValue, + method: "POST", + token: requireToken(), + }); + return cloudTemplateResponseSchema.parse(payload); + }, + + async deleteTemplate(templateId: string) { + await requestCloud(`/v1/templates/${encodeURIComponent(templateId)}`, { + method: "DELETE", + token: requireToken(), + }); + return null; + }, + + async listOrgSkills() { + const payload = await requestCloud("/v1/skills", { + token: requireToken(), + }); + return cloudOrgSkillListResponseSchema.parse(payload); + }, + + async listOrgSkillHubs() { + const payload = await requestCloud("/v1/skill-hubs", { + token: requireToken(), + }); + return cloudOrgSkillHubListResponseSchema.parse(payload); + }, + + async createOrgSkill(inputValue: { shared?: "org" | "public" | null; skillText: string }) { + const payload = await requestCloud("/v1/skills", { + body: inputValue, + method: "POST", + token: requireToken(), + }); + return cloudOrgSkillCreateResponseSchema.parse(payload); + }, + + async addOrgSkillToHub(skillHubId: string, skillId: string) { + return await requestCloud(`/v1/skill-hubs/${encodeURIComponent(skillHubId)}/skills`, { + body: { skillId }, + method: "POST", + token: requireToken(), + }); + }, + async getAppVersionMetadata(): Promise { try { const payload = await requestCloud("/v1/app-version");