Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
.git
.github
.opencode
.codenomad
node_modules
**/node_modules
tmp
dist
**/dist
artifacts
apps/desktop/src-tauri/target
**/target
.env
.env.*
87 changes: 76 additions & 11 deletions apps/app/src/app/lib/den.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "./den-session-events";
import {
desktopFetch,
desktopFetchViaMain,
getDesktopBootstrapConfig as getDesktopBootstrapConfigFromShell,
setDesktopBootstrapConfig as setDesktopBootstrapConfigInShell,
type DesktopBootstrapConfig as ShellDesktopBootstrapConfig,
Expand Down Expand Up @@ -116,6 +117,20 @@ export type DenWorkerTokens = {
workspaceId: string | null;
};

export type DenStaticWorkerAttachInput = {
name: string;
description?: string | null;
url: string;
clientToken: string;
hostToken: string;
activityToken?: string | null;
};

export type DenWorkerLaunchInput = {
name: string;
source?: "manual" | "signup_auto";
};

export type DenMcpToken = {
token: string;
expiresAt: string;
Expand Down Expand Up @@ -1652,17 +1667,16 @@ async function requestJsonRaw<T>(
headers["Content-Type"] = "application/json";
}

const response = await fetchWithTimeout(
resolveFetch(),
url,
{
method: options.method ?? "GET",
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
credentials: "include",
},
options.timeoutMs ?? DEFAULT_DEN_TIMEOUT_MS,
);
const requestInit = {
method: options.method ?? "GET",
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
credentials: "include",
} satisfies RequestInit;
const timeoutMs = options.timeoutMs ?? DEFAULT_DEN_TIMEOUT_MS;
const response = isDesktopRuntime()
? await desktopFetchViaMain(url, requestInit, timeoutMs)
: await fetchWithTimeout(resolveFetch(), url, requestInit, timeoutMs);

const text = await response.text();
let json: T | null = null;
Expand Down Expand Up @@ -1845,6 +1859,31 @@ export function createDenClient(options: { baseUrl: string; apiBaseUrl?: string
return getWorkers(payload);
},

async createWorker(orgId: string, input: DenWorkerLaunchInput): Promise<DenWorkerSummary> {
const payload = await requestJson<unknown>(baseUrls, "/v1/workers", {
method: "POST",
token,
organizationId: orgId,
body: {
name: input.name,
destination: "cloud",
source: input.source ?? "manual",
},
});
const workers = getWorkers({
workers: isRecord(payload) && isRecord(payload.worker)
? [{ ...payload.worker, instance: isRecord(payload.instance) ? payload.instance : null }]
: isRecord(payload)
? [payload]
: [],
});
const worker = workers[0];
if (!worker) {
throw new DenApiError(500, "invalid_worker_create_payload", "Worker launch response was missing worker details.");
}
return worker;
},

async mintMcpToken(orgId: string): Promise<DenMcpToken> {
const payload = await requestJson<unknown>(baseUrls, "/v1/mcp/token", {
method: "POST",
Expand Down Expand Up @@ -1873,6 +1912,32 @@ export function createDenClient(options: { baseUrl: string; apiBaseUrl?: string
return tokens;
},

async attachStaticWorker(orgId: string, input: DenStaticWorkerAttachInput): Promise<DenWorkerSummary> {
const payload = await requestJson<unknown>(baseUrls, "/v1/workers/static-attach", {
method: "POST",
token,
organizationId: orgId,
body: {
name: input.name,
description: input.description ?? undefined,
url: input.url,
clientToken: input.clientToken,
hostToken: input.hostToken,
activityToken: input.activityToken ?? undefined,
},
});
const workers = getWorkers({
workers: isRecord(payload) && isRecord(payload.worker)
? [{ ...payload.worker, instance: isRecord(payload.instance) ? payload.instance : null }]
: [],
});
const worker = workers[0];
if (!worker) {
throw new DenApiError(500, "invalid_worker_attach_payload", "Static worker attach response was missing worker details.");
}
return worker;
},

async listOrgSkills(orgId: string): Promise<DenOrgSkillCard[]> {
const payload = await requestJson<unknown>(baseUrls, "/v1/skills", {
method: "GET",
Expand Down
15 changes: 14 additions & 1 deletion apps/app/src/react-app/domains/settings/cloud/sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -675,19 +675,23 @@ export function MarketplacePluginsSection({
}

export interface CloudWorkersSectionProps {
launchBusy: boolean;
openingWorkerId: string | null;
workers: CloudWorker[];
workersBusy: boolean;
workersError: string | null;
onLaunchWorker: () => void | Promise<void>;
onOpenWorker: (workerId: string, workerName: string) => void | Promise<void>;
onRefreshWorkers: () => void | Promise<void>;
}

export function CloudWorkersSection({
launchBusy,
openingWorkerId,
workers,
workersBusy,
workersError,
onLaunchWorker,
onOpenWorker,
onRefreshWorkers,
}: CloudWorkersSectionProps) {
Expand Down Expand Up @@ -722,6 +726,13 @@ export function CloudWorkersSection({
<SettingsSectionHeaderDescription>{t("den.cloud_workers_hint")}</SettingsSectionHeaderDescription>
</SettingsSectionHeaderContent>
<SettingsSectionHeaderActions>
<Button
size="sm"
disabled={launchBusy || workersBusy || !hasActiveOrg}
onClick={() => void onLaunchWorker()}
>
{launchBusy ? "Launching..." : "Launch cloud worker"}
</Button>
<RefreshButton
busy={workersBusy}
disabled={[workersBusy, !hasActiveOrg].some(Boolean)}
Expand All @@ -735,7 +746,9 @@ export function CloudWorkersSection({
{workersError ? <SettingsNotice tone="error">{workersError}</SettingsNotice> : null}

{!workersBusy && workers.length === 0 ? (
<SettingsListEmptyState>{t("den.no_cloud_workers")}</SettingsListEmptyState>
<SettingsListEmptyState>
No cloud workers are visible for this org yet. Launch one here, then open it from this tab.
</SettingsListEmptyState>
) : null}

{workers.length > 0 ? (
Expand Down
127 changes: 124 additions & 3 deletions apps/app/src/react-app/domains/settings/pages/cloud-workers-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as React from "react";
import { toast } from "@/components/ui/sonner";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { t } from "@/i18n";
import { useCloudSession } from "@/react-app/domains/settings/cloud/cloud-session-provider";
Expand All @@ -13,6 +14,11 @@ export type CloudWorkersViewProps = {
connectRemoteWorkspace: (input: {
openworkHostUrl?: string | null;
openworkToken?: string | null;
openworkClientToken?: string | null;
openworkHostToken?: string | null;
openworkDenBaseUrl?: string | null;
openworkDenOrgId?: string | null;
openworkDenWorkerId?: string | null;
directory?: string | null;
displayName?: string | null;
}) => Promise<boolean>;
Expand All @@ -23,11 +29,19 @@ export function CloudWorkersView({
connectRemoteWorkspace,
onOpenAccount,
}: CloudWorkersViewProps) {
const { activeOrganization: activeOrg, authToken, client, isSignedIn, user } = useCloudSession();
const { activeOrganization: activeOrg, authToken, baseUrl, client, isSignedIn, user } = useCloudSession();
const [workersBusy, setWorkersBusy] = React.useState(false);
const [launchBusy, setLaunchBusy] = React.useState(false);
const [openingWorkerId, setOpeningWorkerId] = React.useState<string | null>(null);
const [attachBusy, setAttachBusy] = React.useState(false);
const [workers, setWorkers] = React.useState<CloudWorker[]>([]);
const [workersError, setWorkersError] = React.useState<string | null>(null);
const [staticWorkerForm, setStaticWorkerForm] = React.useState({
name: "LAN static worker",
url: "",
clientToken: "",
hostToken: "",
});
const activeOrgId = activeOrg?.id ?? "";

const refreshWorkers = React.useCallback(
Expand Down Expand Up @@ -69,6 +83,29 @@ export function CloudWorkersView({
void refreshWorkers(true);
}, [activeOrgId, refreshWorkers, user]);

const launchWorker = React.useCallback(async () => {
if (!activeOrgId) {
setWorkersError(t("den.error_choose_org"));
return;
}

setLaunchBusy(true);
setWorkersError(null);
try {
const worker = await client.createWorker(activeOrgId, {
name: "OpenWork workspace",
source: "manual",
});
setWorkers((current) => [worker, ...current.filter((entry) => entry.workerId !== worker.workerId)]);
toast.success(`Launching ${worker.workerName}`);
void refreshWorkers(true);
} catch (error) {
setWorkersError(error instanceof Error ? error.message : "Cloud worker launch failed.");
} finally {
setLaunchBusy(false);
}
}, [activeOrgId, client, refreshWorkers]);

const openWorker = React.useCallback(
async (workerId: string, workerName: string) => {
if (!activeOrgId) {
Expand All @@ -82,14 +119,19 @@ export function CloudWorkersView({
try {
const tokens = await client.getWorkerTokens(workerId, activeOrgId);
const openworkUrl = tokens.openworkUrl?.trim() ?? "";
const accessToken = tokens.ownerToken?.trim() || tokens.clientToken?.trim() || "";
const accessToken = tokens.clientToken?.trim() || tokens.ownerToken?.trim() || "";
if (!openworkUrl || !accessToken) {
throw new Error(t("den.error_worker_not_ready"));
}

const ok = await connectRemoteWorkspace({
openworkHostUrl: openworkUrl,
openworkToken: accessToken,
openworkClientToken: tokens.clientToken?.trim() || null,
openworkHostToken: tokens.hostToken?.trim() || null,
openworkDenBaseUrl: baseUrl,
openworkDenOrgId: activeOrgId,
openworkDenWorkerId: workerId,
directory: null,
displayName: workerName,
});
Expand All @@ -108,9 +150,47 @@ export function CloudWorkersView({
setOpeningWorkerId(null);
}
},
[activeOrgId, client, connectRemoteWorkspace],
[activeOrgId, baseUrl, client, connectRemoteWorkspace],
);

const attachStaticWorker = React.useCallback(async () => {
if (!activeOrgId) {
setWorkersError(t("den.error_choose_org"));
return;
}

const name = staticWorkerForm.name.trim();
const url = staticWorkerForm.url.trim();
const clientToken = staticWorkerForm.clientToken.trim();
const hostToken = staticWorkerForm.hostToken.trim();
if (!name || !url || !clientToken || !hostToken) {
setWorkersError("Name, URL, client token, and host token are required to attach a static worker.");
return;
}

setAttachBusy(true);
setWorkersError(null);
try {
const worker = await client.attachStaticWorker(activeOrgId, {
name,
url,
clientToken,
hostToken,
});
setWorkers((current) => [worker, ...current.filter((entry) => entry.workerId !== worker.workerId)]);
setStaticWorkerForm((current) => ({ ...current, url: "", clientToken: "", hostToken: "" }));
toast.success(`Attached ${worker.workerName}`);
void refreshWorkers(true);
} catch (error) {
const status = typeof error === "object" && error !== null && "status" in error ? Number((error as { status?: unknown }).status) : null;
setWorkersError(status === 403
? "Only organization owners and admins can attach static workers. Ask an operator to register this worker."
: error instanceof Error ? error.message : "Static worker attach failed.");
} finally {
setAttachBusy(false);
}
}, [activeOrgId, client, refreshWorkers, staticWorkerForm]);

if (!isSignedIn) {
return (
<SettingsStack>
Expand All @@ -130,11 +210,52 @@ export function CloudWorkersView({
return (
<SettingsStack>
<Separator />
<SettingsNotice>
<div className="flex flex-col gap-3">
<div>
<div className="text-sm font-medium">Admin/operator: attach LAN static worker</div>
<div className="text-xs text-muted-foreground">
Organization owners and admins can register a pre-running OpenWork worker without manual database changes. The URL and tokens must match the worker container environment.
</div>
</div>
<div className="grid gap-2 md:grid-cols-2">
<Input
value={staticWorkerForm.name}
onChange={(event) => setStaticWorkerForm((current) => ({ ...current, name: event.currentTarget.value }))}
placeholder="Worker name"
/>
<Input
value={staticWorkerForm.url}
onChange={(event) => setStaticWorkerForm((current) => ({ ...current, url: event.currentTarget.value }))}
placeholder="http://192.168.1.50:8787"
/>
<Input
value={staticWorkerForm.clientToken}
onChange={(event) => setStaticWorkerForm((current) => ({ ...current, clientToken: event.currentTarget.value }))}
placeholder="OPENWORK_TOKEN"
type="password"
/>
<Input
value={staticWorkerForm.hostToken}
onChange={(event) => setStaticWorkerForm((current) => ({ ...current, hostToken: event.currentTarget.value }))}
placeholder="OPENWORK_HOST_TOKEN"
type="password"
/>
</div>
<div>
<Button size="sm" onClick={() => void attachStaticWorker()} disabled={attachBusy || workersBusy || !activeOrgId}>
{attachBusy ? "Attaching..." : "Attach static worker"}
</Button>
</div>
</div>
</SettingsNotice>
<CloudWorkersSection
launchBusy={launchBusy}
openingWorkerId={openingWorkerId}
workers={workers}
workersBusy={workersBusy}
workersError={workersError}
onLaunchWorker={launchWorker}
onOpenWorker={openWorker}
onRefreshWorkers={refreshWorkers}
/>
Expand Down
Loading