mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
fix(cloud): launch workers asynchronously and auto-poll provisioning
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user