Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -111,6 +112,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 @@ -1786,17 +1801,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 @@ -1979,6 +1993,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 @@ -2007,6 +2046,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 @@ -776,19 +776,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 @@ -823,6 +827,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 @@ -836,7 +847,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