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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Repository | null> {
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[];
Expand Down
96 changes: 74 additions & 22 deletions src/components/ui/business/publish-repository-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -43,7 +48,7 @@ interface PublishRepositoryDialogProps {
onPublished: () => void | Promise<void>;
}

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
Expand All @@ -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<PublishReconcileOutcome> => {
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',
Expand All @@ -72,6 +101,7 @@ export function PublishRepositoryDialog({
const [phase, setPhase] = useState<Phase>('idle');
const [result, setResult] = useState<RetryMessageResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [reconciled, setReconciled] = useState<PublishReconcileOutcome | null>(null);
const abortRef = useRef<AbortController | null>(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
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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();
Expand All @@ -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);
}
}
};
Expand All @@ -183,10 +231,10 @@ export function PublishRepositoryDialog({
<span className="font-medium text-foreground capitalize">{row.name}</span>
{flag && <span aria-hidden="true">{flag}</span>}
<span className="ml-auto flex items-center gap-1 text-xs">
{row.state === 'pending' && phase === 'publishing' && (
{row.state === 'pending' && inFlight && (
<>
<Loader2 className="h-3 w-3 animate-spin" />
Sending...
{phase === 'reconciling' ? 'Checking...' : 'Sending...'}
</>
)}
{row.state === 'success' && (
Expand Down Expand Up @@ -268,6 +316,10 @@ export function PublishRepositoryDialog({
</p>
)}

{reconciled && (
<p className={`p-3 rounded-md text-sm ${toneStyles[reconciled.tone]}`}>{reconciled.message}</p>
)}

{result?.posted_error && (
<p className="bg-warning/10 text-warning p-3 rounded-md text-sm">
Published, but the repository could not be marked as posted — the scheduled run may
Expand All @@ -278,7 +330,7 @@ export function PublishRepositoryDialog({
{rows.length > 0 ? (
<div>
<h4 className="text-sm font-medium mb-2">
{phase === 'done' ? 'Integrations:' : 'Will publish to:'}
{reported || inFlight ? 'Integrations:' : 'Will publish to:'}
</h4>
<ul className="space-y-1 text-sm">{rows.map(renderRow)}</ul>
</div>
Expand Down Expand Up @@ -324,10 +376,10 @@ export function PublishRepositoryDialog({
onClick={handlePublishNow}
disabled={busy || !isApiReady || integrationsLoading || integrations.length === 0}
>
{phase === 'publishing' ? (
{phase === 'publishing' || phase === 'reconciling' ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Publishing...
{phase === 'reconciling' ? 'Checking...' : 'Publishing...'}
</>
) : (
<>
Expand Down
Loading