fix(cloud): launch workers asynchronously and auto-poll provisioning

This commit is contained in:
Benjamin Shafii
2026-02-21 21:09:53 -08:00
parent af24d4cedf
commit f0f93273a3
4 changed files with 190 additions and 65 deletions

View File

@@ -332,6 +332,19 @@ body {
font-size: 0.82rem;
}
.ow-connect-steps {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.2rem;
color: #334155;
font-size: 0.8rem;
}
.ow-connect-steps li {
line-height: 1.35;
}
.ow-error-text {
color: var(--ow-danger);
font-weight: 600;

View File

@@ -60,6 +60,7 @@ type LaunchEvent = {
};
const LAST_WORKER_STORAGE_KEY = "openwork:web:last-worker";
const WORKER_STATUS_POLL_MS = 5000;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
@@ -241,6 +242,19 @@ function getWorkersList(payload: unknown): WorkerListItem[] {
return rows;
}
function getWorkerStatusCopy(status: string): string {
if (status === "provisioning") {
return "Provisioning is in progress. We auto-check every 5 seconds.";
}
if (status === "healthy") {
return "Worker is healthy and ready for remote connect.";
}
if (status === "failed") {
return "Provisioning failed. Launch a new worker or retry status.";
}
return `Worker status: ${status}.`;
}
function isWorkerLaunch(value: unknown): value is WorkerLaunch {
if (!isRecord(value)) {
return false;
@@ -731,7 +745,7 @@ export function CloudControlPanel() {
setWorker(restored);
setWorkerLookupId(restored.workerId);
setLaunchStatus(`Recovered worker ${restored.workerName}. Get an access token if needed.`);
setLaunchStatus(`Recovered worker ${restored.workerName}. ${getWorkerStatusCopy(restored.status)}`);
appendEvent("info", "Recovered worker context", `Worker ID ${restored.workerId}`);
} catch {
return;
@@ -753,12 +767,12 @@ export function CloudControlPanel() {
}, [worker]);
useEffect(() => {
if (worker) {
if (worker && worker.status === "healthy") {
setStep(3);
return;
}
if (user || checkoutUrl || paymentReturned) {
if (user || checkoutUrl || paymentReturned || worker) {
setStep(2);
return;
}
@@ -784,6 +798,34 @@ export function CloudControlPanel() {
void handleGenerateKey();
}, [actionBusy, launchBusy, tokenFetchedForWorkerId, user, worker]);
useEffect(() => {
if (!user || !worker || worker.status !== "provisioning") {
return;
}
if (actionBusy !== null || launchBusy) {
return;
}
let cancelled = false;
const poll = async () => {
if (cancelled) {
return;
}
await handleCheckStatus({ workerId: worker.workerId, quiet: true, background: true });
};
void poll();
const interval = window.setInterval(() => {
void poll();
}, WORKER_STATUS_POLL_MS);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [actionBusy, authToken, launchBusy, user?.id, worker?.workerId, worker?.status]);
async function handleAuthSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -865,7 +907,7 @@ export function CloudControlPanel() {
destination: "cloud"
})
},
45000
12000
);
if (response.status === 402) {
@@ -898,12 +940,18 @@ export function CloudControlPanel() {
setWorkerLookupId(parsedWorker.workerId);
setPaymentReturned(false);
setCheckoutUrl(null);
setLaunchStatus(`Worker ${resolvedWorker.workerName} is ${resolvedWorker.status}.`);
appendEvent("success", "Worker launched", `Worker ID ${parsedWorker.workerId}`);
if (resolvedWorker.status === "provisioning") {
setLaunchStatus("Provisioning started. This can take a few minutes, and we will keep checking automatically.");
appendEvent("info", "Provisioning started", `Worker ID ${parsedWorker.workerId}`);
} else {
setLaunchStatus(getWorkerStatusCopy(resolvedWorker.status));
appendEvent("success", "Worker launched", `Worker ID ${parsedWorker.workerId}`);
}
} catch (error) {
const message =
error instanceof DOMException && error.name === "AbortError"
? "Launch request timed out after 45s. Refresh the worker list below to continue without manual IDs."
? "Launch request took longer than expected. Provisioning can continue in the background. Refresh worker status below."
: error instanceof Error
? error.message
: "Unknown network error";
@@ -917,22 +965,34 @@ export function CloudControlPanel() {
}
}
async function handleCheckStatus() {
async function handleCheckStatus(options: { workerId?: string; quiet?: boolean; background?: boolean } = {}) {
const quiet = options.quiet === true;
const background = options.background === true;
if (!user) {
setLaunchError("Sign in before checking worker status.");
if (!quiet) {
setLaunchError("Sign in before checking worker status.");
}
return;
}
const id = workerLookupId.trim() || worker?.workerId || workers[0]?.workerId || "";
const fallbackId = workerLookupId.trim() || worker?.workerId || workers[0]?.workerId || "";
const id = options.workerId ?? fallbackId;
if (!id) {
setLaunchError("No worker selected yet. Launch one first, then use this panel.");
if (!quiet) {
setLaunchError("No worker selected yet. Launch one first, then use this panel.");
}
return;
}
setWorkerLookupId(id);
setActionBusy("status");
setLaunchError(null);
if (!background) {
setActionBusy("status");
}
if (!quiet) {
setLaunchError(null);
}
try {
const { response, payload } = await requestJson(`/v1/workers/${encodeURIComponent(id)}`, {
@@ -942,18 +1002,24 @@ export function CloudControlPanel() {
if (!response.ok) {
const message = getErrorMessage(payload, `Status check failed with ${response.status}.`);
setLaunchError(message);
appendEvent("error", "Status check failed", message);
if (!quiet) {
setLaunchError(message);
appendEvent("error", "Status check failed", message);
}
return;
}
const summary = getWorkerSummary(payload);
if (!summary) {
setLaunchError("Status response was missing worker details.");
appendEvent("error", "Status check failed", "Worker summary missing");
if (!quiet) {
setLaunchError("Status response was missing worker details.");
appendEvent("error", "Status check failed", "Worker summary missing");
}
return;
}
const previousStatus = worker?.workerId === summary.workerId ? worker.status : null;
const nextWorker: WorkerLaunch =
worker && worker.workerId === summary.workerId
? {
@@ -979,15 +1045,35 @@ export function CloudControlPanel() {
setWorker(resolvedWorker);
setWorkerLookupId(summary.workerId);
setLaunchStatus(`Worker ${summary.workerName} is currently ${summary.status}.`);
appendEvent("info", "Status refreshed", `${summary.workerName}: ${summary.status}`);
void refreshWorkers({ keepSelection: true });
if (!quiet) {
setLaunchStatus(`Worker ${summary.workerName} is currently ${summary.status}.`);
appendEvent("info", "Status refreshed", `${summary.workerName}: ${summary.status}`);
} else if (previousStatus && previousStatus !== summary.status) {
setLaunchStatus(getWorkerStatusCopy(summary.status));
if (summary.status === "healthy") {
appendEvent("success", "Provisioning complete", `${summary.workerName} is ready`);
} else if (summary.status === "failed") {
appendEvent("error", "Provisioning failed", `${summary.workerName} failed to provision`);
} else {
appendEvent("info", "Provisioning update", `${summary.workerName}: ${summary.status}`);
}
}
if (!background) {
void refreshWorkers({ keepSelection: true });
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown network error";
setLaunchError(message);
appendEvent("error", "Status check failed", message);
if (!quiet) {
setLaunchError(message);
appendEvent("error", "Status check failed", message);
}
} finally {
setActionBusy(null);
if (!background) {
setActionBusy(null);
}
}
}
@@ -1077,6 +1163,8 @@ export function CloudControlPanel() {
title: "Launch",
detail: checkoutUrl
? "Complete checkout, return, and relaunch"
: worker?.status === "provisioning"
? "Provisioning in background. Auto-checking every 5 seconds."
: launchBusy
? launchStatus
: "Launch a cloud worker from this card"
@@ -1084,7 +1172,10 @@ export function CloudControlPanel() {
{
id: 3,
title: "Connect",
detail: worker ? "Copy OpenWork URL + access token into OpenWork" : "Credentials appear when launch succeeds"
detail:
worker?.status === "healthy"
? "Copy OpenWork URL + access token into OpenWork"
: "Credentials appear after provisioning is healthy"
}
],
[checkoutUrl, launchBusy, launchStatus, user, worker]
@@ -1196,8 +1287,17 @@ export function CloudControlPanel() {
/>
</label>
<button type="button" className="ow-btn-primary" onClick={handleLaunchWorker} disabled={!user || launchBusy}>
{launchBusy ? "Launching..." : `Launch "${workerName || "Cloud Worker"}"`}
<button
type="button"
className="ow-btn-primary"
onClick={handleLaunchWorker}
disabled={!user || launchBusy || worker?.status === "provisioning"}
>
{launchBusy
? "Requesting launch..."
: worker?.status === "provisioning"
? "Provisioning in progress..."
: `Launch "${workerName || "Cloud Worker"}"`}
</button>
<div className="ow-note-box">
@@ -1268,7 +1368,7 @@ export function CloudControlPanel() {
<button
type="button"
className="ow-btn-secondary"
onClick={handleCheckStatus}
onClick={() => void handleCheckStatus()}
disabled={actionBusy !== null || !selectedWorker}
>
{actionBusy === "status" ? "Checking..." : "Check status"}
@@ -1359,7 +1459,7 @@ export function CloudControlPanel() {
) : null}
<div className="ow-inline-actions">
<button type="button" className="ow-btn-secondary" onClick={handleCheckStatus} disabled={actionBusy !== null}>
<button type="button" className="ow-btn-secondary" onClick={() => void handleCheckStatus()} disabled={actionBusy !== null}>
{actionBusy === "status" ? "Checking..." : "Check status"}
</button>
<button type="button" className="ow-btn-secondary" onClick={handleGenerateKey} disabled={actionBusy !== null}>
@@ -1381,7 +1481,12 @@ export function CloudControlPanel() {
</div>
<div className="ow-note-box">
<p>Open OpenWork and paste OpenWork worker URL plus Access token into the remote connect flow.</p>
<p>In OpenWork desktop:</p>
<ol className="ow-connect-steps">
<li>Click <span className="ow-mono">+ Add a worker</span>.</li>
<li>Select <span className="ow-mono">Connect remote</span>.</li>
<li>Paste <span className="ow-mono">OpenWork worker URL</span> and <span className="ow-mono">Access token</span>, then add worker.</li>
</ol>
{!hasWorkspaceScopedUrl && openworkConnectUrl ? (
<p className="ow-caption">Tip: URL should include /w/ws_... . Click Check status to resolve the mounted workspace URL.</p>
) : null}

View File

@@ -62,6 +62,7 @@ pnpm db:migrate
- `GET /v1/me`
- `GET /v1/workers` (list recent workers for signed-in user/org)
- `POST /v1/workers`
- Cloud launches return `202` quickly with worker `status=provisioning` and continue provisioning asynchronously.
- 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.

View File

@@ -162,6 +162,39 @@ function toWorkerResponse(row: WorkerRow, userId: string) {
}
}
async function continueCloudProvisioning(input: { workerId: string; name: string; hostToken: string; clientToken: string }) {
try {
const provisioned = await provisionWorker({
workerId: input.workerId,
name: input.name,
hostToken: input.hostToken,
clientToken: input.clientToken,
})
await db
.update(WorkerTable)
.set({ status: provisioned.status })
.where(eq(WorkerTable.id, input.workerId))
await db.insert(WorkerInstanceTable).values({
id: randomUUID(),
worker_id: input.workerId,
provider: provisioned.provider,
region: provisioned.region,
url: provisioned.url,
status: provisioned.status,
})
} catch (error) {
await db
.update(WorkerTable)
.set({ status: "failed" })
.where(eq(WorkerTable.id, input.workerId))
const message = error instanceof Error ? error.message : "provisioning_failed"
console.error(`[workers] provisioning failed for ${input.workerId}: ${message}`)
}
}
export const workersRouter = express.Router()
workersRouter.get("/", async (req, res) => {
@@ -271,44 +304,16 @@ workersRouter.post("/", async (req, res) => {
},
])
let instance = null
if (parsed.data.destination === "cloud") {
try {
const provisioned = await provisionWorker({
workerId,
name: parsed.data.name,
hostToken,
clientToken,
})
workerStatus = provisioned.status
await db
.update(WorkerTable)
.set({ status: workerStatus })
.where(eq(WorkerTable.id, workerId))
await db.insert(WorkerInstanceTable).values({
id: randomUUID(),
worker_id: workerId,
provider: provisioned.provider,
region: provisioned.region,
url: provisioned.url,
status: provisioned.status,
})
instance = provisioned
} catch (error) {
await db
.update(WorkerTable)
.set({ status: "failed" })
.where(eq(WorkerTable.id, workerId))
const message = error instanceof Error ? error.message : "provisioning_failed"
res.status(502).json({ error: "provisioning_failed", message })
return
}
void continueCloudProvisioning({
workerId,
name: parsed.data.name,
hostToken,
clientToken,
})
}
res.status(201).json({
res.status(parsed.data.destination === "cloud" ? 202 : 201).json({
worker: toWorkerResponse(
{
id: workerId,
@@ -330,7 +335,8 @@ workersRouter.post("/", async (req, res) => {
host: hostToken,
client: clientToken,
},
instance,
instance: null,
launch: parsed.data.destination === "cloud" ? { mode: "async", pollAfterMs: 5000 } : { mode: "instant", pollAfterMs: 0 },
})
})