Skip to content

fix(repositories): stop calling a lost answer a failed publication - #16

Merged
sigmanor merged 2 commits into
mainfrom
fix/publish-now-unknown-outcome
Sep 2, 2026
Merged

sigmanor merged 2 commits into
mainfrom
fix/publish-now-unknown-outcome

Conversation

@sigmanor

@sigmanor sigmanor commented Sep 2, 2026

Copy link
Copy Markdown
Member

Problem

A publish-now request that ends without an answer was reported as Failed to connect to Content Maestro API, which reads as "nothing was published". In production the posts had in fact gone out: backgrounding the PWA mid-publish is enough to trigger it, because the browser suspends the page and tears the connection down while the request is still open. fetch then rejects with a TypeError, and apiErrorMessage asserts a connectivity failure it cannot actually know about.

What changes

  1. An unanswered request now reports an unknown outcome. classifyPublishFailure separates "Content Maestro refused this" (an HTTP answer, shown verbatim as before) from "we never heard back" (timeout, abort, dropped connection), and the second says the publication may still be running instead of claiming it failed.

  2. The dialog then establishes what actually happened. reconcilePublish reads the two records that outlive the request - the run Content Maestro logged in cron-history and the repository's posted flag in Content Alchemist - and reports the real outcome, including the per-integration rows recovered from the run. Each source is read independently, so one of them being unreachable still leaves the other worth reporting; an unreadable run list is reported as unknown rather than as an absent run.

An absent run is deliberately not reported as "nothing was published": runs are logged when they finish, so an unfinished publication looks exactly like one that never happened, and that guess is what causes a double post.

New code

  • src/utils/publish-reconcile.ts - classifyPublishFailure, reconcilePublish (pure, 19 unit tests).
  • src/api.ts - getRepositoryByUrl, a url-filtered lookup that skips the request queue and the display-language filter, and refuses an answer for another repository (an older Content Alchemist ignores the filter and returns the head of the queue).
  • publish-repository-dialog.tsx - a reconciling phase, the verdict banner, and rows built from the recovered run.

Verification

Unit: 47 tests pass (19 new). tsc clean apart from the two errors already on main in src/hooks/useRepositories.ts. npm run build passes. Local eslint is broken on main (unrelated rule-loading error), so it could not be run.

End to end against a local stack (content-alchemist + content-maestro + two connector stubs) with a proxy that forwards the publish request upstream and closes the client connection without answering - the exact production scenario:

Scenario Result
Connection dropped, run reached 1 of 2 integrations "No answer from Content Maestro..." then "Cron History shows the run reached 1 of 2 integrations. The repository left the queue, so finish the rest with Publish again", rows show which integration failed, table picks up the new Date Posted
Connection dropped, both connectors failing "Cron History shows the run published nothing. The repository is still in the queue, so the scheduled run will publish it again", both rows failed, buttons come back for a retry
Content Maestro answers an error (500 from an unreachable Content Alchemist) its own message shown verbatim, no reconciliation, buttons stay available

The browser also replayed the cut POST on its own during the test, and Content Maestro refused the replay with 409 (another publication is already running) - the duplicate-post guard from #15 holding up under the real failure mode.

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.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 2, 2026

Copy link
Copy Markdown

Deploying content-sentinel with  Cloudflare Pages  Cloudflare Pages

Latest commit: c58e016
Status: ✅  Deploy successful!
Preview URL: https://a4bf06f0.content-sentinel.pages.dev
Branch Preview URL: https://fix-publish-now-unknown-outc.content-sentinel.pages.dev

View logs

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".
@sigmanor

sigmanor commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

Review findings addressed in c58e016.

1. findRun could attribute a scheduled run to the manual click - confirmed and fixed. findRun now requires details.manual === true. Verified against the local stack's database: publish-now and Publish-again runs record {"url":...,"sent":[...],"manual":true}, while scheduled runs record no manual field at all ({"failed":["telegram"]}), so the predicate separates them exactly. Nothing is lost for a scheduled run that did publish the repository: it still surfaces through the posted flag, which reports "another run has published it".

The remaining same-window overlap is a manual Publish-again for the same url, which is a deliberate action by the same person rather than a background race.

2. Contradictory verdict when a matched run sent nothing but posted === true - confirmed and fixed. That combination no longer takes the queue note; it now reads "Cron History shows this run published nothing, but the repository is now marked as published, so another run has published it" and reports published: true, so the dialog does not offer a retry of an already-published repository. posted === false and posted === null keep their own wording.

3. getRepositoryByUrl ignoring a 200-with-error-status body - fixed; it now throws on result.status === "error" like its siblings, so a readable failure is reported as unknown rather than as "this repository does not exist".

Tests: 50 pass (3 new - a scheduled run being ignored, and the two sent-nothing verdicts). npm run build passes, tsc clean apart from the two errors already on main.

@sigmanor
sigmanor merged commit 2e2a9b0 into main Sep 2, 2026
1 check passed
@sigmanor
sigmanor deleted the fix/publish-now-unknown-outcome branch September 2, 2026 19:18
@sigmanor

sigmanor commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

🎉 This PR is included in version 1.51.1 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant