feat(den-web): persist user workers and remove manual worker-id recovery

This commit is contained in:
Benjamin Shafii
2026-02-21 18:31:12 -08:00
parent c3494135ff
commit c4c000789e
7 changed files with 383 additions and 48 deletions

View File

@@ -404,6 +404,57 @@ body {
gap: 0.58rem;
}
.ow-worker-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.46rem;
}
.ow-worker-item {
border: 1px solid #dbe3f4;
border-radius: 0.76rem;
background: #fff;
padding: 0.56rem;
display: grid;
gap: 0.4rem;
}
.ow-worker-item.is-active {
border-color: #9fb2eb;
background: #f8fbff;
}
.ow-worker-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.4rem;
}
.ow-worker-meta {
margin: 0;
font-size: 0.72rem;
color: #475569;
word-break: break-all;
}
.ow-badge {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid #b8c8ec;
background: #edf3ff;
color: #1e3a8a;
font-size: 0.64rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 0.2rem 0.42rem;
}
.ow-inline-actions {
display: flex;
flex-wrap: wrap;

View File

@@ -25,6 +25,9 @@ type WorkerSummary = {
workerId: string;
workerName: string;
status: string;
instanceUrl: string | null;
provider: string | null;
isMine: boolean;
};
type WorkerTokens = {
@@ -32,6 +35,16 @@ type WorkerTokens = {
hostToken: string | null;
};
type WorkerListItem = {
workerId: string;
workerName: string;
status: string;
instanceUrl: string | null;
provider: string | null;
isMine: boolean;
createdAt: string | null;
};
type EventLevel = "info" | "success" | "warning" | "error";
type LaunchEvent = {
@@ -150,10 +163,15 @@ function getWorkerSummary(payload: unknown): WorkerSummary | null {
return null;
}
const instance = isRecord(payload.instance) ? payload.instance : null;
return {
workerId: worker.id,
workerName: worker.name,
status: typeof worker.status === "string" ? worker.status : "unknown"
status: typeof worker.status === "string" ? worker.status : "unknown",
instanceUrl: instance && typeof instance.url === "string" ? instance.url : null,
provider: instance && typeof instance.provider === "string" ? instance.provider : null,
isMine: worker.isMine === true
};
}
@@ -173,6 +191,47 @@ function getWorkerTokens(payload: unknown): WorkerTokens | null {
return { clientToken, hostToken };
}
function parseWorkerListItem(value: unknown): WorkerListItem | null {
if (!isRecord(value)) {
return null;
}
const workerId = value.id;
const workerName = value.name;
if (typeof workerId !== "string" || typeof workerName !== "string") {
return null;
}
const instance = isRecord(value.instance) ? value.instance : null;
const createdAt = typeof value.createdAt === "string" ? value.createdAt : null;
return {
workerId,
workerName,
status: typeof value.status === "string" ? value.status : "unknown",
instanceUrl: instance && typeof instance.url === "string" ? instance.url : null,
provider: instance && typeof instance.provider === "string" ? instance.provider : null,
isMine: value.isMine === true,
createdAt
};
}
function getWorkersList(payload: unknown): WorkerListItem[] {
if (!isRecord(payload) || !Array.isArray(payload.workers)) {
return [];
}
const rows: WorkerListItem[] = [];
for (const item of payload.workers) {
const parsed = parseWorkerListItem(item);
if (parsed) {
rows.push(parsed);
}
}
return rows;
}
function isWorkerLaunch(value: unknown): value is WorkerLaunch {
if (!isRecord(value)) {
return false;
@@ -189,6 +248,18 @@ function isWorkerLaunch(value: unknown): value is WorkerLaunch {
);
}
function listItemToWorker(item: WorkerListItem, current: WorkerLaunch | null = null): WorkerLaunch {
return {
workerId: item.workerId,
workerName: item.workerName,
status: item.status,
provider: item.provider,
instanceUrl: item.instanceUrl,
clientToken: current?.workerId === item.workerId ? current.clientToken : null,
hostToken: current?.workerId === item.workerId ? current.hostToken : null
};
}
async function requestJson(path: string, init: RequestInit = {}, timeoutMs = 30000) {
const headers = new Headers(init.headers);
headers.set("Accept", "application/json");
@@ -277,6 +348,9 @@ export function CloudControlPanel() {
const [workerName, setWorkerName] = useState("Founder Ops Pilot");
const [worker, setWorker] = useState<WorkerLaunch | null>(null);
const [workerLookupId, setWorkerLookupId] = useState("");
const [workers, setWorkers] = useState<WorkerListItem[]>([]);
const [workersBusy, setWorkersBusy] = useState(false);
const [workersError, setWorkersError] = useState<string | null>(null);
const [launchBusy, setLaunchBusy] = useState(false);
const [actionBusy, setActionBusy] = useState<"status" | "token" | null>(null);
const [launchStatus, setLaunchStatus] = useState("Name your worker and click launch.");
@@ -287,6 +361,8 @@ export function CloudControlPanel() {
const [events, setEvents] = useState<LaunchEvent[]>([]);
const [copiedField, setCopiedField] = useState<string | null>(null);
const selectedWorker = workers.find((item) => item.workerId === workerLookupId) ?? null;
const progressWidth = step === 1 ? "33.333%" : step === 2 ? "66.666%" : "100%";
function appendEvent(level: EventLevel, label: string, detail: string) {
@@ -306,6 +382,53 @@ export function CloudControlPanel() {
});
}
async function refreshWorkers(options: { keepSelection?: boolean } = {}) {
if (!user) {
setWorkers([]);
setWorkersError(null);
return;
}
setWorkersBusy(true);
setWorkersError(null);
try {
const { response, payload } = await requestJson("/v1/workers?limit=20", {
method: "GET",
headers: authToken ? { Authorization: `Bearer ${authToken}` } : undefined
});
if (!response.ok) {
const message = getErrorMessage(payload, `Failed to load workers (${response.status}).`);
setWorkersError(message);
return;
}
const nextWorkers = getWorkersList(payload);
setWorkers(nextWorkers);
const currentSelection = options.keepSelection ? workerLookupId : "";
const nextSelectedId =
currentSelection && nextWorkers.some((item) => item.workerId === currentSelection)
? currentSelection
: nextWorkers[0]?.workerId ?? "";
setWorkerLookupId(nextSelectedId);
if (nextSelectedId && worker && worker.workerId === nextSelectedId) {
const selected = nextWorkers.find((item) => item.workerId === nextSelectedId) ?? null;
if (selected) {
setWorker((current) => listItemToWorker(selected, current));
}
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown network error";
setWorkersError(message);
} finally {
setWorkersBusy(false);
}
}
async function copyToClipboard(field: string, value: string | null) {
if (!value) {
return;
@@ -351,6 +474,16 @@ export function CloudControlPanel() {
void refreshSession(true);
}, []);
useEffect(() => {
if (!user) {
setWorkers([]);
setWorkersError(null);
return;
}
void refreshWorkers();
}, [user?.id, authToken]);
useEffect(() => {
if (typeof window === "undefined") {
return;
@@ -550,7 +683,7 @@ export function CloudControlPanel() {
} catch (error) {
const message =
error instanceof DOMException && error.name === "AbortError"
? "Launch request timed out after 45s. Retry launch or come back later with your worker ID."
? "Launch request timed out after 45s. Refresh the worker list below to continue without manual IDs."
: error instanceof Error
? error.message
: "Unknown network error";
@@ -560,6 +693,7 @@ export function CloudControlPanel() {
appendEvent("error", "Launch failed", message);
} finally {
setLaunchBusy(false);
void refreshWorkers({ keepSelection: true });
}
}
@@ -569,12 +703,14 @@ export function CloudControlPanel() {
return;
}
const id = workerLookupId.trim();
const id = workerLookupId.trim() || worker?.workerId || workers[0]?.workerId || "";
if (!id) {
setLaunchError("Enter a worker ID first.");
setLaunchError("No worker selected yet. Launch one first, then use this panel.");
return;
}
setWorkerLookupId(id);
setActionBusy("status");
setLaunchError(null);
@@ -603,7 +739,9 @@ export function CloudControlPanel() {
return {
...previous,
workerName: summary.workerName,
status: summary.status
status: summary.status,
provider: summary.provider,
instanceUrl: summary.instanceUrl
};
}
@@ -611,8 +749,8 @@ export function CloudControlPanel() {
workerId: summary.workerId,
workerName: summary.workerName,
status: summary.status,
provider: null,
instanceUrl: null,
provider: summary.provider,
instanceUrl: summary.instanceUrl,
clientToken: null,
hostToken: null
};
@@ -621,6 +759,7 @@ export function CloudControlPanel() {
setWorkerLookupId(summary.workerId);
setLaunchStatus(`Worker ${summary.workerName} is currently ${summary.status}.`);
appendEvent("info", "Status refreshed", `${summary.workerName}: ${summary.status}`);
void refreshWorkers({ keepSelection: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown network error";
setLaunchError(message);
@@ -636,12 +775,14 @@ export function CloudControlPanel() {
return;
}
const id = workerLookupId.trim();
const id = workerLookupId.trim() || worker?.workerId || workers[0]?.workerId || "";
if (!id) {
setLaunchError("Enter a worker ID before generating an API key.");
setLaunchError("No worker selected yet. Launch one first, then generate a key.");
return;
}
setWorkerLookupId(id);
setActionBusy("token");
setLaunchError(null);
@@ -688,6 +829,7 @@ export function CloudControlPanel() {
setLaunchStatus("Generated a fresh worker API key.");
appendEvent("success", "Generated new worker API key", `Worker ID ${id}`);
void refreshWorkers({ keepSelection: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown network error";
setLaunchError(message);
@@ -848,19 +990,60 @@ export function CloudControlPanel() {
) : null}
<div className="ow-lookup-box">
<p className="ow-section-title">Come back later</p>
<p className="ow-section-title">Your workers</p>
<p className="ow-caption">No Worker ID guessing. Pick from your recent workers and continue.</p>
{workersBusy ? <p className="ow-caption">Loading workers...</p> : null}
{workersError ? <p className="ow-error-text">{workersError}</p> : null}
{workers.length > 0 ? (
<ul className="ow-worker-list">
{workers.map((item) => (
<li
key={item.workerId}
className={`ow-worker-item ${workerLookupId === item.workerId ? "is-active" : ""}`}
>
<div className="ow-worker-head">
<div>
<p className="ow-step-title">{item.workerName}</p>
<p className="ow-step-detail">{item.status}</p>
</div>
{item.isMine ? <span className="ow-badge">Yours</span> : null}
</div>
<p className="ow-worker-meta ow-mono">{item.instanceUrl ?? "URL pending provisioning"}</p>
<button
type="button"
className="ow-btn-secondary"
onClick={() => {
setWorkerLookupId(item.workerId);
setWorker((current) => listItemToWorker(item, current));
}}
>
{workerLookupId === item.workerId ? "Selected" : "Select"}
</button>
</li>
))}
</ul>
) : null}
{workers.length === 0 && !workersBusy ? (
<p className="ow-caption">No workers yet. Launch one and it will appear here automatically.</p>
) : null}
<div className="ow-inline-actions">
<input
className="ow-input ow-mono"
value={workerLookupId}
onChange={(event) => setWorkerLookupId(event.target.value)}
placeholder="Worker ID"
/>
<button
type="button"
className="ow-btn-secondary"
onClick={() => void refreshWorkers({ keepSelection: true })}
disabled={workersBusy}
>
Refresh list
</button>
<button
type="button"
className="ow-btn-secondary"
onClick={handleCheckStatus}
disabled={actionBusy !== null}
disabled={actionBusy !== null || !selectedWorker}
>
{actionBusy === "status" ? "Checking..." : "Check status"}
</button>
@@ -868,7 +1051,7 @@ export function CloudControlPanel() {
type="button"
className="ow-btn-secondary"
onClick={handleGenerateKey}
disabled={actionBusy !== null}
disabled={actionBusy !== null || !selectedWorker}
>
{actionBusy === "token" ? "Generating..." : "New API key"}
</button>

View File

@@ -60,9 +60,11 @@ pnpm db:migrate
- `GET /health`
- `GET /` demo web app (sign-up + auth + worker launch)
- `GET /v1/me`
- `GET /v1/workers` (list recent workers for signed-in user/org)
- `POST /v1/workers`
- Returns `402 payment_required` with Polar checkout URL when paywall is enabled and entitlement is missing.
- `GET /v1/workers/:id`
- Includes latest instance metadata when available.
- `POST /v1/workers/:id/tokens`
## CI deployment (dev == prod)

View File

@@ -0,0 +1,3 @@
ALTER TABLE `worker` ADD `created_by_user_id` varchar(64);
--> statement-breakpoint
CREATE INDEX `worker_created_by_user_id` ON `worker` (`created_by_user_id`);

View File

@@ -15,6 +15,13 @@
"when": 1771639607782,
"tag": "0001_auth_columns_fix",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1771741800000,
"tag": "0002_worker_created_by",
"breakpoints": true
}
]
}

View File

@@ -132,6 +132,7 @@ export const WorkerTable = mysqlTable(
{
id: id().primaryKey(),
org_id: varchar("org_id", { length: 64 }).notNull(),
created_by_user_id: varchar("created_by_user_id", { length: 64 }),
name: varchar("name", { length: 255 }).notNull(),
description: varchar("description", { length: 1024 }),
destination: mysqlEnum("destination", WorkerDestination).notNull(),
@@ -141,7 +142,11 @@ export const WorkerTable = mysqlTable(
sandbox_backend: varchar("sandbox_backend", { length: 64 }),
...timestamps,
},
(table) => [index("worker_org_id").on(table.org_id), index("worker_status").on(table.status)],
(table) => [
index("worker_org_id").on(table.org_id),
index("worker_created_by_user_id").on(table.created_by_user_id),
index("worker_status").on(table.status),
],
)
export const WorkerInstanceTable = mysqlTable(

View File

@@ -1,7 +1,7 @@
import { randomBytes, randomUUID } from "crypto"
import express from "express"
import { fromNodeHeaders } from "better-auth/node"
import { eq } from "drizzle-orm"
import { and, desc, eq } from "drizzle-orm"
import { z } from "zod"
import { auth } from "../auth.js"
import { requireCloudWorkerAccess } from "../billing/polar.js"
@@ -20,8 +20,15 @@ const createSchema = z.object({
imageVersion: z.string().optional(),
})
const listSchema = z.object({
limit: z.coerce.number().int().min(1).max(50).default(20),
})
const token = () => randomBytes(32).toString("hex")
type WorkerRow = typeof WorkerTable.$inferSelect
type WorkerInstanceRow = typeof WorkerInstanceTable.$inferSelect
async function requireSession(req: express.Request, res: express.Response) {
const session = await auth.api.getSession({
headers: fromNodeHeaders(req.headers),
@@ -45,8 +52,88 @@ async function getOrgId(userId: string) {
return membership[0].org_id
}
async function getLatestWorkerInstance(workerId: string) {
const rows = await db
.select()
.from(WorkerInstanceTable)
.where(eq(WorkerInstanceTable.worker_id, workerId))
.orderBy(desc(WorkerInstanceTable.created_at))
.limit(1)
return rows[0] ?? null
}
function toInstanceResponse(instance: WorkerInstanceRow | null) {
if (!instance) {
return null
}
return {
provider: instance.provider,
region: instance.region,
url: instance.url,
status: instance.status,
createdAt: instance.created_at,
updatedAt: instance.updated_at,
}
}
function toWorkerResponse(row: WorkerRow, userId: string) {
return {
id: row.id,
orgId: row.org_id,
createdByUserId: row.created_by_user_id,
isMine: row.created_by_user_id === userId,
name: row.name,
description: row.description,
destination: row.destination,
status: row.status,
imageVersion: row.image_version,
workspacePath: row.workspace_path,
sandboxBackend: row.sandbox_backend,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
export const workersRouter = express.Router()
workersRouter.get("/", async (req, res) => {
const session = await requireSession(req, res)
if (!session) return
const orgId = await getOrgId(session.user.id)
if (!orgId) {
res.json({ workers: [] })
return
}
const parsed = listSchema.safeParse({ limit: req.query.limit })
if (!parsed.success) {
res.status(400).json({ error: "invalid_request", details: parsed.error.flatten() })
return
}
const rows = await db
.select()
.from(WorkerTable)
.where(eq(WorkerTable.org_id, orgId))
.orderBy(desc(WorkerTable.created_at))
.limit(parsed.data.limit)
const workers = await Promise.all(
rows.map(async (row) => {
const instance = await getLatestWorkerInstance(row.id)
return {
...toWorkerResponse(row, session.user.id),
instance: toInstanceResponse(instance),
}
}),
)
res.json({ workers })
})
workersRouter.post("/", async (req, res) => {
const session = await requireSession(req, res)
if (!session) return
@@ -86,12 +173,12 @@ workersRouter.post("/", async (req, res) => {
const orgId =
(await getOrgId(session.user.id)) ?? (await ensureDefaultOrg(session.user.id, session.user.name ?? session.user.email ?? "Personal"))
const workerId = randomUUID()
let workerStatus: "provisioning" | "healthy" | "failed" | "stopped" =
parsed.data.destination === "cloud" ? "provisioning" : "healthy"
let workerStatus: WorkerRow["status"] = parsed.data.destination === "cloud" ? "provisioning" : "healthy"
await db.insert(WorkerTable).values({
id: workerId,
org_id: orgId,
created_by_user_id: session.user.id,
name: parsed.data.name,
description: parsed.data.description,
destination: parsed.data.destination,
@@ -156,17 +243,23 @@ workersRouter.post("/", async (req, res) => {
}
res.status(201).json({
worker: {
id: workerId,
orgId,
name: parsed.data.name,
description: parsed.data.description ?? null,
destination: parsed.data.destination,
status: workerStatus,
imageVersion: parsed.data.imageVersion ?? null,
workspacePath: parsed.data.workspacePath ?? null,
sandboxBackend: parsed.data.sandboxBackend ?? null,
},
worker: toWorkerResponse(
{
id: workerId,
org_id: orgId,
created_by_user_id: session.user.id,
name: parsed.data.name,
description: parsed.data.description ?? null,
destination: parsed.data.destination,
status: workerStatus,
image_version: parsed.data.imageVersion ?? null,
workspace_path: parsed.data.workspacePath ?? null,
sandbox_backend: parsed.data.sandboxBackend ?? null,
created_at: new Date(),
updated_at: new Date(),
},
session.user.id,
),
tokens: {
host: hostToken,
client: clientToken,
@@ -188,28 +281,19 @@ workersRouter.get("/:id", async (req, res) => {
const rows = await db
.select()
.from(WorkerTable)
.where(eq(WorkerTable.id, req.params.id))
.where(and(eq(WorkerTable.id, req.params.id), eq(WorkerTable.org_id, orgId)))
.limit(1)
if (rows.length === 0 || rows[0].org_id !== orgId) {
if (rows.length === 0) {
res.status(404).json({ error: "worker_not_found" })
return
}
const instance = await getLatestWorkerInstance(rows[0].id)
res.json({
worker: {
id: rows[0].id,
orgId: rows[0].org_id,
name: rows[0].name,
description: rows[0].description,
destination: rows[0].destination,
status: rows[0].status,
imageVersion: rows[0].image_version,
workspacePath: rows[0].workspace_path,
sandboxBackend: rows[0].sandbox_backend,
createdAt: rows[0].created_at,
updatedAt: rows[0].updated_at,
},
worker: toWorkerResponse(rows[0], session.user.id),
instance: toInstanceResponse(instance),
})
})