fix: prevent duplicate posts with a pending-post workflow (v1.0.6) - #1813
Conversation
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>
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
| { | ||
| id: response.id, | ||
| releaseURL: finalize.releaseURL, | ||
| postId: finalize.postId, | ||
| status: 'success', | ||
| }, | ||
| ]; | ||
| } | ||
|
|
||
| pendingData = finalize.pendingData; | ||
| } |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
| lastMediaId, | ||
| checkToken, | ||
| pendingData.type, | ||
| integration | ||
| ), | ||
| }; |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
There was a problem hiding this comment.
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
postWorkflowV106and 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
PostActivityto support the pending flow viapostSocialPending, plus new activities forcheckPostStatus/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}`,
};
| return { | ||
| status: 'completed', | ||
| postId: pendingData.containerId!, | ||
| releaseURL: `https://www.threads.net/@${integration.profile}`, | ||
| }; |
| return { | ||
| status: 'completed', | ||
| postId: pendingData.carouselId, | ||
| releaseURL: `https://www.instagram.com/${integration.profile}`, | ||
| }; |
| // 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**/ | ||
| } |
🧩 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
doneLength 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
Move Static verification completed. I did not execute runtime tests. ✅ Action performedReview finished.
|
|
Note
|
| 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
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
- gitroomhq/postiz-app#468 — Both changes modify Instagram story publishing behavior.
- gitroomhq/postiz-app#522 — Both changes extend Instagram provider and standalone provider posting APIs.
- gitroomhq/postiz-app#775 — Both changes modify the Threads posting flow.
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.
Comment @coderabbitai help to get the list of available commands.
There was a problem hiding this comment.
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
pokedis assigned but never read.The
pokehandler must stay sosignalWithStartfromsearchForMissingThreeHoursPostsdoes not leave an unhandled signal. Thepokedvariable 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 | 🔵 TrivialDeploy the workers before this client change.
startWorkflownow requestspostWorkflowV106by name. If a backend instance starts this workflow type before every orchestrator worker on themaintask queue runs a bundle that exportspostWorkflowV106, the execution stays unstartable until a matching worker appears. Line 751 swallows the error, so the failure is silent.searchForMissingThreeHoursPostsinapps/orchestrator/src/activities/post.activity.tstargets 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 valueGuard the wrapper against a non-pending
postPendingresult.
postreadsresponse.pendingDatawithout checkingresponse.status.postPendingalways returnspendingtoday, 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 withpendingDataundefined.♻️ 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
📒 Files selected for processing (12)
apps/orchestrator/src/activities/post.activity.tsapps/orchestrator/src/workflows/index.tsapps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.6.tslibraries/nestjs-libraries/src/database/prisma/posts/posts.service.tslibraries/nestjs-libraries/src/integrations/social.abstract.tslibraries/nestjs-libraries/src/integrations/social/facebook.provider.tslibraries/nestjs-libraries/src/integrations/social/instagram.provider.tslibraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.tslibraries/nestjs-libraries/src/integrations/social/social.integrations.interface.tslibraries/nestjs-libraries/src/integrations/social/threads.provider.tslibraries/nestjs-libraries/src/integrations/social/tiktok.provider.tslibraries/nestjs-libraries/src/integrations/social/youtube.provider.ts
| } 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
🗄️ 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 || trueRepository: 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.
| // 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
🗄️ 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.
| 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 }; | ||
| } |
There was a problem hiding this comment.
🩺 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.tsRepository: 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"
doneRepository: 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.
| 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]), | ||
| }; | ||
| } |
There was a problem hiding this comment.
🗄️ 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.
| 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.
| 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 }); | ||
| } |
There was a problem hiding this comment.
🗄️ 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
Rangeheader and answers200with the full file. The upload then declares a chunk-sizedContent-LengthandContent-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.
| // 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; | ||
| } |
There was a problem hiding this comment.
🩺 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.
| // 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>
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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(
| 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>
There was a problem hiding this comment.
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;
}
…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>
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>
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:
postWorkflowV106(v1.0.5 untouched, per the workflow-immutability rule; both start sites flipped): the publish activity runs withmaximumAttempts: 1, timeouts are treated as "outcome unknown — never retry, warn the user", and waiting happens in the workflow with durable timers.pending/checkPostStatus/finalizePostwith a documentedreadystate for multi-stage flows): opt-in per provider with safe defaults, so the other ~28 providers are byte-for-byte unaffected.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).post()wrappers, now capped below the 10-minute activity timeout so they fail cleanly instead of timing out into a retry.Other information:
postSocialactivity behaves identically for in-flight runs (verified against origin/main call-by-call).SocialAbstract.fetchauto-retrying publish POSTs on 429/500.Checklist:
Put a "X" in the boxes below to indicate you have followed the checklist;
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes