Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/11779-flow-status-truth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/app-shell': patch
---

Studio's Automations pillar now shows the state a flow actually runs in, and shows it the same way in the rail, the flow header and the Problems panel (objectui#11779).

- **Trigger.** The flow header's Trigger pill reads the trigger the flow's Start node declares, in the Start node inspector's own words. A record-triggered flow reads its event and object (for example *Record updated · task*), where it read the flow type *autolaunched*. A flow with no trigger on its Start node still reads its type.
- **Status.** The header's Status pill reads the engine's runtime state for the flow, as the rail does: *On*, *Off*, *Not running here*, or *Unpublished draft* for a flow the engine does not have. A published flow without a `status` key used to read *draft*, although the engine runs it. Where no runtime state is available (the metadata-admin page, or a backend without `GET /api/v1/automation/_status`), the pill reads the flow's own switch: *Enabled* or *Disabled*.
- **Not running here.** An enabled flow whose declared trigger is not armed on this deployment, such as a scheduled flow while package-authored scheduled work is switched off, reads a grey *Not running here* in the rail and the header instead of a green *On*. Its hover text is the platform's reason, and the Problems panel shows the same reason as a note, which is not counted as a problem. A flow that declares no trigger still reads *On*.
- **New flows start off.** A flow created with *New* is saved with `status: 'obsolete'`, so its switch reads *Disabled*, as the "Off by default · review before enabling" bar says. It used to read *Enabled*, and a package publish armed it as soon as it had a trigger.
9 changes: 9 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3133,6 +3133,12 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.auto.offTitle': 'Disabled — will not run',
'engine.studio.auto.onBound': 'Enabled · bound to its trigger',
'engine.studio.auto.onUnbound': 'Enabled · no trigger (run manually)',
// objectui#11779 — an enabled flow whose declared trigger is not armed on this
// deployment; the title is for a backend that sends no `reason` (with one,
// the platform's sentence is shown instead).
'engine.studio.auto.notRunning': 'Not running here',
'engine.studio.auto.notRunningTitle': 'Enabled, but its trigger is not armed on this deployment',
'engine.studio.auto.unpublishedTitle': 'Not deployed yet — publish to deploy it',
'engine.studio.auto.enabled': 'Enabled',
'engine.studio.auto.disabled': 'Disabled',
'engine.studio.auto.enableTitle': 'Enable this automation (publish to apply)',
Expand Down Expand Up @@ -6023,6 +6029,9 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.auto.offTitle': '已停用 — 不会运行',
'engine.studio.auto.onBound': '已启用 · 已绑定触发器',
'engine.studio.auto.onUnbound': '已启用 · 无触发器(手动运行)',
'engine.studio.auto.notRunning': '未在此运行',
'engine.studio.auto.notRunningTitle': '已启用,但它的触发器在此部署中未生效',
'engine.studio.auto.unpublishedTitle': '尚未部署 — 发布后才会部署',
'engine.studio.auto.enabled': '已启用',
'engine.studio.auto.disabled': '已停用',
'engine.studio.auto.enableTitle': '启用此自动化(发布后生效)',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#11779 — the flow header's Trigger and Status pills, on a host that
* reads no runtime state (the metadata-admin page renders this same preview,
* with no `FlowRuntimeContext` provider).
*
* - Trigger is read from the Start node, never from the flow-level `type`
* alone: a record trigger reads its event and object; a time-relative sweep
* reads as one.
* - Status, with no runtime reading, is the draft's own switch as the engine
* reads it: a flow with no `status` key (or `draft`) is armed like `active`,
* so it reads "Enabled" — the old header read it "draft".
*
* The Studio-hosted readings (runtime rows through the real pillar) are pinned
* in `StudioDesignSurface.flowStatusTruth-11779.test.tsx`.
*/

import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';

vi.mock('./useFlowNodePalette', () => ({
useActionConfigSchemas: () => ({}),
useFlowNodePalette: () => [],
}));

vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('not found', { status: 404 })),
);

import { FlowPreview } from './FlowPreview';
import { t } from '../i18n';

afterEach(cleanup);

const end = { id: 'end', type: 'end', label: 'End' };
const edges = [{ id: 'e1', source: 'start', target: 'end' }];

function flow(start: Record<string, unknown>, extra: Record<string, unknown> = {}): Record<string, unknown> {
return { name: 'f1', label: 'F1', type: 'autolaunched', nodes: [{ id: 'start', type: 'start', label: 'Start', ...start }, end], edges, ...extra };
}

function pill(label: string, draft: Record<string, unknown>, locale = 'en-US'): { value: string; pill: HTMLElement } {
render(<FlowPreview type="flow" name="f1" draft={draft} locale={locale} />);
const labelEl = screen.getByText(`${label}:`);
return { value: labelEl.nextElementSibling?.textContent ?? '', pill: labelEl.parentElement as HTMLElement };
}

describe('the Trigger pill reads the Start node (objectui#11779)', () => {
it('a record trigger reads its event and object, not "autolaunched"', () => {
const { value } = pill('Trigger', flow({ config: { triggerType: 'record-after-update', objectName: 'task' } }));
expect(value).toBe('Record updated · task');
});

it('in zh, the same words the Start node inspector uses', () => {
const { value } = pill('触发', flow({ config: { triggerType: 'record-after-update', objectName: 'task' } }), 'zh-CN');
expect(value).toBe('记录更新后 · task');
});

it('a time-relative sweep reads as one, with the object it sweeps', () => {
const { value } = pill(
'Trigger',
flow({ config: { timeRelative: { object: 'contract', dateField: 'end_date', withinDays: 30 }, schedule: { type: 'cron', expression: '0 7 * * *' } } }),
);
expect(value).toBe('Time-relative (date sweep) · contract');
});

it('a flow with no trigger on its Start node falls back to the flow type — the control', () => {
expect(pill('Trigger', flow({})).value).toBe('autolaunched');
});
});

describe('the Status pill without a runtime reading (objectui#11779)', () => {
it('a flow with no `status` key reads Enabled, not "draft"', () => {
const { value } = pill('Status', flow({}));
expect(value).toBe(t('engine.studio.auto.enabled', 'en'));
expect(value).not.toMatch(/draft/i);
});

it('`status: draft` reads Enabled too — the engine arms a draft flow', () => {
expect(pill('Status', flow({}, { status: 'draft' })).value).toBe(t('engine.studio.auto.enabled', 'en'));
});

it('`status: obsolete` reads Disabled — the control', () => {
expect(pill('Status', flow({}, { status: 'obsolete' })).value).toBe(t('engine.studio.auto.disabled', 'en'));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
* pull in ReactFlow + its deps every time the metadata-admin loads,
* which is too heavy for a glance-preview. Instead we render:
*
* 1. A header strip with type / status / runAs / version.
* 1. A header strip with trigger / status / runAs / version — the trigger
* the Start node declares, and the status the deployment reports
* (objectui#11779).
* 2. A topologically ordered step list inferred from `nodes` +
* `edges`. Each step shows label, action type, branch markers,
* and outgoing edge conditions so authors can sanity-check the
Expand All @@ -19,6 +21,7 @@
*/

import * as React from 'react';
import { resolveFlowTriggerKind } from '@objectstack/spec/automation';
import {
AlertCircle,
Bug,
Expand All @@ -43,9 +46,19 @@ import { NESTED_NODE_KIND, parseNestedNodeId, encodeNestedNodeId } from '../insp
import { FlowSimulatorPanel } from './FlowSimulatorPanel.js';
import { FlowRunsPanel } from './FlowRunsPanel.js';
import { ProblemsPanel } from './ProblemsPanel.js';
import { buildFlowProblems, deriveInvalidElements, freshNodeId, type FlowProblem } from './flow-problems.js';
import {
FlowRuntimeContext,
buildFlowProblems,
deriveFlowRunStatus,
deriveInvalidElements,
describeFlowRunStatus,
freshNodeId,
type FlowProblem,
type FlowRunStatusView,
} from './flow-problems.js';
import { useConnectorRegistry } from '../inspectors/connector-input-fields.js';
import { hasCommittedConnectorAction } from '../inspectors/flow-scope.js';
import { fieldsForNodeType, localizeFlowFields } from '../inspectors/flow-node-config.js';

/**
* This preview reads the draft's nodes and edges and hands them straight to
Expand All @@ -60,6 +73,44 @@ import { hasCommittedConnectorAction } from '../inspectors/flow-scope.js';
type FlowNode = FlowDesignerNode;
type FlowEdge = FlowDesignerEdge;

/**
* The header's Trigger pill (objectui#11779): the trigger the engine arms this
* flow on, read from its Start node by the spec's own resolver
* (`resolveFlowTriggerKind`, the authoring-time mirror of the engine's binding
* resolution, in the engine's precedence). The flow-level `type` used to be
* shown instead, so a record-triggered flow read "autolaunched".
*
* - a record trigger reads as its event (the Start node's `triggerType`, e.g.
* "Record updated"), with the object it watches;
* - a time-relative sweep reads as one, with the object it sweeps;
* - a schedule (a `schedule` cadence on the Start node, or `type: 'schedule'`)
* and an inbound API hook each read as that kind;
* - a flow the resolver finds no trigger on — a manual / autolaunched Start,
* a screen flow — falls back to the flow's `type`, as before.
*
* The words are the Start node inspector's own option labels for the same
* tokens (`flow-node-config`), localized the way the inspector localizes them,
* so the header and the inspector name one trigger one way.
*/
function flowTriggerLabel(d: Record<string, unknown>, nodes: FlowNode[], locale?: string): string {
const kind = resolveFlowTriggerKind(d);
if (!kind) return translateFlowMeta('type', String(d.type ?? 'autolaunched'), locale);
const config = nodes.find((n) => n?.type === 'start')?.config ?? {};
// A record kind means the Start node's `triggerType` IS a `record-*` token —
// the resolver read it to answer.
const token = kind === 'record_change' ? String(config.triggerType) : kind;
const options = localizeFlowFields('start', fieldsForNodeType('start'), locale).find((f) => f.id === 'triggerType')?.options;
const label = options?.find((o) => o.value === token)?.label ?? token;
const sweep = config.timeRelative as { object?: unknown } | undefined;
const object =
kind === 'record_change'
? config.objectName
: kind === 'time_relative'
? (typeof sweep?.object === 'string' ? sweep.object : config.objectName)
: undefined;
return typeof object === 'string' && object ? `${label} · ${object}` : label;
}

interface FlowVariable {
name: string;
type?: string;
Expand All @@ -77,6 +128,14 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa
const edges = React.useMemo<FlowEdge[]>(() => (Array.isArray(d.edges) ? (d.edges as FlowEdge[]) : []), [d.edges]);
const variables: FlowVariable[] = Array.isArray(d.variables) ? (d.variables as FlowVariable[]) : [];

// objectui#11779 — the host's runtime row for this flow (see
// `FlowRuntimeContext`): the ONE run status the header's Status pill and the
// Problems panel show, derived as the host's rail derives it. Read before the
// empty-flow return below, so the hook order never depends on the draft.
const runtime = React.useContext(FlowRuntimeContext);
const runStatus = deriveFlowRunStatus(runtime, d.status);
const runView = describeFlowRunStatus(runStatus, locale);

const designMode = !!(editing && onSelectionChange);
const canEdit = designMode && !!onPatch;
const selectedId = selection && selection.kind === 'node' ? selection.id : null;
Expand Down Expand Up @@ -180,8 +239,6 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa

// Run history needs the published flow name (the engine keys runs by it).
const flowName = typeof d.name === 'string' && d.name ? d.name : '';
const flowType = String(d.type ?? 'autolaunched');
const status = String(d.status ?? (d.active ? 'active' : 'draft'));
const runAs = String(d.runAs ?? 'user');
const version = d.version != null ? String(d.version) : undefined;
const errorStrategy = (d.errorHandling as any)?.strategy as string | undefined;
Expand Down Expand Up @@ -217,8 +274,14 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa
{/* Visual canvas */}
<div className="flex flex-col min-w-0 min-h-0">
<div className="rounded-none border-b bg-muted/30 px-3 py-2 text-xs flex flex-wrap items-center gap-x-4 gap-y-1">
<Pill icon={Zap} label={tr('engine.flowPreview.pill.trigger', locale)} value={translateFlowMeta('type', flowType, locale)} />
<Pill icon={CircleDot} label={tr('engine.flowPreview.pill.status', locale)} value={translateFlowMeta('status', status, locale)} tone={status === 'active' ? 'green' : status === 'draft' ? 'gray' : 'amber'} />
<Pill icon={Zap} label={tr('engine.flowPreview.pill.trigger', locale)} value={flowTriggerLabel(d, nodes, locale)} />
<Pill
icon={CircleDot}
label={tr('engine.flowPreview.pill.status', locale)}
value={runView.label}
tone={runView.tone}
title={runView.title}
/>
<Pill icon={Settings2} label={tr('engine.flowPreview.pill.runAs', locale)} value={translateFlowMeta('runAs', runAs, locale)} />
{version && <Pill label="v" value={version} />}
{errorStrategy && <Pill icon={GitBranch} label={tr('engine.flowPreview.pill.onError', locale)} value={translateFlowMeta('onError', errorStrategy, locale)} />}
Expand Down Expand Up @@ -355,6 +418,7 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa
selectedKey={selectedKey}
onSelectProblem={handleSelectProblem}
locale={locale}
runStatus={runStatus}
/>
</div>
) : showDebug ? (
Expand Down Expand Up @@ -421,21 +485,26 @@ function Pill({
icon: Icon,
label,
value,
tone = 'gray',
tone = 'plain',
title,
}: {
icon?: React.ComponentType<{ className?: string }>;
label: string;
value: string;
tone?: 'gray' | 'green' | 'amber';
tone?: FlowRunStatusView['tone'];
/** Hover text — the run status's reason, for the Status pill. */
title?: string;
}) {
const cls =
tone === 'green'
? 'text-emerald-700'
? 'text-emerald-700 dark:text-emerald-400'
: tone === 'amber'
? 'text-amber-700'
: 'text-foreground';
? 'text-amber-700 dark:text-amber-300'
: tone === 'muted'
? 'text-muted-foreground'
: 'text-foreground';
return (
<span className="inline-flex items-center gap-1">
<span className="inline-flex items-center gap-1" title={title}>
{Icon && <Icon className="h-3 w-3 text-muted-foreground" />}
<span className="text-muted-foreground">{label}:</span>
<span className={`font-medium ${cls}`}>{value}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
*/

import * as React from 'react';
import { AlertCircle, AlertTriangle, CheckCircle2, CircleDot, GitBranch } from 'lucide-react';
import { AlertCircle, AlertTriangle, CheckCircle2, CircleDot, CircleSlash, GitBranch } from 'lucide-react';
import { cn } from '@object-ui/components';
import type { FlowProblem } from './flow-problems.js';
import { describeFlowRunStatus, type FlowProblem, type FlowRunStatus } from './flow-problems.js';
import { t as tr } from '../i18n.js';

export interface ProblemsPanelProps {
Expand All @@ -23,6 +23,14 @@ export interface ProblemsPanelProps {
onSelectProblem: (problem: FlowProblem) => void;
/** UI locale for the panel chrome. */
locale?: string;
/**
* The flow's run status (objectui#11779), the one the header's Status pill
* shows. A flow the deployment does not run on its trigger (`not-running`)
* gets a note above the list: a deployment fact, not an authoring problem,
* so it is never counted, never styled as an error, and the structural
* verdict below it stands as it is.
*/
runStatus?: FlowRunStatus;
}

function targetLabel(p: FlowProblem): string {
Expand All @@ -31,9 +39,10 @@ function targetLabel(p: FlowProblem): string {
return 'flow';
}

export function ProblemsPanel({ problems, selectedKey, onSelectProblem, locale }: ProblemsPanelProps) {
export function ProblemsPanel({ problems, selectedKey, onSelectProblem, locale, runStatus }: ProblemsPanelProps) {
const errorCount = problems.filter((p) => p.level === 'error').length;
const warningCount = problems.length - errorCount;
const runNote = runStatus?.kind === 'not-running' ? describeFlowRunStatus(runStatus, locale) : null;

return (
<div className="flex h-full flex-col text-xs">
Expand All @@ -50,6 +59,19 @@ export function ProblemsPanel({ problems, selectedKey, onSelectProblem, locale }
</span>
)}
</div>
{runNote && (
<div
role="note"
data-testid="flow-run-status-note"
className="mx-1.5 mt-1.5 flex items-start gap-2 rounded-md border border-dashed px-2 py-1.5 text-muted-foreground"
>
<CircleSlash className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1">
<span className="block leading-snug text-foreground">{runNote.label}</span>
{runNote.title && <span className="mt-0.5 block text-[10px] leading-snug">{runNote.title}</span>}
</span>
</div>
)}
{problems.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-1.5 p-4 text-center text-muted-foreground">
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
Expand Down
Loading
Loading