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 && } - {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:'}

    {rows.map(renderRow)}
@@ -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, + }; +};