From 843e9e662a818ef3248c7f6ac51c19d35d55073c Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 2 Aug 2026 16:04:50 -0700 Subject: [PATCH] [eric] cloud runs: lend the account before the workflow goes up, so a cloud run can actually sign its calls --- .../workflows/cloud/credential_readiness.py | 43 +++++ backend/apps/workflows/cloud/handover.py | 63 ++++++ backend/apps/workflows/cloud/status.py | 5 + backend/config/entity_references.py | 4 + backend/tests/test_cloud_credential_wiring.py | 180 ++++++++++++++++++ backend/tests/test_cloud_workflow_target.py | 18 ++ .../pages/Workflows/app/CloudRunSection.tsx | 6 + .../src/app/pages/Workflows/app/cloudApi.ts | 8 + .../pages/Workflows/app/cloudAvailability.ts | 8 +- 9 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 backend/apps/workflows/cloud/credential_readiness.py create mode 100644 backend/tests/test_cloud_credential_wiring.py diff --git a/backend/apps/workflows/cloud/credential_readiness.py b/backend/apps/workflows/cloud/credential_readiness.py new file mode 100644 index 00000000..13953efb --- /dev/null +++ b/backend/apps/workflows/cloud/credential_readiness.py @@ -0,0 +1,43 @@ +"""Whether this account has an AI connection the cloud could actually run with. + +A cloud run signs its LLM calls with the user's OWN subscription, handed up by +`credential_lease`. Only a rotating OAuth connection can be handed up: an API key has no +refresh token, and the runner cannot mint one. So an account whose only provider is a +Gemini or OpenAI key can never run in the cloud, and the honest moment to say so is +before the user schedules anything, not at 9am when the run refuses. +""" +from __future__ import annotations + +from typing import List, Literal, Optional + +from pydantic import BaseModel, ConfigDict +from typeguard import typechecked + +from backend.apps.nine_router import credential_store + +CONNECT_HINT = ( + "Cloud runs sign in with your own Claude or ChatGPT subscription, so connect one in " + "Settings to run a workflow in the cloud. An API key alone can't be used up there." +) + + +class CredentialReadiness(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + # ready: something is leasable or already lent. none_eligible: only API keys, or nothing at all. + state: Literal["ready", "none_eligible"] + connection_ids: List[str] = [] + # Written for the user, present only when they cannot proceed. + reason: Optional[str] = None + + @property + def ok(self) -> bool: + return self.state == "ready" + + +@typechecked +def cloud_credential_readiness() -> CredentialReadiness: + ids = credential_store.list_oauth_connection_ids() + if not ids: + return CredentialReadiness(state="none_eligible", reason=CONNECT_HINT) + return CredentialReadiness(state="ready", connection_ids=ids) diff --git a/backend/apps/workflows/cloud/handover.py b/backend/apps/workflows/cloud/handover.py index 0d92caae..7027f0c7 100644 --- a/backend/apps/workflows/cloud/handover.py +++ b/backend/apps/workflows/cloud/handover.py @@ -15,8 +15,10 @@ from typing import Optional from pydantic import BaseModel, ConfigDict from typeguard import typechecked +from backend.apps.nine_router import credential_lease, credential_store from backend.apps.workflows import scheduler, storage from backend.apps.workflows.cloud import client as cloud +from backend.apps.workflows.cloud.credential_readiness import cloud_credential_readiness from backend.apps.workflows.cloud.definition import cloud_definition, definition_signature from backend.apps.workflows.cloud.portable_context import portable_context from backend.apps.workflows.cloud.schedule import ScheduleSupported, to_cloud_schedule, wire @@ -44,6 +46,42 @@ class TargetOutcome(BaseModel): message: Optional[str] = None +LEASE_FAILED = ( + "Couldn't lend your AI account to the cloud, so nothing was scheduled there. " + "This workflow still runs on this device. Try again in a moment." +) +LEASE_STRANDED = ( + "We couldn't confirm whether your AI account reached the cloud, so nothing was scheduled " + "there. Open Settings and reconnect the provider before trying again." +) + + +@typechecked +async def lend_credential_for_cloud() -> TargetOutcome: + """Make sure the cloud holds a credential it can sign this user's runs with. + + Already-lent is the common case (one lease covers every cloud workflow), so this is a + no-op after the first one. + """ + readiness = cloud_credential_readiness() + if not readiness.ok: + return TargetOutcome(ok=False, message=readiness.reason) + + for connection_id in readiness.connection_ids: + outcome = await credential_lease.lease_to_cloud(connection_id) + if outcome.status in ("leased", "not_rotatable"): + # not_rotatable here means the local copy has already been stripped, i.e. the cloud has it. + return TargetOutcome(ok=True) + if outcome.status == "not_signed_in": + return TargetOutcome(ok=False, message=SIGN_IN_MESSAGE) + if outcome.status == "ownership_unknown": + logger.error("credential lease outcome unknown: %s", outcome.detail) + return TargetOutcome(ok=False, message=LEASE_STRANDED) + logger.info("credential lease for %s failed: %s %s", connection_id, outcome.status, outcome.detail) + + return TargetOutcome(ok=False, message=LEASE_FAILED) + + @typechecked async def hand_to_cloud(wf: Workflow, enabled: bool) -> TargetOutcome: mapping = to_cloud_schedule(wf.schedule) @@ -51,6 +89,11 @@ async def hand_to_cloud(wf: Workflow, enabled: bool) -> TargetOutcome: return TargetOutcome(ok=False, message=mapping.reason) if enabled and not scheduler.is_schedule_configured(wf.schedule): return TargetOutcome(ok=False, message="Finish setting up the schedule before choosing where it runs.") + # Lend the credential BEFORE the workflow goes up. The other order parks a workflow in the cloud + # that cannot sign a single call, and the user only finds out when 9am comes and goes. + lent = await lend_credential_for_cloud() + if not lent.ok: + return TargetOutcome(ok=False, message=lent.message) definition = cloud_definition(wf) context = portable_context().as_body() try: @@ -104,10 +147,30 @@ async def take_back(wf: Workflow, enabled: bool) -> TargetOutcome: wf.next_run_at = scheduler.compute_next_fire(wf) if wf.schedule.enabled else None wf.updated_at = datetime.now() storage.save_workflow(wf) + await p_reclaim_credential_if_last(wf.id) scheduler.kick() return TargetOutcome(ok=True) +@typechecked +async def p_reclaim_credential_if_last(leaving_id: str) -> None: + """Bring custody home once nothing is left in the cloud that needs it. + + Reclaiming while another cloud workflow is still scheduled would break that one, so the + last one out turns off the lights. Best-effort: a failure here leaves the credential + lent, which still works, rather than failing a toggle the user already got. + """ + if any( + w.id != leaving_id and w.execution_target == "cloud" + for w in storage.list_workflows() + ): + return + for connection_id in credential_store.list_oauth_connection_ids(): + outcome = await credential_lease.release_to_device(connection_id) + if outcome.status not in ("released", "no_such_connection"): + logger.info("could not reclaim %s: %s %s", connection_id, outcome.status, outcome.detail) + + @typechecked async def release_before_removing(wf: Workflow) -> TargetOutcome: """Take the cloud copy down before a workflow disappears from this machine. diff --git a/backend/apps/workflows/cloud/status.py b/backend/apps/workflows/cloud/status.py index 48f6ce67..3ff4dcfc 100644 --- a/backend/apps/workflows/cloud/status.py +++ b/backend/apps/workflows/cloud/status.py @@ -15,6 +15,7 @@ from typeguard import typechecked from backend.apps.workflows import storage from backend.apps.workflows.cloud import client as cloud +from backend.apps.workflows.cloud.credential_readiness import CredentialReadiness, cloud_credential_readiness from backend.apps.workflows.cloud.definition import cloud_definition, definition_signature from backend.apps.workflows.cloud.portable_context import portable_context from backend.apps.workflows.cloud.schedule import ScheduleSupported, to_cloud_schedule, wire @@ -56,6 +57,9 @@ class CloudStatusReady(CloudStatusBase): # None when this control plane cannot tell us whether the runner could do the job. capability: Optional[cloud.CloudCapability] = None hosted: Optional[HostedState] = None + # Whether this account owns an AI connection the cloud could sign runs with. Read locally, + # because it is our 9router db that knows, not the control plane. + credential: CredentialReadiness CloudStatus = Union[CloudStatusReady, CloudStatusSignedOut, CloudStatusUnknown] @@ -138,5 +142,6 @@ async def compute_status(wf: Workflow) -> CloudStatus: usage=pre.usage, capability=pre.capability, hosted=hosted, + credential=cloud_credential_readiness(), **shared, ) diff --git a/backend/config/entity_references.py b/backend/config/entity_references.py index cc7c2400..b1fe0833 100644 --- a/backend/config/entity_references.py +++ b/backend/config/entity_references.py @@ -30,6 +30,8 @@ class EntityKind(str, Enum): OUTPUT = "output" WORKSPACE = "workspace" CLOUD_WORKFLOW = "cloud_workflow" + # A provider login in 9router's own db, not one of our JSON records. + PROVIDER_CONNECTION = "provider_connection" class EntityStore(BaseModel): @@ -64,6 +66,7 @@ ENTITY_STORES: List[EntityStore] = [ EntityStore(kind=EntityKind.WORKSPACE, module="backend.apps.outputs.outputs", lookup="read_workspace"), # The one referent that does not live on this machine. preflight asks the cloud whether it still has the row; a miss renders as "nothing is running this", never as a silent blank. EntityStore(kind=EntityKind.CLOUD_WORKFLOW, module="backend.apps.workflows.cloud.client", lookup="preflight"), + EntityStore(kind=EntityKind.PROVIDER_CONNECTION, module="backend.apps.nine_router.credential_store", lookup="read_credential"), ] CROSS_ENTITY_REFERENCES: List[EntityReference] = [ @@ -100,6 +103,7 @@ CROSS_ENTITY_REFERENCES: List[EntityReference] = [ EntityReference(module="backend.apps.workflows.models", model="AskRunBody", field="run_id", target=EntityKind.WORKFLOW_RUN), EntityReference(module="backend.apps.workflows.models", model="MissedRun", field="workflow_id", target=EntityKind.WORKFLOW), EntityReference(module="backend.apps.workflows.models", model="Workflow", field="cloud_workflow_id", target=EntityKind.CLOUD_WORKFLOW), + EntityReference(module="backend.apps.workflows.cloud.credential_readiness", model="CredentialReadiness", field="connection_ids", target=EntityKind.PROVIDER_CONNECTION), EntityReference(module="backend.apps.workflows.models", model="Workflow", field="dashboard_id", target=EntityKind.DASHBOARD), EntityReference(module="backend.apps.workflows.models", model="Workflow", field="edit_agent_session_id", target=EntityKind.SESSION), EntityReference(module="backend.apps.workflows.models", model="Workflow", field="last_run_id", target=EntityKind.WORKFLOW_RUN), diff --git a/backend/tests/test_cloud_credential_wiring.py b/backend/tests/test_cloud_credential_wiring.py new file mode 100644 index 00000000..f608c319 --- /dev/null +++ b/backend/tests/test_cloud_credential_wiring.py @@ -0,0 +1,180 @@ +"""A cloud workflow may never exist without a credential the cloud can sign it with. + +Before this wiring, `lease_to_cloud` had zero callers outside its own unit tests. Every part +worked and nothing joined them, so every cloud run in existence died at dispatch with +`no_cloud_credential` and the user found out at 9am. These tests pin the join. +""" +import pytest + +from backend.apps.nine_router.credential_lease import LeaseOutcome +from backend.apps.workflows.cloud import credential_readiness, handover + + +@pytest.fixture +def p_oauth(monkeypatch): + def set_ids(ids): + monkeypatch.setattr( + credential_readiness.credential_store, "list_oauth_connection_ids", lambda: list(ids) + ) + monkeypatch.setattr( + handover.credential_store, "list_oauth_connection_ids", lambda: list(ids) + ) + return set_ids + + +@pytest.fixture +def p_lease(monkeypatch): + calls = [] + + def set_result(*statuses): + seq = list(statuses) + + async def fake(connection_id: str) -> LeaseOutcome: + calls.append(connection_id) + return LeaseOutcome(status=seq.pop(0) if seq else "cloud_rejected") + + monkeypatch.setattr(handover.credential_lease, "lease_to_cloud", fake) + return calls + + return set_result + + +def test_an_account_with_only_api_keys_cannot_use_cloud_runs(p_oauth): + # Gemini AI Studio and a raw OpenAI key are apikey rows: no refresh token, nothing to lend. + p_oauth([]) + r = credential_readiness.cloud_credential_readiness() + assert r.ok is False + assert r.state == "none_eligible" + assert "Claude or ChatGPT" in (r.reason or ""), "must name what to connect, not just refuse" + assert "API key" in (r.reason or ""), "the API-key user needs to know why theirs will not do" + + +def test_an_oauth_connection_reads_as_ready(p_oauth): + p_oauth(["conn-claude"]) + r = credential_readiness.cloud_credential_readiness() + assert r.ok is True + assert r.connection_ids == ["conn-claude"] + assert r.reason is None + + +@pytest.mark.asyncio +async def test_lending_succeeds_on_the_first_usable_connection(p_oauth, p_lease): + p_oauth(["conn-a", "conn-b"]) + calls = p_lease("leased") + out = await handover.lend_credential_for_cloud() + assert out.ok is True + assert calls == ["conn-a"], "one lease covers every cloud workflow; do not lend them all" + + +@pytest.mark.asyncio +async def test_an_already_stripped_connection_counts_as_lent(p_oauth, p_lease): + # not_rotatable means the local refresh token is already gone, i.e. the cloud has it. + p_oauth(["conn-a"]) + p_lease("not_rotatable") + assert (await handover.lend_credential_for_cloud()).ok is True + + +@pytest.mark.asyncio +async def test_a_refused_connection_falls_through_to_the_next(p_oauth, p_lease): + p_oauth(["conn-dead", "conn-good"]) + calls = p_lease("cloud_rejected", "leased") + out = await handover.lend_credential_for_cloud() + assert out.ok is True + assert calls == ["conn-dead", "conn-good"] + + +@pytest.mark.asyncio +async def test_every_connection_failing_refuses_with_words_a_user_can_act_on(p_oauth, p_lease): + p_oauth(["conn-a"]) + p_lease("cloud_rejected") + out = await handover.lend_credential_for_cloud() + assert out.ok is False + assert "still runs on this device" in (out.message or ""), "say what DID happen, not just what failed" + + +@pytest.mark.asyncio +async def test_signed_out_says_sign_in_rather_than_a_lease_error(p_oauth, p_lease): + p_oauth(["conn-a"]) + p_lease("not_signed_in") + out = await handover.lend_credential_for_cloud() + assert out.ok is False + assert out.message == handover.SIGN_IN_MESSAGE + + +@pytest.mark.asyncio +async def test_an_unknown_lease_outcome_tells_the_user_to_reconnect(p_oauth, p_lease): + # The token is off this device and we cannot prove the cloud took it. Silence here strands them. + p_oauth(["conn-a"]) + p_lease("ownership_unknown") + out = await handover.lend_credential_for_cloud() + assert out.ok is False + assert "reconnect" in (out.message or "").lower() + + +@pytest.mark.asyncio +async def test_no_eligible_connection_refuses_before_anything_is_lent(p_oauth, p_lease): + p_oauth([]) + calls = p_lease("leased") + out = await handover.lend_credential_for_cloud() + assert out.ok is False + assert calls == [], "nothing to lend, so nothing should have been attempted" + assert "Claude or ChatGPT" in (out.message or "") + + +# The join itself. Everything above passes even if hand_to_cloud never calls any of it, which is +# exactly the shape of the bug being fixed: the parts all worked and nothing wired them together. + +from backend.apps.workflows import storage +from backend.apps.workflows.cloud import client as cloud +from backend.apps.workflows.models import ScheduleConfig, Workflow, WorkflowStep + +pytestmark = pytest.mark.usefixtures("isolated_workflows_data") + + +def p_wf() -> Workflow: + wf = Workflow( + title="Morning digest", + steps=[WorkflowStep(text="summarize the news")], + schedule=ScheduleConfig( + enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0, timezone="UTC" + ), + ) + storage.save_workflow(wf) + return wf + + +@pytest.mark.asyncio +async def test_a_workflow_never_reaches_the_cloud_without_a_credential(monkeypatch, p_oauth): + """The 9am bug, pinned: no lendable account means the push must not happen at all.""" + p_oauth([]) + wf = p_wf() + talked: list = [] + + async def p_call(method, path, body=None): + talked.append(path) + raise AssertionError("pushed a workflow the cloud could never run") + + monkeypatch.setattr(cloud, "p_call", p_call) + + out = await handover.hand_to_cloud(wf, enabled=True) + assert out.ok is False + assert talked == [], "the credential check has to come BEFORE the push" + assert wf.execution_target == "device", "a refused handover leaves it running here" + assert "Claude or ChatGPT" in (out.message or "") + + +@pytest.mark.asyncio +async def test_a_failed_lease_leaves_the_workflow_on_this_device(monkeypatch, p_oauth, p_lease): + p_oauth(["conn-a"]) + p_lease("cloud_rejected") + wf = p_wf() + + async def p_call(method, path, body=None): + raise AssertionError("pushed despite the lease failing") + + monkeypatch.setattr(cloud, "p_call", p_call) + + out = await handover.hand_to_cloud(wf, enabled=True) + assert out.ok is False + assert wf.execution_target == "device" + assert storage.get_workflow(wf.id).execution_target == "device", "and it stayed that way on disk" diff --git a/backend/tests/test_cloud_workflow_target.py b/backend/tests/test_cloud_workflow_target.py index 9d56949f..d38849ff 100644 --- a/backend/tests/test_cloud_workflow_target.py +++ b/backend/tests/test_cloud_workflow_target.py @@ -17,6 +17,24 @@ from backend.apps.workflows.models import ScheduleConfig, Workflow, WorkflowStep pytestmark = pytest.mark.usefixtures("isolated_workflows_data") +@pytest.fixture(autouse=True) +def p_credential_already_lent(monkeypatch): + """These tests are about the timer, not credential custody. Without this they would reach the + real lease, which reads settings this harness never signs in, and every handover would refuse + with a sign-in message instead of the answer under test. Custody has its own file: + test_cloud_credential_wiring.py.""" + from backend.apps.workflows.cloud import handover + + async def lent(): + return handover.TargetOutcome(ok=True) + + async def reclaimed(leaving_id: str) -> None: + return None + + monkeypatch.setattr(handover, "lend_credential_for_cloud", lent) + monkeypatch.setattr(handover, "p_reclaim_credential_if_last", reclaimed) + + def p_sched(**overrides) -> ScheduleConfig: base = dict(enabled=True, repeat_unit="day", repeat_every=1, hour=9, minute=0, timezone="UTC") base.update(overrides) diff --git a/frontend/src/app/pages/Workflows/app/CloudRunSection.tsx b/frontend/src/app/pages/Workflows/app/CloudRunSection.tsx index 13736877..5175cfed 100644 --- a/frontend/src/app/pages/Workflows/app/CloudRunSection.tsx +++ b/frontend/src/app/pages/Workflows/app/CloudRunSection.tsx @@ -108,6 +108,9 @@ const CloudRunSection: React.FC<{ workflow: Workflow; cloud: CloudStatusHandle } {availability.kind === 'blocked' && availability.action === 'plans' && ( )} + {availability.kind === 'blocked' && availability.action === 'connect' && ( + + )} @@ -139,6 +142,9 @@ const CloudRunSection: React.FC<{ workflow: Workflow; cloud: CloudStatusHandle } {availability.action === 'plans' && ( )} + {availability.action === 'connect' && ( + + )} )} diff --git a/frontend/src/app/pages/Workflows/app/cloudApi.ts b/frontend/src/app/pages/Workflows/app/cloudApi.ts index 8589f904..ec2d3e9f 100644 --- a/frontend/src/app/pages/Workflows/app/cloudApi.ts +++ b/frontend/src/app/pages/Workflows/app/cloudApi.ts @@ -35,6 +35,13 @@ interface CloudStatusShared { schedule_reason: string | null; } +/** Whether an AI account exists that the cloud could sign runs with. An API key alone cannot. */ +export interface CloudCredential { + state: 'ready' | 'none_eligible'; + connection_ids: string[]; + reason: string | null; +} + export interface CloudStatusReady extends CloudStatusShared { state: 'ready'; plan: string | null; @@ -43,6 +50,7 @@ export interface CloudStatusReady extends CloudStatusShared { /** Null when the control plane could not tell us; create re-checks either way. */ capability: CloudCapability | null; hosted: HostedState | null; + credential: CloudCredential; } export interface CloudStatusSignedOut extends CloudStatusShared { diff --git a/frontend/src/app/pages/Workflows/app/cloudAvailability.ts b/frontend/src/app/pages/Workflows/app/cloudAvailability.ts index ec08107f..b65995ef 100644 --- a/frontend/src/app/pages/Workflows/app/cloudAvailability.ts +++ b/frontend/src/app/pages/Workflows/app/cloudAvailability.ts @@ -11,7 +11,7 @@ export type CloudProbe = export type CloudAvailability = | { kind: 'checking' } | { kind: 'unknown'; detail: string | null } - | { kind: 'blocked'; reason: string; action: 'sign_in' | 'plans' | null } + | { kind: 'blocked'; reason: string; action: 'sign_in' | 'plans' | 'connect' | null } | { kind: 'available' }; const PLAN_REQUIRED = 'Cloud runs come with Pro and up. On this plan, workflows run on this device.'; @@ -53,6 +53,12 @@ export function cloudAvailability(probe: CloudProbe): CloudAvailability { if (status.capability && !status.capability.ok && status.capability.reason) { return { kind: 'blocked', reason: status.capability.reason, action: null }; } + // Before the plan, deliberately. Someone whose only provider is an API key cannot run in the + // cloud at any price, so leading them to the pricing page would sell them a thing that still + // would not work. + if (status.credential && status.credential.state !== 'ready' && status.credential.reason) { + return { kind: 'blocked', reason: status.credential.reason, action: 'connect' }; + } return blockedForAccount(status) ?? { kind: 'available' }; }