diff --git a/src/api.ts b/src/api.ts
index 78d2465..67ba2ba 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -228,6 +228,60 @@ export async function getRepositories(
return handleLanguageFallback(() => createRequest(textLanguage), textLanguage, createRequest);
}
+/**
+ * Fetches a single repository by url, bypassing both the request queue and the
+ * display-language filter.
+ *
+ * It exists for reconciliation: after a publication whose answer was lost, the
+ * posted flag is the only record that says whether the post went out, and it has
+ * to be readable even for an item that has no text in the display language.
+ */
+export async function getRepositoryByUrl(url: string): Promise {
+ const { baseUrl, headers, isConfigured } = getApiConfig();
+
+ if (!isConfigured) {
+ throw new Error("API not configured. Check the Content Alchemist settings.");
+ }
+
+ const response = await fetch(`${baseUrl}/get-repository/`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ url, limit: 1 }),
+ });
+
+ if (!response.ok) {
+ if (response.status === 429) {
+ throw new Error("Rate limit exceeded. Please try again later.");
+ }
+ throw new Error(`Failed to fetch the repository: ${response.status}`);
+ }
+
+ const result = await response.json();
+
+ // Content Alchemist answers some errors with HTTP 200 and an error status in
+ // the body; treating one as an empty result would report a readable failure as
+ // "this repository does not exist".
+ if (result?.status === "error") {
+ throw new Error(result.message || "Content Alchemist rejected the request");
+ }
+
+ const items: Repository[] = result?.data?.items ?? [];
+ const item = items[0];
+
+ if (!item) {
+ return null;
+ }
+
+ // An older Content Alchemist ignores the url filter and answers with the head
+ // of the queue instead; reporting that item's posted state as this one's would
+ // be worse than reporting nothing.
+ if (item.url !== url) {
+ throw new Error("Content Alchemist ignored the url filter");
+ }
+
+ return item;
+}
+
export interface ManualGenerateResponse {
status: string;
added?: string[];
diff --git a/src/components/ui/business/publish-repository-dialog.tsx b/src/components/ui/business/publish-repository-dialog.tsx
index c65dde0..e7ab8c4 100644
--- a/src/components/ui/business/publish-repository-dialog.tsx
+++ b/src/components/ui/business/publish-repository-dialog.tsx
@@ -10,8 +10,13 @@ import {
} from '../base/dialog';
import { Button } from '../base/button';
import { IntegrationIcon, getLanguageFlag } from './integration-icons';
-import { publishMessageNow, type RetryMessageResult } from '../../../api/index';
-import { maestroErrorMessage } from '../../../utils/api-error';
+import { getCronJobHistory, publishMessageNow, type RetryMessageResult } from '../../../api/index';
+import { getRepositoryByUrl } from '../../../api';
+import {
+ classifyPublishFailure,
+ reconcilePublish,
+ type PublishReconcileOutcome,
+} from '../../../utils/publish-reconcile';
import {
buildPublishRows,
publishedSomething,
@@ -43,7 +48,7 @@ interface PublishRepositoryDialogProps {
onPublished: () => void | Promise;
}
-type Phase = 'idle' | 'promoting' | 'publishing' | 'done';
+type Phase = 'idle' | 'promoting' | 'publishing' | 'reconciling' | 'done';
/**
* A safety net rather than a cancellation: aborting the request does not stop the
@@ -52,6 +57,30 @@ type Phase = 'idle' | 'promoting' | 'publishing' | 'done';
*/
const PUBLISH_TIMEOUT_MS = 240_000;
+/** How many recent `message` runs to search for this repository's run. */
+const RECONCILE_HISTORY_LIMIT = 20;
+
+/**
+ * Recovers what a publication did when its own answer never arrived, from the two
+ * records that outlive the request: Content Maestro's run log and the posted flag
+ * in Content Alchemist. Each is read independently - one of them being
+ * unreachable still leaves the other worth reporting.
+ */
+const collectReconciliation = async (url: string, since: number): Promise => {
+ const [repository, history] = await Promise.allSettled([
+ getRepositoryByUrl(url),
+ getCronJobHistory('message', 1, RECONCILE_HISTORY_LIMIT),
+ ]);
+
+ return reconcilePublish({
+ url,
+ since,
+ posted: repository.status === 'fulfilled' ? repository.value?.posted ?? null : null,
+ history: history.status === 'fulfilled' ? history.value.data : [],
+ historyRead: history.status === 'fulfilled',
+ });
+};
+
const toneStyles = {
success: 'bg-success/20 text-success',
partial: 'bg-warning/10 text-warning',
@@ -72,6 +101,7 @@ export function PublishRepositoryDialog({
const [phase, setPhase] = useState('idle');
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
+ const [reconciled, setReconciled] = useState(null);
const abortRef = useRef(null);
// The repository the state on screen belongs to. One dialog instance serves
// every row, so a request that outlives its own target must not write its
@@ -87,16 +117,21 @@ export function PublishRepositoryDialog({
setPhase('idle');
setResult(null);
setError(null);
+ setReconciled(null);
}, [repository?.id]);
useEffect(() => () => abortRef.current?.abort(), []);
- const busy = phase === 'promoting' || phase === 'publishing';
+ const busy = phase === 'promoting' || phase === 'publishing' || phase === 'reconciling';
useEffect(() => {
onBusyChange?.(busy);
}, [busy, onBusyChange]);
- const rows = buildPublishRows(integrations, phase === 'idle' || phase === 'promoting' ? null : result);
+ const inFlight = phase === 'publishing' || phase === 'reconciling';
+ // Once a run has been reconciled, the rows recovered from Cron History are the
+ // only record of what reached which integration.
+ const reported = result ?? reconciled?.result ?? null;
+ const rows = buildPublishRows(integrations, inFlight ? null : reported);
const summary = result ? summarizePublishResult(result) : null;
const handlePromote = async () => {
@@ -125,14 +160,19 @@ export function PublishRepositoryDialog({
controller.abort();
}, PUBLISH_TIMEOUT_MS);
abortRef.current = controller;
+ const startedAt = Date.now();
setPhase('publishing');
setError(null);
- let answered = false;
+ setReconciled(null);
+ // A request that produced no result leaves the choices in place: refusals (a
+ // cron run holding the lock, an item already published) are worth another
+ // try, and the backend refuses a duplicate on its own.
+ let finalPhase: Phase = 'idle';
try {
const publishResult = await publishMessageNow(repository.url, { signal: controller.signal });
- answered = true;
if (targetIdRef.current !== targetId) return;
+ finalPhase = 'done';
setResult(publishResult);
if (publishedSomething(publishResult)) {
await onPublished();
@@ -141,25 +181,33 @@ export function PublishRepositoryDialog({
// Retargeting the dialog aborts this request on purpose; its outcome
// belongs to a repository nobody is looking at any more.
if (targetIdRef.current !== targetId) return;
- setError(
- timedOut.value
- ? 'Content Maestro did not answer in time. The publication may still be running — check Cron History before trying again.'
- : maestroErrorMessage(err)
- );
+
+ const failure = classifyPublishFailure(err, timedOut.value);
+ setError(failure.message);
// The run may have published before the connection broke, so the list has
// to be refreshed either way.
await onPublished();
+ if (targetIdRef.current !== targetId) return;
+
+ if (failure.unknown) {
+ // Nothing here can be inferred from the failed request, so ask the two
+ // services what actually happened instead of guessing.
+ setPhase('reconciling');
+ const outcome = await collectReconciliation(repository.url, startedAt);
+ if (targetIdRef.current !== targetId) return;
+ setReconciled(outcome);
+ if (outcome.published) {
+ finalPhase = 'done';
+ await onPublished();
+ }
+ }
} finally {
window.clearTimeout(timeout);
if (abortRef.current === controller) {
abortRef.current = null;
}
if (targetIdRef.current === targetId) {
- // A request that never produced a result leaves the choices in place, with
- // the error above them: refusals (a cron run holding the lock, an item
- // already published) are the kind of thing that is worth another try, and
- // the backend refuses a duplicate on its own.
- setPhase(answered ? 'done' : 'idle');
+ setPhase(finalPhase);
}
}
};
@@ -183,10 +231,10 @@ export function PublishRepositoryDialog({
{row.name}
{flag && {flag}}
- {row.state === 'pending' && phase === 'publishing' && (
+ {row.state === 'pending' && inFlight && (
<>
- Sending...
+ {phase === 'reconciling' ? 'Checking...' : 'Sending...'}
>
)}
{row.state === 'success' && (
@@ -268,6 +316,10 @@ export function PublishRepositoryDialog({
)}
+ {reconciled && (
+ {reconciled.message}
+ )}
+
{result?.posted_error && (
Published, but the repository could not be marked as posted — the scheduled run may
@@ -278,7 +330,7 @@ export function PublishRepositoryDialog({
{rows.length > 0 ? (
- {phase === 'done' ? 'Integrations:' : 'Will publish to:'}
+ {reported || inFlight ? 'Integrations:' : 'Will publish to:'}
@@ -324,10 +376,10 @@ export function PublishRepositoryDialog({
onClick={handlePublishNow}
disabled={busy || !isApiReady || integrationsLoading || integrations.length === 0}
>
- {phase === 'publishing' ? (
+ {phase === 'publishing' || phase === 'reconciling' ? (
<>
- Publishing...
+ {phase === 'reconciling' ? 'Checking...' : 'Publishing...'}
>
) : (
<>
diff --git a/src/utils/publish-reconcile.test.ts b/src/utils/publish-reconcile.test.ts
new file mode 100644
index 0000000..8659247
--- /dev/null
+++ b/src/utils/publish-reconcile.test.ts
@@ -0,0 +1,195 @@
+import { describe, expect, it } from 'vitest';
+import type { CronJobHistory } from '@/api/index';
+import { classifyPublishFailure, reconcilePublish } from './publish-reconcile';
+
+const URL = 'https://github.com/owner/repo';
+const NOW = Date.parse('2026-09-02T18:40:00Z');
+
+const run = (overrides: Partial & { sent?: string[]; failed?: string[] } = {}): CronJobHistory => {
+ const { sent, failed, ...rest } = overrides;
+ return {
+ name: 'message',
+ timestamp: '2026-09-02T18:42:17Z',
+ status: 1,
+ output: 'Manual publish finished',
+ details: { url: URL, manual: true, sent: sent ?? ['bluesky', 'threads'], failed: failed ?? [] },
+ ...rest,
+ };
+};
+
+const reconcile = (overrides: Partial[0]> = {}) =>
+ reconcilePublish({ url: URL, since: NOW, history: [], posted: null, historyRead: true, ...overrides });
+
+describe('classifyPublishFailure', () => {
+ it('treats a timeout as an unknown outcome', () => {
+ const failure = classifyPublishFailure(new Error('whatever'), true);
+
+ expect(failure.unknown).toBe(true);
+ expect(failure.message).toContain('may still be running');
+ });
+
+ it('treats an aborted request as an unknown outcome', () => {
+ const abort = Object.assign(new Error('The user aborted a request.'), { name: 'AbortError' });
+ const failure = classifyPublishFailure(abort, false);
+
+ expect(failure.unknown).toBe(true);
+ expect(failure.message).toContain('may still be running');
+ });
+
+ it('treats a dropped connection as an unknown outcome rather than a failed publication', () => {
+ const failure = classifyPublishFailure(new TypeError('Failed to fetch'), false);
+
+ expect(failure.unknown).toBe(true);
+ expect(failure.message).toContain('may still be running');
+ expect(failure.message).not.toContain('Failed to connect');
+ });
+
+ it('keeps an answered refusal as a known failure and shows what was said', () => {
+ const failure = classifyPublishFailure(new Error('repository is already published'), false);
+
+ expect(failure.unknown).toBe(false);
+ expect(failure.message).toBe('Content Maestro: repository is already published');
+ });
+});
+
+describe('reconcilePublish', () => {
+ it('confirms a complete run from the recorded one', () => {
+ const outcome = reconcile({ history: [run()], posted: true });
+
+ expect(outcome.published).toBe(true);
+ expect(outcome.tone).toBe('success');
+ expect(outcome.message).toBe('Cron History confirms the post went out to all 2 integrations.');
+ expect(outcome.result?.succeeded).toEqual(['bluesky', 'threads']);
+ });
+
+ it('names the single integration a one-integration run reached', () => {
+ const outcome = reconcile({ history: [run({ sent: ['bluesky'] })], posted: true });
+
+ expect(outcome.message).toContain('the integration');
+ });
+
+ it('sends a partial run that left the queue to Publish again', () => {
+ const outcome = reconcile({
+ history: [run({ sent: ['bluesky'], failed: ['threads'] })],
+ posted: true,
+ });
+
+ expect(outcome.published).toBe(true);
+ expect(outcome.tone).toBe('partial');
+ expect(outcome.message).toContain('reached 1 of 2 integrations');
+ expect(outcome.message).toContain('Publish again');
+ });
+
+ it('says a partial run that stayed in the queue will be published again', () => {
+ const outcome = reconcile({
+ history: [run({ sent: ['bluesky'], failed: ['threads'] })],
+ posted: false,
+ });
+
+ expect(outcome.message).toContain('still in the queue');
+ });
+
+ it('reports a run that published nothing as a failure', () => {
+ const outcome = reconcile({ history: [run({ sent: [], failed: ['bluesky', 'threads'] })], posted: false });
+
+ expect(outcome.published).toBe(false);
+ expect(outcome.tone).toBe('error');
+ expect(outcome.message).toContain('published nothing');
+ expect(outcome.result?.failed).toEqual(['bluesky', 'threads']);
+ });
+
+ it('ignores a scheduled run for the same repository', () => {
+ const scheduled = run({ details: { url: URL, manual: false, sent: ['bluesky', 'threads'] } });
+ const outcome = reconcile({ history: [scheduled], posted: false });
+
+ expect(outcome.result).toBeNull();
+ expect(outcome.message).toContain('has not finished yet');
+ });
+
+ it('does not send a run that published nothing to Publish again', () => {
+ const outcome = reconcile({ history: [run({ sent: [], failed: ['bluesky'] })], posted: true });
+
+ expect(outcome.published).toBe(true);
+ expect(outcome.message).toContain('another run has published it');
+ expect(outcome.message).not.toContain('Publish again');
+ });
+
+ it('admits when a run that published nothing cannot be placed in the queue', () => {
+ const outcome = reconcile({ history: [run({ sent: [], failed: ['bluesky'] })], posted: null });
+
+ expect(outcome.published).toBe(false);
+ expect(outcome.message).toContain('could not be checked');
+ });
+
+ it('admits when the posted state could not be read', () => {
+ const outcome = reconcile({ history: [run({ sent: ['bluesky'], failed: ['threads'] })], posted: null });
+
+ expect(outcome.message).toContain('could not be checked');
+ });
+
+ it('trusts the posted flag when no run is recorded yet', () => {
+ const outcome = reconcile({ posted: true });
+
+ expect(outcome.published).toBe(true);
+ expect(outcome.tone).toBe('partial');
+ expect(outcome.message).toContain('marked as published');
+ });
+
+ it('does not claim nothing was published while the run may still be finishing', () => {
+ const outcome = reconcile({ posted: false });
+
+ expect(outcome.published).toBe(false);
+ expect(outcome.message).toContain('has not finished yet');
+ });
+
+ it('reports an unreadable run list as unknown instead of as an absent run', () => {
+ const outcome = reconcile({ posted: false, historyRead: false });
+
+ expect(outcome.published).toBe(false);
+ expect(outcome.result).toBeNull();
+ expect(outcome.message).toContain('could not be checked');
+ });
+
+ it('still trusts the posted flag when the run list is unreadable', () => {
+ const outcome = reconcile({ posted: true, historyRead: false });
+
+ expect(outcome.published).toBe(true);
+ });
+
+ it('ignores a run recorded for another repository', () => {
+ const other = run({ details: { url: 'https://github.com/owner/other', sent: ['bluesky'] } });
+ const outcome = reconcile({ history: [other], posted: false });
+
+ expect(outcome.result).toBeNull();
+ expect(outcome.message).toContain('has not finished yet');
+ });
+
+ it('ignores a run that predates the request by more than the clock tolerance', () => {
+ const stale = run({ timestamp: '2026-09-02T18:00:00Z' });
+ const outcome = reconcile({ history: [stale], posted: false });
+
+ expect(outcome.result).toBeNull();
+ });
+
+ it('keeps a run whose timestamp is only slightly older than the request', () => {
+ const skewed = run({ timestamp: '2026-09-02T18:35:00Z' });
+ const outcome = reconcile({ history: [skewed], posted: true });
+
+ expect(outcome.published).toBe(true);
+ });
+
+ it('keeps a matching run whose timestamp cannot be parsed', () => {
+ const outcome = reconcile({ history: [run({ timestamp: 'not a date' })], posted: true });
+
+ expect(outcome.published).toBe(true);
+ });
+
+ it('takes the newest matching run', () => {
+ const older = run({ timestamp: '2026-09-02T18:41:00Z', sent: [], failed: ['bluesky'] });
+ const newer = run({ timestamp: '2026-09-02T18:42:17Z' });
+ const outcome = reconcile({ history: [newer, older], posted: true });
+
+ expect(outcome.published).toBe(true);
+ expect(outcome.result?.succeeded).toEqual(['bluesky', 'threads']);
+ });
+});
diff --git a/src/utils/publish-reconcile.ts b/src/utils/publish-reconcile.ts
new file mode 100644
index 0000000..a74fad0
--- /dev/null
+++ b/src/utils/publish-reconcile.ts
@@ -0,0 +1,219 @@
+import type { CronJobHistory, RetryMessageResult } from '../api/index';
+import { maestroErrorMessage } from './api-error';
+
+/**
+ * How much older than the request a recorded run may be and still be considered
+ * this run. The browser clock and the server clock are independent, and a phone
+ * that has just woken up can be minutes off, so the window has to absorb skew.
+ * Matching an older run of the same repository is not a real risk: a published
+ * repository leaves the queue, and publish-now refuses one that already left.
+ */
+const HISTORY_CLOCK_TOLERANCE_MS = 15 * 60_000;
+
+export interface PublishFailureClassification {
+ /**
+ * True when the request failed without an answer, so the run may have gone
+ * through regardless. Only a reply from Content Maestro can rule that out.
+ */
+ unknown: boolean;
+ message: string;
+}
+
+const isAbortError = (error: unknown): boolean =>
+ typeof error === 'object' && error !== null && (error as { name?: string }).name === 'AbortError';
+
+/**
+ * Separates "Content Maestro refused this" from "we never heard back".
+ *
+ * The distinction is the whole point: a lost answer used to be reported as a
+ * connection failure, which reads as "nothing was published" - and the posts had
+ * in fact gone out. A backgrounded PWA is enough to trigger it, because the
+ * browser suspends the page and tears the connection down mid-request.
+ */
+export const classifyPublishFailure = (error: unknown, timedOut: boolean): PublishFailureClassification => {
+ if (timedOut) {
+ return {
+ unknown: true,
+ message: 'Content Maestro did not answer in time. The publication may still be running.',
+ };
+ }
+
+ if (isAbortError(error)) {
+ return {
+ unknown: true,
+ message: 'The request ended before Content Maestro answered. The publication may still be running.',
+ };
+ }
+
+ // A rejected fetch: the service was unreachable, or the browser dropped the
+ // connection - suspending a backgrounded tab does exactly that. Which of the
+ // two it was cannot be told from here, and they mean opposite things.
+ if (error instanceof TypeError) {
+ return {
+ unknown: true,
+ message: 'No answer from Content Maestro - the connection dropped, which is what happens '
+ + 'when the browser suspends a tab in the background. The publication may still be running.',
+ };
+ }
+
+ return { unknown: false, message: maestroErrorMessage(error) };
+};
+
+export interface PublishReconcileInput {
+ url: string;
+ /** Epoch milliseconds at which the publish request was sent. */
+ since: number;
+ /** Recent `message` runs as Content Maestro reports them, newest first. */
+ history: CronJobHistory[];
+ /** Posted flag straight from Content Alchemist; null when it could not be read. */
+ posted: boolean | null;
+ /**
+ * Whether the run list could be read at all. An unreadable list is not an
+ * absent run, and the two must not lead to the same conclusion.
+ */
+ historyRead: boolean;
+}
+
+export interface PublishReconcileOutcome {
+ tone: 'success' | 'partial' | 'error';
+ message: string;
+ /** The recorded run, shaped so the dialog can render its per-integration rows. */
+ result: RetryMessageResult | null;
+ /** True when something definitely went out, so the dialog must not offer a retry. */
+ published: boolean;
+}
+
+/**
+ * The newest recorded run for this repository that can belong to our request.
+ *
+ * Only manual runs qualify: publish-now records itself as one, and a scheduled
+ * run for the same repository - which happens when an earlier run published it
+ * but failed to mark it posted, so it never left the queue - is a different
+ * publication. Reporting its result as ours is exactly the misattribution this
+ * reconciliation exists to avoid. A scheduled run is not lost by this: it still
+ * shows up through the posted flag below.
+ */
+const findRun = (url: string, since: number, history: CronJobHistory[]): CronJobHistory | undefined =>
+ history.find(entry => {
+ if (entry.details?.url !== url || entry.details?.manual !== true) return false;
+ const at = Date.parse(entry.timestamp);
+ // An unparsable timestamp must not discard an otherwise matching run: the url
+ // already narrows it down to this repository.
+ return Number.isNaN(at) || at >= since - HISTORY_CLOCK_TOLERANCE_MS;
+ });
+
+const queueNote = (posted: boolean | null, everythingSent: boolean): string => {
+ if (posted === null) return ' Whether it left the publication queue could not be checked.';
+ if (posted) {
+ return everythingSent
+ ? ''
+ : ' The repository left the queue, so finish the rest with "Publish again" in Cron History.';
+ }
+ return ' The repository is still in the queue, so the scheduled run will publish it again.';
+};
+
+/**
+ * Works out what a publication whose answer was lost actually did, from the two
+ * records that outlive the request: the run Content Maestro logged, and the
+ * repository's posted state in Content Alchemist.
+ */
+export const reconcilePublish = ({
+ url,
+ since,
+ history,
+ posted,
+ historyRead,
+}: PublishReconcileInput): PublishReconcileOutcome => {
+ const run = historyRead ? findRun(url, since, history) : undefined;
+
+ if (run) {
+ const sent = run.details?.sent ?? [];
+ const failed = run.details?.failed ?? [];
+ const result: RetryMessageResult = {
+ url,
+ status: run.status,
+ message: run.output ?? '',
+ succeeded: sent,
+ failed,
+ // Cron History keeps only the names, so there is no per-integration error
+ // to recover here.
+ outcomes: null,
+ posted: posted ?? undefined,
+ };
+
+ if (sent.length === 0) {
+ // The queue note does not apply here: this run sent nothing, so it cannot
+ // be the reason the repository left the queue. A posted repository means
+ // some other run published it, and saying "finish the rest" would be both
+ // wrong and an invitation to publish it twice.
+ if (posted === true) {
+ return {
+ tone: 'partial',
+ message: 'Cron History shows this run published nothing, but the repository is now marked '
+ + 'as published, so another run has published it. Open Cron History for the details.',
+ result,
+ published: true,
+ };
+ }
+
+ return {
+ tone: 'error',
+ message: 'Cron History shows the run published nothing.'
+ + (posted === false
+ ? ' The repository is still in the queue, so the scheduled run will publish it again.'
+ : ' Whether it left the publication queue could not be checked.'),
+ result,
+ published: false,
+ };
+ }
+
+ if (failed.length > 0) {
+ return {
+ tone: 'partial',
+ message: `Cron History shows the run reached ${sent.length} of ${sent.length + failed.length}`
+ + ' integrations.' + queueNote(posted, false),
+ result,
+ published: true,
+ };
+ }
+
+ const reach = sent.length === 1 ? 'the integration' : `all ${sent.length} integrations`;
+
+ return {
+ tone: 'success',
+ message: `Cron History confirms the post went out to ${reach}.` + queueNote(posted, true),
+ result,
+ published: true,
+ };
+ }
+
+ if (posted === true) {
+ return {
+ tone: 'partial',
+ message: 'No run is recorded yet, but the repository is now marked as published, so a '
+ + 'publication did go through. Open Cron History for the details.',
+ result: null,
+ published: true,
+ };
+ }
+
+ if (posted === false && historyRead) {
+ return {
+ tone: 'error',
+ // The run is logged when it ends, so an unfinished publication looks exactly
+ // like one that never happened. Saying "nothing was published" would be a
+ // guess, and it is the guess that causes a double post.
+ message: 'No run is recorded and the repository is still in the queue: either nothing was '
+ + 'published, or the run has not finished yet - Cron History will show it once it does.',
+ result: null,
+ published: false,
+ };
+ }
+
+ return {
+ tone: 'error',
+ message: 'What happened could not be checked. Open Cron History before trying again.',
+ result: null,
+ published: false,
+ };
+};