diff --git a/frontend/src/app/pages/Workflows/app/CloudRunSection.tsx b/frontend/src/app/pages/Workflows/app/CloudRunSection.tsx new file mode 100644 index 00000000..114c5aa0 --- /dev/null +++ b/frontend/src/app/pages/Workflows/app/CloudRunSection.tsx @@ -0,0 +1,160 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; +import { useAppDispatch } from '@/shared/hooks'; +import { openSettingsCard } from '@/shared/state/dashboardLayoutSlice'; +import type { Workflow } from '@/shared/state/workflowsSlice'; +import { useWC } from './uiKit'; +import type { WCPalette } from './uiKit'; +import { clockOf, relativeDayLabel } from './model'; +import { cloudAvailability, usageText } from './cloudAvailability'; +import type { CloudProbe } from './cloudAvailability'; +import type { HostedState } from './cloudApi'; +import type { CloudStatusHandle } from './useCloudStatus'; + +const CLOUD_PATH = 'M17.5 19a4.5 4.5 0 0 0 .5-8.97A6 6 0 0 0 6.2 10.5 4 4 0 0 0 6.5 19z'; + +function hostedOf(probe: CloudProbe) { + if (probe.phase !== 'answered' || probe.status.state !== 'ready') return null; + return probe.status.hosted; +} + +// The cloud's own clock, printed from the answer we just fetched; our mirrored copy only moves on a probe. +function nextCloudRunText(hosted: HostedState): string { + if (!hosted.enabled) return 'Paused in the cloud'; + if (!hosted.next_run_at) return 'No cloud run scheduled'; + const at = new Date(hosted.next_run_at); + return `Next cloud run ${relativeDayLabel(at)} at ${clockOf(at)}`; +} + +const Bullet: React.FC<{ wc: WCPalette; accent?: boolean; children: React.ReactNode }> = ({ wc, accent, children }) => ( +
+
+
+
+ {children} +
+); + +const Note: React.FC<{ wc: WCPalette; tone: 'quiet' | 'warn'; children: React.ReactNode }> = ({ wc, tone, children }) => ( +
+ {children} +
+); + +const CloudRunSection: React.FC<{ workflow: Workflow; cloud: CloudStatusHandle }> = ({ workflow, cloud }) => { + const WC = useWC(); + const dispatch = useAppDispatch(); + const availability = cloudAvailability(cloud.probe); + const hosted = hostedOf(cloud.probe); + const target = cloud.probe.phase === 'answered' ? cloud.probe.status.target : workflow.execution_target ?? 'device'; + const onCloud = target === 'cloud'; + const usage = usageText(cloud.probe, availability); + const canPickCloud = availability.kind === 'available' && !cloud.pending; + + const seg = (active: boolean, enabled: boolean): CSSProperties => ({ + flex: 1, padding: '6px 2px', borderRadius: 7, border: 'none', fontSize: 11.5, fontWeight: 600, + cursor: enabled ? 'pointer' : 'default', + background: active ? WC.paper : 'transparent', + color: active ? WC.ink : enabled ? WC.muted : WC.faint, + boxShadow: active ? WC.shadow.sm : 'none', + }); + + const link: CSSProperties = { + background: 'none', border: 'none', padding: 0, marginLeft: 4, cursor: 'pointer', + color: WC.accent, fontSize: 11.5, fontWeight: 600, textDecoration: 'underline', + }; + + return ( +
+
+ + + Runs on + +
+ + +
+
+ + {cloud.pending && Talking to the cloud…} + + {!cloud.pending && cloud.refusal && {cloud.refusal}} + + {!cloud.pending && !cloud.refusal && availability.kind === 'checking' && ( + Checking what your account allows… + )} + + {!cloud.pending && !cloud.refusal && availability.kind === 'unknown' && ( + + + Can't reach the cloud, so we can't tell whether this can run there. + {onCloud + ? ' It stays scheduled in the cloud; nothing changed.' + : ' This workflow still runs on this device.'} + + + + )} + + {!cloud.pending && !cloud.refusal && availability.kind === 'blocked' && ( + + + {availability.reason} + {availability.action === 'sign_in' && ( + + )} + {availability.action === 'plans' && ( + + )} + + + )} + + {!cloud.pending && !cloud.refusal && availability.kind === 'available' && !onCloud && ( + Cloud runs fire on our servers, so they still happen with this app closed. + )} + + {!cloud.pending && onCloud && hosted === null && cloud.probe.phase === 'answered' && cloud.probe.status.state === 'ready' && ( + + + This is set to run in the cloud, but the cloud has no copy of it, so nothing is running it. + + + + )} + + {!cloud.pending && onCloud && hosted && !hosted.in_sync && ( + + + The cloud is still running the version you sent it. Your later edits are not up there yet. + + + + )} + + {onCloud && hosted && {nextCloudRunText(hosted)}} + {usage && {usage}} +
+ ); +}; + +export default CloudRunSection; diff --git a/frontend/src/app/pages/Workflows/app/ScheduleCard.tsx b/frontend/src/app/pages/Workflows/app/ScheduleCard.tsx index a9ab59a5..94c20975 100644 --- a/frontend/src/app/pages/Workflows/app/ScheduleCard.tsx +++ b/frontend/src/app/pages/Workflows/app/ScheduleCard.tsx @@ -7,6 +7,8 @@ import { freqOf, patchForFreq, intervalMinutes, timeInputValue, parseTimeInput, ordinal, nextRunText, type Freq, } from './model'; import { useWorkflowPatch } from './useWorkflowPatch'; +import { useCloudStatus } from './useCloudStatus'; +import CloudRunSection from './CloudRunSection'; import RepeatField from './RepeatField'; const FREQS: Array<[Freq, string]> = [['daily', 'Daily'], ['weekly', 'Weekly'], ['monthly', 'Monthly'], ['interval', 'Interval']]; @@ -15,9 +17,11 @@ const DAY_LABELS: Array<[string, number]> = [['S', 0], ['M', 1], ['T', 2], ['W', const ScheduleCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { const WC = useWC(); const patch = useWorkflowPatch(); + const cloud = useCloudStatus(workflow); const sched = workflow.schedule; const freq = freqOf(sched); const enabled = sched.enabled; + const onCloud = workflow.execution_target === 'cloud'; const patchSched = (p: Partial) => patch(workflow, { schedule: { ...sched, ...p } }); @@ -32,6 +36,11 @@ const ScheduleCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { // Turning a weekly schedule on with no days picked is "unconfigured", so the backend silently forces it back off and the switch looks dead. Seed today's weekday so the default Weekly 9am toggles on (and stays on) in one click. const toggleEnabled = () => { + // While the cloud holds the timer, this switch is the CLOUD's switch: flipping only our copy would pause nothing. + if (onCloud) { + cloud.choose('cloud', !enabled); + return; + } if (!enabled && sched.repeat_unit === 'week' && sched.on_days.length === 0) { patchSched({ enabled: true, on_days: [new Date().getDay()] }); } else { @@ -197,16 +206,21 @@ const ScheduleCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { {describeSchedule(sched)}
-
-
- Next run {nextRunText(workflow, workflow.next_run_at ? new Date(workflow.next_run_at) : null)} -
+ {/* On cloud our copy of next_run_at is a mirror that only refreshes on a probe, so the cloud section prints the time it just fetched instead. */} + {!onCloud && ( +
+
+ Next run {nextRunText(workflow, workflow.next_run_at ? new Date(workflow.next_run_at) : null)} +
+ )} {maxRuns != null && (
{sched.runs_count} of {maxRuns} run{maxRuns === 1 ? '' : 's'} done
)} + +
); }; diff --git a/frontend/src/app/pages/Workflows/app/cloudApi.ts b/frontend/src/app/pages/Workflows/app/cloudApi.ts new file mode 100644 index 00000000..8589f904 --- /dev/null +++ b/frontend/src/app/pages/Workflows/app/cloudApi.ts @@ -0,0 +1,123 @@ +import { API_BASE, getAuthToken } from '@/shared/config'; + +// Mirrors backend/apps/workflows/cloud/status.py. `unknown` is a first-class answer, not a +// degraded `ready`: it carries no plan, no limits and no counts, so a failed fetch cannot be +// rendered as "you are not entitled" or "0 runs left". +export type CloudTarget = 'device' | 'cloud'; + +export interface CloudLimits { + workflows: number; + runs_per_month: number; + concurrent: number; +} + +export interface CloudUsage { + workflows_enabled: number; + runs_this_month: number; +} + +export interface CloudCapability { + ok: boolean; + reason: string | null; +} + +export interface HostedState { + id: string; + enabled: boolean; + next_run_at: string | null; + /** False when the workflow was edited after we pushed it, so the cloud holds older prose. */ + in_sync: boolean; +} + +interface CloudStatusShared { + target: CloudTarget; + schedule_supported: boolean; + schedule_reason: string | null; +} + +export interface CloudStatusReady extends CloudStatusShared { + state: 'ready'; + plan: string | null; + limits: CloudLimits; + usage: CloudUsage; + /** Null when the control plane could not tell us; create re-checks either way. */ + capability: CloudCapability | null; + hosted: HostedState | null; +} + +export interface CloudStatusSignedOut extends CloudStatusShared { + state: 'signed_out'; +} + +export interface CloudStatusUnknown extends CloudStatusShared { + state: 'unknown'; + detail: string; +} + +export type CloudStatus = CloudStatusReady | CloudStatusSignedOut | CloudStatusUnknown; + +export interface CloudRun { + id: string; + status: string; + started_at: string | null; + finished_at: string | null; + error: string | null; + answer: string | null; + notices: string[]; + cost_usd: number | null; +} + +export type CloudRunsResponse = + | { state: 'ready'; runs: CloudRun[] } + | { state: 'signed_out' | 'unknown'; detail: string | null }; + +export interface TargetOutcome { + ok: boolean; + message: string | null; +} + +const base = `${API_BASE}/cloud_workflows`; + +function headers(): Record { + let tok = ''; + try { tok = getAuthToken(); } catch { tok = ''; } + return { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) }; +} + +// Null means our own backend did not answer, which the caller must render as "cannot tell" rather than as a denial. +export async function fetchCloudStatus(workflowId: string): Promise { + try { + const res = await fetch(`${base}/${encodeURIComponent(workflowId)}/status`, { headers: headers() }); + if (!res.ok) return null; + return (await res.json()) as CloudStatus; + } catch { + return null; + } +} + +export async function fetchCloudRuns(workflowId: string): Promise { + try { + const res = await fetch(`${base}/${encodeURIComponent(workflowId)}/runs`, { headers: headers() }); + if (!res.ok) return { state: 'unknown', detail: null }; + return (await res.json()) as CloudRunsResponse; + } catch { + return { state: 'unknown', detail: null }; + } +} + +export async function setCloudTarget( + workflowId: string, + body: { target: CloudTarget; enabled: boolean }, +): Promise { + try { + const res = await fetch(`${base}/${encodeURIComponent(workflowId)}/target`, { + method: 'POST', + headers: headers(), + body: JSON.stringify(body), + }); + if (!res.ok) return { ok: false, message: 'Something went wrong on this machine, so nothing changed.' }; + return (await res.json()) as TargetOutcome; + } catch { + return { ok: false, message: 'Something went wrong on this machine, so nothing changed.' }; + } +} diff --git a/frontend/src/app/pages/Workflows/app/cloudAvailability.ts b/frontend/src/app/pages/Workflows/app/cloudAvailability.ts new file mode 100644 index 00000000..ec08107f --- /dev/null +++ b/frontend/src/app/pages/Workflows/app/cloudAvailability.ts @@ -0,0 +1,68 @@ +import type { CloudStatus, CloudStatusReady } from './cloudApi'; + +// One probe, three honest outcomes plus "still asking". Anything we have not heard back about is +// `unknown`, never a refusal: a hiccup that renders as "not entitled" is a paywall built out of a +// dropped packet. +export type CloudProbe = + | { phase: 'checking' } + | { phase: 'unreachable' } + | { phase: 'answered'; status: CloudStatus }; + +export type CloudAvailability = + | { kind: 'checking' } + | { kind: 'unknown'; detail: string | null } + | { kind: 'blocked'; reason: string; action: 'sign_in' | 'plans' | null } + | { kind: 'available' }; + +const PLAN_REQUIRED = 'Cloud runs come with Pro and up. On this plan, workflows run on this device.'; + +function blockedForAccount(status: CloudStatusReady): CloudAvailability | null { + if (status.limits.workflows === 0) { + return { kind: 'blocked', reason: PLAN_REQUIRED, action: 'plans' }; + } + // A workflow already up there is holding one of the slots, so its own slot must not read as full. + const holdsASlot = status.hosted !== null; + if (!holdsASlot && status.usage.workflows_enabled >= status.limits.workflows) { + return { + kind: 'blocked', + reason: `${status.usage.workflows_enabled} of ${status.limits.workflows} cloud workflows used. Turn one off to move this one up.`, + action: null, + }; + } + return null; +} + +/** Whether the Cloud choice can be offered, and if not, the sentence that says why. + * Reasons about the workflow itself come first: telling someone to upgrade for a job the runner + * could never do is a sale, not an answer. */ +export function cloudAvailability(probe: CloudProbe): CloudAvailability { + if (probe.phase === 'checking') return { kind: 'checking' }; + if (probe.phase === 'unreachable') return { kind: 'unknown', detail: null }; + const status = probe.status; + if (!status.schedule_supported && status.schedule_reason) { + return { kind: 'blocked', reason: status.schedule_reason, action: null }; + } + if (status.state === 'unknown') return { kind: 'unknown', detail: status.detail }; + if (status.state === 'signed_out') { + return { + kind: 'blocked', + reason: 'Sign in to your OpenSwarm account to run workflows in the cloud.', + action: 'sign_in', + }; + } + if (status.capability && !status.capability.ok && status.capability.reason) { + return { kind: 'blocked', reason: status.capability.reason, action: null }; + } + return blockedForAccount(status) ?? { kind: 'available' }; +} + +/** The account-wide ceiling, said so plainly nobody reads it as this one workflow's count. + * Null whenever we are unsure of the numbers, or cloud is not on the table for this workflow. */ +export function usageText(probe: CloudProbe, availability: CloudAvailability): string | null { + if (probe.phase !== 'answered' || probe.status.state !== 'ready') return null; + const onCloud = probe.status.target === 'cloud'; + if (!onCloud && availability.kind !== 'available') return null; + const { usage, limits } = probe.status; + if (limits.runs_per_month === 0) return null; + return `Your plan: ${usage.runs_this_month} of ${limits.runs_per_month} cloud runs this month`; +} diff --git a/frontend/src/app/pages/Workflows/app/cloudRunRow.ts b/frontend/src/app/pages/Workflows/app/cloudRunRow.ts new file mode 100644 index 00000000..c1e05559 --- /dev/null +++ b/frontend/src/app/pages/Workflows/app/cloudRunRow.ts @@ -0,0 +1,86 @@ +import type { CloudRun } from './cloudApi'; +import type { RunStatus } from './uiKit'; + +// A cloud run that never started reports as "dispatch_unavailable: fly_capacity: ...", which is a +// sentence for us, not for the person who was expecting a report at 9am. Every refusal the +// dispatcher can produce gets a plain answer to the only question they have: did it run, and why not. +const REFUSAL_TEXT: Record = { + runner_not_configured: "Cloud runs weren't available on our side, so this didn't start. You weren't charged for it.", + callback_not_configured: "Cloud runs weren't available on our side, so this didn't start. You weren't charged for it.", + fly_unauthorized: "Cloud runs weren't available on our side, so this didn't start. You weren't charged for it.", + fly_rejected: "Cloud runs weren't available on our side, so this didn't start. You weren't charged for it.", + fly_capacity: "The cloud had no room at that moment, so this didn't start. You weren't charged for it.", + fly_unreachable: "We couldn't reach the machine meant to run this, so it didn't start. You weren't charged for it.", + workflow_definition_invalid: + "The cloud's copy of this workflow was unreadable, so nothing ran. Switch it back to this device and up to the cloud again to resend it.", + no_cloud_credential: + "No AI account is connected to the cloud for this workspace, so there was nothing to run this with.", + slot_already_run: 'This slot had already run, so it was not run a second time.', +}; + +const STATUS_LABEL: Record = { + pending: 'Starting', + running: 'Running', + succeeded: 'Success', + failed: 'Failed', + dispatch_unavailable: "Didn't run", +}; + +const STATUS_TONE: Record = { + pending: 'running', + running: 'running', + succeeded: 'success', + failed: 'failure', + dispatch_unavailable: 'skipped', +}; + +export interface CloudHistoryRow { + id: string; + label: string; + tone: RunStatus; + summary: string; + when: Date | null; + durationText: string; + costText: string; +} + +/** Split "reason: detail" on the FIRST colon only, and only accept a reason we actually know. + * An unrecognised prefix falls through to the raw text: showing the truth beats guessing at it. */ +export function explainCloudFailure(error: string | null): string { + if (!error) return ''; + const at = error.indexOf(': '); + if (at < 0) return error; + const reason = error.slice(0, at); + const detail = error.slice(at + 2); + // The runner-capability detail is already the sentence we would have written. + if (reason === 'runner_capability') return detail; + return REFUSAL_TEXT[reason] ?? error; +} + +function duration(run: CloudRun): string { + if (!run.started_at || !run.finished_at) return ''; + const ms = new Date(run.finished_at).getTime() - new Date(run.started_at).getTime(); + if (Number.isNaN(ms) || ms < 0) return ''; + const s = Math.round(ms / 1000); + return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`; +} + +function cost(run: CloudRun): string { + if (run.cost_usd === null || run.cost_usd === undefined) return ''; + if (run.cost_usd === 0) return '$0.00'; + return run.cost_usd < 0.01 ? '<$0.01' : `$${run.cost_usd.toFixed(2)}`; +} + +export function toCloudHistoryRow(run: CloudRun, fallbackTitle: string): CloudHistoryRow { + const failed = run.status === 'failed' || run.status === 'dispatch_unavailable'; + const explained = explainCloudFailure(run.error); + return { + id: run.id, + label: STATUS_LABEL[run.status] ?? run.status, + tone: STATUS_TONE[run.status] ?? 'skipped', + summary: (failed && explained) || run.answer || fallbackTitle, + when: run.started_at ? new Date(run.started_at) : null, + durationText: duration(run), + costText: cost(run), + }; +} diff --git a/frontend/src/app/pages/Workflows/app/useCloudRuns.ts b/frontend/src/app/pages/Workflows/app/useCloudRuns.ts new file mode 100644 index 00000000..6f58672c --- /dev/null +++ b/frontend/src/app/pages/Workflows/app/useCloudRuns.ts @@ -0,0 +1,49 @@ +import { useEffect, useRef, useState } from 'react'; +import { fetchCloudRuns } from './cloudApi'; +import type { CloudRunsResponse } from './cloudApi'; + +export type CloudRunsProbe = + | { phase: 'idle' } + | { phase: 'checking' } + | { phase: 'answered'; response: CloudRunsResponse }; + +/** Run history for the cloud copy. Only asks when the workflow is actually up there, so a + * device-only workflow never makes a network call to find out it has no cloud runs. */ +export function useCloudRuns(workflowId: string, hosted: boolean, revision: string): CloudRunsProbe { + const [probe, setProbe] = useState({ phase: 'idle' }); + const live = useRef(true); + + useEffect(() => { + live.current = true; + return () => { live.current = false; }; + }, []); + + useEffect(() => { + if (!hosted) { + setProbe({ phase: 'idle' }); + return; + } + setProbe((prev) => (prev.phase === 'answered' ? prev : { phase: 'checking' })); + fetchCloudRuns(workflowId).then((response) => { + if (live.current) setProbe({ phase: 'answered', response }); + }); + }, [workflowId, hosted, revision]); + + // A cloud run reports to the cloud, not to us, so a run in flight is the one case worth asking again about. The poll stops itself the moment nothing is live. + const watching = + probe.phase === 'answered' && + probe.response.state === 'ready' && + probe.response.runs.some((r) => r.status === 'pending' || r.status === 'running'); + + useEffect(() => { + if (!hosted || !watching) return undefined; + const timer = setInterval(() => { + fetchCloudRuns(workflowId).then((response) => { + if (live.current) setProbe({ phase: 'answered', response }); + }); + }, 30000); + return () => clearInterval(timer); + }, [hosted, watching, workflowId]); + + return probe; +} diff --git a/frontend/src/app/pages/Workflows/app/useCloudStatus.ts b/frontend/src/app/pages/Workflows/app/useCloudStatus.ts new file mode 100644 index 00000000..f38e3b1c --- /dev/null +++ b/frontend/src/app/pages/Workflows/app/useCloudStatus.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useAppDispatch } from '@/shared/hooks'; +import { fetchWorkflows } from '@/shared/state/workflowsSlice'; +import type { Workflow } from '@/shared/state/workflowsSlice'; +import { fetchCloudStatus, setCloudTarget } from './cloudApi'; +import type { CloudTarget, TargetOutcome } from './cloudApi'; +import type { CloudProbe } from './cloudAvailability'; + +export interface CloudStatusHandle { + probe: CloudProbe; + /** True while a flip is in flight; the control must not accept a second one. */ + pending: boolean; + /** Set only by a refused flip, and cleared by the next attempt. */ + refusal: string | null; + choose: (target: CloudTarget, enabled: boolean) => void; + refresh: () => void; +} + +/** The cloud's answer for one workflow, fetched off the render path so the Workflows app paints + * and stays usable whether or not there is a cloud, an account, or a network. */ +export function useCloudStatus(workflow: Workflow): CloudStatusHandle { + const dispatch = useAppDispatch(); + const [probe, setProbe] = useState({ phase: 'checking' }); + const [pending, setPending] = useState(false); + const [refusal, setRefusal] = useState(null); + const live = useRef(true); + const workflowId = workflow.id; + const dashboardId = workflow.dashboard_id; + + useEffect(() => { + live.current = true; + return () => { live.current = false; }; + }, []); + + const refresh = useCallback(() => { + fetchCloudStatus(workflowId).then((status) => { + if (!live.current) return; + setProbe(status ? { phase: 'answered', status } : { phase: 'unreachable' }); + }); + }, [workflowId]); + + // Only a different workflow blanks the answer. Re-asking about the SAME one keeps the last answer on screen while it happens, so an edit does not strobe the card. + useEffect(() => { setProbe({ phase: 'checking' }); }, [workflowId]); + + // Re-ask when the workflow changes in a way the answer depends on: a different schedule can stop being expressible in the cloud, and edited steps can stop being runnable there. + useEffect(() => { refresh(); }, [refresh, workflow.updated_at]); + + const choose = useCallback((target: CloudTarget, enabled: boolean) => { + if (pending) return; + setPending(true); + setRefusal(null); + setCloudTarget(workflowId, { target, enabled }).then((outcome: TargetOutcome) => { + if (!live.current) return; + setPending(false); + if (!outcome.ok) setRefusal(outcome.message); + // Refresh either way: a refusal usually means the reasons on screen are stale too. + refresh(); + if (outcome.ok) dispatch(fetchWorkflows(dashboardId ?? undefined)); + }); + }, [dispatch, pending, refresh, workflowId, dashboardId]); + + return { probe, pending, refusal, choose, refresh }; +} diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index fd84212c..b5f77347 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -74,6 +74,9 @@ export interface Workflow { steps: WorkflowStep[]; actions: ActionsConfig; schedule: ScheduleConfig; + /** Where a SCHEDULED fire runs. Only the cloud_workflows routes may change it, and only once + * the cloud has actually taken (or released) the workflow. Manual runs are always local. */ + execution_target?: 'device' | 'cloud'; permissions: PermissionTier[]; source_session_id?: string | null; dashboard_id?: string | null; @@ -200,12 +203,14 @@ interface State { allRuns: WorkflowRun[]; allRunsLoading: boolean; runningToast: RunningToast | null; + /** One-off explanation for something the user asked for that the server refused. */ + noticeToast: string | null; runControlPending: Record; deleted: Workflow[]; deletedLoading: boolean; } -const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false, allRuns: [], allRunsLoading: false, runningToast: null, runControlPending: {}, deleted: [], deletedLoading: false }; +const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false, allRuns: [], allRunsLoading: false, runningToast: null, noticeToast: null, runControlPending: {}, deleted: [], deletedLoading: false }; function mergeRunIntoState(state: State, r: WorkflowRun) { const arr = state.runs[r.workflow_id] || []; @@ -402,8 +407,13 @@ export const discardDraft = createAsyncThunk('workflows/discardDraft', async (id return (await res.json()) as Workflow; }); -export const deleteWorkflow = createAsyncThunk('workflows/delete', async (id: string) => { - await fetch(`${API}/${id}`, { method: 'DELETE' }); +export const deleteWorkflow = createAsyncThunk('workflows/delete', async (id: string, { rejectWithValue }) => { + const res = await fetch(`${API}/${id}`, { method: 'DELETE' }); + // A refused delete must not remove the card: the workflow is still there, and if it is cloud-hosted it is still running. + if (!res.ok) { + const body = await res.json().catch(() => null); + return rejectWithValue(typeof body?.detail === 'string' ? body.detail : "Couldn't delete this workflow. Try again in a moment."); + } return id; }); @@ -570,6 +580,9 @@ const slice = createSlice({ dismissRunningToast(state) { state.runningToast = null; }, + dismissNoticeToast(state) { + state.noticeToast = null; + }, }, extraReducers: (builder) => { builder @@ -591,6 +604,9 @@ const slice = createSlice({ .addCase(updateWorkflow.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; }) .addCase(commitDraft.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; }) .addCase(discardDraft.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; }) + .addCase(deleteWorkflow.rejected, (state, action) => { + state.noticeToast = typeof action.payload === 'string' ? action.payload : "Couldn't delete this workflow. Try again in a moment."; + }) .addCase(deleteWorkflow.fulfilled, (state, action) => { delete state.items[action.payload]; delete state.runs[action.payload]; @@ -670,5 +686,6 @@ export const { upsertWorkflow, removeWorkflow, dismissRunningToast, + dismissNoticeToast, } = slice.actions; export default slice.reducer;