mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 14:47:53 +08:00
feat(automations): show a run's live phase (#16740)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Vasco Schiavo <115561717+VascoSch92@users.noreply.github.com> Co-authored-by: VascoSch92 <vasco.schiavo@protonmail.com>
This commit is contained in:
co-authored by
Claude Opus 5
Vasco Schiavo
VascoSch92
parent
861e9ef501
commit
8511fff62d
@@ -1,12 +1,24 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { AutomationCard } from "#/components/features/automations/automation-card";
|
||||
import {
|
||||
AutomationRunStatus,
|
||||
type Automation,
|
||||
type AutomationRun,
|
||||
} from "#/types/automation";
|
||||
import { useAutomationRunSummaries } from "#/hooks/query/use-automation-run-summaries";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import { server } from "#/mocks/node";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import type { InterfaceListInsights } from "#/manifests/types";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
@@ -158,7 +170,9 @@ describe("AutomationCard", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("automation-health-badge")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("automation-health-badge"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("automation-last-run-automation-1"),
|
||||
).toHaveTextContent("AUTOMATIONS$DETAIL$TIME_MINUTES_AGO");
|
||||
@@ -171,3 +185,115 @@ describe("AutomationCard", () => {
|
||||
expect(screen.getByText("100%")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AutomationCard — run phase", () => {
|
||||
const localBackend: Backend = {
|
||||
id: "local-1",
|
||||
name: "Local 1",
|
||||
host: "http://localhost:8000",
|
||||
apiKey: "k",
|
||||
kind: "local",
|
||||
};
|
||||
|
||||
const insightAutomation: Automation = {
|
||||
id: "auto-with-active-run",
|
||||
name: "Digest",
|
||||
prompt: null,
|
||||
enabled: true,
|
||||
trigger: { type: "cron", schedule_human: "cron" },
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const insightsSpec: InterfaceListInsights = {
|
||||
health: {
|
||||
healthy: "Healthy",
|
||||
failing: "Failing",
|
||||
running: "Running",
|
||||
disabled: "Disabled",
|
||||
neverRun: "Never run",
|
||||
checking: "Checking",
|
||||
},
|
||||
lastRun: { label: "Last run", never: "Never", justNow: "Just now" },
|
||||
stats: {
|
||||
runs: "Runs",
|
||||
recentSuccess: "Success",
|
||||
averageDuration: "Duration",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([localBackend]);
|
||||
setActiveSelection({ backendId: localBackend.id });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
function Harness() {
|
||||
const byId = useAutomationRunSummaries([insightAutomation]);
|
||||
return (
|
||||
<AutomationCard
|
||||
automation={insightAutomation}
|
||||
onToggle={vi.fn()}
|
||||
onRunNow={vi.fn()}
|
||||
onExport={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
insights={{ spec: insightsSpec, state: byId.get(insightAutomation.id) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderHarness() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ActiveBackendProvider>
|
||||
<Harness />
|
||||
</ActiveBackendProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("shows the active run's phase using only the run-summaries fetch insights already makes — no extra request", async () => {
|
||||
// Arrange: count every hit to the runs endpoint the card's insights
|
||||
// already fetch (via useAutomationRunSummaries) — if displaying the
|
||||
// phase required a second request, this would be > 1.
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.get("*/api/automation/v1/:id/runs", () => {
|
||||
callCount += 1;
|
||||
return HttpResponse.json({
|
||||
runs: [
|
||||
{
|
||||
id: "run-active",
|
||||
status: AutomationRunStatus.RUNNING,
|
||||
conversation_id: null,
|
||||
bash_command_id: null,
|
||||
error_detail: null,
|
||||
phase_code: "running_agent",
|
||||
phase_label: null,
|
||||
phase_updated_at: null,
|
||||
started_at: "2026-01-01T09:00:00Z",
|
||||
completed_at: null,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
renderHarness();
|
||||
|
||||
// Assert: the phase renders ...
|
||||
await screen.findByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT);
|
||||
// ... and exactly one request was made — the pre-existing insights
|
||||
// fetch, not a new one just for the phase.
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router";
|
||||
|
||||
import { ActivityLogItem } from "#/components/features/automations/detail/activity-log-item";
|
||||
import {
|
||||
AutomationRunStatus,
|
||||
type AutomationRun,
|
||||
} from "#/types/automation";
|
||||
import { AutomationRunStatus, type AutomationRun } from "#/types/automation";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
@@ -25,27 +22,24 @@ const LOGS_BUTTON_NAME = (name: string) =>
|
||||
// The modal is wired to react-query + the conversation lookup. The
|
||||
// ActivityLogItem tests focus on the trigger button; we mock the modal so
|
||||
// they don't need to bring up the entire query stack.
|
||||
vi.mock(
|
||||
"#/components/features/automations/detail/run-logs-modal",
|
||||
() => ({
|
||||
RunLogsModal: ({
|
||||
isOpen,
|
||||
onClose,
|
||||
bashCommandId,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
bashCommandId: string | null;
|
||||
}) =>
|
||||
isOpen ? (
|
||||
<div data-testid="logs-modal" data-bash-command-id={bashCommandId}>
|
||||
<button type="button" onClick={onClose}>
|
||||
close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}),
|
||||
);
|
||||
vi.mock("#/components/features/automations/detail/run-logs-modal", () => ({
|
||||
RunLogsModal: ({
|
||||
isOpen,
|
||||
onClose,
|
||||
bashCommandId,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
bashCommandId: string | null;
|
||||
}) =>
|
||||
isOpen ? (
|
||||
<div data-testid="logs-modal" data-bash-command-id={bashCommandId}>
|
||||
<button type="button" onClick={onClose}>
|
||||
close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
const localBackend: Backend = {
|
||||
id: "local-1",
|
||||
@@ -288,3 +282,242 @@ describe("ActivityLogItem — run cost", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityLogItem — run phase", () => {
|
||||
beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([localBackend]);
|
||||
setActiveSelection({ backendId: localBackend.id });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
it("shows the phase as the place of failure for a FAILED run", () => {
|
||||
// Arrange: the run failed after reaching sandbox_provisioning — a code
|
||||
// known to the frontend, so it renders translated (raw key in tests).
|
||||
const run = makeRun({
|
||||
status: AutomationRunStatus.FAILED,
|
||||
conversation_id: null,
|
||||
bash_command_id: null,
|
||||
phase_code: "sandbox_provisioning",
|
||||
phase_label: null,
|
||||
phase_updated_at: "2026-01-01T10:01:30Z",
|
||||
});
|
||||
|
||||
// Act
|
||||
renderItem(run);
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.getByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_SANDBOX_PROVISIONING),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not display the phase for a COMPLETED run, even though it was saved", () => {
|
||||
// Arrange: the run completed, but a phase was recorded along the way.
|
||||
const run = makeRun({
|
||||
status: AutomationRunStatus.COMPLETED,
|
||||
phase_code: "running_agent",
|
||||
phase_label: null,
|
||||
phase_updated_at: "2026-01-01T10:01:30Z",
|
||||
});
|
||||
|
||||
// Act
|
||||
renderItem(run);
|
||||
|
||||
// Assert: the field exists on the run (saved), but nothing renders it.
|
||||
expect(run.phase_code).toBe("running_agent");
|
||||
expect(screen.queryByTestId("run-phase")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityLogItem — run phase absent entirely", () => {
|
||||
beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([localBackend]);
|
||||
setActiveSelection({ backendId: localBackend.id });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders a RUNNING row unchanged, without console errors, when the automation service omits phase fields entirely", () => {
|
||||
// Arrange: an older automation service response — no phase_code,
|
||||
// phase_label or phase_updated_at keys at all (not even null).
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const run = makeRun({
|
||||
status: AutomationRunStatus.RUNNING,
|
||||
completed_at: null,
|
||||
});
|
||||
delete (run as Partial<AutomationRun>).phase_code;
|
||||
delete (run as Partial<AutomationRun>).phase_label;
|
||||
delete (run as Partial<AutomationRun>).phase_updated_at;
|
||||
|
||||
// Act
|
||||
expect(() => renderItem(run)).not.toThrow();
|
||||
|
||||
// Assert: renders as before — status badge present, no phase node, no
|
||||
// console noise from the missing fields.
|
||||
expect(
|
||||
screen.getByText(I18nKey.AUTOMATIONS$DETAIL$RUNNING),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("run-phase")).not.toBeInTheDocument();
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityLogItem — run phase updates without reload", () => {
|
||||
beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([localBackend]);
|
||||
setActiveSelection({ backendId: localBackend.id });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
it("reflects a new phase_code from a prop update (as a poll refetch would produce), with no remount needed", () => {
|
||||
// Arrange
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const buildTree = (run: AutomationRun) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ActiveBackendProvider>
|
||||
<MemoryRouter>
|
||||
<ActivityLogItem run={run} />
|
||||
</MemoryRouter>
|
||||
</ActiveBackendProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const runningRun = makeRun({
|
||||
status: AutomationRunStatus.RUNNING,
|
||||
completed_at: null,
|
||||
phase_code: "queued",
|
||||
phase_label: null,
|
||||
});
|
||||
const { rerender } = render(buildTree(runningRun));
|
||||
expect(
|
||||
screen.getByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_QUEUED),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Act: same run id, new phase — what the 3s poll in
|
||||
// useAutomationRuns would hand down as a new `run` prop.
|
||||
rerender(
|
||||
buildTree({
|
||||
...runningRun,
|
||||
phase_code: "running_agent",
|
||||
phase_label: null,
|
||||
}),
|
||||
);
|
||||
|
||||
// Assert: the new phase is shown, the old one is gone — no reload.
|
||||
expect(
|
||||
screen.getByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_QUEUED),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityLogItem — run phase (badge still shown)", () => {
|
||||
beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([localBackend]);
|
||||
setActiveSelection({ backendId: localBackend.id });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
it("shows only the existing status badge (not merely 'no phase') for an active run whose phase is blank on both fields", () => {
|
||||
// Arrange
|
||||
const run = makeRun({
|
||||
status: AutomationRunStatus.RUNNING,
|
||||
completed_at: null,
|
||||
phase_code: null,
|
||||
phase_label: "",
|
||||
});
|
||||
|
||||
// Act
|
||||
renderItem(run);
|
||||
|
||||
// Assert: no phase node, but the status badge is still on the row —
|
||||
// the requirement is "only the existing status badge", not "nothing".
|
||||
expect(screen.queryByTestId("run-phase")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(I18nKey.AUTOMATIONS$DETAIL$RUNNING),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the status badge beside a phase reported as a bare code", () => {
|
||||
// Arrange: the service accepts `{"code": "custom_step"}` with no label.
|
||||
const run = makeRun({
|
||||
status: AutomationRunStatus.RUNNING,
|
||||
completed_at: null,
|
||||
phase_code: "custom_step",
|
||||
phase_label: "",
|
||||
});
|
||||
|
||||
// Act
|
||||
renderItem(run);
|
||||
|
||||
// Assert: the phase reaches the row, and it is added to the badge rather
|
||||
// than replacing it.
|
||||
expect(screen.getByTestId("run-phase")).toHaveTextContent("custom_step");
|
||||
expect(
|
||||
screen.getByText(I18nKey.AUTOMATIONS$DETAIL$RUNNING),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityLogItem — run phase hidden for CANCELLED/SKIPPED", () => {
|
||||
beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([localBackend]);
|
||||
setActiveSelection({ backendId: localBackend.id });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[AutomationRunStatus.CANCELLED, I18nKey.AUTOMATIONS$DETAIL$CANCELLED],
|
||||
[AutomationRunStatus.SKIPPED, I18nKey.AUTOMATIONS$DETAIL$SKIPPED],
|
||||
])(
|
||||
"does not show the phase for a %s run, even with a known phase_code on record",
|
||||
(status, badgeKey) => {
|
||||
// Arrange
|
||||
const run = makeRun({
|
||||
status,
|
||||
phase_code: "running_agent",
|
||||
phase_label: null,
|
||||
completed_at: "2026-01-01T10:02:00Z",
|
||||
});
|
||||
|
||||
// Act
|
||||
renderItem(run);
|
||||
|
||||
// Assert: phase hidden, but the status badge for this terminal
|
||||
// status is still shown.
|
||||
expect(screen.queryByTestId("run-phase")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(I18nKey.AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText(badgeKey)).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
formatRunPhaseAge,
|
||||
resolveRunPhaseText,
|
||||
RunPhase,
|
||||
} from "#/components/features/automations/detail/run-phase";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
// Source of truth for translated values — not a hand-maintained duplicate.
|
||||
import translationData from "#/i18n/translation.json";
|
||||
import { AutomationRunStatus } from "#/types/automation";
|
||||
import { formatRelativeTime } from "#/utils/format-relative-time";
|
||||
|
||||
type TranslationEntry = Record<string, string>;
|
||||
const TRANSLATIONS = translationData as unknown as Record<
|
||||
string,
|
||||
TranslationEntry
|
||||
>;
|
||||
|
||||
// `t()` is mocked to resolve against the real translation.json content for
|
||||
// French ("fr"), the same pattern used elsewhere in this repo (see
|
||||
// server-status.test.tsx) to assert on genuine translated copy rather than
|
||||
// the ambient test i18n backend, which never resolves real values.
|
||||
function translate(key: string, options?: Record<string, unknown>): string {
|
||||
const value = TRANSLATIONS[key]?.fr ?? key;
|
||||
if (!options) return value;
|
||||
// Interpolate the same way i18next does, so a test can assert on "il y a
|
||||
// 12min" rather than on the untouched "il y a {{count}}min" placeholder.
|
||||
return value.replace(/{{(\w+)}}/g, (_, name: string) =>
|
||||
String(options[name] ?? `{{${name}}}`),
|
||||
);
|
||||
}
|
||||
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: translate,
|
||||
i18n: { language: "fr" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const minutesAgo = (minutes: number) =>
|
||||
new Date(Date.now() - minutes * 60_000).toISOString();
|
||||
|
||||
const RUNNING = AutomationRunStatus.RUNNING;
|
||||
|
||||
describe("RunPhase — known code, non-English language", () => {
|
||||
it("shows the French translation.json value for a known phase code, not the raw code", () => {
|
||||
render(
|
||||
<RunPhase status={RUNNING} code="sandbox_provisioning" label={null} />,
|
||||
);
|
||||
|
||||
const expected =
|
||||
TRANSLATIONS[I18nKey.AUTOMATIONS$DETAIL$PHASE_SANDBOX_PROVISIONING].fr;
|
||||
expect(screen.getByText(expected)).toBeInTheDocument();
|
||||
expect(screen.queryByText("sandbox_provisioning")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunPhase — unknown code (custom automations)", () => {
|
||||
it("shows the label as-is, including emoji and non-Latin text, for an unknown code", () => {
|
||||
render(
|
||||
<RunPhase status={RUNNING} code="poll_prs" label="🔍 Опрашиваем PR-ы" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("🔍 Опрашиваем PR-ы")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the raw code when the automation reported a code and no label", () => {
|
||||
// The service accepts `{"code": "checking_out"}` with no label at all, so
|
||||
// this run has a real phase and dropping it would show nothing.
|
||||
render(<RunPhase status={RUNNING} code="checking_out" label={null} />);
|
||||
|
||||
expect(screen.getByTestId("run-phase")).toHaveTextContent("checking_out");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["null code and empty label", null, ""],
|
||||
["undefined on both fields (an older service)", undefined, undefined],
|
||||
["null on both fields", null, null],
|
||||
["whitespace on both fields", " ", " "],
|
||||
])(
|
||||
"renders nothing and never touches the console with %s",
|
||||
(_case, code, label) => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
expect(() =>
|
||||
render(<RunPhase status={RUNNING} code={code} label={label} />),
|
||||
).not.toThrow();
|
||||
|
||||
expect(screen.queryByTestId("run-phase")).not.toBeInTheDocument();
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("boundary: a 200-character label (the contract's max) reaches the DOM whole, so truncation stays visual", () => {
|
||||
// Arrange: the longest label the backend contract allows.
|
||||
const label = "x".repeat(200);
|
||||
|
||||
// Act
|
||||
render(<RunPhase status={RUNNING} code="poll_prs" label={label} />);
|
||||
|
||||
// Assert: the full string is still there — clipping must be CSS, never a
|
||||
// JS slice that would lose characters no tooltip could bring back.
|
||||
expect(screen.getByTestId("run-phase")).toHaveTextContent(label);
|
||||
});
|
||||
|
||||
it("renders the label when only a label was reported and no code", () => {
|
||||
// The service accepts a phase carrying just a label, so a custom
|
||||
// automation may report one. An absent code is the most unknown code
|
||||
// there is, and an unknown code falls back to its label.
|
||||
render(
|
||||
<RunPhase status={RUNNING} code={null} label="Reticulating splines" />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("run-phase")).toHaveTextContent(
|
||||
"Reticulating splines",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunPhase — phase text is data, not a lookup into JavaScript itself", () => {
|
||||
// `phase_code` is author-supplied, so a plain-object lookup answers these
|
||||
// from `Object.prototype` and hands a function to `t()` — taking the card
|
||||
// tree down inside render instead of falling back to the label.
|
||||
it.each(["toString", "constructor", "__proto__", "hasOwnProperty"])(
|
||||
"treats %s as an ordinary unknown code and shows the label",
|
||||
(code) => {
|
||||
expect(() =>
|
||||
render(<RunPhase status={RUNNING} code={code} label="Полный аудит" />),
|
||||
).not.toThrow();
|
||||
|
||||
expect(screen.getByTestId("run-phase")).toHaveTextContent("Полный аудит");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("resolveRunPhaseText — one answer for the row and its tooltip", () => {
|
||||
// The row shows a clipped copy of this text and the tooltip shows all of
|
||||
// it, so both have to resolve the same phase the same way; a surface that
|
||||
// resolved it on its own could show one thing and reveal another.
|
||||
const t = (key: string) => TRANSLATIONS[key]?.fr ?? key;
|
||||
|
||||
it("resolves a known code to its translation", () => {
|
||||
expect(resolveRunPhaseText(t, "sandbox_provisioning", null)).toBe(
|
||||
TRANSLATIONS[I18nKey.AUTOMATIONS$DETAIL$PHASE_SANDBOX_PROVISIONING].fr,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves an unknown code to the author's label, verbatim", () => {
|
||||
expect(resolveRunPhaseText(t, "poll_prs", "🔍 Опрашиваем PR-ы")).toBe(
|
||||
"🔍 Опрашиваем PR-ы",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a phase carrying only a label to that label", () => {
|
||||
expect(resolveRunPhaseText(t, null, "Reticulating splines")).toBe(
|
||||
"Reticulating splines",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a phase carrying only a code to that code, verbatim", () => {
|
||||
// Mirrors the backend contract: `code` and `label` are independently
|
||||
// optional, so a code-only phase is valid and must reach the screen.
|
||||
expect(resolveRunPhaseText(t, "checking_out", null)).toBe("checking_out");
|
||||
expect(resolveRunPhaseText(t, "checking_out", "")).toBe("checking_out");
|
||||
});
|
||||
|
||||
it("prefers the author's label over the raw code when both are present", () => {
|
||||
expect(resolveRunPhaseText(t, "poll_prs", "Polling PRs")).toBe(
|
||||
"Polling PRs",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls through a whitespace-only label, which the service stores as sent", () => {
|
||||
// Only a phase blank on *both* fields is rejected: `{"code": "x",
|
||||
// "label": " "}` is recorded verbatim, and rendering that label would
|
||||
// put an empty span on the row with the age dangling beside it.
|
||||
expect(resolveRunPhaseText(t, "checking_out", " ")).toBe("checking_out");
|
||||
expect(resolveRunPhaseText(t, " ", " ")).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves to null when there is nothing to show", () => {
|
||||
expect(resolveRunPhaseText(t, "", "")).toBeNull();
|
||||
expect(resolveRunPhaseText(t, null, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRunPhaseAge — telling a moving run from a stalled one", () => {
|
||||
it("reports how long the run has held the phase, localized", () => {
|
||||
expect(formatRunPhaseAge(minutesAgo(12), "fr", translate)).toBe(
|
||||
"il y a 12min",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a phase written seconds ago as 'just now', not as zero minutes", () => {
|
||||
expect(formatRunPhaseAge(minutesAgo(0), "fr", translate)).toBe(
|
||||
TRANSLATIONS[I18nKey.AUTOMATIONS$DETAIL$TIME_JUST_NOW].fr,
|
||||
);
|
||||
});
|
||||
|
||||
it("boundary: 60 minutes rolls over to the hours wording", () => {
|
||||
expect(formatRunPhaseAge(minutesAgo(60), "fr", translate)).toBe(
|
||||
"il y a 1h",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when the service reported no timestamp", () => {
|
||||
expect(formatRunPhaseAge(null, "fr", translate)).toBeNull();
|
||||
expect(formatRunPhaseAge(undefined, "fr", translate)).toBeNull();
|
||||
});
|
||||
|
||||
it("negative: returns null for timestamps the formatter would print as garbage", () => {
|
||||
// The guard is the whole point: left to itself, the shared relative-time
|
||||
// formatter prints "Invalid Date" for an unparseable string and a 1970
|
||||
// date for the epoch, which is how the backend leaves a datetime unset.
|
||||
expect(formatRelativeTime("not-a-date", "fr", translate)).toMatch(
|
||||
/Invalid/,
|
||||
);
|
||||
expect(formatRelativeTime("1970-01-01T00:00:00Z", "fr", translate)).toMatch(
|
||||
/1970/,
|
||||
);
|
||||
|
||||
expect(formatRunPhaseAge("not-a-date", "fr", translate)).toBeNull();
|
||||
expect(
|
||||
formatRunPhaseAge("1970-01-01T00:00:00Z", "fr", translate),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunPhase — how long the run has been in this phase", () => {
|
||||
it("shows the age beside the phase, so a stalled run reads differently from a moving one", () => {
|
||||
render(
|
||||
<RunPhase
|
||||
status={RUNNING}
|
||||
code="running_agent"
|
||||
label={null}
|
||||
updatedAt={minutesAgo(41)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("run-phase-age")).toHaveTextContent(
|
||||
"il y a 41min",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the age in its own node, outside the clipped text, so a maximum-length label cannot push it out of sight", () => {
|
||||
// Arrange: the longest label the backend contract allows, in the surface
|
||||
// with the least room.
|
||||
const label = "x".repeat(200);
|
||||
|
||||
render(
|
||||
<RunPhase
|
||||
status={RUNNING}
|
||||
code="poll_prs"
|
||||
label={label}
|
||||
updatedAt={minutesAgo(7)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const text = screen.getByTestId("run-phase");
|
||||
const age = screen.getByTestId("run-phase-age");
|
||||
expect(text).not.toContainElement(age);
|
||||
});
|
||||
|
||||
it("shows no age at all against a service that reports phases without a timestamp", () => {
|
||||
render(<RunPhase status={RUNNING} code="running_agent" label={null} />);
|
||||
|
||||
expect(screen.getByTestId("run-phase")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("run-phase-age")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["unparseable", "not-a-date"],
|
||||
[
|
||||
"the epoch, which is how an unset datetime arrives",
|
||||
"1970-01-01T00:00:00Z",
|
||||
],
|
||||
])("shows no age when the timestamp is %s", (_case, updatedAt) => {
|
||||
render(
|
||||
<RunPhase
|
||||
status={RUNNING}
|
||||
code="running_agent"
|
||||
label={null}
|
||||
updatedAt={updatedAt}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("run-phase-age")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops the age on a failed run, whose phase is where it stopped rather than something still running", () => {
|
||||
// A months-old failure would otherwise render an absolute date — a
|
||||
// second timestamp beside the one the row already shows.
|
||||
render(
|
||||
<RunPhase
|
||||
status={AutomationRunStatus.FAILED}
|
||||
code="sandbox_provisioning"
|
||||
label={null}
|
||||
updatedAt={minutesAgo(41)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("run-phase")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("run-phase-age")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunPhase — reachable without a mouse", () => {
|
||||
it("leaves the whole phase, age included, in the accessibility tree", () => {
|
||||
// Truncation is CSS only, so a 200-character label is readable in full
|
||||
// by a screen reader as long as nothing hides it — and nothing may hide
|
||||
// it behind a tab stop, because every surface nests the phase inside a
|
||||
// link, where a focusable descendant is invalid and swallows Enter.
|
||||
const label = "x".repeat(200);
|
||||
|
||||
render(
|
||||
<RunPhase
|
||||
status={RUNNING}
|
||||
code="poll_prs"
|
||||
label={label}
|
||||
updatedAt={minutesAgo(7)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const text = screen.getByTestId("run-phase");
|
||||
const age = screen.getByTestId("run-phase-age");
|
||||
expect(text).toHaveTextContent(label);
|
||||
expect(age).toHaveTextContent("il y a 7min");
|
||||
expect(text).not.toHaveAttribute("aria-hidden");
|
||||
expect(age).not.toHaveAttribute("aria-hidden");
|
||||
// No focusable ancestor: the phase must never be its own tab stop
|
||||
// inside the link every surface wraps it in.
|
||||
expect(text.closest("[tabindex]")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -465,6 +465,46 @@ describe("home automations composer layout", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the pinned card's active-run phase using only the shared latest-run fetch, no extra request (home surface)", async () => {
|
||||
// Arrange: a single automation with a RUNNING run that has a phase.
|
||||
// `getAutomationRuns` is the one query both the row and the pinned
|
||||
// dashboard card read (shared cache key + params) — if showing the
|
||||
// phase required a second fetch, the call count below would exceed 1.
|
||||
vi.mocked(AutomationService.getAutomationRuns).mockResolvedValue({
|
||||
runs: [
|
||||
makeRun({
|
||||
status: AutomationRunStatus.RUNNING,
|
||||
completed_at: null,
|
||||
phase_code: "running_agent",
|
||||
phase_label: null,
|
||||
}),
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Act
|
||||
renderHomeAutomations(
|
||||
<>
|
||||
<PinnedAutomationsDashboard />
|
||||
<RunningAutomationsList />
|
||||
</>,
|
||||
);
|
||||
await screen.findByTestId("running-automations-list");
|
||||
await user.click(screen.getByTestId("running-automation-menu-auto-1"));
|
||||
await user.click(screen.getByTestId("running-automation-pin-auto-1"));
|
||||
|
||||
// Assert: the pinned card shows the phase ...
|
||||
const dashboard = await screen.findByTestId("pinned-automations-dashboard");
|
||||
expect(
|
||||
await within(dashboard).findByText(
|
||||
"AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
// ... and only one runs request was ever made for this automation.
|
||||
expect(AutomationService.getAutomationRuns).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows an error toast when turning an automation off fails", async () => {
|
||||
vi.mocked(AutomationService.toggleAutomation).mockRejectedValue(
|
||||
new Error("backend unavailable"),
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import { HomeAutomationRunTooltip } from "#/components/features/home/featured-automations/home-automation-run-tooltip";
|
||||
import type { LatestAutomationRunState } from "#/hooks/query/use-latest-automation-runs";
|
||||
import {
|
||||
AutomationRunStatus,
|
||||
type Automation,
|
||||
type AutomationRun,
|
||||
} from "#/types/automation";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
// Counted keys keep their count, so a test can assert which relative-time
|
||||
// key was chosen *and* the number handed to it, not just the key.
|
||||
t: (key: string, options?: Record<string, unknown>) =>
|
||||
options?.count === undefined ? key : `${key}:${options.count}`,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
}));
|
||||
|
||||
const automation: Automation = {
|
||||
id: "auto-1",
|
||||
name: "Release notes drafter",
|
||||
prompt: "Draft release notes",
|
||||
trigger: { type: "cron", schedule: "0 12 * * 5" },
|
||||
enabled: true,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
function makeRun(overrides: Partial<AutomationRun> = {}): AutomationRun {
|
||||
return {
|
||||
id: "run-1",
|
||||
status: AutomationRunStatus.RUNNING,
|
||||
conversation_id: null,
|
||||
bash_command_id: null,
|
||||
error_detail: null,
|
||||
started_at: "2026-08-01T10:00:00Z",
|
||||
completed_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeState(run: AutomationRun | null): LatestAutomationRunState {
|
||||
return {
|
||||
latestRun: run,
|
||||
recentRuns: run ? [run] : [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
// The row that opens this hovercard clips a long phase; the hovercard is the
|
||||
// place where the whole phase has to be readable.
|
||||
const LONG_LABEL =
|
||||
"Rendering the changelog for 37 merged pull requests across 4 repositories";
|
||||
|
||||
describe("HomeAutomationRunTooltip — phase", () => {
|
||||
it("shows the phase of a running run in full", () => {
|
||||
render(
|
||||
<HomeAutomationRunTooltip
|
||||
automation={automation}
|
||||
runState={makeState(
|
||||
makeRun({ phase_code: "drafting_notes", phase_label: LONG_LABEL }),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("run-phase-row")).toHaveTextContent(LONG_LABEL);
|
||||
});
|
||||
|
||||
it("translates a phase code the frontend knows instead of printing it raw", () => {
|
||||
render(
|
||||
<HomeAutomationRunTooltip
|
||||
automation={automation}
|
||||
runState={makeState(
|
||||
makeRun({ phase_code: "bundle_upload", phase_label: null }),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("run-phase-row")).toHaveTextContent(
|
||||
"AUTOMATIONS$DETAIL$PHASE_BUNDLE_UPLOAD",
|
||||
);
|
||||
});
|
||||
|
||||
it("says how long the run has held the phase, so a stall is visible without opening the run", () => {
|
||||
render(
|
||||
<HomeAutomationRunTooltip
|
||||
automation={automation}
|
||||
runState={makeState(
|
||||
makeRun({
|
||||
phase_code: "drafting_notes",
|
||||
phase_label: LONG_LABEL,
|
||||
phase_updated_at: new Date(Date.now() - 25 * 60_000).toISOString(),
|
||||
}),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("run-phase-row")).toHaveTextContent(
|
||||
"AUTOMATIONS$DETAIL$TIME_MINUTES_AGO:25",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the phase without an age against a service that sends no timestamp", () => {
|
||||
render(
|
||||
<HomeAutomationRunTooltip
|
||||
automation={automation}
|
||||
runState={makeState(
|
||||
makeRun({ phase_code: "drafting_notes", phase_label: LONG_LABEL }),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const row = screen.getByTestId("run-phase-row");
|
||||
expect(row).toHaveTextContent(LONG_LABEL);
|
||||
expect(row.textContent).not.toContain("AUTOMATIONS$DETAIL$TIME_");
|
||||
});
|
||||
|
||||
it("omits the phase row for a finished run, matching every other surface", () => {
|
||||
render(
|
||||
<HomeAutomationRunTooltip
|
||||
automation={automation}
|
||||
runState={makeState(
|
||||
makeRun({
|
||||
status: AutomationRunStatus.COMPLETED,
|
||||
completed_at: "2026-08-01T10:05:00Z",
|
||||
phase_code: "running_agent",
|
||||
phase_label: null,
|
||||
}),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("run-phase-row")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the phase row when the run never reported one", () => {
|
||||
render(
|
||||
<HomeAutomationRunTooltip
|
||||
automation={automation}
|
||||
runState={makeState(makeRun())}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("run-phase-row")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { useAutomationRunSummaries } from "#/hooks/query/use-automation-run-summaries";
|
||||
import AutomationService from "#/api/automation-service/automation-service.api";
|
||||
import {
|
||||
AutomationRunStatus,
|
||||
type Automation,
|
||||
type AutomationRun,
|
||||
} from "#/types/automation";
|
||||
|
||||
vi.mock("#/api/automation-service/automation-service.api", () => ({
|
||||
default: {
|
||||
getAutomationRuns: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/contexts/active-backend-context", () => ({
|
||||
useActiveBackend: () => ({
|
||||
backend: { id: "test-backend", kind: "local" },
|
||||
orgId: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const automations = [
|
||||
{ id: "auto-1", name: "Nightly QA" },
|
||||
] as unknown as Automation[];
|
||||
|
||||
function makeRun(status: AutomationRunStatus): AutomationRun {
|
||||
return {
|
||||
id: "run-1",
|
||||
status,
|
||||
conversation_id: null,
|
||||
bash_command_id: null,
|
||||
error_detail: null,
|
||||
started_at: "2026-08-01T10:00:00Z",
|
||||
completed_at:
|
||||
status === AutomationRunStatus.RUNNING ? null : "2026-08-01T10:05:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// The dashboard reads its cards from this hook. Its phase — and above all the
|
||||
// phase's age — is only honest if the data behind it keeps moving: a run that
|
||||
// is working through its phases must not sit on the card reading "Queued ·
|
||||
// 40m ago", which is exactly the stall signal the age exists to send.
|
||||
describe("useAutomationRunSummaries — keeping an in-flight card current", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("refetches while a sampled run is still in flight", async () => {
|
||||
vi.mocked(AutomationService.getAutomationRuns).mockResolvedValue({
|
||||
runs: [makeRun(AutomationRunStatus.RUNNING)],
|
||||
total: 1,
|
||||
});
|
||||
|
||||
renderHook(() => useAutomationRunSummaries(automations), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(AutomationService.getAutomationRuns).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(AutomationService.getAutomationRuns).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
});
|
||||
|
||||
it("stops once every sampled run has finished", async () => {
|
||||
vi.mocked(AutomationService.getAutomationRuns).mockResolvedValue({
|
||||
runs: [makeRun(AutomationRunStatus.COMPLETED)],
|
||||
total: 1,
|
||||
});
|
||||
|
||||
renderHook(() => useAutomationRunSummaries(automations), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(AutomationService.getAutomationRuns).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
|
||||
expect(AutomationService.getAutomationRuns).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops when the newest run finished, despite an older stuck run", async () => {
|
||||
// A crashed dispatcher leaves an old run non-terminal forever. The card
|
||||
// renders `runs[0]` and nothing else, so polling on *any* sampled run
|
||||
// pins this automation to a permanent refetch that never changes the
|
||||
// screen — one wasted request every 15s, per automation, indefinitely.
|
||||
vi.mocked(AutomationService.getAutomationRuns).mockResolvedValue({
|
||||
runs: [
|
||||
makeRun(AutomationRunStatus.COMPLETED),
|
||||
{ ...makeRun(AutomationRunStatus.RUNNING), id: "run-stuck" },
|
||||
],
|
||||
total: 2,
|
||||
});
|
||||
|
||||
renderHook(() => useAutomationRunSummaries(automations), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(AutomationService.getAutomationRuns).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
|
||||
expect(AutomationService.getAutomationRuns).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MOCK_AUTOMATION_RUNS } from "#/mocks/automation-runs.mock";
|
||||
import {
|
||||
resolveRunPhaseText,
|
||||
shouldShowRunPhase,
|
||||
} from "#/components/features/automations/detail/run-phase";
|
||||
import { AutomationRunStatus, type AutomationRun } from "#/types/automation";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
// `VITE_MOCK_API=true` is how the automations UI is developed and demoed
|
||||
// without a local automation service, so a phase branch that no fixture
|
||||
// reaches is a branch nobody sees before it ships. These assertions are on
|
||||
// the fixtures themselves: they fail when a case is dropped, which is the
|
||||
// only way a rendering branch quietly stops being exercised.
|
||||
const ALL_RUNS: AutomationRun[] = Object.values(MOCK_AUTOMATION_RUNS).flat();
|
||||
const IN_FLIGHT = ALL_RUNS.filter(
|
||||
(run) =>
|
||||
run.status === AutomationRunStatus.RUNNING ||
|
||||
run.status === AutomationRunStatus.PENDING,
|
||||
);
|
||||
|
||||
// Stands in for `t()`: a known code must resolve to something other than the
|
||||
// code itself, so the assertions below cannot pass on a raw code.
|
||||
const t = (key: I18nKey) => `translated:${key}`;
|
||||
|
||||
describe("mock automation runs — the phase branches mock mode has to reach", () => {
|
||||
it("has in-flight runs at all, or no surface ever shows a phase", () => {
|
||||
expect(IN_FLIGHT.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("covers a phase code the frontend translates", () => {
|
||||
const translated = IN_FLIGHT.filter(
|
||||
(run) =>
|
||||
resolveRunPhaseText(t, run.phase_code, run.phase_label)?.startsWith(
|
||||
"translated:",
|
||||
) ?? false,
|
||||
);
|
||||
|
||||
expect(translated.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("covers a custom automation's own code, falling back to its free-form label", () => {
|
||||
const custom = IN_FLIGHT.filter(
|
||||
(run) =>
|
||||
run.phase_code != null &&
|
||||
run.phase_label != null &&
|
||||
resolveRunPhaseText(t, run.phase_code, run.phase_label) ===
|
||||
run.phase_label,
|
||||
);
|
||||
|
||||
expect(custom.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("covers a phase carrying only a label, which the service also accepts", () => {
|
||||
const labelOnly = IN_FLIGHT.filter(
|
||||
(run) => run.phase_code == null && !!run.phase_label,
|
||||
);
|
||||
|
||||
expect(labelOnly.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("covers a phase carrying only a code, which the service also accepts", () => {
|
||||
const codeOnly = IN_FLIGHT.filter(
|
||||
(run) =>
|
||||
!run.phase_label &&
|
||||
!!run.phase_code &&
|
||||
resolveRunPhaseText(t, run.phase_code, run.phase_label) ===
|
||||
run.phase_code,
|
||||
);
|
||||
|
||||
expect(codeOnly.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("covers an in-flight run with no phase fields at all — an older service", () => {
|
||||
const noPhase = IN_FLIGHT.filter(
|
||||
(run) => run.phase_code === undefined && run.phase_label === undefined,
|
||||
);
|
||||
|
||||
expect(noPhase.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("covers a failed run that kept the phase it stopped at", () => {
|
||||
const failedWithPhase = ALL_RUNS.filter(
|
||||
(run) =>
|
||||
run.status === AutomationRunStatus.FAILED &&
|
||||
resolveRunPhaseText(t, run.phase_code, run.phase_label) != null,
|
||||
);
|
||||
|
||||
expect(failedWithPhase.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("covers a finished run that has a phase on record but never shows it", () => {
|
||||
const hiddenPhase = ALL_RUNS.filter(
|
||||
(run) =>
|
||||
!shouldShowRunPhase(run.status) &&
|
||||
resolveRunPhaseText(t, run.phase_code, run.phase_label) != null,
|
||||
);
|
||||
|
||||
expect(hiddenPhase.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("gives every phase-bearing run a usable timestamp, or the age would read 'Invalid Date'", () => {
|
||||
const withPhase = ALL_RUNS.filter(
|
||||
(run) => run.phase_code || run.phase_label,
|
||||
);
|
||||
expect(withPhase.length).toBeGreaterThan(0);
|
||||
|
||||
for (const run of withPhase) {
|
||||
if (run.phase_updated_at == null) continue;
|
||||
expect(Number.isNaN(new Date(run.phase_updated_at).getTime())).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every fixture within the service's own limits on code and label", () => {
|
||||
for (const run of ALL_RUNS) {
|
||||
if (run.phase_code)
|
||||
expect(run.phase_code.length).toBeLessThanOrEqual(128);
|
||||
if (run.phase_label)
|
||||
expect(run.phase_label.length).toBeLessThanOrEqual(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,7 @@ const sampleRow = (
|
||||
conversation_url: "http://localhost:8000/conversations/c1",
|
||||
error: "boom",
|
||||
cost: null,
|
||||
phase: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -113,6 +114,127 @@ describe("automation-activity-log-export", () => {
|
||||
expect(csv.split("\n")[1]).toContain("0.4213");
|
||||
});
|
||||
|
||||
it("carries the run's raw phase_code into the export row's phase field", () => {
|
||||
// Arrange
|
||||
const run = sampleRun({ phase_code: "sandbox_provisioning" });
|
||||
|
||||
// Act
|
||||
const row = mapAutomationRunToExportRow(run, automation);
|
||||
|
||||
// Assert
|
||||
expect(row.phase).toBe("sandbox_provisioning");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["null", null],
|
||||
["undefined", undefined],
|
||||
])(
|
||||
"normalizes a %s phase_code to a null phase field",
|
||||
(_label, phase_code) => {
|
||||
// Arrange
|
||||
const run = sampleRun({ phase_code });
|
||||
|
||||
// Act
|
||||
const row = mapAutomationRunToExportRow(run, automation);
|
||||
|
||||
// Assert
|
||||
expect(row.phase).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["an empty", ""],
|
||||
["a whitespace-only", " "],
|
||||
])(
|
||||
"falls back to the label when phase_code is %s string",
|
||||
(_label, phase_code) => {
|
||||
// The service stores a whitespace-only field as sent — it rejects only
|
||||
// a phase blank on *both* — so a blank code arrives alongside a real
|
||||
// label. Nullish-coalescing exports the blank and loses the label,
|
||||
// which is the empty cell the fallback exists to prevent.
|
||||
const run = sampleRun({
|
||||
phase_code,
|
||||
phase_label: "Running QA checks on PR #4821",
|
||||
});
|
||||
|
||||
const row = mapAutomationRunToExportRow(run, automation);
|
||||
|
||||
expect(row.phase).toBe("Running QA checks on PR #4821");
|
||||
},
|
||||
);
|
||||
|
||||
it("trims a padded phase before exporting it", () => {
|
||||
// Arrange
|
||||
const run = sampleRun({ phase_code: " queued ", phase_label: null });
|
||||
|
||||
// Act
|
||||
const row = mapAutomationRunToExportRow(run, automation);
|
||||
|
||||
// Assert
|
||||
expect(row.phase).toBe("queued");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["completed", AutomationRunStatus.COMPLETED],
|
||||
["cancelled", AutomationRunStatus.CANCELLED],
|
||||
])(
|
||||
"exports the last phase of a %s run, which no screen shows",
|
||||
(_label, status) => {
|
||||
// The export deliberately does not go through `shouldShowRunPhase`:
|
||||
// that predicate decides what deserves screen space, and hides the
|
||||
// phase once a run's status says everything. A record is not a screen —
|
||||
// filtering here would make a run that finished in a known phase
|
||||
// indistinguishable from one that never reported a phase at all.
|
||||
const run = sampleRun({
|
||||
status,
|
||||
phase_code: "running_agent",
|
||||
phase_label: null,
|
||||
});
|
||||
|
||||
const row = mapAutomationRunToExportRow(run, automation);
|
||||
|
||||
expect(row.phase).toBe("running_agent");
|
||||
},
|
||||
);
|
||||
|
||||
it("includes a phase column in the CSV header", () => {
|
||||
const rows: AutomationRunExportRow[] = [sampleRow({ phase: "queued" })];
|
||||
const csv = serializeActivityLogRowsCsv(rows);
|
||||
expect(csv.split("\n")[0].split(",")).toContain("phase");
|
||||
});
|
||||
|
||||
it("renders an empty cell (not the string 'null') for a run without a phase", () => {
|
||||
// Arrange: one run with a phase, one without — both in the same export.
|
||||
// `trigger` is overridden to a single-key object so its JSON-stringified,
|
||||
// quoted cell has no internal comma to confuse the naive column split
|
||||
// below (the CSV escaping itself is covered by a separate test).
|
||||
const rows: AutomationRunExportRow[] = [
|
||||
sampleRow({
|
||||
run_id: "r-with-phase",
|
||||
trigger: { type: "cron" },
|
||||
phase: "running_agent",
|
||||
}),
|
||||
sampleRow({
|
||||
run_id: "r-without-phase",
|
||||
trigger: { type: "cron" },
|
||||
phase: null,
|
||||
}),
|
||||
];
|
||||
|
||||
// Act
|
||||
const csv = serializeActivityLogRowsCsv(rows);
|
||||
const lines = csv.trim().split("\n");
|
||||
const header = lines[0].split(",");
|
||||
const phaseColumnIndex = header.indexOf("phase");
|
||||
|
||||
// Assert
|
||||
const withPhaseCells = lines[1].split(",");
|
||||
const withoutPhaseCells = lines[2].split(",");
|
||||
expect(withPhaseCells[phaseColumnIndex]).toBe("running_agent");
|
||||
expect(withoutPhaseCells[phaseColumnIndex]).toBe("");
|
||||
expect(withoutPhaseCells[phaseColumnIndex]).not.toBe("null");
|
||||
});
|
||||
|
||||
it("serializes CSV with conversation URL and escaped fields", () => {
|
||||
const rows: AutomationRunExportRow[] = [
|
||||
sampleRow({
|
||||
@@ -151,14 +273,22 @@ describe("automation-activity-log-export", () => {
|
||||
"http://localhost:8000/conversations/c2",
|
||||
);
|
||||
expect(AutomationService.listAutomationRuns).toHaveBeenCalledTimes(2);
|
||||
expect(AutomationService.listAutomationRuns).toHaveBeenNthCalledWith(1, "a1", {
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
expect(AutomationService.listAutomationRuns).toHaveBeenNthCalledWith(2, "a1", {
|
||||
limit: 100,
|
||||
offset: 1,
|
||||
});
|
||||
expect(AutomationService.listAutomationRuns).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"a1",
|
||||
{
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
},
|
||||
);
|
||||
expect(AutomationService.listAutomationRuns).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"a1",
|
||||
{
|
||||
limit: 100,
|
||||
offset: 1,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("downloads JSON after paging list runs", async () => {
|
||||
@@ -213,4 +343,20 @@ describe("automation-activity-log-export", () => {
|
||||
const blob = vi.mocked(downloadBlob).mock.calls[0][0] as Blob;
|
||||
expect(blob.type).toBe("text/csv;charset=utf-8");
|
||||
});
|
||||
|
||||
it("falls back to the label when a phase carries no code, so an empty cell means no phase", () => {
|
||||
// Arrange — the service accepts a phase with only a label, and the UI
|
||||
// shows it. Exporting the code alone would make such a run look identical
|
||||
// to one that never reported a phase.
|
||||
const run = sampleRun({
|
||||
phase_code: null,
|
||||
phase_label: "Reticulating splines",
|
||||
});
|
||||
|
||||
// Act
|
||||
const row = mapAutomationRunToExportRow(run, automation);
|
||||
|
||||
// Assert
|
||||
expect(row.phase).toBe("Reticulating splines");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
extensionModuleCardSurfaceClassName,
|
||||
} from "#/utils/extension-module-card-classes";
|
||||
import { toLatestRunState } from "./to-latest-run-state";
|
||||
import { RunPhase, shouldShowRunPhase } from "./detail/run-phase";
|
||||
import { RunStatusBadge } from "./detail/run-status-badge";
|
||||
import { AutomationRunActivitySparkline } from "#/components/features/home/featured-automations/automation-run-activity-sparkline";
|
||||
import type { RunSummaryState } from "#/manifests/automation-insights";
|
||||
@@ -101,6 +102,7 @@ export function AutomationCard({
|
||||
errorDetail != null &&
|
||||
shortErrorDetail != null &&
|
||||
shouldShowAutomationErrorHovercard(errorDetail, shortErrorDetail);
|
||||
const showPhase = shouldShowRunPhase(latestRun?.status);
|
||||
const disableAnimation = import.meta.env.MODE === "test";
|
||||
|
||||
return (
|
||||
@@ -209,6 +211,15 @@ export function AutomationCard({
|
||||
<>
|
||||
<RunStatusBadge status={latestRun.status} iconOnly showLabel />
|
||||
|
||||
{showPhase ? (
|
||||
<RunPhase
|
||||
status={latestRun.status}
|
||||
code={latestRun.phase_code}
|
||||
label={latestRun.phase_label}
|
||||
updatedAt={latestRun.phase_updated_at}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{shortErrorDetail ? (
|
||||
showErrorHovercard && errorDetail ? (
|
||||
<Tooltip
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
type Automation,
|
||||
type AutomationRun,
|
||||
} from "#/types/automation";
|
||||
import { isInvalidTimestamp } from "#/utils/format-relative-time";
|
||||
import { RunStatusBadge } from "./run-status-badge";
|
||||
import { RunPhase, shouldShowRunPhase } from "./run-phase";
|
||||
import { RunLogsModal } from "./run-logs-modal";
|
||||
|
||||
interface ActivityLogItemProps {
|
||||
@@ -27,12 +29,6 @@ function formatRunTimestamp(dateStr: string, locale: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function isInvalidTimestamp(dateStr: string | null | undefined): boolean {
|
||||
if (!dateStr) return true;
|
||||
const t = new Date(dateStr).getTime();
|
||||
return Number.isNaN(t) || t === 0;
|
||||
}
|
||||
|
||||
function getConversationUrl(conversationId: string): string {
|
||||
// In agent-canvas, conversations are at /conversations/:id
|
||||
return `/conversations/${conversationId}`;
|
||||
@@ -65,6 +61,7 @@ export function ActivityLogItem({ run, automation }: ActivityLogItemProps) {
|
||||
run.status === AutomationRunStatus.COMPLETED ||
|
||||
run.status === AutomationRunStatus.FAILED;
|
||||
const showNoConversationLabel = !hasConversation && isTerminal;
|
||||
const showPhase = shouldShowRunPhase(run.status);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
// The backend leaves started_at unset (epoch/zero) while a run is Pending
|
||||
// and only populates it once execution begins. Show the user's local time
|
||||
@@ -116,7 +113,7 @@ export function ActivityLogItem({ run, automation }: ActivityLogItemProps) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{formattedCost && (
|
||||
<span
|
||||
data-testid="run-cost"
|
||||
@@ -127,6 +124,15 @@ export function ActivityLogItem({ run, automation }: ActivityLogItemProps) {
|
||||
</span>
|
||||
)}
|
||||
{logsButton}
|
||||
{showPhase && (
|
||||
<RunPhase
|
||||
status={run.status}
|
||||
code={run.phase_code}
|
||||
label={run.phase_label}
|
||||
updatedAt={run.phase_updated_at}
|
||||
wide
|
||||
/>
|
||||
)}
|
||||
<RunStatusBadge status={run.status} />
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Tooltip } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { AutomationRunStatus } from "#/types/automation";
|
||||
import {
|
||||
formatRelativeTime,
|
||||
isInvalidTimestamp,
|
||||
} from "#/utils/format-relative-time";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
/**
|
||||
* Whether a run's phase is worth showing at all: it answers "what is it doing
|
||||
* now" or "where did it stop", so a finished, cancelled or skipped run has
|
||||
* nothing to add. Shared by every surface, so one run cannot show a phase on
|
||||
* one screen and hide it on another.
|
||||
*/
|
||||
export function shouldShowRunPhase(
|
||||
status: AutomationRunStatus | null | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
status === AutomationRunStatus.FAILED ||
|
||||
status === AutomationRunStatus.PENDING ||
|
||||
status === AutomationRunStatus.RUNNING
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the phase's age is worth showing. Only for a run still in flight:
|
||||
* the age exists to separate a moving run from a stalled one, and a run that
|
||||
* has failed is neither — its phase is the place it stopped. Left in, the age
|
||||
* of an old failure degrades to an absolute date (`formatRelativeTime` gives
|
||||
* up past a week), which reads as a second timestamp beside the one the row
|
||||
* already shows.
|
||||
*/
|
||||
export function shouldShowRunPhaseAge(
|
||||
status: AutomationRunStatus | null | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
status === AutomationRunStatus.PENDING ||
|
||||
status === AutomationRunStatus.RUNNING
|
||||
);
|
||||
}
|
||||
|
||||
interface RunPhaseFields {
|
||||
/** `AutomationRun.status` — decides whether the age is meaningful. */
|
||||
status: AutomationRunStatus | null | undefined;
|
||||
/** `AutomationRun.phase_code` — `null`/absent means no phase reported. */
|
||||
code: string | null | undefined;
|
||||
/** `AutomationRun.phase_label` — free-form author text, not interface copy. */
|
||||
label: string | null | undefined;
|
||||
/** `AutomationRun.phase_updated_at` — when this phase was last written. */
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
interface RunPhaseProps extends RunPhaseFields {
|
||||
/** More width before clipping, for rows far wider than a card. */
|
||||
wide?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codes the frontend can translate: the automation service's own milestones
|
||||
* and the preset templates'. Any other code is by design unknown — custom
|
||||
* automations emit their own — and falls back to `phase_label`.
|
||||
*/
|
||||
const KNOWN_PHASE_CODES: Record<string, I18nKey> = {
|
||||
queued: I18nKey.AUTOMATIONS$DETAIL$PHASE_QUEUED,
|
||||
sandbox_provisioning: I18nKey.AUTOMATIONS$DETAIL$PHASE_SANDBOX_PROVISIONING,
|
||||
bundle_upload: I18nKey.AUTOMATIONS$DETAIL$PHASE_BUNDLE_UPLOAD,
|
||||
entrypoint_start: I18nKey.AUTOMATIONS$DETAIL$PHASE_ENTRYPOINT_START,
|
||||
preparing: I18nKey.AUTOMATIONS$DETAIL$PHASE_PREPARING,
|
||||
running_agent: I18nKey.AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT,
|
||||
};
|
||||
|
||||
/**
|
||||
* The one place a stored phase becomes text, so a run cannot read one way on
|
||||
* a card and another way in that card's own tooltip. An absent code counts as
|
||||
* unrecognized rather than as an absent phase: the service accepts a phase
|
||||
* carrying only a label, and dropping those would hide a real phase.
|
||||
*
|
||||
* `code` and `label` are independently optional in the service's contract, so
|
||||
* the last resort is the raw code — a code-only phase is a real phase and
|
||||
* must reach the screen. It is shown as stored rather than prettified: the
|
||||
* code is data like the label, and turning `poll_prs` into "Poll prs" would
|
||||
* invent English-shaped copy no automation author wrote.
|
||||
*
|
||||
* Both fields are author-supplied, which is why the lookup is an own-property
|
||||
* check and both are trimmed. A code of `toString` would otherwise resolve to
|
||||
* `Object.prototype.toString` and be handed to `t()`, and the service stores
|
||||
* a whitespace-only field as sent — it rejects only a phase blank on *both*.
|
||||
*/
|
||||
export function resolveRunPhaseText(
|
||||
t: (key: I18nKey) => string,
|
||||
code: string | null | undefined,
|
||||
label: string | null | undefined,
|
||||
): string | null {
|
||||
const knownKey =
|
||||
code && Object.hasOwn(KNOWN_PHASE_CODES, code)
|
||||
? KNOWN_PHASE_CODES[code]
|
||||
: undefined;
|
||||
if (knownKey) return t(knownKey);
|
||||
return label?.trim() || code?.trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the run has been in this phase, as localized relative time.
|
||||
*
|
||||
* This is the half of the phase that separates progress from a stall: the
|
||||
* phase text alone says a run is "Running agent", and only its age says
|
||||
* whether it entered that phase seconds ago or forty minutes ago. Returns
|
||||
* `null` when the service reported no usable timestamp — an older service
|
||||
* omits the field entirely, and an unset datetime arrives as the epoch — so
|
||||
* an age nobody can compute never surfaces as "Invalid Date" or "Jan 1, 1970".
|
||||
*/
|
||||
export function formatRunPhaseAge(
|
||||
updatedAt: string | null | undefined,
|
||||
locale: string,
|
||||
t: (key: I18nKey, options?: Record<string, unknown>) => string,
|
||||
): string | null {
|
||||
if (!updatedAt || isInvalidTimestamp(updatedAt)) return null;
|
||||
return formatRelativeTime(updatedAt, locale, t);
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved phase of a run, or `null` when it has none worth showing.
|
||||
* Every surface goes through this — the clipped row below, the home
|
||||
* hovercard's wrapping one — so they cannot drift into resolving a phase, or
|
||||
* deciding to show its age, on their own terms.
|
||||
*/
|
||||
export function useRunPhase({
|
||||
status,
|
||||
code,
|
||||
label,
|
||||
updatedAt,
|
||||
}: RunPhaseFields): { text: string; age: string | null } | null {
|
||||
const { t, i18n } = useTranslation("openhands");
|
||||
|
||||
const text = resolveRunPhaseText(t, code, label);
|
||||
if (!text) return null;
|
||||
|
||||
const age = shouldShowRunPhaseAge(status)
|
||||
? formatRunPhaseAge(updatedAt, i18n.language, t)
|
||||
: null;
|
||||
|
||||
return { text, age };
|
||||
}
|
||||
|
||||
/**
|
||||
* A run's current or last-known phase and how long it has held it, clipped to
|
||||
* the room the surface has with the full text one hover away — author-supplied
|
||||
* labels routinely outgrow any row. The label is data, not interface copy, so
|
||||
* it is rendered as-is: passing it through `t()` would be wrong, it is not a
|
||||
* key. The age sits outside the clipped text so a long label can never push it
|
||||
* out of sight — it is the part that stays legible when everything else is cut.
|
||||
*
|
||||
* The text stays in the accessibility tree rather than hiding behind an
|
||||
* accessible name on a focusable wrapper. Every surface nests this inside a
|
||||
* link — the activity log's `<a>`, the cards' `role="link"` — where a tab
|
||||
* stop is invalid interactive nesting, and Enter on it bubbled to the card
|
||||
* and navigated the user away from the text they were trying to read.
|
||||
* Truncation is CSS only, so the full label is already in the DOM and reads
|
||||
* in full; the tooltip is the sighted mouse user's route to it.
|
||||
*/
|
||||
export function RunPhase({
|
||||
status,
|
||||
code,
|
||||
label,
|
||||
updatedAt,
|
||||
wide = false,
|
||||
}: RunPhaseProps) {
|
||||
const phase = useRunPhase({ status, code, label, updatedAt });
|
||||
if (!phase) return null;
|
||||
|
||||
const { text, age } = phase;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
<>
|
||||
{text}
|
||||
{age ? <span className="mt-1 block text-muted">{age}</span> : null}
|
||||
</>
|
||||
}
|
||||
placement="top"
|
||||
closeDelay={100}
|
||||
disableAnimation={import.meta.env.MODE === "test"}
|
||||
classNames={{
|
||||
content:
|
||||
"max-w-xs whitespace-pre-wrap break-words rounded-xl border border-[var(--oh-border)] bg-base-secondary px-3 py-2 text-left text-xs text-white shadow-xl",
|
||||
}}
|
||||
>
|
||||
<span className="flex min-w-0 cursor-default items-center gap-1">
|
||||
<span
|
||||
data-testid="run-phase"
|
||||
className={cn(
|
||||
"min-w-0 truncate text-xs text-muted",
|
||||
wide ? "max-w-[28rem]" : "max-w-[12rem]",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
{age ? (
|
||||
<span
|
||||
data-testid="run-phase-age"
|
||||
className="shrink-0 whitespace-nowrap text-xs text-muted"
|
||||
>
|
||||
· {age}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,15 @@ import { useTranslation } from "react-i18next";
|
||||
import type { LatestAutomationRunState } from "#/hooks/query/use-latest-automation-runs";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import ClockIcon from "#/icons/clock.svg?react";
|
||||
import { AutomationRunStatus, type Automation } from "#/types/automation";
|
||||
import {
|
||||
shouldShowRunPhase,
|
||||
useRunPhase,
|
||||
} from "#/components/features/automations/detail/run-phase";
|
||||
import {
|
||||
AutomationRunStatus,
|
||||
type Automation,
|
||||
type AutomationRun,
|
||||
} from "#/types/automation";
|
||||
import { formatRelativeTime } from "#/utils/format-relative-time";
|
||||
import { AutomationHealthIndicator } from "./automation-health-indicator";
|
||||
import {
|
||||
@@ -38,6 +46,37 @@ function PreviewRow({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The run's phase and how long it has held it. The row that opens this
|
||||
* hovercard has to clip a long phase; here there is room to wrap, so this is
|
||||
* where the whole thing is readable — and the age is what says whether the
|
||||
* run is moving through phases or sitting in one. Only the layout differs
|
||||
* from the row's: what the phase says, and whether its age is meaningful,
|
||||
* comes from the same hook the row uses.
|
||||
*/
|
||||
function PhaseRow({ run }: { run: AutomationRun | null }) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const phase = useRunPhase({
|
||||
status: run?.status,
|
||||
code: run?.phase_code,
|
||||
label: run?.phase_label,
|
||||
updatedAt: run?.phase_updated_at,
|
||||
});
|
||||
|
||||
if (!run || !shouldShowRunPhase(run.status) || !phase) return null;
|
||||
|
||||
const { text, age } = phase;
|
||||
|
||||
return (
|
||||
<PreviewRow label={t(I18nKey.AUTOMATIONS$DETAIL$PHASE)}>
|
||||
<span data-testid="run-phase-row">
|
||||
{text}
|
||||
{age ? <span className="text-muted"> · {age}</span> : null}
|
||||
</span>
|
||||
</PreviewRow>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Left-nav-style hovercard body for an automation's latest-run health.
|
||||
* Matches `ConversationCardPreview` layout: title + label/value rows.
|
||||
@@ -79,6 +118,8 @@ export function HomeAutomationRunTooltip({
|
||||
</span>
|
||||
</PreviewRow>
|
||||
|
||||
<PhaseRow run={latestRun} />
|
||||
|
||||
{timestamp ? (
|
||||
<PreviewRow label={t(I18nKey.AUTOMATIONS$DETAIL$LAST_RUN)}>
|
||||
{formatRelativeTime(timestamp, i18n.language, t)}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { buildAutomationMetadataPills } from "#/components/features/automations/build-automation-pills";
|
||||
import { EditAutomationModal } from "#/components/features/automations/detail/edit-automation-modal";
|
||||
import {
|
||||
RunPhase,
|
||||
shouldShowRunPhase,
|
||||
} from "#/components/features/automations/detail/run-phase";
|
||||
import { RunStatusBadge } from "#/components/features/automations/detail/run-status-badge";
|
||||
import { AutomationRunStats } from "#/components/features/automations/automation-run-insights";
|
||||
import { toRunSummaryState } from "#/components/features/automations/to-latest-run-state";
|
||||
@@ -88,6 +92,7 @@ export function PinnedAutomationCard({
|
||||
() => buildAutomationMetadataPills(automation, scheduleLabel),
|
||||
[automation, scheduleLabel],
|
||||
);
|
||||
const showPhase = shouldShowRunPhase(latestRun?.status);
|
||||
const errorDetail =
|
||||
latestRun?.status === AutomationRunStatus.FAILED
|
||||
? latestRun.error_detail?.trim() || null
|
||||
@@ -284,6 +289,15 @@ export function PinnedAutomationCard({
|
||||
}
|
||||
/>
|
||||
|
||||
{showPhase ? (
|
||||
<RunPhase
|
||||
status={latestRun.status}
|
||||
code={latestRun.phase_code}
|
||||
label={latestRun.phase_label}
|
||||
updatedAt={latestRun.phase_updated_at}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{shortErrorDetail ? (
|
||||
showErrorHovercard && errorDetail ? (
|
||||
<Tooltip
|
||||
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
summarizeAutomationRuns,
|
||||
type RunSummaryState,
|
||||
} from "#/manifests/automation-insights";
|
||||
import type { Automation } from "#/types/automation";
|
||||
import {
|
||||
AutomationRunStatus,
|
||||
type Automation,
|
||||
type AutomationRun,
|
||||
type AutomationRunsResponse,
|
||||
} from "#/types/automation";
|
||||
|
||||
/**
|
||||
* The newest runs sampled per automation. Matches the detail page's default
|
||||
@@ -14,6 +19,20 @@ import type { Automation } from "#/types/automation";
|
||||
*/
|
||||
const RECENT_RUN_SAMPLE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Poll interval while the newest run is non-terminal, matching
|
||||
* `useLatestAutomationRuns`: one request per listed automation, so a slower
|
||||
* cadence than the detail page's 3s. Without it the dashboard's phase and its
|
||||
* age freeze at whatever the first fetch saw, and a healthy run moving
|
||||
* through its phases reads as one stuck in the first — the opposite of what
|
||||
* the age is for.
|
||||
*/
|
||||
const IN_FLIGHT_POLL_INTERVAL_MS = 15_000;
|
||||
|
||||
const isInFlight = (run: AutomationRun) =>
|
||||
run.status === AutomationRunStatus.PENDING ||
|
||||
run.status === AutomationRunStatus.RUNNING;
|
||||
|
||||
interface UseAutomationRunSummariesOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
@@ -47,6 +66,18 @@ export function useAutomationRunSummaries(
|
||||
),
|
||||
staleTime: 60 * 1000,
|
||||
enabled: enabled && !!automation.id,
|
||||
refetchInterval: (query: {
|
||||
state: { data?: AutomationRunsResponse };
|
||||
}) => {
|
||||
// The newest run only, which is the one the dashboard renders.
|
||||
// `.some()` over the whole sample keeps polling forever when an
|
||||
// older run was left non-terminal by a crashed dispatcher, and
|
||||
// every one of those requests changes nothing on screen.
|
||||
const latest = query.state.data?.runs?.[0];
|
||||
return latest && isInFlight(latest)
|
||||
? IN_FLIGHT_POLL_INTERVAL_MS
|
||||
: false;
|
||||
},
|
||||
})),
|
||||
combine: (results) => {
|
||||
const byId = new Map<string, RunSummaryState>();
|
||||
|
||||
@@ -13854,6 +13854,125 @@
|
||||
"uk": "Пропущено",
|
||||
"ca": "Omès"
|
||||
},
|
||||
"AUTOMATIONS$DETAIL$PHASE": {
|
||||
"ar": "المرحلة",
|
||||
"ca": "Fase",
|
||||
"de": "Phase",
|
||||
"en": "Phase",
|
||||
"es": "Fase",
|
||||
"fr": "Phase",
|
||||
"it": "Fase",
|
||||
"ja": "フェーズ",
|
||||
"ko-KR": "단계",
|
||||
"no": "Fase",
|
||||
"pt": "Fase",
|
||||
"tr": "Aşama",
|
||||
"uk": "Етап",
|
||||
"zh-CN": "阶段",
|
||||
"zh-TW": "階段"
|
||||
},
|
||||
"AUTOMATIONS$DETAIL$PHASE_QUEUED": {
|
||||
"ar": "في قائمة الانتظار",
|
||||
"ca": "A la cua",
|
||||
"de": "In der Warteschlange",
|
||||
"en": "Queued",
|
||||
"es": "En cola",
|
||||
"fr": "En file d'attente",
|
||||
"it": "In coda",
|
||||
"ja": "待機中",
|
||||
"ko-KR": "대기 중",
|
||||
"no": "I kø",
|
||||
"pt": "Na fila",
|
||||
"tr": "Sırada",
|
||||
"uk": "У черзі",
|
||||
"zh-CN": "排队中",
|
||||
"zh-TW": "排隊中"
|
||||
},
|
||||
"AUTOMATIONS$DETAIL$PHASE_SANDBOX_PROVISIONING": {
|
||||
"ar": "جارٍ إعداد بيئة العمل",
|
||||
"ca": "Configurant l'entorn aïllat",
|
||||
"de": "Sandbox wird eingerichtet",
|
||||
"en": "Setting up sandbox",
|
||||
"es": "Configurando entorno aislado",
|
||||
"fr": "Préparation du bac à sable",
|
||||
"it": "Configurazione sandbox",
|
||||
"ja": "サンドボックスを準備中",
|
||||
"ko-KR": "샌드박스 준비 중",
|
||||
"no": "Setter opp sandkasse",
|
||||
"pt": "Configurando sandbox",
|
||||
"tr": "Sandbox hazırlanıyor",
|
||||
"uk": "Налаштування пісочниці",
|
||||
"zh-CN": "正在配置沙盒",
|
||||
"zh-TW": "正在設定沙盒"
|
||||
},
|
||||
"AUTOMATIONS$DETAIL$PHASE_BUNDLE_UPLOAD": {
|
||||
"ar": "جارٍ رفع الحزمة",
|
||||
"ca": "Pujant el paquet",
|
||||
"de": "Paket wird hochgeladen",
|
||||
"en": "Uploading bundle",
|
||||
"es": "Subiendo paquete",
|
||||
"fr": "Téléversement du paquet",
|
||||
"it": "Caricamento del pacchetto",
|
||||
"ja": "バンドルをアップロード中",
|
||||
"ko-KR": "번들 업로드 중",
|
||||
"no": "Laster opp pakke",
|
||||
"pt": "Enviando pacote",
|
||||
"tr": "Paket yükleniyor",
|
||||
"uk": "Завантаження пакета",
|
||||
"zh-CN": "正在上传软件包",
|
||||
"zh-TW": "正在上傳套件"
|
||||
},
|
||||
"AUTOMATIONS$DETAIL$PHASE_ENTRYPOINT_START": {
|
||||
"ar": "جارٍ البدء",
|
||||
"ca": "S'està iniciant",
|
||||
"de": "Wird gestartet",
|
||||
"en": "Starting",
|
||||
"es": "Iniciando",
|
||||
"fr": "Démarrage",
|
||||
"it": "Avvio in corso",
|
||||
"ja": "起動中",
|
||||
"ko-KR": "시작 중",
|
||||
"no": "Starter",
|
||||
"pt": "Iniciando",
|
||||
"tr": "Başlatılıyor",
|
||||
"uk": "Запуск",
|
||||
"zh-CN": "正在启动",
|
||||
"zh-TW": "正在啟動"
|
||||
},
|
||||
"AUTOMATIONS$DETAIL$PHASE_PREPARING": {
|
||||
"ar": "جارٍ التحضير",
|
||||
"ca": "Preparant",
|
||||
"de": "Wird vorbereitet",
|
||||
"en": "Preparing",
|
||||
"es": "Preparando",
|
||||
"fr": "Préparation",
|
||||
"it": "Preparazione",
|
||||
"ja": "準備中",
|
||||
"ko-KR": "준비 중",
|
||||
"no": "Forbereder",
|
||||
"pt": "Preparando",
|
||||
"tr": "Hazırlanıyor",
|
||||
"uk": "Підготовка",
|
||||
"zh-CN": "正在准备",
|
||||
"zh-TW": "正在準備"
|
||||
},
|
||||
"AUTOMATIONS$DETAIL$PHASE_RUNNING_AGENT": {
|
||||
"ar": "الوكيل قيد التشغيل",
|
||||
"ca": "Executant l'agent",
|
||||
"de": "Agent wird ausgeführt",
|
||||
"en": "Running agent",
|
||||
"es": "Ejecutando agente",
|
||||
"fr": "Agent en cours d'exécution",
|
||||
"it": "Agente in esecuzione",
|
||||
"ja": "エージェントを実行中",
|
||||
"ko-KR": "에이전트 실행 중",
|
||||
"no": "Kjører agent",
|
||||
"pt": "Executando agente",
|
||||
"tr": "Ajan çalışıyor",
|
||||
"uk": "Агент виконується",
|
||||
"zh-CN": "代理运行中",
|
||||
"zh-TW": "代理執行中"
|
||||
},
|
||||
"AUTOMATIONS$DETAIL$INACTIVE": {
|
||||
"en": "Inactive",
|
||||
"ja": "非アクティブ",
|
||||
|
||||
@@ -285,6 +285,11 @@ export const AUTOMATION_HANDLERS = [
|
||||
conversation_id: null,
|
||||
bash_command_id: null,
|
||||
error_detail: null,
|
||||
// A freshly dispatched run has not reported a phase yet — the service
|
||||
// sends the fields as null rather than omitting them.
|
||||
phase_code: null,
|
||||
phase_label: null,
|
||||
phase_updated_at: null,
|
||||
started_at: new Date().toISOString(),
|
||||
completed_at: null,
|
||||
};
|
||||
|
||||
@@ -7,12 +7,36 @@ const daysAgo = (days: number, hour = 9) => {
|
||||
return d.toISOString();
|
||||
};
|
||||
|
||||
const minutesAgo = (minutes: number) =>
|
||||
new Date(Date.now() - minutes * 60_000).toISOString();
|
||||
|
||||
/** Omitting this entirely is a case too: an older service sends no phase. */
|
||||
interface PhaseFixture {
|
||||
code: string | null;
|
||||
label: string | null;
|
||||
/** Minutes since the phase was last written; defaults to the run's start. */
|
||||
ageMinutes?: number;
|
||||
}
|
||||
|
||||
function phaseFields(phase: PhaseFixture | undefined, fallbackAt: string) {
|
||||
if (!phase) return {};
|
||||
return {
|
||||
phase_code: phase.code,
|
||||
phase_label: phase.label,
|
||||
phase_updated_at:
|
||||
phase.ageMinutes === undefined
|
||||
? fallbackAt
|
||||
: minutesAgo(phase.ageMinutes),
|
||||
};
|
||||
}
|
||||
|
||||
function makeRun(
|
||||
id: string,
|
||||
status: AutomationRunStatus,
|
||||
startedDaysAgo: number,
|
||||
hour = 9,
|
||||
hasConversation = true,
|
||||
phase?: PhaseFixture,
|
||||
): AutomationRun {
|
||||
const started = daysAgo(startedDaysAgo, hour);
|
||||
return {
|
||||
@@ -26,17 +50,56 @@ function makeRun(
|
||||
status === AutomationRunStatus.FAILED
|
||||
? "Process exited with code 1"
|
||||
: null,
|
||||
...phaseFields(phase, started),
|
||||
started_at: started,
|
||||
completed_at: new Date(new Date(started).getTime() + 120_000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A run that has not reached a terminal status, started relative to page load
|
||||
* so its elapsed time keeps growing while the dashboard stays open. These are
|
||||
* the rows the phase exists for: without one they render as a bare
|
||||
* "Running"/"Pending" pill for however long the run takes.
|
||||
*/
|
||||
function makeInFlightRun(
|
||||
id: string,
|
||||
status: AutomationRunStatus.RUNNING | AutomationRunStatus.PENDING,
|
||||
startedMinutesAgo: number,
|
||||
hasConversation = true,
|
||||
phase?: PhaseFixture,
|
||||
): AutomationRun {
|
||||
const startedAt = minutesAgo(startedMinutesAgo);
|
||||
return {
|
||||
id,
|
||||
status,
|
||||
conversation_id: hasConversation ? `conv-${id}` : null,
|
||||
bash_command_id: hasConversation ? `cmd-${id}` : null,
|
||||
error_detail: null,
|
||||
...phaseFields(phase, startedAt),
|
||||
started_at: startedAt,
|
||||
completed_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Newest run first: the cards and home rows read runs[0] as the latest, so
|
||||
// the in-flight rows below are the ones they show. Only three automations get
|
||||
// one: `latestRun` also drives health, "Last run" and the status badge, so a
|
||||
// never-terminal fixture pins all three to "running".
|
||||
export const MOCK_AUTOMATION_RUNS: Record<string, AutomationRun[]> = {
|
||||
"a1000000-0000-0000-0000-000000000001": [
|
||||
makeRun("r1-01", AutomationRunStatus.COMPLETED, 0),
|
||||
makeRun("r1-02", AutomationRunStatus.COMPLETED, 1),
|
||||
makeRun("r1-03", AutomationRunStatus.FAILED, 2),
|
||||
makeRun("r1-04", AutomationRunStatus.COMPLETED, 3),
|
||||
// Failed, keeping the phase it stopped at: died while the sandbox came up.
|
||||
makeRun("r1-03", AutomationRunStatus.FAILED, 2, 9, true, {
|
||||
code: "sandbox_provisioning",
|
||||
label: null,
|
||||
}),
|
||||
// A phase on record that no screen shows, but the export still carries.
|
||||
makeRun("r1-04", AutomationRunStatus.COMPLETED, 3, 9, true, {
|
||||
code: "running_agent",
|
||||
label: null,
|
||||
}),
|
||||
makeRun("r1-05", AutomationRunStatus.COMPLETED, 4),
|
||||
makeRun("r1-06", AutomationRunStatus.COMPLETED, 7),
|
||||
makeRun("r1-07", AutomationRunStatus.FAILED, 8),
|
||||
@@ -45,6 +108,11 @@ export const MOCK_AUTOMATION_RUNS: Record<string, AutomationRun[]> = {
|
||||
makeRun("r1-10", AutomationRunStatus.COMPLETED, 11),
|
||||
],
|
||||
"a1000000-0000-0000-0000-000000000002": [
|
||||
// Queued: no sandbox or conversation yet, and only the phase says so.
|
||||
makeInFlightRun("r2-00", AutomationRunStatus.PENDING, 2, false, {
|
||||
code: "queued",
|
||||
label: null,
|
||||
}),
|
||||
makeRun("r2-01", AutomationRunStatus.COMPLETED, 0, 1),
|
||||
makeRun("r2-02", AutomationRunStatus.COMPLETED, 1, 1),
|
||||
makeRun("r2-03", AutomationRunStatus.COMPLETED, 2, 1),
|
||||
@@ -52,6 +120,14 @@ export const MOCK_AUTOMATION_RUNS: Record<string, AutomationRun[]> = {
|
||||
makeRun("r2-05", AutomationRunStatus.COMPLETED, 4, 1),
|
||||
],
|
||||
"a1000000-0000-0000-0000-000000000003": [
|
||||
// The case the issue is about: 52 minutes into a job that usually takes
|
||||
// two, stuck on one step for 41 of them. The code is the automation's
|
||||
// own, so its label is shown verbatim — and is long enough to be clipped.
|
||||
makeInFlightRun("r3-00", AutomationRunStatus.RUNNING, 52, true, {
|
||||
code: "diffing_docs_tree",
|
||||
label: "Diffing 340 changed files against main",
|
||||
ageMinutes: 41,
|
||||
}),
|
||||
makeRun("r3-01", AutomationRunStatus.COMPLETED, 1),
|
||||
// Terminal statuses the backend emits besides COMPLETED/FAILED.
|
||||
makeRun("r3-02", AutomationRunStatus.CANCELLED, 2),
|
||||
@@ -63,6 +139,21 @@ export const MOCK_AUTOMATION_RUNS: Record<string, AutomationRun[]> = {
|
||||
],
|
||||
"a1000000-0000-0000-0000-000000000005": [],
|
||||
"a1000000-0000-0000-0000-000000000006": [
|
||||
// A label with no code: the service accepts that, so the UI must show it.
|
||||
makeInFlightRun("r6-00", AutomationRunStatus.RUNNING, 13, true, {
|
||||
code: null,
|
||||
label: "Running QA checks on PR #4821",
|
||||
ageMinutes: 4,
|
||||
}),
|
||||
// A code with no label: also accepted, and shown as the raw code.
|
||||
makeInFlightRun("r6-06", AutomationRunStatus.RUNNING, 6, true, {
|
||||
code: "checking_out",
|
||||
label: null,
|
||||
ageMinutes: 2,
|
||||
}),
|
||||
// No phase fields at all — an older service: a status pill, no empty
|
||||
// slot. Below the head, so it costs no card its health.
|
||||
makeInFlightRun("r6-07", AutomationRunStatus.RUNNING, 21),
|
||||
makeRun("r6-01", AutomationRunStatus.COMPLETED, 0, 14),
|
||||
makeRun("r6-02", AutomationRunStatus.COMPLETED, 0, 11),
|
||||
makeRun("r6-03", AutomationRunStatus.FAILED, 1, 16),
|
||||
|
||||
@@ -93,6 +93,21 @@ export interface AutomationRun {
|
||||
* that added the field, hence optional.
|
||||
*/
|
||||
cost?: number | null;
|
||||
/**
|
||||
* Machine-readable code for the run's current or last-known phase (e.g.
|
||||
* "sandbox_provisioning"). `null` means nothing has reported one; absent
|
||||
* entirely against an automation service that predates phase reporting.
|
||||
* Code and label are one value, always written together.
|
||||
*/
|
||||
phase_code?: string | null;
|
||||
/**
|
||||
* Author-supplied description of the phase (at most 200 characters, no
|
||||
* control or separator characters, emoji and non-Latin text allowed).
|
||||
* Data, not translatable interface copy.
|
||||
*/
|
||||
phase_label?: string | null;
|
||||
/** UTC datetime the phase was last written. Same nullability as `phase_code`. */
|
||||
phase_updated_at?: string | null;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
@@ -123,4 +138,9 @@ export interface AutomationRunExportRow {
|
||||
* missing field to null so every exported record has the same shape.
|
||||
*/
|
||||
cost: number | null;
|
||||
/**
|
||||
* The raw `phase_code`, like `status`, falling back to `phase_label` for a
|
||||
* phase reported without a code. Null only when the run has no phase.
|
||||
*/
|
||||
phase: string | null;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const CSV_COLUMNS = [
|
||||
// Appended rather than grouped with the other run metrics so existing
|
||||
// consumers that read the CSV by column position keep working.
|
||||
"cost",
|
||||
"phase",
|
||||
] as const;
|
||||
|
||||
export function getActivityLogExportFilename(
|
||||
@@ -104,6 +105,20 @@ export function mapAutomationRunToExportRow(
|
||||
// Exports carry the raw number and leave formatting to the consumer; the
|
||||
// Activity Log row is the only place that renders it as USD.
|
||||
cost: run.cost ?? null,
|
||||
// Deliberately not filtered through `shouldShowRunPhase`: that predicate
|
||||
// decides what is worth a user's attention on screen, and hides the phase
|
||||
// of a run whose status already says everything. An export is a record,
|
||||
// not a screen — a completed run's last phase is real data, and dropping
|
||||
// it here would make the phase of a run that finished indistinguishable
|
||||
// from one that never reported a phase at all.
|
||||
//
|
||||
// Falling back to the label: without it, a phase reported with only a
|
||||
// label would export as an empty cell, indistinguishable from no phase.
|
||||
// Trimmed rather than nullish-coalesced: the service stores a
|
||||
// whitespace-only field as sent — it rejects only a phase blank on
|
||||
// *both* — so `??` would let a blank code suppress a real label, which
|
||||
// is exactly the empty cell this fallback exists to prevent.
|
||||
phase: run.phase_code?.trim() || run.phase_label?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,20 @@ export function formatDate(dateStr: string, locale: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a timestamp is one nothing can be computed from. Besides an
|
||||
* unparseable string, that includes the epoch: the backend leaves unset
|
||||
* datetimes at zero, and formatting one produces "Jan 1, 1970" rather than
|
||||
* an honest "unknown".
|
||||
*/
|
||||
export function isInvalidTimestamp(
|
||||
dateStr: string | null | undefined,
|
||||
): boolean {
|
||||
if (!dateStr) return true;
|
||||
const parsed = new Date(dateStr).getTime();
|
||||
return Number.isNaN(parsed) || parsed === 0;
|
||||
}
|
||||
|
||||
export function formatRelativeTime(
|
||||
dateStr: string,
|
||||
locale: string,
|
||||
|
||||
Reference in New Issue
Block a user