Skip to content

fix: prevent duplicate posts with a pending-post workflow (v1.0.6) - #1813

Merged
nevo-david merged 4 commits into
mainfrom
fix/duplicate-posts-pending-workflow
Aug 3, 2026
Merged

fix: prevent duplicate posts with a pending-post workflow (v1.0.6)#1813
nevo-david merged 4 commits into
mainfrom
fix/duplicate-posts-pending-workflow

Conversation

@nevo-david

@nevo-david nevo-david commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What kind of change does this PR introduce?

Bug fix (duplicate posting) + internal workflow infrastructure.

Why was this change needed?

Users reported duplicate posts (a real YouTube double-post incident on 2026-07-30). The root cause is structural: providers published and waited (polling loops, media uploads, post-publish steps) inside a single Temporal activity. When that activity timed out or failed after the irreversible publish call, Temporal's retries and the workflow's retry loop re-ran the whole activity — publishing again. TikTok's ~9-minute status poll, YouTube's single-call upload+publish, Instagram's container polls and post-publish permalink fetches, Facebook's story loop, and Threads' unbounded processing recursion were all variants of the same bug class.

This PR separates mutation from waiting:

  • New postWorkflowV106 (v1.0.5 untouched, per the workflow-immutability rule; both start sites flipped): the publish activity runs with maximumAttempts: 1, timeouts are treated as "outcome unknown — never retry, warn the user", and waiting happens in the workflow with durable timers.
  • New provider contract (pending / checkPostStatus / finalizePost with a documented ready state for multi-stage flows): opt-in per provider with safe defaults, so the other ~28 providers are byte-for-byte unaffected.
  • Providers adopted: TikTok (status polling moved to the workflow), YouTube (raw resumable upload sessions — nothing exists on the channel until the final byte, so failures leave zero residue and resume from the exact byte offset), Instagram + Instagram Standalone (containers up front, PUBLISHED-status crash recovery, best-effort permalinks), Facebook stories (per-item publish with an arm→confirm→publish handshake since FB has no queryable story-publish state), Threads (bounded polling — the old recursion could hang forever — plus crash recovery).
  • Old workflows still running keep the exact blocking behavior through the providers' post() wrappers, now capped below the 10-minute activity timeout so they fail cleanly instead of timing out into a retry.

Other information:

  • The system is in production: no activity signatures changed, no migrations needed, v1.0.1–v1.0.5 workflow files are byte-identical to main, and the postSocial activity behaves identically for in-flight runs (verified against origin/main call-by-call).
  • Tested against real platforms: TikTok and YouTube posts through both the new v1.0.6 path and the legacy v1.0.5 path (including large-video resumable batching). Suggested additional staging tests: IG single/carousel/multi-story, FB video+photo story, Threads text/carousel.
  • Follow-ups identified but out of scope: Reddit multi-subreddit loop (top remaining duplicate vector), Slack/Farcaster/Lemmy loops, and SocialAbstract.fetch auto-retrying publish POSTs on 429/500.

Checklist:

Put a "X" in the boxes below to indicate you have followed the checklist;

  • I have read the CONTRIBUTING guide.
  • I have signed the Contributor License Agreement (CLA) (ICLA for individuals, CCLA for entities).
  • I confirm I have not used AI to submit this PR or generate code for it.
  • I checked that there were no similar issues or PRs already open for this.
  • This PR fixes just ONE issue

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added resumable publishing for Facebook, Instagram, Threads, TikTok, and YouTube.
    • Posts can now continue through pending states while media processing completes.
    • Added support for checking publication progress and finalizing completed posts.
    • YouTube uploads now support interruption recovery and optional thumbnail application.
  • Bug Fixes

    • Prevented duplicate posts after uncertain timeouts or interrupted publishing attempts.
    • Improved handling of upload failures, token refreshes, and platform processing errors.
    • Posting results are now preserved even when optional streak updates cannot start.

Publishing and waiting no longer share one Temporal activity: providers can
return a 'pending' PostResponse and the new postWorkflowV106 resolves it with
durable timers via read-only checkPostStatus polls and finalizePost mutations
(maximumAttempts: 1, timeouts treated as unknown outcome - never retried).

Providers adopted:
- TikTok: publish returns pending with publish_id, status polled by the workflow
- YouTube: raw resumable upload sessions - no video exists until the final byte,
  crashed uploads leave no channel residue and resume from the exact byte offset
- Instagram (+standalone): containers created up front, processing polled by the
  workflow, publish/permalink in finalize with PUBLISHED-status crash recovery
- Facebook stories: per-item publish with an arm->confirm->publish handshake
  (no queryable publish state) and durable progress
- Threads: bounded container polling (was unbounded recursion), publish in
  finalize with PUBLISHED-status crash recovery

Old workflow versions keep exact blocking behavior through the providers'
post() wrappers, capped below the 10-minute activity timeout so a timeout can
never trigger a duplicate publish. Permalink fetches are best-effort - a
cosmetic URL can no longer fail (and re-publish) a live post.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 05:30
@postiz-contribution postiz-contribution Bot added the contribution:approved Approved contributor label Jul 31, 2026
@postiz-agent

postiz-agent Bot commented Jul 31, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

Comment on lines +833 to +843
{
id: response.id,
releaseURL: finalize.releaseURL,
postId: finalize.postId,
status: 'success',
},
];
}

pendingData = finalize.pendingData;
}

This comment was marked as outdated.

Comment on lines +874 to +879
lastMediaId,
checkToken,
pendingData.type,
integration
),
};

This comment was marked as outdated.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new Temporal posting workflow (v1.0.6) and a “pending-post” provider contract so that irreversible publish mutations do not get retried on activity timeouts/failures, preventing duplicate posts across multiple social providers.

Changes:

  • Added postWorkflowV106 and switched workflow start/signal sites to v1.0.6.
  • Extended the social provider contract with postPending + checkPostStatus + finalizePost, and updated several providers (YouTube/TikTok/IG/Facebook/Threads) to use pending resolution.
  • Updated PostActivity to support the pending flow via postSocialPending, plus new activities for checkPostStatus / finalizePost.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts Moves YouTube video publishing to resumable upload with pending resolution.
libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts Returns pending after publish and adds read-only status checks for workflow polling.
libraries/nestjs-libraries/src/integrations/social/threads.provider.ts Splits container creation from publish and adds pending resolution + bounded waits.
libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts Defines the pending-post contract types and required provider hooks.
libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts Delegates pending/status/finalize to the shared Instagram provider.
libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts Refactors IG container polling/publish into checkPostStatus + finalizePost.
libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts Refactors FB story flow into pending + safe publish handshake to avoid duplicates.
libraries/nestjs-libraries/src/integrations/social.abstract.ts Adds default throwing implementations for pending workflow hooks.
libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts Starts postWorkflowV106 for new posts instead of v1.0.5.
apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.ts New workflow that resolves pending results with durable timers and safer retry rules.
apps/orchestrator/src/workflows/index.ts Exports the new v1.0.6 workflow.
apps/orchestrator/src/activities/post.activity.ts Adds postSocialPending, plus activities for checkPostStatus / finalizePost.
Suppressed comments (1)

libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts:820

  • For single-media posts, when the container reports status_code=PUBLISHED this returns postId = containerId (creation id). The platform postId should be the media id returned by media_publish; persisting the container id can break later comment/insight calls that expect the real media id. A crash-recovery lookup (or an explicit unconfirmed state) is safer than saving the container id as the published postId.
          return {
            status: 'completed',
            postId: containerId,
            releaseURL: `https://www.instagram.com/${integration.profile}`,
          };

Comment thread libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts Outdated
Comment on lines +540 to +544
return {
status: 'completed',
postId: pendingData.containerId!,
releaseURL: `https://www.threads.net/@${integration.profile}`,
};
Comment on lines +791 to +795
return {
status: 'completed',
postId: pendingData.carouselId,
releaseURL: `https://www.instagram.com/${integration.profile}`,
};
Comment on lines +280 to +299
// The post is already published at this point: the streak is best-effort,
// failing the activity here would retry it and publish again.
try {
await this._temporalService.client
.getRawClient()
.workflow.start('streakWorkflow', {
args: [{ organizationId: integration.organizationId }],
workflowId: `streak_${integration.organizationId}`,
taskQueue: 'main',
workflowIdConflictPolicy: 'TERMINATE_EXISTING',
typedSearchAttributes: new TypedSearchAttributes([
{
key: organizationId,
value: integration.organizationId,
},
]),
});
} catch (err) {
/**empty**/
}
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- Changed files against base ---'
git diff --name-status origin/main...HEAD

echo
echo '--- Diff summary ---'
git diff --stat origin/main...HEAD

echo
echo '--- Workflow and lifecycle symbol locations ---'
rg -n -C 2 --glob '!node_modules/**' \
  '\b(postWorkflowV106|checkPostStatus|finalizePost|pending)\b' .

echo
echo '--- Workflow file outlines ---'
fd -t f -i '.*workflow.*\.(ts|tsx)$|.*workflow.*\.(js|jsx)$' . \
  | while IFS= read -r file; do
      if rg -q '\bpostWorkflowV106\b' "$file"; then
        echo "### $file"
        ast-grep outline "$file" --items all || true
      fi
    done

Length of output: 62503


🏁 Script executed:

#!/bin/bash
set -euo pipefail

show() {
  local file="$1" start="$2" end="$3"
  echo
  echo "### $file:$start-$end"
  sed -n "${start},${end}p" "$file" | nl -ba -v "$start"
}

show apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.ts 1 115
show apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.ts 210 445
show apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.ts 445 650
show apps/orchestrator/src/activities/post.activity.ts 55 345
show libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts 85 195
show libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts 569 680
show libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts 762 930
show libraries/nestjs-libraries/src/integrations/social/threads.provider.ts 505 602
show libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts 588 786
show libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts 415 492

echo
echo '### Start-site diff'
git diff origin/main...HEAD -- \
  apps/orchestrator/src/activities/post.activity.ts \
  libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts \
  apps/orchestrator/src/workflows/index.ts

echo
echo '### Existing workflow retry configuration for comparison'
fd -t f -i 'post.workflow.v1.0.5.ts' . -x sh -c \
  'echo "### $1"; sed -n "1,120p" "$1" | nl -ba' sh {}

Length of output: 1845


🏁 Script executed:

#!/bin/bash
set -euo pipefail

show() {
  local file="$1" start="$2" end="$3"
  echo
  echo "### $file:$start-$end"
  sed -n "${start},${end}p" "$file" | awk -v n="$start" '{printf "%6d  %s\n", n++, $0}'
}

show apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.ts 1 115
show apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.ts 210 445
show apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.ts 445 650
show apps/orchestrator/src/activities/post.activity.ts 55 345
show libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts 85 195
show libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts 569 680
show libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts 762 930
show libraries/nestjs-libraries/src/integrations/social/threads.provider.ts 505 602
show libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts 588 786
show libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts 415 492

echo
echo '### Start-site diff'
git diff origin/main...HEAD -- \
  apps/orchestrator/src/activities/post.activity.ts \
  libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts \
  apps/orchestrator/src/workflows/index.ts

echo
echo '### Existing workflow retry configuration for comparison'
fd -t f -i 'post.workflow.v1.0.5.ts' . -x sh -c \
  'echo "### $1"; sed -n "1,120p" "$1" | awk "{printf \"%6d  %s\\n\", NR, \$0}"' sh {}

Length of output: 74129


@nevo-david I found one duplicate-publication path.

  • postComment remains in proxyTaskQueue, which has maximumAttempts: 3.
  • The v1.0.6 workflow calls it at Line 377.
  • If the provider accepts a comment and the activity then times out or fails, Temporal can retry postComment and create a duplicate comment.
  • The workflow retry loop can also call it again after an unknown failure.

Move postComment to the no-retry mutation activity proxy, or give comment publishing an equivalent pending-status workflow. This change is necessary to apply the new no-retry guarantee to comments.

Static verification completed. I did not execute runtime tests.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Note

.coderabbit.yaml has unrecognized properties

CodeRabbit is using all valid settings from your configuration. Unrecognized properties (listed below) have been ignored and may indicate typos or deprecated fields that can be removed.

⚠️ Parsing warnings (1)
Validation error: Unrecognized key: "autopilot"
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Walkthrough

The change introduces resumable social publishing. Providers expose pending, status, and finalization operations. A new Temporal workflow coordinates polling, retries, state updates, notifications, plugs, and repeat posts. Workflow startup now targets version 1.0.6.

Changes

Pending posting contracts and activity bridge

Layer / File(s) Summary
Pending posting contracts and activity bridge
libraries/nestjs-libraries/src/integrations/social/*, apps/orchestrator/src/activities/post.activity.ts
Adds pending response types and provider lifecycle methods. Activities support pending posting, status checks, and finalization.

Facebook pending story lifecycle

Layer / File(s) Summary
Facebook pending story lifecycle
libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts
Facebook story uploads now return pending data, check readiness, finalize stories incrementally, and prevent duplicate publication.

Instagram pending container lifecycle

Layer / File(s) Summary
Instagram pending container lifecycle
libraries/nestjs-libraries/src/integrations/social/instagram*.provider.ts
Instagram separates container creation, status checks, and finalization. The blocking compatibility path remains available.

Threads pending container lifecycle

Layer / File(s) Summary
Threads pending container lifecycle
libraries/nestjs-libraries/src/integrations/social/threads.provider.ts
Threads supports pending containers, bounded status polling, finalization, and permalink fallback handling.

TikTok pending upload lifecycle

Layer / File(s) Summary
TikTok pending upload lifecycle
libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts
TikTok returns pending publish identifiers and separates status checks from the legacy blocking wrapper.

YouTube resumable upload lifecycle

Layer / File(s) Summary
YouTube resumable upload lifecycle
libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts
YouTube uses resumable sessions, ranged uploads, progress checks, finalization, and thumbnail processing.

Versioned pending-post workflow

Layer / File(s) Summary
Versioned pending-post workflow
apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.ts
Adds pending publication polling, categorized error handling, duplicate safeguards, notifications, webhooks, plugs, and repeat-post child workflows.

Workflow version wiring

Layer / File(s) Summary
Workflow version wiring
apps/orchestrator/src/workflows/index.ts, apps/orchestrator/src/activities/post.activity.ts, libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts
Exports and starts postWorkflowV106, including missing-post recovery.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Workflow as postWorkflowV106
  participant Activity as PostActivity
  participant Provider as SocialProvider
  participant State as PostState
  Workflow->>Activity: postSocialPending(post)
  Activity->>Provider: postPending(postDetails, integration)
  Provider-->>Activity: pendingData
  loop Durable polling
    Workflow->>Activity: checkPostStatus(pendingData)
    Activity->>Provider: checkPostStatus(pendingData)
    Provider-->>Activity: pending or ready
  end
  Workflow->>Activity: finalizePost(pendingData)
  Activity->>Provider: finalizePost(pendingData)
  Provider-->>Activity: completed response
  Workflow->>State: update state and send notifications
Loading

Possibly related issues

  • gitroomhq/postiz-app issue 1321 — The best-effort streak workflow startup directly addresses retry behavior caused by streak startup errors.

Possibly related PRs

Suggested reviewers: giladresisi

Poem

A rabbit hops through pending queues,
With story uploads, clips, and views.
It checks, then posts, then checks once more,
While durable workflows guard the door.
New version paths now bloom and run—
The carrot-shaped release is done! 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: preventing duplicate posts through the new pending-post workflow in v1.0.6.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/duplicate-posts-pending-workflow

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

apps/orchestrator/src/activities/post.activity.ts

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.

apps/orchestrator/src/workflows/index.ts

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.

apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.ts

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.

  • 9 others

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (3)
apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.ts (1)

109-112: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

poked is assigned but never read.

The poke handler must stay so signalWithStart from searchForMissingThreeHoursPosts does not leave an unhandled signal. The poked variable itself has no reader in the workflow. Either remove the variable or use it to skip the scheduled wait at Line 131.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.ts`
around lines 109 - 112, Remove the unread poked state while preserving the
setHandler(poke, ...) registration required for signalWithStart from
searchForMissingThreeHoursPosts; keep the handler as a no-op so the signal
remains handled.
libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts (1)

726-750: 🩺 Stability & Availability | 🔵 Trivial

Deploy the workers before this client change.

startWorkflow now requests postWorkflowV106 by name. If a backend instance starts this workflow type before every orchestrator worker on the main task queue runs a bundle that exports postWorkflowV106, the execution stays unstartable until a matching worker appears. Line 751 swallows the error, so the failure is silent. searchForMissingThreeHoursPosts in apps/orchestrator/src/activities/post.activity.ts targets the same name, so both paths depend on the same rollout order.

Roll out the workers first, then the backend.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts` around
lines 726 - 750, Ensure the deployment rollout publishes orchestrator workers
exporting postWorkflowV106 to the main task queue before deploying the backend
changes that invoke it via the Temporal workflow start path. Apply this ordering
consistently for both the posts service workflow start call and
searchForMissingThreeHoursPosts, and do not deploy the backend until matching
workers are available.
libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts (1)

931-991: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Guard the wrapper against a non-pending postPending result.

post reads response.pendingData without checking response.status. postPending always returns pending today, so the wrapper is correct. The Facebook and YouTube wrappers check the status first. Add the same check so a future non-pending return does not enter the loop with pendingData undefined.

♻️ Proposed guard
     const [firstPost] = postDetails;
     const [response] = await this.postPending(
       id,
       token,
       postDetails,
       integration,
       type
     );
 
+    if (response.status !== 'pending') {
+      return [response];
+    }
+
     let pendingData = response.pendingData;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts`
around lines 931 - 991, Update the post method to inspect response.status
immediately after postPending returns, before reading response.pendingData or
entering the polling loop. Handle any non-pending response using the same
outcome behavior as the Facebook and YouTube wrappers, while preserving the
existing pending polling flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.ts`:
- Around line 478-501: Restrict the retry loop in the main post publishing flow
around handleActivityError and postSocialPending so only failures that prove the
publish did not reach the platform, such as refresh_token, are retried. Treat an
unknown result from postSocialPending like the existing timeout branch: preserve
the ERROR state and exit without another iteration, while leaving unknown
failures from postComment and plug activities unchanged.
- Around line 305-341: The error handling around resolvePending must stop
retrying when finalizePost times out. Detect the timeout outcome from
handleActivityError(err), call markUnconfirmed(err), and return false, matching
the existing stop behavior; preserve retry handling for other transient errors
and existing bad-body handling.

In `@libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts`:
- Around line 569-615: Update the workflow’s maxPendingChecks configuration to
scale with the full story size, using pendingData.items.length or a fixed budget
above the maximum check/finalize operations required for every item. Ensure
multi-item stories can complete the arm/confirm handoff and each item’s final
publish without reaching markUnconfirmed or reporting ERROR prematurely.

In `@libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts`:
- Around line 467-479: Update the PUBLISH_COMPLETE handling to read
publicaly_available_post_id?.[0] once, then branch on that indexed value rather
than the array. Use the indexed ID for the TikTok video URL and string postId;
when it is absent, preserve the profile URL and pendingData.publishId fallback.

In `@libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts`:
- Around line 451-460: Update youtubeChunkStream to validate ranged HTTP
responses before returning response.body: require a 206 status and verify the
body matches the requested inclusive byte count (end - start + 1). Reject or
throw on any unexpected status or length so failed or full-file responses are
never streamed to YouTube; keep the local createReadStream path unchanged.
- Around line 810-843: Update the timeout guard in the finalizePost loop to
reserve time for the next finalizePost batch by comparing elapsed time against
the overall limit minus YOUTUBE_UPLOAD_BATCH_MS. Keep the existing 4.5-minute
wrapper limit and error behavior, while ensuring no new batch starts unless it
can complete within that budget.

---

Nitpick comments:
In `@apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.ts`:
- Around line 109-112: Remove the unread poked state while preserving the
setHandler(poke, ...) registration required for signalWithStart from
searchForMissingThreeHoursPosts; keep the handler as a no-op so the signal
remains handled.

In `@libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts`:
- Around line 726-750: Ensure the deployment rollout publishes orchestrator
workers exporting postWorkflowV106 to the main task queue before deploying the
backend changes that invoke it via the Temporal workflow start path. Apply this
ordering consistently for both the posts service workflow start call and
searchForMissingThreeHoursPosts, and do not deploy the backend until matching
workers are available.

In `@libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts`:
- Around line 931-991: Update the post method to inspect response.status
immediately after postPending returns, before reading response.pendingData or
entering the polling loop. Handle any non-pending response using the same
outcome behavior as the Facebook and YouTube wrappers, while preserving the
existing pending polling flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 12c1da82-448e-40c3-8540-17986e4ad1cf

📥 Commits

Reviewing files that changed from the base of the PR and between cf4c432 and e2cdd5e.

📒 Files selected for processing (12)
  • apps/orchestrator/src/activities/post.activity.ts
  • apps/orchestrator/src/workflows/index.ts
  • apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.ts
  • libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts
  • libraries/nestjs-libraries/src/integrations/social.abstract.ts
  • libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts
  • libraries/nestjs-libraries/src/integrations/social/threads.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts
  • libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts

Comment on lines +305 to +341
} catch (err) {
const handle = await handleActivityError(err);

// token refreshed, check again right away
if (handle.type === 'retry') {
continue;
}

// the token could not be refreshed while checking, but the platform
// already accepted the post - warn about a possible live post
if (handle.type === 'stop') {
await markUnconfirmed(err);
return false;
}

// the platform explicitly failed the post, it was not published
if (handle.type === 'bad-body') {
await changeState(postsList[0].id, 'ERROR', err, postsList);
await inAppNotification(
post.organizationId,
`Error posting on ${post.integration?.providerIdentifier} for ${post?.integration?.name}`,
`An error occurred while posting on ${
post.integration?.providerIdentifier
}${handle.message ? `: ${handle.message}` : ``}`,
true,
false,
'fail'
);
return false;
}

// unknown error on a read-only check, retry a few more times
errorAttempts++;
if (errorAttempts >= iterate.length) {
break;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect checkPostStatus/finalizePost pending-state handshakes in social providers.
set -euo pipefail

fd -e ts . libraries/nestjs-libraries/src/integrations/social \
  --exec rg -n -C 12 'checkPostStatus|finalizePost|attempting|confirmed' {}

Repository: gitroomhq/postiz-app

Length of output: 29765


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file outline =="
ast-grep outline apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.ts --view expanded | sed -n '1,220p'

echo "== relevant resolvePending section =="
sed -n '260,355p' apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.ts | nl -ba -v260

echo "== relevant outer catch/timeout section =="
sed -n '450,480p' apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.ts | nl -ba -v450

echo "== handleActivityError definition =="
rg -n -C 12 'function handleActivityError|export async function handleActivityError|const handleActivityError|handleActivityError' apps/orchestrator src libraries -S | sed -n '1,220p'

echo "== finalize metadata and proxy =="
rg -n -C 12 'finalizePost|maximumAttempts|ActivityFn|Proxy|handler' apps libraries -S | sed -n '1,260p'

Repository: gitroomhq/postiz-app

Length of output: 620


🏁 Script executed:

#!/bin/bash
set -euo pipefail
export PATH="/usr/local/bin:/usr/bin:/bin"

echo "== post workflow v1.0.6 relevant sections =="
awk '
NR>=260 && NR<=355 {printf "%4d\t%s\n", NR, $0}
NR>=450 && NR<=480 {printf "%4d\t%s\n", NR, $0}
' apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.ts

echo "== handleActivityError occurrences =="
rg -n -C 12 'handleActivityError' apps libraries -S | head -n 180 || true

echo "== finalize/post proxy occurrences =="
rg -n -C 12 'finalizePost|maximumAttempts|proxyMutationTaskQueue|ActivityFn' apps libraries -S | head -n 260 || true

Repository: gitroomhq/postiz-app

Length of output: 41053


Stop the pending-resolution loop when finalizePost times out.

resolvePending counts timeout as a transient unknown error, then retries checkPostStatus/finalizePost. A timeout marks the outcome unknown even if the provider-level mutation was already irreversible, so the workflow can re-authorize and run it again. Treat a finalized mutation timeout the same as the workflow-level timeout case: mark it unconfirmed and return false instead of retrying.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.ts`
around lines 305 - 341, The error handling around resolvePending must stop
retrying when finalizePost times out. Detect the timeout outcome from
handleActivityError(err), call markUnconfirmed(err), and return false, matching
the existing stop behavior; preserve retry handling for other transient errors
and existing bad-body handling.

Comment on lines +478 to +501
// for other errors, change state and inform the user if needed
await changeState(postsList[0].id, 'ERROR', err, postsList);

if (handle.type === 'stop') {
return false;
}

// specific case for bad body errors
if (handle.type === 'bad-body') {
await inAppNotification(
post.organizationId,
`Error posting${i === 0 ? ' ' : ' comments '}on ${
post.integration?.providerIdentifier
} for ${post?.integration?.name}`,
`An error occurred while posting${i === 0 ? ' ' : ' comments '}on ${
post.integration?.providerIdentifier
}${handle.message ? `: ${handle.message}` : ``}`,
true,
false,
'fail'
);
return false;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

An unknown failure from postSocialPending re-runs the publish.

handleActivityError returns unknown for every failure that is neither a TimeoutFailure nor an ApplicationFailure with type refresh_token or bad_body. In that case this block sets the post state to ERROR and the for (const _ of iterate) loop calls postSocialPending again, up to five times.

A timeout is not the only unconfirmed outcome. A worker crash, a cancellation, or a connection reset after the platform accepted the request also fails the activity without a TimeoutFailure cause. postSocialPending runs on the mutation proxy with maximumAttempts: 1 for exactly this reason, so the workflow-level retry reintroduces the duplicate publish that Lines 45-48 describe.

Restrict the retry loop to failures that prove the publish did not reach the platform, for example refresh_token. Treat unknown from postSocialPending as unconfirmed, as the timeout branch at Line 469 does. unknown from postComment and from the plug activities can keep the current retry behavior.

🛡️ Proposed handling for an unconfirmed main publish
         // the activity timed out: the platform may still complete the publish
         // in the background, so never retry it
-        if (handle.type === 'timeout') {
+        // the same applies to any unclassified failure of the publish mutation:
+        // the activity runs with maximumAttempts: 1 and its outcome is unknown
+        if (handle.type === 'timeout' || (i === 0 && handle.type === 'unknown')) {
           try {
             await markUnconfirmed(err);
           } catch (e) {
             /**empty**/
           }
           return false;
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.ts`
around lines 478 - 501, Restrict the retry loop in the main post publishing flow
around handleActivityError and postSocialPending so only failures that prove the
publish did not reach the platform, such as refresh_token, are retried. Treat an
unknown result from postSocialPending like the existing timeout branch: preserve
the ERROR state and exit without another iteration, while leaving unknown
failures from postComment and plug activities unchanged.

Comment on lines +569 to +615
override async checkPostStatus(
accessToken: string,
pendingData: {
postType: 'story';
items: { kind: 'video' | 'photo'; mediaId: string }[];
publishedCount: number;
lastPostId: string;
attempting?: number | null;
confirmed?: boolean;
},
integration: Integration
): Promise<PendingCheckResponse> {
// A confirmed publish attempt died without reporting its result: Facebook
// has no API to ask whether a story was published, so never publish that
// item again - stop with an explicit warning instead.
if (pendingData.attempting != null && pendingData.confirmed) {
throw new BadBody(
this.identifier,
'{}',
'{}',
'Facebook may have already published part of the story, please check your page before posting again to avoid duplicates'
);
}

// wait for every not-yet-published video to finish processing, photos are
// ready as soon as they are uploaded
for (const item of pendingData.items.slice(pendingData.publishedCount)) {
if (item.kind !== 'video') {
continue;
}

if (!(await this.fbVideoStatus(item.mediaId, accessToken))) {
return { status: 'pending', pendingData };
}
}

// witness the armed publish so finalizePost knows the attempt is uniquely
// accounted for before it mutates anything
if (pendingData.attempting != null && !pendingData.confirmed) {
return {
status: 'ready',
pendingData: { ...pendingData, confirmed: true },
};
}

return { status: 'ready', pendingData };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the check budget and the sleep interval in the new workflow.
fd -t f 'post.workflow.v1.0.6.ts' apps/orchestrator --exec rg -n 'maxPendingChecks|iterate|sleep\(' {}

Repository: gitroomhq/postiz-app

Length of output: 693


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Facebook provider outline =="
ast-grep outline libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts --view compact | sed -n '1,220p' || true

echo
echo "== Relevant facebook.provider.ts sections =="
sed -n '520,705p' libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts

echo
echo "== Workflow relevant lines =="
sed -n '240,360p' apps/orchestrator/src/lib/post.workflow.v1.0.6.ts
sed -n '540,600p' apps/orchestrator/src/lib/post.workflow.v1.0.6.ts

Repository: gitroomhq/postiz-app

Length of output: 6007


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate workflow files =="
git ls-files | rg 'post.workflow.v1.0.6|postWorkflowV106|maxPendingChecks|checkPostStatus|finalizePost' || true

echo
echo "== Search workflow maxPendingChecks and finalize/check call sites =="
rg -n "maxPendingChecks|checkPostStatus|finalizePost|sleep\\(" apps/orchestrator -S

echo
echo "== Inspect workflow files =="
for f in $(git ls-files apps/orchestrator | rg 'post.workflow.v1.0.6'); do
  echo "--- $f"
  wc -l "$f"
  sed -n '240,360p' "$f"
  sed -n '540,600p' "$f"
done

Repository: gitroomhq/postiz-app

Length of output: 11488


Set maxPendingChecks for the full story publish handoff.

post.workflow.v1.0.6.ts has maxPendingChecks = 45; this is enough for one 15-item story. Multi-item stories still need more than one iteration per item, because they can take one checkPostStatus plus one finalizePost for arm/confirm and another finalizePost for each published item. If the limit is reached before the last item’s final publish, the workflow calls markUnconfirmed and reports ERROR even though media was already published. Scale the budget with pendingData.items.length or raise it above the maximum per-story check/finalize count.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts`
around lines 569 - 615, Update the workflow’s maxPendingChecks configuration to
scale with the full story size, using pendingData.items.length or a fixed budget
above the maximum check/finalize operations required for every item. Ensure
multi-item stories can complete the arm/confirm handoff and each item’s final
publish without reaching markUnconfirmed or reporting ERROR prematurely.

Comment on lines +467 to +479
if (status === 'PUBLISH_COMPLETE') {
return {
status: 'completed',
releaseURL: !publicaly_available_post_id
? `https://www.tiktok.com/@${integration.profile}`
: `https://www.tiktok.com/@${integration.profile}/video/` +
publicaly_available_post_id,
// TikTok returns the id as a number, releaseId in the db is a string
postId: !publicaly_available_post_id
? pendingData.publishId
: String(publicaly_available_post_id?.[0]),
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Index publicaly_available_post_id consistently.

publicaly_available_post_id is an array. An empty array is truthy, so the !publicaly_available_post_id guard does not catch it. In that case releaseURL becomes https://www.tiktok.com/@profile/video/ and postId becomes the string "undefined", which is then persisted as releaseId.

The releaseURL branch also interpolates the whole array. For more than one element it produces a comma-joined value.

Read publicaly_available_post_id?.[0] once and branch on that value.

🐛 Proposed fix
     if (status === 'PUBLISH_COMPLETE') {
+      const publicPostId = publicaly_available_post_id?.[0];
+
       return {
         status: 'completed',
-        releaseURL: !publicaly_available_post_id
+        releaseURL: !publicPostId
           ? `https://www.tiktok.com/@${integration.profile}`
-          : `https://www.tiktok.com/@${integration.profile}/video/` +
-            publicaly_available_post_id,
+          : `https://www.tiktok.com/@${integration.profile}/video/${publicPostId}`,
         // TikTok returns the id as a number, releaseId in the db is a string
-        postId: !publicaly_available_post_id
-          ? pendingData.publishId
-          : String(publicaly_available_post_id?.[0]),
+        postId: !publicPostId ? pendingData.publishId : String(publicPostId),
       };
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (status === 'PUBLISH_COMPLETE') {
return {
status: 'completed',
releaseURL: !publicaly_available_post_id
? `https://www.tiktok.com/@${integration.profile}`
: `https://www.tiktok.com/@${integration.profile}/video/` +
publicaly_available_post_id,
// TikTok returns the id as a number, releaseId in the db is a string
postId: !publicaly_available_post_id
? pendingData.publishId
: String(publicaly_available_post_id?.[0]),
};
}
if (status === 'PUBLISH_COMPLETE') {
const publicPostId = publicaly_available_post_id?.[0];
return {
status: 'completed',
releaseURL: !publicPostId
? `https://www.tiktok.com/@${integration.profile}`
: `https://www.tiktok.com/@${integration.profile}/video/${publicPostId}`,
// TikTok returns the id as a number, releaseId in the db is a string
postId: !publicPostId ? pendingData.publishId : String(publicPostId),
};
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts` around
lines 467 - 479, Update the PUBLISH_COMPLETE handling to read
publicaly_available_post_id?.[0] once, then branch on that indexed value rather
than the array. Use the indexed ID for the TikTok video URL and string postId;
when it is absent, preserve the profile URL and pendingData.publishId fallback.

Comment on lines +451 to +460
private async youtubeChunkStream(path: string, start: number, end: number) {
if (path.indexOf('http') === 0) {
const response = await fetch(path, {
headers: { Range: `bytes=${start}-${end}` },
});
return response.body;
}

return createReadStream(path, { start, end });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate the ranged response before streaming it to YouTube.

youtubeChunkStream returns response.body without checking the status or the returned range. Two failure modes follow:

  • The remote store ignores the Range header and answers 200 with the full file. The upload then declares a chunk-sized Content-Length and Content-Range, so the request body and the declared range disagree.
  • The request fails with a 4xx or 5xx. The error page body is streamed to YouTube as video bytes and corrupts the upload at that offset.

Require 206 and the expected byte count before returning the body.

🐛 Proposed fix
   private async youtubeChunkStream(path: string, start: number, end: number) {
     if (path.indexOf('http') === 0) {
       const response = await fetch(path, {
         headers: { Range: `bytes=${start}-${end}` },
       });
+      if (response.status !== 206) {
+        throw new BadBody(
+          this.identifier,
+          '{}',
+          '{}',
+          'The video storage did not return the requested byte range, please try again'
+        );
+      }
       return response.body;
     }
 
     return createReadStream(path, { start, end });
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts`
around lines 451 - 460, Update youtubeChunkStream to validate ranged HTTP
responses before returning response.body: require a 206 status and verify the
body matches the requested inclusive byte count (end - start + 1). Reject or
throw on any unexpected status or length so failed or full-file responses are
never streamed to YouTube; keep the local createReadStream path unchanged.

Comment on lines +810 to +843
// eslint-disable-next-line no-constant-condition
while (true) {
// Cap below the 10-minute activity timeout of the old workflows using
// this method: failing here is safe (no video exists until the upload
// completes and the abandoned session just expires), timing the
// activity out is not.
if (Date.now() - started > 4.5 * 60 * 1000) {
throw new BadBody(
this.identifier,
'{}',
'{}',
'The video upload took too long, please try a smaller video'
);
}

const finalize = await this.finalizePost(
accessToken,
pendingData,
integration
);

if (finalize.status === 'completed') {
return [
{
id: response.id,
releaseURL: finalize.releaseURL,
postId: finalize.postId,
status: 'success',
},
];
}

pendingData = finalize.pendingData;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Account for the batch budget in the wrapper timeout.

The loop checks the elapsed time before it calls finalizePost, not during. finalizePost runs up to YOUTUBE_UPLOAD_BATCH_MS, which is 4 minutes. The second iteration therefore starts at about 4 minutes, passes the 4.5-minute check, and can run for another 4 minutes. Worst-case elapsed time is about 8.5 minutes, not 4.5 minutes.

Legacy workflows use a 10-minute activity timeout, so this leaves little margin. No duplicate video results, because the video only exists after the final byte, but the post fails and the bytes are re-uploaded.

Compare the elapsed time against a budget that includes the next batch.

🐛 Proposed fix
-      if (Date.now() - started > 4.5 * 60 * 1000) {
+      // Leave room for one more full upload batch before the 10-minute
+      // activity timeout of the old workflows.
+      if (
+        Date.now() - started >
+        8 * 60 * 1000 - YoutubeProvider.YOUTUBE_UPLOAD_BATCH_MS
+      ) {
         throw new BadBody(
           this.identifier,
           '{}',
           '{}',
           'The video upload took too long, please try a smaller video'
         );
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// eslint-disable-next-line no-constant-condition
while (true) {
// Cap below the 10-minute activity timeout of the old workflows using
// this method: failing here is safe (no video exists until the upload
// completes and the abandoned session just expires), timing the
// activity out is not.
if (Date.now() - started > 4.5 * 60 * 1000) {
throw new BadBody(
this.identifier,
'{}',
'{}',
'The video upload took too long, please try a smaller video'
);
}
const finalize = await this.finalizePost(
accessToken,
pendingData,
integration
);
if (finalize.status === 'completed') {
return [
{
id: response.id,
releaseURL: finalize.releaseURL,
postId: finalize.postId,
status: 'success',
},
];
}
pendingData = finalize.pendingData;
}
// eslint-disable-next-line no-constant-condition
while (true) {
// Cap below the 10-minute activity timeout of the old workflows using
// this method: failing here is safe (no video exists until the upload
// completes and the abandoned session just expires), timing the
// activity out is not.
// Leave room for one more full upload batch before the 10-minute
// activity timeout of the old workflows.
if (
Date.now() - started >
8 * 60 * 1000 - YoutubeProvider.YOUTUBE_UPLOAD_BATCH_MS
) {
throw new BadBody(
this.identifier,
'{}',
'{}',
'The video upload took too long, please try a smaller video'
);
}
const finalize = await this.finalizePost(
accessToken,
pendingData,
integration
);
if (finalize.status === 'completed') {
return [
{
id: response.id,
releaseURL: finalize.releaseURL,
postId: finalize.postId,
status: 'success',
},
];
}
pendingData = finalize.pendingData;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts`
around lines 810 - 843, Update the timeout guard in the finalizePost loop to
reserve time for the next finalizePost batch by comparing elapsed time against
the overall limit minus YOUTUBE_UPLOAD_BATCH_MS. Keep the existing 4.5-minute
wrapper limit and error behavior, while ensuring no new batch starts unless it
can complete within that budget.

- TikTok: index publicaly_available_post_id once - an empty array is truthy
  and produced postId "undefined" and a broken releaseURL (CodeRabbit)
- TikTok/YouTube: require 206 on ranged media reads so a store that ignores
  Range can't corrupt the chunked upload (CodeRabbit)
- YouTube: legacy wrapper cap now reserves room for a full upload batch below
  the 10-minute activity timeout (CodeRabbit/Sentry)
- Instagram: remove leftover debug console.log (Copilot)
- Workflow: raise maxPendingChecks to 90 so multi-item stories and chunked
  uploads can't exhaust the budget mid-flow (CodeRabbit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 05:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts:455

  • checkPostStatus currently treats any error (including a BadBody thrown by this.fetch for 4xx platform rejections) as a transient failure and returns pending. That can cause the workflow to keep polling until it exhausts its budget and then mark the post as “unconfirmed”, even though TikTok already provided a definitive failure. Re-throw BadBody so explicit platform rejections fail fast and surface the right error to the user.
    } catch (err) {
      if (err instanceof RefreshToken) {
        throw err;
      }

      // Transient API error while checking the status: the post may already
      // be live, so keep polling instead of failing it - if the API stays
      // broken the caller exhausts its checks and warns the user properly.
      return { status: 'pending', pendingData };
    }

// a HEAD request for remote URLs, statSync for local files.
private async youtubeMediaSize(path: string): Promise<number> {
if (path.indexOf('http') === 0) {
const head = await fetch(path, { method: 'HEAD' });
The user-influenced media path was fetched with the raw global fetch in the
TikTok/YouTube size and chunk helpers, bypassing the SSRF guard this.fetch
applies to every other outbound request (flagged by Copilot on the YouTube
HEAD request; applied to all four call sites for consistency). Self-hosted
deployments on private networks keep the documented
DISABLE_SSRF_PROTECTION=true opt-out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 06:01
Comment on lines +316 to +322
// already accepted the post - warn about a possible live post
if (handle.type === 'stop') {
await markUnconfirmed(err);
return false;
}

// the platform explicitly failed the post, it was not published

This comment was marked as outdated.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Suppressed comments (5)

libraries/nestjs-libraries/src/integrations/social/threads.provider.ts:544

  • When the container status is PUBLISHED, checkPostStatus() returns postId = pendingData.containerId. In this provider, containerId is the creation_id and threads_publish returns a separate thread id (see finalizePost/publishThread). Persisting the creation_id as postId can break follow-up actions that require the real thread id (e.g., comments).
      return {
        status: 'completed',
        postId: pendingData.containerId!,
        releaseURL: `https://www.threads.net/@${integration.profile}`,
      };

apps/orchestrator/src/activities/post.activity.ts:284

  • postSocialInternal() starts streakWorkflow immediately after post/postPending returns, but postPending can return a 'pending' response (e.g., YouTube resumable uploads) before the post is actually published. This can start a streak for posts that later fail/unconfirm and there's no rollback.
    // The post is already published at this point: the streak is best-effort,
    // failing the activity here would retry it and publish again.
    try {
      await this._temporalService.client
        .getRawClient()

libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts:451

  • checkPostStatus() treats any non-RefreshToken error as transient and returns pending. This swallows BadBody errors thrown by this.fetch (e.g., 4xx platform rejection), causing the workflow to poll until it times out instead of failing with the platform's error.
    } catch (err) {
      if (err instanceof RefreshToken) {
        throw err;
      }

libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts:448

  • youtubeMediaSize() returns Number(content-length) without validating it. If the header is missing/invalid (or returns a non-numeric value), this becomes NaN and will later be sent as X-Upload-Content-Length / used in math for ranges.
      return Number(length);

libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts:698

  • tiktokMediaSize() returns Number(content-length) without validating it. If the header value is non-numeric, this becomes NaN and will break chunk planning / upload init parameters later in the flow.
      const length = head.headers.get('content-length');
      if (!length) {
        throw new BadBody(

Comment on lines +308 to +320
const handle = await handleActivityError(err);

// token refreshed, check again right away
if (handle.type === 'retry') {
continue;
}

// the token could not be refreshed while checking, but the platform
// already accepted the post - warn about a possible live post
if (handle.type === 'stop') {
await markUnconfirmed(err);
return false;
}
errorAttempts accumulated across the whole polling loop, so 5 non-consecutive
transient blips over a long multi-batch upload (YouTube deliberately throws
plain errors on 429/5xx expecting probe-and-resume) would falsely abort a
healthy upload with the unconfirmed warning. The budget now bounds consecutive
failures only; maxPendingChecks still bounds the loop overall (Sentry).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 06:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

Suppressed comments (1)

libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts:450

  • In checkPostStatus(), the catch block treats any non-RefreshToken error as transient and returns {status:'pending'}. However this.fetch() throws BadBody on explicit API failures (4xx/invalid publish_id/etc). Swallowing BadBody will keep polling until the workflow exhausts checks and marks the post as “unconfirmed”, instead of failing it immediately with the platform’s error.
      if (err instanceof RefreshToken) {
        throw err;
      }

@nevo-david
nevo-david added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit e7ad24a Aug 3, 2026
12 checks passed
@nevo-david
nevo-david deleted the fix/duplicate-posts-pending-workflow branch August 3, 2026 07:30
giladresisi added a commit that referenced this pull request Aug 7, 2026
…anent

PR #1813's resumable uploader reads the stored video back in 8 MiB ranged
GETs and threw BadBody on any non-206 response, which the v1.0.6 workflow
treats as a permanent platform rejection. One bad range response out of
the ~128 needed for a 1 GB video killed the whole post, even though the
YouTube upload session was still resumable.

A 200-with-full-body for a Range request is documented Cloudflare
behaviour, not a corrupt object: Cloudflare may drop Content-Length on a
transformed response, and answers a range request on a length-less object
with the full content. Throw a plain Error instead, so the workflow's
existing retry machinery probes the session and resumes from the
committed byte offset. Google-side BadBody/RefreshToken classifications
are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
giladresisi added a commit that referenced this pull request Aug 7, 2026
The YouTube and TikTok chunked uploaders read the stored video back from
media storage with their own HEAD/ranged GET helpers, and those helpers
never asked for identity encoding. Every other media read in the project
does: #1835 added `accept-encoding: identity` to SocialAbstract's
mediaSize / mediaChunk / mediaStream and to the providers that read media
themselves, because a transformed (compressed) response loses its
Content-Length - and Cloudflare answers a range request on an object with
no Content-Length by returning the full body with a 200 instead of the
requested 206.

That is very likely what has been failing ~1GB YouTube uploads: a single
200 out of the ~128 ranged reads a 1GB video needs is thrown as BadBody,
which the v1.0.6 workflow treats as a permanent platform rejection, so
the whole post is marked ERROR even though the upload session is still
resumable.

The gap looks like an artifact of merge ordering rather than a decision:
#1813 introduced these two bespoke helpers on Aug 3, and #1835 swept
identity encoding across the project on Aug 4 - by then the sweep had
been written against a tree that did not contain them, so YouTube and
TikTok were missed while X and LinkedIn (bespoke at the time too) were
consolidated onto the shared helpers and fixed.

Behaviour on a non-206 is deliberately left untouched: if the customer
report recurs after this ships, the next step is to reconsider treating
the documented 200-with-full-body answer as transient (a plain Error the
workflow retries and resumes from the committed byte offset) instead of
as a permanent failure.

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

Labels

contribution:approved Approved contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants