From 425df32fc45bc3490f5cac7e02c6170307ecb1a5 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 4 Sep 2026 08:24:20 -0700 Subject: [PATCH] [eric] workflows: a cloud with no workflows API reads as unavailable with the honest reason, not as the cloud declining the request Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01C9zwUaHucUgrdxvK8FvjYT --- backend/apps/workflows/cloud/status.py | 11 +++++++- .../tests/test_cloud_status_unavailable.py | 28 +++++++++++++++++++ .../src/app/pages/Workflows/app/cloudApi.ts | 8 +++++- .../Workflows/app/cloudAvailability.test.ts | 16 +++++++++++ .../pages/Workflows/app/cloudAvailability.ts | 1 + 5 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_cloud_status_unavailable.py create mode 100644 frontend/src/app/pages/Workflows/app/cloudAvailability.test.ts diff --git a/backend/apps/workflows/cloud/status.py b/backend/apps/workflows/cloud/status.py index 3ff4dcfc..7b2b5e7c 100644 --- a/backend/apps/workflows/cloud/status.py +++ b/backend/apps/workflows/cloud/status.py @@ -49,6 +49,13 @@ class CloudStatusUnknown(CloudStatusBase): detail: str +# The cloud we are signed into has no workflows API at all (the preflight route 404s): this app is ahead +# of that control plane. Nothing was sent, and the toggle should say so instead of "declined". +class CloudStatusUnavailable(CloudStatusBase): + state: Literal["unavailable"] = "unavailable" + reason: str + + class CloudStatusReady(CloudStatusBase): state: Literal["ready"] = "ready" plan: Optional[str] = None @@ -62,7 +69,7 @@ class CloudStatusReady(CloudStatusBase): credential: CredentialReadiness -CloudStatus = Union[CloudStatusReady, CloudStatusSignedOut, CloudStatusUnknown] +CloudStatus = Union[CloudStatusReady, CloudStatusSignedOut, CloudStatusUnknown, CloudStatusUnavailable] @typechecked @@ -124,6 +131,8 @@ async def compute_status(wf: Workflow) -> CloudStatus: except cloud.CloudUnreachable as exc: return CloudStatusUnknown(detail=exc.detail, **shared) except cloud.CloudRefused as exc: + if exc.status == 404: + return CloudStatusUnavailable(reason="Cloud runs are not available on your OpenSwarm Cloud yet; this version of the app is ahead of it. Nothing was sent.", **shared) return CloudStatusUnknown(detail=exc.message, **shared) p_mirror_cloud_state(wf, pre.hosted) diff --git a/backend/tests/test_cloud_status_unavailable.py b/backend/tests/test_cloud_status_unavailable.py new file mode 100644 index 00000000..34ec0e01 --- /dev/null +++ b/backend/tests/test_cloud_status_unavailable.py @@ -0,0 +1,28 @@ +"""A 404 on the cloud's preflight route means the signed-in cloud has no workflows API: this app is +ahead of it. That is a blocked state with the honest words, not "The cloud declined this request".""" + +import asyncio + +from backend.apps.workflows.cloud import client as cloud +from backend.apps.workflows.cloud import status as status_mod +from backend.apps.workflows.models import Workflow + + +def p_wf() -> Workflow: + return Workflow(name="probe", steps=[]) + + +def test_a_404_preflight_is_unavailable_and_says_nothing_was_sent(monkeypatch): + async def refuse(definition, hosted_id=None): + raise cloud.CloudRefused("The cloud declined this request.", 404) + monkeypatch.setattr(cloud, "preflight", refuse) + st = asyncio.run(status_mod.compute_status(p_wf())) + assert st.state == "unavailable" and "not available on your OpenSwarm Cloud yet" in st.reason and "Nothing was sent" in st.reason + + +def test_any_other_refusal_stays_unknown_with_the_clouds_words(monkeypatch): + async def refuse(definition, hosted_id=None): + raise cloud.CloudRefused("Your plan does not include cloud runs.", 403) + monkeypatch.setattr(cloud, "preflight", refuse) + st = asyncio.run(status_mod.compute_status(p_wf())) + assert st.state == "unknown" and st.detail == "Your plan does not include cloud runs." diff --git a/frontend/src/app/pages/Workflows/app/cloudApi.ts b/frontend/src/app/pages/Workflows/app/cloudApi.ts index ec2d3e9f..77b213dc 100644 --- a/frontend/src/app/pages/Workflows/app/cloudApi.ts +++ b/frontend/src/app/pages/Workflows/app/cloudApi.ts @@ -62,7 +62,13 @@ export interface CloudStatusUnknown extends CloudStatusShared { detail: string; } -export type CloudStatus = CloudStatusReady | CloudStatusSignedOut | CloudStatusUnknown; +// The signed-in cloud has no workflows API (its preflight route 404s): this app is ahead of it. +export interface CloudStatusUnavailable extends CloudStatusShared { + state: 'unavailable'; + reason: string; +} + +export type CloudStatus = CloudStatusReady | CloudStatusSignedOut | CloudStatusUnknown | CloudStatusUnavailable; export interface CloudRun { id: string; diff --git a/frontend/src/app/pages/Workflows/app/cloudAvailability.test.ts b/frontend/src/app/pages/Workflows/app/cloudAvailability.test.ts new file mode 100644 index 00000000..62a59128 --- /dev/null +++ b/frontend/src/app/pages/Workflows/app/cloudAvailability.test.ts @@ -0,0 +1,16 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { cloudAvailability } from './cloudAvailability'; +import type { CloudProbe } from './cloudApi'; + +// A cloud with no workflows API used to read as "The cloud declined this request" under an unknown +// state (Haik, 2026-09-03: "requests routed to Cloud don't go through"). It is a blocked state with a +// reason that says the app is ahead of the cloud and nothing was sent. + +test('a cloud without the workflows API blocks the toggle with the honest reason', () => { + const probe = { phase: 'answered', status: { state: 'unavailable', reason: 'Cloud runs are not available on your OpenSwarm Cloud yet; this version of the app is ahead of it. Nothing was sent.', target: 'device', schedule_supported: true, schedule_reason: null } } as unknown as CloudProbe; + const a = cloudAvailability(probe); + assert.equal(a.kind, 'blocked'); + assert.match((a as { reason: string }).reason, /not available on your OpenSwarm Cloud yet/); + assert.equal((a as { action: string | null }).action, null); +}); diff --git a/frontend/src/app/pages/Workflows/app/cloudAvailability.ts b/frontend/src/app/pages/Workflows/app/cloudAvailability.ts index b65995ef..c5f61963 100644 --- a/frontend/src/app/pages/Workflows/app/cloudAvailability.ts +++ b/frontend/src/app/pages/Workflows/app/cloudAvailability.ts @@ -43,6 +43,7 @@ export function cloudAvailability(probe: CloudProbe): CloudAvailability { return { kind: 'blocked', reason: status.schedule_reason, action: null }; } if (status.state === 'unknown') return { kind: 'unknown', detail: status.detail }; + if (status.state === 'unavailable') return { kind: 'blocked', reason: status.reason, action: null }; if (status.state === 'signed_out') { return { kind: 'blocked',