From 26cc9307db848c255bb8408e1e324936f3f384a5 Mon Sep 17 00:00:00 2001
From: sigmanor
Date: Wed, 2 Sep 2026 22:03:23 +0300
Subject: [PATCH 1/2] fix(repositories): stop calling a lost answer a failed
publication
A publish-now request that ends without an answer said "Failed to connect
to Content Maestro API", which reads as "nothing was published" - and the
posts had gone out. Backgrounding the PWA is enough to cause it: the
browser suspends the page and tears the connection down mid-request.
An unanswered request now reports an unknown outcome, and the dialog goes
on to establish the real one from the two records that outlive the
request - the run Content Maestro logged and the posted flag in Content
Alchemist - so the per-integration results are shown even though the
answer carrying them was lost. An answered refusal keeps saying what
Content Maestro said, and is not reconciled.
An absent run is deliberately not reported as "nothing was published":
runs are logged when they finish, so an unfinished one looks exactly the
same, and that guess is what causes a double post.
---
src/api.ts | 46 +++++
.../ui/business/publish-repository-dialog.tsx | 96 +++++++--
src/utils/publish-reconcile.test.ts | 172 ++++++++++++++++
src/utils/publish-reconcile.ts | 193 ++++++++++++++++++
4 files changed, 485 insertions(+), 22 deletions(-)
create mode 100644 src/utils/publish-reconcile.test.ts
create mode 100644 src/utils/publish-reconcile.ts
diff --git a/src/api.ts b/src/api.ts
index 78d2465..61b3594 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -228,6 +228,52 @@ 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();
+ 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..c3aa2bd
--- /dev/null
+++ b/src/utils/publish-reconcile.test.ts
@@ -0,0 +1,172 @@
+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('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..a615fd6
--- /dev/null
+++ b/src/utils/publish-reconcile.ts
@@ -0,0 +1,193 @@
+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. */
+const findRun = (url: string, since: number, history: CronJobHistory[]): CronJobHistory | undefined =>
+ history.find(entry => {
+ if (entry.details?.url !== url) 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) {
+ return {
+ tone: 'error',
+ message: 'Cron History shows the run published nothing.' + queueNote(posted, false),
+ 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,
+ };
+};
From c58e016ab783973e74668760594813d739bfe343 Mon Sep 17 00:00:00 2001
From: sigmanor
Date: Wed, 2 Sep 2026 22:15:54 +0300
Subject: [PATCH 2/2] fix(repositories): attribute a reconciled run to the
right publication
Three problems found in review of the reconciliation:
Any recorded run for the repository was accepted as ours, but Cron
History marks manual runs and a scheduled one for the same repository is
a different publication - which happens whenever an earlier run
published it and failed to mark it posted, so it never left the queue.
Only manual runs are matched now; a scheduled one still surfaces through
the posted flag.
A run that sent nothing was described with the queue note, so a
repository some other run had published read as "the run published
nothing, so finish the rest with Publish again" - contradictory, and an
invitation to publish it twice. That case now says another run published
it, and reports the repository as published.
getRepositoryByUrl trusted the body of any 200, but Content Alchemist
answers some errors that way, which turned a readable failure into "this
repository does not exist".
---
src/api.ts | 8 ++++++++
src/utils/publish-reconcile.test.ts | 23 +++++++++++++++++++++
src/utils/publish-reconcile.ts | 32 ++++++++++++++++++++++++++---
3 files changed, 60 insertions(+), 3 deletions(-)
diff --git a/src/api.ts b/src/api.ts
index 61b3594..67ba2ba 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -257,6 +257,14 @@ export async function getRepositoryByUrl(url: string): Promise {
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 });
diff --git a/src/utils/publish-reconcile.ts b/src/utils/publish-reconcile.ts
index a615fd6..a74fad0 100644
--- a/src/utils/publish-reconcile.ts
+++ b/src/utils/publish-reconcile.ts
@@ -83,10 +83,19 @@ export interface PublishReconcileOutcome {
published: boolean;
}
-/** The newest recorded run for this repository that can belong to our request. */
+/**
+ * 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) return false;
+ 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.
@@ -133,9 +142,26 @@ export const reconcilePublish = ({
};
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.' + queueNote(posted, false),
+ 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,
};