Skip to content

promote: fix false "posted" status + missing View-post links (reconcile browser posts) - #103

Merged
ralyodio merged 1 commit into
masterfrom
promote/reconcile-browser-posts
Jul 17, 2026
Merged

ralyodio merged 1 commit into
masterfrom
promote/reconcile-browser-posts

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

The bug (reported)

On the Promote page, reddit / instagram / mastodon (and other cookie-auth platforms) show no "View post" link, and posts read as posted even though they never landed in the account (e.g. no matching post in Reddit history).

Root cause

Cookie-auth platforms post asynchronously. postViaAccount (lib/sp/post.ts) only enqueues an sp_post for the Playwright worker and returns { ok:true, webUrl:"" } before publishing. The sweep (lib/promote/sweep.ts) treated that as success and wrote the promo_post as status='posted' with post_url=null:

  • Missing link → the UI only renders "View post" when post_url is set.
  • False positive → the row is posted at enqueue time; if the async browser post later fails (dead cookies / login wall / image-gen failure) nothing ever reconciled it back, so it stays green with no link.

API-based platforms (bluesky/telegram/discord + OAuth reddit/mastodon) post synchronously and return a real URL — which is why those work.

Fix — reconcile the async outcome

Mirror the existing reconcileOutreach pattern:

  • Migration promo_post.sp_post_id → sp_post links the promo row to its queued browser post.
  • sweep.ts records cookie posts as pending (null url, no posted_at) with the sp_post_id; only synchronous posts are posted immediately. Adds a pending counter.
  • post.ts — PostOk.pending flag; the cookie branch returns pending:true.
  • reconcilePromo.ts (new) — when the worker publishes, set the real post_url + posted; when it fails, refund the credit and mark failed. Idempotent; no-op for non-Promote posts.
  • browserPost.ts calls reconcilePromo in both the success and failure paths.
  • UI — pending posts read as "posting…" until they settle.
  • Tests for reconcilePromo (posted / failed-refund / idempotent / non-promo).

Result

Cookie posts now show posting… → posted (with a real View-post link) or failed (with the error + credit refunded) based on what actually happened — no more phantom "posted".

⚠️ Apply the migration by hand

Per the runbook, prod migration history has diverged — apply supabase/migrations/20260717140000_promo_post_sp_link.sql via psql over the pooler after merge (nullable column + index, safe).

Not covered

Historical rows already mislabeled posted with a null URL aren't retroactively reconciled (no sp_post link existed for them) — this fixes it going forward.

Verified

tsc (app + worker) clean · next build compiles · reconcilePromo tests 4/4 · generate-pitch tests still green.

🤖 Generated with Claude Code

…post links

Cookie-auth platforms (reddit, instagram, mastodon, x, linkedin, facebook,
threads) post asynchronously: postViaAccount only *enqueues* an sp_post and
returns { ok:true, webUrl:"" } before Playwright actually publishes. The sweep
took that as success and wrote the promo_post as `posted` with a null post_url —
so the "View post" link was hidden and a post that later failed (dead cookies /
login wall) still read as "posted". That's the reported reddit false-positive.

Fix: record cookie posts as `pending` linked to their sp_post, and reconcile the
real outcome from the worker once the browser post settles.

- migration: promo_post.sp_post_id → sp_post (link for reconciliation)
- lib/sp/post.ts: PostOk.pending flag; cookie branch returns pending:true
- lib/promote/sweep.ts: pending-aware insert (status/url/posted_at/sp_post_id),
  pending counter; only sync API posts are `posted` immediately
- lib/promote/reconcilePromo.ts: on publish → set post_url + posted; on failure
  → refund the credit + mark failed (idempotent; no-op for non-promo posts)
- lib/sp/browserPost.ts: call reconcilePromo in the success + fail paths
- app/(app)/promote/[id]/page.tsx: show pending as "posting…"
- worker log + PromoteSweepResult: track pending
- tests: reconcilePromo (posted/failed-refund/idempotent/non-promo)

Note: existing historical rows already mislabeled `posted` with null URL aren't
retroactively reconciled (no sp_post link existed); this fixes it going forward.
Migration must be applied to prod by hand (psql over pooler) per the divergence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@ralyodio
ralyodio merged commit c3c77cb into master Jul 17, 2026
8 checks passed
@ralyodio
ralyodio deleted the promote/reconcile-browser-posts branch July 17, 2026 08:52
ralyodio added a commit that referenced this pull request Jul 17, 2026
…+ dead-session detection) (#105)

* promote: repair browser-post selectors (reddit/facebook/threads/instagram)

Best-effort fixes for the browser-automation posting failures now surfacing
correctly after the #103 reconciliation fix. Each targets the exact element the
Playwright error resolved to:

- instagram: file input is display:none — wait for state:'attached', not
  'visible' (a hidden input never becomes visible → the reported timeout).
- reddit: scope the lexical body editor to :visible — a hidden editor template
  otherwise won .first() and never became visible.
- facebook: drop the `[aria-label*="create"]` fallback (it substring-matched
  "…why you created moshcoding" on a hidden element); match the composer by
  button text instead; scope the editor to :visible.
- threads: broaden the compose trigger (labels + "What's new?") and scope the
  editor to :visible.

CAVEAT: these are UNVERIFIED — browser automation of live social sites can't be
tested without the account's cookie session + a browser, and the sites change
their DOM frequently. The durable fix is connecting reddit/facebook/threads via
OAuth (the API adapters in lib/sp/post.ts already post reliably and return a
real URL); Instagram has no post API so it stays browser-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* promote: evidence-based browser-post fixes from captured failure DOM

Pulled the real page HTML/URL from the failed sp_post rows (debug_html/url) and
fixed against the actual DOM instead of guessing:

- reddit: the real body editor is inside <shreddit-composer name="body">; Reddit
  ALSO renders a hidden duplicate data-lexical-editor in the flair-picker modal
  (<r-post-flair-edit-modal>, slot="editor") which .first() kept grabbing → the
  "resolved to hidden" timeout. Scope to the body composer + :visible.
- assertLoggedIn: wait for networkidle before judging (Instagram/Threads redirect
  to login CLIENT-SIDE after domcontentloaded, so the old immediate URL check
  missed dead sessions) and flag a near-empty shell as a login wall. Dead
  sessions now fail with SESSION_EXPIRED → account marked token_expired → the UI
  prompts a cookie reconnect instead of an opaque selector timeout.
- captureDom: capture <body> outerHTML, not the whole document — FB's multi-MB
  <head> was eating the 600k budget and truncating before the composer, making
  FB failures undiagnosable.

Diagnosis from the captured DOM (NOT code-fixable — user action):
- instagram cookies are DEAD (redirects to /accounts/emailsignup) → re-export.
- threads session dead/blocked (served a ~40-char shell) → re-export.
- facebook IS logged in ("(3) Facebook") but its composer was past the old
  truncation; the wider capture + #105 selector tweaks should surface next run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
ralyodio added a commit that referenced this pull request Jul 30, 2026
…gram)

Best-effort fixes for the browser-automation posting failures now surfacing
correctly after the #103 reconciliation fix. Each targets the exact element the
Playwright error resolved to:

- instagram: file input is display:none — wait for state:'attached', not
  'visible' (a hidden input never becomes visible → the reported timeout).
- reddit: scope the lexical body editor to :visible — a hidden editor template
  otherwise won .first() and never became visible.
- facebook: drop the `[aria-label*="create"]` fallback (it substring-matched
  "…why you created moshcoding" on a hidden element); match the composer by
  button text instead; scope the editor to :visible.
- threads: broaden the compose trigger (labels + "What's new?") and scope the
  editor to :visible.

CAVEAT: these are UNVERIFIED — browser automation of live social sites can't be
tested without the account's cookie session + a browser, and the sites change
their DOM frequently. The durable fix is connecting reddit/facebook/threads via
OAuth (the API adapters in lib/sp/post.ts already post reliably and return a
real URL); Instagram has no post API so it stays browser-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant