From d45c1c623119864edc5e79ac8a13418114022fa6 Mon Sep 17 00:00:00 2001 From: Enno Gelhaus Date: Wed, 2 Sep 2026 16:52:37 +0200 Subject: [PATCH 01/14] feat(staging): auto-resolve merge conflicts --- .github/workflows/staging-conflicts.yml | 108 ++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 .github/workflows/staging-conflicts.yml diff --git a/.github/workflows/staging-conflicts.yml b/.github/workflows/staging-conflicts.yml new file mode 100644 index 0000000000..6d9c3817df --- /dev/null +++ b/.github/workflows/staging-conflicts.yml @@ -0,0 +1,108 @@ +name: Resolve staging conflicts + +on: + push: + branches: + - main + - staging + workflow_dispatch: + +concurrency: + group: staging-conflict-resolve + cancel-in-progress: false + +jobs: + resolve: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + + steps: + - name: Checkout staging + uses: actions/checkout@v6 + with: + ref: staging + fetch-depth: 0 + ssh-key: ${{ secrets.STAGING_MERGE_KEY }} + persist-credentials: true + + - name: Probe merge + id: probe + run: | + git config user.name "postiz-merge-bot" + git config user.email "bot@postiz.com" + + if git merge --no-commit --no-ff origin/main; then + git merge --abort 2>/dev/null || git reset --hard HEAD + echo "conflicted=false" >> "$GITHUB_OUTPUT" + echo "staging merges cleanly into main, nothing to do" + else + echo "conflicted=true" >> "$GITHUB_OUTPUT" + echo "Conflicted paths:" + git diff --name-only --diff-filter=U + fi + + - name: Resolve with Claude + if: steps.probe.outputs.conflicted == 'true' + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + prompt: | + The repository is checked out on `staging`, part-way through + `git merge --no-ff origin/main`, and the merge has conflicts. + + Resolve every conflicted file so the result preserves the intent of + both sides. Then `git add` the resolved paths and create the merge + commit with: + + git commit --no-edit --trailer "Resolved-by: claude-code-action" + + Constraints: + - Change nothing beyond what the conflict resolution requires. + - Do not push, switch branches, create branches, or amend history. + - Do not post comments, open issues, or touch any pull request. + - If a conflict is ambiguous enough that you would be guessing at + the correct resolution, stop without committing and explain why. + claude_args: | + --max-turns 25 + --model claude-sonnet-5 + --allowedTools "Read,Glob,Grep,Edit,Write,Bash(git status:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git ls-files:*),Bash(git add:*),Bash(git commit:*)" + + - name: Verify resolution + if: steps.probe.outputs.conflicted == 'true' + run: | + if git ls-files -u | grep -q .; then + echo "::error::Unmerged paths remain in the index" + git ls-files -u + exit 1 + fi + + if git rev-parse -q --verify MERGE_HEAD >/dev/null; then + echo "::error::Merge was never committed" + exit 1 + fi + + if git grep -nI -e '^<<<<<<< ' -e '^=======$' -e '^>>>>>>> ' HEAD; then + echo "::error::Conflict markers present in the committed tree" + exit 1 + fi + + if [ -n "$(git status --porcelain)" ]; then + echo "::error::Working tree is dirty after the commit" + git status --porcelain + exit 1 + fi + + if ! git merge-base --is-ancestor origin/main HEAD; then + echo "::error::HEAD does not contain origin/main, wrong commit shape" + exit 1 + fi + + echo "Resolution commit:" + git log -1 --stat + + - name: Push staging + if: steps.probe.outputs.conflicted == 'true' + run: git push origin HEAD:staging From 59448d3be746510eeb86012e4d90c0a2a47e6873 Mon Sep 17 00:00:00 2001 From: Enno Gelhaus Date: Wed, 2 Sep 2026 16:58:24 +0200 Subject: [PATCH 02/14] fix(staging): claude code crash --- .github/workflows/staging-conflicts.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/staging-conflicts.yml b/.github/workflows/staging-conflicts.yml index 6d9c3817df..7aae1775a9 100644 --- a/.github/workflows/staging-conflicts.yml +++ b/.github/workflows/staging-conflicts.yml @@ -1,10 +1,8 @@ name: Resolve staging conflicts on: - push: - branches: - - main - - staging + schedule: + - cron: "*/10 * * * *" workflow_dispatch: concurrency: @@ -17,6 +15,9 @@ jobs: timeout-minutes: 20 permissions: contents: read + if: >- + github.event_name != 'workflow_run' || + github.event.workflow_run.head_branch == 'main' steps: - name: Checkout staging @@ -49,6 +50,7 @@ jobs: with: anthropic_api_key: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }} + allowed_bots: "github-actions[bot]" prompt: | The repository is checked out on `staging`, part-way through `git merge --no-ff origin/main`, and the merge has conflicts. From 2db51c7f6e28963526e7960179f386fc940e148e Mon Sep 17 00:00:00 2001 From: Enno Gelhaus Date: Wed, 2 Sep 2026 17:17:21 +0200 Subject: [PATCH 03/14] feat(staging): increase max claude turns --- .github/workflows/staging-conflicts.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/staging-conflicts.yml b/.github/workflows/staging-conflicts.yml index 7aae1775a9..1795ccda5e 100644 --- a/.github/workflows/staging-conflicts.yml +++ b/.github/workflows/staging-conflicts.yml @@ -68,7 +68,7 @@ jobs: - If a conflict is ambiguous enough that you would be guessing at the correct resolution, stop without committing and explain why. claude_args: | - --max-turns 25 + --max-turns 50 --model claude-sonnet-5 --allowedTools "Read,Glob,Grep,Edit,Write,Bash(git status:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git ls-files:*),Bash(git add:*),Bash(git commit:*)" From 3c4cc6d7ba72ae5722bac4c7fb1bdc1a350085c5 Mon Sep 17 00:00:00 2001 From: Enno Gelhaus Date: Wed, 2 Sep 2026 17:26:53 +0200 Subject: [PATCH 04/14] feat(staging): remove claude max turns --- .github/workflows/staging-conflicts.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/staging-conflicts.yml b/.github/workflows/staging-conflicts.yml index 1795ccda5e..8621e70655 100644 --- a/.github/workflows/staging-conflicts.yml +++ b/.github/workflows/staging-conflicts.yml @@ -68,7 +68,6 @@ jobs: - If a conflict is ambiguous enough that you would be guessing at the correct resolution, stop without committing and explain why. claude_args: | - --max-turns 50 --model claude-sonnet-5 --allowedTools "Read,Glob,Grep,Edit,Write,Bash(git status:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git ls-files:*),Bash(git add:*),Bash(git commit:*)" From 9c01436447fafcde6e2245452075e6af846e2ece Mon Sep 17 00:00:00 2001 From: Enno Gelhaus Date: Wed, 2 Sep 2026 17:41:26 +0200 Subject: [PATCH 05/14] debug(staging): add debugging for merge conflict workflow --- .github/workflows/staging-conflicts.yml | 72 +++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/.github/workflows/staging-conflicts.yml b/.github/workflows/staging-conflicts.yml index 8621e70655..b1fc421a3f 100644 --- a/.github/workflows/staging-conflicts.yml +++ b/.github/workflows/staging-conflicts.yml @@ -1,5 +1,15 @@ name: Resolve staging conflicts +# Reproduces a staging <- main merge inside the runner. If it conflicts, Claude +# resolves the conflicts and creates the merge commit. Nothing is commented, +# nothing is pushed unless the resolution passes every verification below. +# +# claude-code-action rejects `push` events, so detection runs on a schedule. +# +# Required secrets: +# STAGING_MERGE_KEY private half of a write-enabled deploy key +# CLAUDE_CODE_OAUTH_TOKEN Claude API key, despite the secret name + on: schedule: - cron: "*/10 * * * *" @@ -15,9 +25,6 @@ jobs: timeout-minutes: 20 permissions: contents: read - if: >- - github.event_name != 'workflow_run' || - github.event.workflow_run.head_branch == 'main' steps: - name: Checkout staging @@ -28,6 +35,27 @@ jobs: ssh-key: ${{ secrets.STAGING_MERGE_KEY }} persist-credentials: true + # Runs before anything billable. If the deploy key cannot push, the job + # dies here at zero cost instead of after a paid resolution. + - name: Configure push credentials + env: + SSH_KEY: ${{ secrets.STAGING_MERGE_KEY }} + run: | + mkdir -p ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/staging_merge + chmod 600 ~/.ssh/staging_merge + ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts + echo "GIT_SSH_COMMAND=ssh -i $HOME/.ssh/staging_merge -o IdentitiesOnly=yes" >> "$GITHUB_ENV" + + - name: Preflight push + run: | + git remote set-url origin "git@github.com:${{ github.repository }}.git" + ssh -T git@github.com 2>&1 | tee /tmp/ssh.log || true + grep -q "successfully authenticated" /tmp/ssh.log || { + echo "::error::deploy key did not authenticate"; exit 1; } + git push --dry-run origin HEAD:staging + echo "push path verified" + - name: Probe merge id: probe run: | @@ -44,6 +72,16 @@ jobs: git diff --name-only --diff-filter=U fi + # Refuse to let Claude touch its own workflow or any other CI definition. + - name: Guard CI paths + if: steps.probe.outputs.conflicted == 'true' + run: | + if git diff --name-only --diff-filter=U | grep -q '^\.github/'; then + echo "::error::conflict under .github/, resolve this one by hand" + git merge --abort + exit 1 + fi + - name: Resolve with Claude if: steps.probe.outputs.conflicted == 'true' uses: anthropics/claude-code-action@v1 @@ -65,9 +103,12 @@ jobs: - Change nothing beyond what the conflict resolution requires. - Do not push, switch branches, create branches, or amend history. - Do not post comments, open issues, or touch any pull request. + - Never modify anything under `.github/`. If a conflict is there, + stop immediately without committing. - If a conflict is ambiguous enough that you would be guessing at the correct resolution, stop without committing and explain why. claude_args: | + --max-turns 12 --model claude-sonnet-5 --allowedTools "Read,Glob,Grep,Edit,Write,Bash(git status:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git ls-files:*),Bash(git add:*),Bash(git commit:*)" @@ -101,9 +142,32 @@ jobs: exit 1 fi + if git diff --name-only origin/staging..HEAD | grep -q '^\.github/'; then + echo "::error::resolution modified .github/, refusing" + exit 1 + fi + echo "Resolution commit:" git log -1 --stat + # Saved before the push, so a push failure never costs a second run. + # Recover with: git fetch ./resolved.bundle HEAD + # git push origin FETCH_HEAD:staging + - name: Archive resolution + if: steps.probe.outputs.conflicted == 'true' + run: git bundle create /tmp/resolved.bundle HEAD ^origin/staging ^origin/main + + - uses: actions/upload-artifact@v4 + if: steps.probe.outputs.conflicted == 'true' + with: + name: staging-resolution-${{ github.run_id }} + path: /tmp/resolved.bundle + retention-days: 14 + + # The action rewrites `origin` to an HTTPS URL during its own git setup, + # so the SSH remote is reasserted here. - name: Push staging if: steps.probe.outputs.conflicted == 'true' - run: git push origin HEAD:staging + run: | + git remote set-url origin "git@github.com:${{ github.repository }}.git" + git push origin HEAD:staging From 7e86a129bddefceb11f83004790a1b4c572561da Mon Sep 17 00:00:00 2001 From: Enno Gelhaus Date: Wed, 2 Sep 2026 17:45:14 +0200 Subject: [PATCH 06/14] fix(staging): streamline preflight push verification process --- .github/workflows/staging-conflicts.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/staging-conflicts.yml b/.github/workflows/staging-conflicts.yml index b1fc421a3f..cafaf1e2cc 100644 --- a/.github/workflows/staging-conflicts.yml +++ b/.github/workflows/staging-conflicts.yml @@ -47,12 +47,11 @@ jobs: ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts echo "GIT_SSH_COMMAND=ssh -i $HOME/.ssh/staging_merge -o IdentitiesOnly=yes" >> "$GITHUB_ENV" + # `git push --dry-run` exercises the exact path the real push takes. + # A bare `ssh -T` does not, since GIT_SSH_COMMAND applies only to git. - name: Preflight push run: | git remote set-url origin "git@github.com:${{ github.repository }}.git" - ssh -T git@github.com 2>&1 | tee /tmp/ssh.log || true - grep -q "successfully authenticated" /tmp/ssh.log || { - echo "::error::deploy key did not authenticate"; exit 1; } git push --dry-run origin HEAD:staging echo "push path verified" @@ -170,4 +169,4 @@ jobs: if: steps.probe.outputs.conflicted == 'true' run: | git remote set-url origin "git@github.com:${{ github.repository }}.git" - git push origin HEAD:staging + git push origin HEAD:staging \ No newline at end of file From b8427ba3b2d576ba26e9e84f7fdcc29b7ab41a89 Mon Sep 17 00:00:00 2001 From: Enno Gelhaus Date: Wed, 2 Sep 2026 17:52:38 +0200 Subject: [PATCH 07/14] fix(staging): drop max-turns cap reintroduced by debug commit 3c4cc6d7 removed --max-turns so a multi-file resolution could finish. 9c014364 re-applied the workflow from a pre-3c4cc6d7 buffer and brought the cap back, so every run with more than a couple of conflicts died on "Reached maximum number of turns (12)". timeout-minutes: 20 remains the outer bound. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/staging-conflicts.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/staging-conflicts.yml b/.github/workflows/staging-conflicts.yml index cafaf1e2cc..a9dbfe3df5 100644 --- a/.github/workflows/staging-conflicts.yml +++ b/.github/workflows/staging-conflicts.yml @@ -107,7 +107,6 @@ jobs: - If a conflict is ambiguous enough that you would be guessing at the correct resolution, stop without committing and explain why. claude_args: | - --max-turns 12 --model claude-sonnet-5 --allowedTools "Read,Glob,Grep,Edit,Write,Bash(git status:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git ls-files:*),Bash(git add:*),Bash(git commit:*)" From ec162d2ac19f7ec736d2ead907369d4b8d12f274 Mon Sep 17 00:00:00 2001 From: Enno Gelhaus Date: Wed, 2 Sep 2026 17:58:20 +0200 Subject: [PATCH 08/14] fix(staging): resolve .github conflicts by taking main's copy The workflow file was added independently on main and staging, so the merge base has no version of the path and git reports add/add. Any edit to it on main therefore conflicts regardless of staging's content, and the old guard turned that into a hard failure -- the workflow deadlocked on every change to itself. CI definitions belong to main in a main->staging flow, so conflicts under .github/ are now settled with `git checkout --theirs` before Claude runs. Verify asserts each .github/ path the merge touched is byte-identical to main's copy, per-path rather than as a blanket diff, since staging carries its own non-conflicting CODEOWNERS and build.yml. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/staging-conflicts.yml | 40 +++++++++++++++++-------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/.github/workflows/staging-conflicts.yml b/.github/workflows/staging-conflicts.yml index a9dbfe3df5..6f44cd2e10 100644 --- a/.github/workflows/staging-conflicts.yml +++ b/.github/workflows/staging-conflicts.yml @@ -71,16 +71,25 @@ jobs: git diff --name-only --diff-filter=U fi - # Refuse to let Claude touch its own workflow or any other CI definition. - - name: Guard CI paths + # CI definitions always come from main, so conflicts under .github/ are + # settled here by taking main's side. This keeps Claude away from them + # and stops this workflow from deadlocking on edits to itself: it was + # added independently on both branches, so git sees add/add and every + # change to it on main conflicts no matter how staging's copy looks. + - name: Take main's CI definitions if: steps.probe.outputs.conflicted == 'true' run: | - if git diff --name-only --diff-filter=U | grep -q '^\.github/'; then - echo "::error::conflict under .github/, resolve this one by hand" - git merge --abort - exit 1 + git diff --name-only -z --diff-filter=U -- .github/ > /tmp/ci_paths + if [ ! -s /tmp/ci_paths ]; then + echo "no conflicts under .github/" + exit 0 fi + echo "taking main's copy of:" + tr '\0' '\n' < /tmp/ci_paths + xargs -0 git checkout --theirs -- < /tmp/ci_paths + xargs -0 git add -- < /tmp/ci_paths + - name: Resolve with Claude if: steps.probe.outputs.conflicted == 'true' uses: anthropics/claude-code-action@v1 @@ -102,8 +111,9 @@ jobs: - Change nothing beyond what the conflict resolution requires. - Do not push, switch branches, create branches, or amend history. - Do not post comments, open issues, or touch any pull request. - - Never modify anything under `.github/`. If a conflict is there, - stop immediately without committing. + - Never modify anything under `.github/`. Conflicts there are + already resolved and staged for you; leave them exactly as they + are and resolve only the remaining paths. - If a conflict is ambiguous enough that you would be guessing at the correct resolution, stop without committing and explain why. claude_args: | @@ -140,10 +150,16 @@ jobs: exit 1 fi - if git diff --name-only origin/staging..HEAD | grep -q '^\.github/'; then - echo "::error::resolution modified .github/, refusing" - exit 1 - fi + # Every .github/ path the merge touched must be either untouched by + # the merge or byte-identical to main's copy, so a resolution can + # never smuggle in a CI change of its own. + for path in $(git diff --name-only origin/staging..HEAD -- .github/); do + if ! git diff --quiet origin/main HEAD -- "$path"; then + echo "::error::$path differs from main's copy, refusing" + git diff origin/main HEAD -- "$path" + exit 1 + fi + done echo "Resolution commit:" git log -1 --stat From 3e6206f7b3a7116031054a40647ebceb765bdafe Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Thu, 3 Sep 2026 12:53:08 +0700 Subject: [PATCH 09/14] feat(orchestrator): post workflow v1.1.2 retries on heartbeat timeout with no heartbeat details Replaces the v1.1.1 timing window with the timeout failure's lastHeartbeatDetails. --- .../src/activities/post.activity.ts | 2 +- apps/orchestrator/src/workflows/index.ts | 1 + .../post-workflows/post.workflow.v1.1.2.ts | 728 ++++++++++++++++++ .../database/prisma/posts/posts.service.ts | 2 +- 4 files changed, 731 insertions(+), 2 deletions(-) create mode 100644 apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.1.2.ts diff --git a/apps/orchestrator/src/activities/post.activity.ts b/apps/orchestrator/src/activities/post.activity.ts index d7cacf7066..271213f2aa 100644 --- a/apps/orchestrator/src/activities/post.activity.ts +++ b/apps/orchestrator/src/activities/post.activity.ts @@ -117,7 +117,7 @@ export class PostActivity { for (const post of list) { await this._temporalService.client .getRawClient() - .workflow.signalWithStart('postWorkflowV111', { + .workflow.signalWithStart('postWorkflowV112', { workflowId: `post_${post.id}`, taskQueue: 'main', signal: 'poke', diff --git a/apps/orchestrator/src/workflows/index.ts b/apps/orchestrator/src/workflows/index.ts index 83a1477f52..a949c98937 100644 --- a/apps/orchestrator/src/workflows/index.ts +++ b/apps/orchestrator/src/workflows/index.ts @@ -9,6 +9,7 @@ export * from './post-workflows/post.workflow.v1.0.8'; export * from './post-workflows/post.workflow.v1.0.9'; export * from './post-workflows/post.workflow.v1.1.0'; export * from './post-workflows/post.workflow.v1.1.1'; +export * from './post-workflows/post.workflow.v1.1.2'; export * from './autopost.workflow'; export * from './digest.email.workflow'; export * from './missing.post.workflow'; diff --git a/apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.1.2.ts b/apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.1.2.ts new file mode 100644 index 0000000000..f2976a2645 --- /dev/null +++ b/apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.1.2.ts @@ -0,0 +1,728 @@ +import { PostActivity } from '@gitroom/orchestrator/activities/post.activity'; +import { + ActivityFailure, + ApplicationFailure, + startChild, + proxyActivities, + sleep, + defineSignal, + setHandler, +} from '@temporalio/workflow'; +import dayjs from 'dayjs'; +import { Integration } from '@prisma/client'; +import { capitalize, sortBy } from 'lodash'; +import { PostResponse } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { + TimeoutFailure, + TimeoutType, + TypedSearchAttributes, +} from '@temporalio/common'; +import { postId as postIdSearchParam } from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute'; + +// The publishing activities heartbeat every 15s (withHeartbeat sends its first +// heartbeat at the first interval tick, not at entry), so a heartbeat timeout +// whose failure carries no heartbeat details means the server never received +// one: the worker never ran the activity, or it died within its first seconds. +const HEARTBEAT_TIMEOUT = 3 * 60 * 1000; + +const proxyTaskQueue = (taskQueue: string) => { + return proxyActivities({ + startToCloseTimeout: '10 minute', + taskQueue, + retry: { + maximumAttempts: 3, + backoffCoefficient: 1, + initialInterval: '2 minutes', + }, + }); +}; + +// postComment publishes through providers that can legitimately run long +// (media conversion + upload), so it gets a large time budget. The +// heartbeatTimeout exists to detect an activity that was never started: the +// activity heartbeats every 15s, so no heartbeat at all means the worker never +// ran it and nothing was published. No SDK retries: an +// automatic retry of a heartbeat timeout would run again even when the first +// attempt did publish, duplicating the comment. The workflow decides whether +// a failure is safe to retry (see handleActivityError). +const proxyCommentTaskQueue = (taskQueue: string) => { + return proxyActivities({ + startToCloseTimeout: '30 minute', + heartbeatTimeout: HEARTBEAT_TIMEOUT, + taskQueue, + retry: { + maximumAttempts: 1, + }, + }); +}; + +// checkPostStatus is a single read-only status call, so it gets a short timeout +// and fast retries - retrying it can never duplicate a post. +const proxyCheckTaskQueue = (taskQueue: string) => { + return proxyActivities({ + startToCloseTimeout: '2 minute', + taskQueue, + retry: { + maximumAttempts: 3, + backoffCoefficient: 1, + initialInterval: '10 seconds', + }, + }); +}; + +// postSocialPending / finalizePost run irreversible publishing mutations, so no +// automatic retries - a retried activity whose previous (timed-out) attempt +// still completed in the background would publish twice. The workflow retries +// deliberately, and treats timeouts as "outcome unknown". +// The heartbeatTimeout exists to detect an activity that was never started: +// both activities heartbeat every 15s, so no heartbeat at all means the +// worker never ran them and nothing was published. The workflow +// decides whether that is safe to retry (see handleActivityError); every +// other timeout still marks the post as unconfirmed. +const proxyMutationTaskQueue = (taskQueue: string) => { + return proxyActivities({ + startToCloseTimeout: '30 minute', + heartbeatTimeout: HEARTBEAT_TIMEOUT, + taskQueue, + retry: { + maximumAttempts: 1, + }, + }); +}; + +const { + getPostsList, + getPost, + inAppNotification, + changeState, + updatePost, + sendWebhooks, + isCommentable, +} = proxyActivities({ + startToCloseTimeout: '10 minute', + retry: { + maximumAttempts: 3, + backoffCoefficient: 1, + initialInterval: '2 minutes', + }, +}); + +const poke = defineSignal('poke'); + +const iterate = Array.from({ length: 5 }); + +// ~30 minutes at 20s interval (longer than the old in-activity loop, timers are +// free). Multi-item flows (stories, chunked uploads) consume several checks per +// item, so the budget must cover the largest realistic post, not one poll cycle. +const maxPendingChecks = 90; + +export async function postWorkflowV112({ + taskQueue, + postId, + organizationId, + postNow = false, +}: { + taskQueue: string; + postId: string; + organizationId: string; + postNow?: boolean; +}) { + // Dynamic task queue, for concurrency + const { + getIntegrationById, + refreshTokenWithCause, + internalPlugs, + globalPlugs, + processInternalPlug, + processPlug, + } = proxyTaskQueue(taskQueue); + + const { checkPostStatus } = proxyCheckTaskQueue(taskQueue); + + const { postComment } = proxyCommentTaskQueue(taskQueue); + + const { postSocialPending, finalizePost } = proxyMutationTaskQueue(taskQueue); + + let poked = false; + setHandler(poke, () => { + poked = true; + }); + + // get all the posts and comments to post + const firstPost = await getPost(organizationId, postId); + + // in case doesn't exists for some reason, fail it + if (!firstPost) { + await changeState(postId, 'ERROR', 'No Post'); + return; + } + + if (!postNow && firstPost.state !== 'QUEUE') { + await changeState(firstPost.id, 'ERROR', 'Already posted', [firstPost]); + return; + } + + // wait for the scheduled publish date + if (!postNow) { + await sleep( + dayjs(firstPost.publishDate).isBefore(dayjs()) + ? 0 + : dayjs(firstPost.publishDate).diff(dayjs(), 'millisecond') + ); + } + + // Captured AFTER the scheduling sleep: the repeat-post delay is + // "interval minus time spent publishing", so it must be measured from the + // publish time, not from when the workflow was started. Measuring from the + // workflow start subtracted the whole scheduling wait from the interval + // (a post scheduled further out than its interval repeated immediately). + const startTime = new Date(); + + const postsListBefore = await getPostsList(organizationId, postId); + const [post] = postsListBefore; + + if (!post) { + await changeState(postId, 'ERROR', 'No Post'); + return; + } + + // if refresh is needed from last time, let's inform the user + if (post.integration?.refreshNeeded) { + await inAppNotification( + post.organizationId, + `We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name}`, + `We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name} because you need to reconnect it. Please enable it and try again.`, + true, + false, + 'info' + ); + + await changeState( + postsListBefore[0].id, + 'ERROR', + 'Refresh channel needed', + postsListBefore + ); + return; + } + + // if it's disabled, inform the user + if (post.integration?.disabled) { + await inAppNotification( + post.organizationId, + `We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name}`, + `We couldn't post to ${post.integration?.providerIdentifier} for ${post?.integration?.name} because it's disabled. Please enable it and try again.`, + true, + false, + 'info' + ); + + await changeState( + postsListBefore[0].id, + 'ERROR', + 'Channel disabled', + postsListBefore + ); + return; + } + + // Do we need to post comment for this social? + const toComment: boolean = + postsListBefore.length === 1 + ? false + : await isCommentable(post.integration); + + const postsList = toComment ? postsListBefore : [postsListBefore[0]]; + + // list of all the saved results + const postsResults: PostResponse[] = []; + + // Every catch block below used to repeat the same failure classification, so + // it is centralized here: detect the failure type, refresh the token when + // needed, and tell the caller what to do. + // 'retry' - the token was refreshed, or the activity never started (heartbeat + // timeout with no heartbeat), run the action again + // 'stop' - the token could not be refreshed + // 'bad-body' - the platform rejected the action + // 'timeout' - the activity timed out, its outcome is unknown + // 'unknown' - anything else (transient errors) + const handleActivityError = async ( + err: unknown, + getIntegration?: () => Promise, + heartbeats?: boolean + ): Promise<{ + type: 'retry' | 'stop' | 'bad-body' | 'timeout' | 'unknown'; + message: string; + }> => { + if ( + err instanceof ActivityFailure && + err.cause instanceof TimeoutFailure + ) { + // The server copies the last heartbeat details it received into the + // timeout failure. None at all (undefined) means it never received a + // heartbeat: the worker never ran the activity, so nothing was published + // and it is safe to run again. Any details mean the activity ran and + // then stalled, so its outcome is unknown. This relies on withHeartbeat + // always sending a non-empty string (": entered" at least): + // a heartbeat sent with undefined details also leaves the failure + // without details. Only the callers of heartbeating activities opt in; + // no time window, so activity dispatch delay cannot skew the decision. + if ( + heartbeats && + err.cause.timeoutType === TimeoutType.HEARTBEAT && + !err.cause.lastHeartbeatDetails + ) { + return { type: 'retry', message: '' }; + } + return { type: 'timeout', message: '' }; + } + + const cause = + err instanceof ActivityFailure && err.cause instanceof ApplicationFailure + ? err.cause + : undefined; + + if (cause?.type === 'refresh_token') { + const refresh = await refreshTokenWithCause( + getIntegration ? await getIntegration() : post.integration, + cause.message || '' + ); + if (!refresh || !refresh.accessToken) { + return { type: 'stop', message: cause.message || '' }; + } + + if (!getIntegration) { + post.integration.token = refresh.accessToken; + } + + return { type: 'retry', message: cause.message || '' }; + } + + if (cause?.type === 'bad_body') { + return { type: 'bad-body', message: cause.message || '' }; + } + + return { type: 'unknown', message: '' }; + }; + + // The platform may have accepted the post but we can't confirm it was + // published - mark the error with a distinct message so the user checks the + // account before reposting manually and duplicating it. + const markUnconfirmed = async (err: any) => { + await changeState(postsList[0].id, 'ERROR', err, postsList); + await inAppNotification( + post.organizationId, + `We couldn't confirm your post on ${capitalize( + post.integration?.providerIdentifier + )}`, + `Your post was sent to ${capitalize( + post.integration?.providerIdentifier + )}, but we couldn't confirm it was published. Please check your ${ + post?.integration?.name + } account before posting again to avoid duplicates.`, + true, + false, + 'fail' + ); + }; + + // The post/comment was already accepted by the platform but returned as + // "pending": poll the read-only status check with durable timers until it + // completes. Errors are fully handled here (never rethrown), otherwise they + // would bubble to the posting retry loop and re-run the publish. + const resolvePending = async ( + pending: PostResponse + ): Promise => { + let pendingData = pending.pendingData; + let errorAttempts = 0; + let heartbeats = false; + + for (let check = 0; check < maxPendingChecks; check++) { + // only finalizePost heartbeats, so a checkPostStatus failure must never + // be classified as never started + heartbeats = false; + try { + let result = await checkPostStatus(post.integration, pendingData); + + // commit the check's state BEFORE finalizePost runs: if finalize dies + // mid-mutation, the next check must see what it had already authorized, + // so providers can detect the interrupted attempt instead of running + // the mutation again + if (result.status !== 'completed') { + pendingData = result.pendingData; + } + + // polling is done, run the remaining provider mutations + if (result.status === 'ready') { + heartbeats = true; + result = await finalizePost(post.integration, result.pendingData); + } + + if (result.status === 'completed') { + return { + id: pending.id, + postId: result.postId, + releaseURL: result.releaseURL, + status: 'success', + }; + } + + pendingData = result.pendingData; + + // a fully successful iteration proves the platform is reachable: the + // error budget bounds consecutive failures, not blips accumulated over + // a long upload + errorAttempts = 0; + } catch (err) { + const handle = await handleActivityError(err, undefined, heartbeats); + + // token refreshed, or finalize never started, 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; + } + } + + // the platform is still processing, wait before the next check + await sleep('20 seconds'); + } + + // no verdict from the platform after all the checks + await markUnconfirmed('Could not confirm the post status'); + return false; + }; + + // iterate over the posts + for (let i = 0; i < postsList.length; i++) { + const before = postsResults.length; + // once the platform accepted the post, the catch below must never retry + // the publish - retrying after updatePost / notification errors would + // duplicate the post + let posted = false; + let updated = false; + // this is a small trick to repeat an action in case of token refresh + for (const _ of iterate) { + // both publish calls run heartbeating activities, but the timed-out + // status checks below must never be mistaken for never-started + let heartbeats = false; + try { + // first post the main post + if (i === 0) { + heartbeats = true; + postsResults.push( + ...(await postSocialPending(post.integration as Integration, [ + postsList[i], + ])) + ); + + // then post the comments if any + } else { + if (postsList[i].delay) { + await sleep(60000 * Math.max(0, Number(postsList[i].delay ?? 0))); + } + + heartbeats = true; + postsResults.push( + ...(await postComment( + postsResults[0].postId, + postsResults.length === 1 + ? undefined + : postsResults[i - 1].postId, + post.integration, + [postsList[i]] + )) + ); + } + + posted = true; + + // the platform accepted the post but is still processing it: resolve + // it here before marking anything, resolvePending handles its own + // errors so a failed status check can never re-run the publish above + if (postsResults[i].status === 'pending') { + let resolved: PostResponse | false = false; + try { + resolved = await resolvePending(postsResults[i]); + } catch (err) { + // never let a pending-resolution error reach the outer catch, it + // would retry the post and duplicate it. Best-effort error state, + // otherwise the post stays in QUEUE and the missing-posts sweep + // would re-publish it. + try { + await markUnconfirmed(err); + } catch (e) { + /**empty**/ + } + resolved = false; + } + if (!resolved) { + return false; + } + postsResults[i] = resolved; + } + + // mark post as successful + await updatePost( + postsList[i].id, + postsResults[i].postId, + postsResults[i].releaseURL + ); + updated = true; + + if (i === 0) { + // send notification on a sucessful post + await inAppNotification( + post.integration.organizationId, + `Your post has been published on ${capitalize( + post.integration.providerIdentifier + )}`, + `Your post has been published on ${capitalize( + post.integration.providerIdentifier + )} at ${postsResults[0].releaseURL}`, + true, + true + ); + } + + // break the current while to move to the next post + break; + } catch (err) { + // the post is already live: never re-run the publish + if (posted) { + if (!updated) { + // still marked QUEUE, record the error so the missing-posts sweep + // doesn't re-publish it + try { + await markUnconfirmed(err); + } catch (e) { + /**empty**/ + } + return false; + } + + // already marked published, a failed notification shouldn't abort + // the rest of the flow + break; + } + + const handle = await handleActivityError(err, undefined, heartbeats); + + // token refreshed, or the publish never started, repeat the action + if (handle.type === 'retry') { + continue; + } + + // the activity timed out: the platform may still complete the publish + // in the background, so never retry it + if (handle.type === 'timeout') { + try { + await markUnconfirmed(err); + } catch (e) { + /**empty**/ + } + return false; + } + + // 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; + } + } + } + + if (postsResults.length === before) { + // all retries exhausted without success: record it, otherwise the post + // stays in QUEUE with no error and the missing-posts sweep re-publishes + // it. A retried publish may have run without reporting, so treat the + // outcome as unknown. + try { + await markUnconfirmed('Could not publish after several attempts'); + } catch (e) { + /**empty**/ + } + return false; + } + } + + // send webhooks for the post + await sendWebhooks( + postsResults[0].postId, + post.organizationId, + post.integration.id + ); + + // load internal plugs like repost by other users + const internalPlugsList = await internalPlugs( + post.integration, + JSON.parse(post.settings) + ); + + // load global plugs, like repost a post if it gets to a certain number of likes + const globalPlugsList = (await globalPlugs(post.integration)).reduce( + (all, current) => { + for (let i = 1; i <= current.totalRuns; i++) { + all.push({ + ...current, + delay: current.delay * i, + }); + } + + return all; + }, + [] + ); + + // Check if the post is repeatable + const repeatPost = !post.intervalInDays + ? [] + : [ + { + type: 'repeat-post', + delay: + post.intervalInDays * 24 * 60 * 60 * 1000 - + (new Date().getTime() - startTime.getTime()), + }, + ]; + + // Sort all the actions by delay, so we can process them in order + const list = sortBy( + [...internalPlugsList, ...globalPlugsList, ...repeatPost], + 'delay' + ); + + // process all the plugs in order, we are using while because in some cases we need to remove items from the list + while (list.length > 0) { + // get the next to process + const todo = list.shift(); + + // wait for the delay + await sleep(Math.max(0, Number(todo.delay ?? 0))); + + // process internal plug + if (todo.type === 'internal-plug') { + for (const _ of iterate) { + try { + await processInternalPlug({ ...todo, post: postsResults[0].postId }); + } catch (err) { + const handle = await handleActivityError(err, () => + getIntegrationById(organizationId, todo.integration) + ); + + if (handle.type === 'stop' || handle.type === 'bad-body') { + break; + } + + continue; + } + break; + } + } + + // process global plug + if (todo.type === 'global') { + for (const _ of iterate) { + try { + const process = await processPlug({ + ...todo, + postId: postsResults[0].postId, + }); + if (process) { + const toDelete = list + .reduce((all, current, index) => { + if (current.plugId === todo.plugId) { + all.push(index); + } + + return all; + }, []) + .reverse(); + + for (const index of toDelete) { + list.splice(index, 1); + } + } + } catch (err) { + const handle = await handleActivityError(err); + + if (handle.type === 'stop' || handle.type === 'bad-body') { + break; + } + + continue; + } + + break; + } + } + + // process repeat post in a new workflow, this is important so the other plugs can keep running + if (todo.type === 'repeat-post') { + await startChild(postWorkflowV112, { + parentClosePolicy: 'ABANDON', + args: [ + { + taskQueue, + postId, + organizationId, + postNow: true, + }, + ], + workflowId: `post_${post.id}_${makeId(10)}`, + typedSearchAttributes: new TypedSearchAttributes([ + { + key: postIdSearchParam, + value: postId, + }, + ]), + }); + } + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts index bc67276986..13e684f813 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -727,7 +727,7 @@ export class PostsService { try { await this._temporalService.client .getRawClient() - ?.workflow.start('postWorkflowV111', { + ?.workflow.start('postWorkflowV112', { workflowId: `post_${postId}`, taskQueue: 'main', workflowIdConflictPolicy: 'TERMINATE_EXISTING', From 27812e74d03433490e42ff7f96b0149008c3980a Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:09:40 +0700 Subject: [PATCH 10/14] docs(roadmap): expand roadmap with frontend modernization and media storage milestones --- ROADMAP.md | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3e2684f5a8..9525505ad3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,21 @@ -# Crove Roadmap +# Crove Post Roadmap -## Provider readiness +## 1. Frontend & UI/UX Modernization (Crove OS Standards) + +- [ ] **Design System & Visual Refresh**: + - Migrate legacy Postiz purple/neon styles to the unified Crove OS Design System (modern dark mode, refined zinc neutrals, subtle glassmorphism). + - Standardize UI components with Tailwind and native primitives across Navigation, Modals, Forms, and Buttons. +- [ ] **Workspace & Organization Switcher Overhaul**: + - Replace stock dropdown with a sleek, multi-tenant Workspace Selector featuring avatar/initials, active checkmarks, and Super-Admin/Role badges. + - Optimize SWR cache invalidation for seamless zero-reload workspace switching. +- [ ] **Post Composer & Media Preview Rework**: + - Redesign the post creation modal with live multi-channel previews (X, LinkedIn, Facebook, Instagram, TikTok, Threads). + - Modernize character counters, hashtag generators, and AI assistant side panels. +- [ ] **Calendar & Analytics Experience**: + - Implement a modern responsive calendar grid with smooth drag-and-drop post scheduling. + - Redesign analytics dashboards with clean charts, engagement heatmaps, and exportable reports. + +## 2. Provider Readiness & Integrations ### TikTok Content Posting API @@ -11,3 +26,16 @@ - [ ] Resolve or document the stock Postiz defaults that preselect public visibility and enable comments before submitting the TikTok audit. - [ ] Submit the Content Posting API audit only after the recorded behavior matches the requested products and scopes. +## 3. Media & Storage Architecture + +- [ ] **Cloudflare R2 Direct Upload & Streaming**: + - Optimize multipart chunked uploads for large video files (Reels, TikTok, YouTube Shorts). + - Implement client-side video transcode checks and automatic thumbnail generation via Cloudflare CDN. + +## 4. AI & Ecosystem Intelligence + +- [ ] **Brand Voice & Copilot Enhancements**: + - Integrate brand voice guidelines and tone-of-voice presets into the OpenAI-compatible AI Copilot engine. + - Expand Mastra / MCP agent capabilities for autonomous multi-channel campaign scheduling. + + From 4f296fc0900109bd511315bbff8d45d98bd210ed Mon Sep 17 00:00:00 2001 From: Nevo David Date: Thu, 3 Sep 2026 14:49:58 +0700 Subject: [PATCH 11/14] feat: better onboarding --- .../onboarding/onboarding.modal.tsx | 508 ++++++++++++++++-- .../public-api/mcp.client.icons.tsx | 453 ++++++++++++++++ .../public-api/public.component.tsx | 266 +++++---- .../translation/locales/en/translation.json | 20 + 4 files changed, 1092 insertions(+), 155 deletions(-) create mode 100644 apps/frontend/src/components/public-api/mcp.client.icons.tsx diff --git a/apps/frontend/src/components/onboarding/onboarding.modal.tsx b/apps/frontend/src/components/onboarding/onboarding.modal.tsx index 6076915922..09ff8d7bd6 100644 --- a/apps/frontend/src/components/onboarding/onboarding.modal.tsx +++ b/apps/frontend/src/components/onboarding/onboarding.modal.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { FC, useCallback, useMemo, useState } from 'react'; +import React, { FC, Fragment, useCallback, useMemo, useState } from 'react'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import useSWR from 'swr'; import { orderBy } from 'lodash'; @@ -9,6 +9,17 @@ import SafeImage from '@gitroom/react/helpers/safe.image'; import { AddProviderComponent } from '@gitroom/frontend/components/launches/add.provider.component'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { useModals } from '@gitroom/frontend/components/layout/new-modal'; +import { useUser } from '@gitroom/frontend/components/layout/user.context'; +import { useVariables } from '@gitroom/react/helpers/variable.context'; +import { + CopyButton, + getMcpConfig, + getMcpOauthUrl, + isChatOnlyMcpClient, + localCliSteps, + McpAuth, +} from '@gitroom/frontend/components/public-api/public.component'; +import { McpClientIcon } from '@gitroom/frontend/components/public-api/mcp.client.icons'; interface OnboardingModalProps { onClose: () => void; @@ -19,11 +30,18 @@ export const OnboardingModal: FC = ({ onClose }) => { const modals = useModals(); const t = useT(); + const steps = useMemo( + () => [ + t('connect_channels', 'Connect Channels'), + t('connect_agents', 'Connect Agents'), + t('watch_tutorial', 'Watch Tutorial'), + ], + [t] + ); + return ( -
- +
+
-
+
{/* Step indicators */}
-
-
- 1 -
- - {t('connect_channels', 'Connect Channels')} - -
-
-
-
( + + {index > 0 && ( +
)} - > - 2 -
- - {t('watch_tutorial', 'Watch Tutorial')} - -
+
+
+ {index + 1} +
+ + {label} + +
+ + ))}
{/* Step content */} @@ -100,7 +104,13 @@ export const OnboardingModal: FC = ({ onClose }) => { /> )} {step === 2 && ( - setStep(1)} onFinish={onClose} /> + setStep(1)} + onNext={() => setStep(3)} + /> + )} + {step === 3 && ( + setStep(2)} onFinish={onClose} /> )}
@@ -240,7 +250,409 @@ const OnboardingStep1: FC<{ onNext: () => void; onSkip: () => void }> = ({ ); }; -const OnboardingStep2: FC<{ onBack: () => void; onFinish: () => void }> = ({ +const onboardingAgents = [ + 'Claude', + 'ChatGPT', + 'Claude Code', + 'Cursor', + 'Codex', + 'Grok Bot', +] as const; + +type OnboardingAgent = (typeof onboardingAgents)[number]; + +// Not an agent, a tab showing the raw API key for people integrating by hand +const apiTab = 'API' as const; +type OnboardingTab = OnboardingAgent | typeof apiTab; + +const cliCommands = localCliSteps.map((step) => step.code); + +// Cursor one-click install: https://cursor.com/docs/mcp/install-links +const getCursorInstallUrl = ( + auth: McpAuth, + mcpBase: string, + apiKey: string +) => { + const server = + auth === 'oauth' + ? { url: getMcpOauthUrl(mcpBase) } + : { + url: `${mcpBase}/mcp`, + headers: { Authorization: `Bearer ${apiKey}` }, + }; + return `cursor://anysphere.cursor-deeplink/mcp/install?name=postiz&config=${btoa( + JSON.stringify(server) + )}`; +}; + +const OnboardingStep2: FC<{ onBack: () => void; onNext: () => void }> = ({ + onBack, + onNext, +}) => { + const t = useT(); + const user = useUser(); + const { backendUrl, mcpUrl, billingEnabled } = useVariables(); + const [agent, setAgent] = useState('Claude'); + const [auth, setAuth] = useState('oauth'); + const [revealed, setRevealed] = useState(false); + const mcpBase = mcpUrl || backendUrl; + const apiKey = user?.publicApi || ''; + const available = !!apiKey && !!user?.tier?.public_api; + + const { config, hint } = + agent === apiTab + ? { config: '', hint: '' } + : getMcpConfig(agent, auth, mcpBase, apiKey); + + const maskedConfig = + revealed || auth === 'oauth' || !apiKey + ? config + : config.replace( + new RegExp(apiKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), + '*'.repeat(apiKey.length) + ); + + const connector = + agent === 'Claude' && billingEnabled + ? { + href: 'https://claude.ai/directory/postiz', + label: t('add_to_claude', 'Add to Claude'), + } + : agent === 'Cursor' + ? { + href: getCursorInstallUrl(auth, mcpBase, apiKey), + label: t('add_to_cursor', 'Add to Cursor'), + } + : null; + + const maskedApiKey = revealed ? apiKey : '*'.repeat(apiKey.length); + + const chatSection = ( +
+
+
{t('chat', 'Chat')}
+
+ {t( + 'chat_onboarding_description', + 'No MCP or CLI settings needed. Paste this into the chat, the agent installs the Postiz CLI and asks you for your API key.' + )} +
+
+
+
+
+            {config}
+          
+
+ +
+
+
+
+ {t('api_key', 'API Key')} +
+
+            {maskedApiKey}
+          
+
+ + +
+
+
+
+ ); + + const apiSection = ( + <> +
+
+
+ {t('documentation', 'Documentation')} +
+
+ {t( + 'api_onboarding_description', + 'Use the Postiz API from your own code, n8n or any other automation' + )} +
+
+ + + {t('read_the_api_docs', 'Read the API docs')} + +
+
+
+
+ {t('api_key', 'API Key')} +
+
+ {t( + 'api_key_onboarding_description', + 'Send it as the Authorization header on every request' + )} +
+
+
+
+            {maskedApiKey}
+          
+
+ + +
+
+
+ + ); + + const connectorSection = connector && ( +
+
+
+ {t('connector', 'Connector')} +
+
+ {t( + 'connector_onboarding_description', + 'The fastest way: add Postiz with one click, you will be asked to sign in' + )} +
+
+ + + {connector.label} + +
+ ); + + const mcpSection = ( +
+
+
{t('mcp', 'MCP')}
+
+ {t( + 'mcp_onboarding_description', + 'Give your agent Postiz tools to create, schedule and manage posts' + )} +
+
+
+
+
+ {t('auth_method', 'Authentication')} +
+
+ {(['oauth', 'apikey'] as const).map((m) => ( + + ))} +
+
+
+
+ {hint} + {auth === 'oauth' && + ` ${t( + 'oauth_sign_in_hint', + 'Your agent will open a browser window to sign in to Postiz.' + )}`} +
+
+            {maskedConfig}
+          
+
+ {auth === 'apikey' && ( + + )} + +
+
+
+
+ ); + + const cliSection = ( +
+
+
{t('cli', 'CLI')}
+
+ {t( + 'cli_onboarding_description', + 'Install the Postiz CLI and the skill that teaches your agent how to use it' + )} +
+
+
+
+          {cliCommands.join('\n')}
+        
+
+ +
+
+
+ ); + + return ( +
+
+
+ {t('connect_your_ai_agent', 'Connect Your AI Agent')} +
+
+ {t( + 'connect_agent_description', + 'Pick the agent you use and let it create and schedule posts for you' + )} +
+
+ + {available ? ( +
+
+ {[...onboardingAgents, apiTab].map((item) => ( + + ))} +
+ + {agent === apiTab ? ( + apiSection + ) : isChatOnlyMcpClient(agent) ? ( + chatSection + ) : ( + <> + {connectorSection} +
+ {mcpSection} + {cliSection} +
+ + )} +
+ ) : ( +
+ {t( + 'agent_access_unavailable', + 'Agent access is not available for your current plan or role. You can set it up later under Settings > Developers.' + )} +
+ )} + + {/* Action buttons */} +
+ +
+ {t( + 'agent_settings_later', + 'More agents and full instructions are available under Settings > Developers' + )} +
+ +
+
+ ); +}; + +const OnboardingStep3: FC<{ onBack: () => void; onFinish: () => void }> = ({ onBack, onFinish, }) => { diff --git a/apps/frontend/src/components/public-api/mcp.client.icons.tsx b/apps/frontend/src/components/public-api/mcp.client.icons.tsx new file mode 100644 index 0000000000..42daa32787 --- /dev/null +++ b/apps/frontend/src/components/public-api/mcp.client.icons.tsx @@ -0,0 +1,453 @@ +'use client'; + +import { FC } from 'react'; + +// Logos for the MCP clients shown in Settings > Developers and in onboarding. +// Monochrome marks use currentColor so they follow the button text color, +// brand marks (Claude, VS Code, Gemini) keep their colors. +const icons: Record> = { + Claude: ({ size }) => ( + + + + ), + 'Claude Code': ({ size }) => ( + + + + ), + ChatGPT: ({ size }) => ( + + + + ), + 'Grok Bot': ({ size }) => ( + + + + + + + + + ), + Codex: ({ size }) => ( + + + + ), + Cursor: ({ size }) => ( + + + + ), + 'VS Code / Copilot': ({ size }) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + Windsurf: ({ size }) => ( + + + + ), + 'Gemini CLI': ({ size }) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + Warp: ({ size }) => ( + + + + ), + Amp: ({ size }) => ( + + + + + + + ), + // Not a client: the onboarding "API" tab + API: ({ size }) => ( + + + + + ), +}; + +export const McpClientIcon: FC<{ client: string; size?: number }> = ({ + client, + size = 16, +}) => { + const Icon = icons[client]; + if (!Icon) { + return null; + } + return ; +}; diff --git a/apps/frontend/src/components/public-api/public.component.tsx b/apps/frontend/src/components/public-api/public.component.tsx index de8a72deab..bfc90a8667 100644 --- a/apps/frontend/src/components/public-api/public.component.tsx +++ b/apps/frontend/src/components/public-api/public.component.tsx @@ -10,9 +10,25 @@ import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { useDecisionModal } from '@gitroom/frontend/components/layout/new-modal'; import { DeveloperComponent } from '@gitroom/frontend/components/developer/developer.component'; +import { McpClientIcon } from '@gitroom/frontend/components/public-api/mcp.client.icons'; import clsx from 'clsx'; -const mcpClients = [ +// Remote clients can't set headers, they get a URL to paste (hint = where) +export const remoteMcpClients = { + Claude: + 'In Claude go to Settings > Connectors > Add custom connector and paste this URL.', + ChatGPT: + 'In ChatGPT go to Settings > Connectors > Create and paste this URL.', +} as const; + +// Clients with no MCP or CLI settings: you paste instructions into the chat, +// the agent installs the CLI itself and asks you for the API key +export const chatOnlyMcpClients = { + 'Grok Bot': + 'Install the Postiz CLI with `npm install -g postiz`, then install the Postiz skill with `npx skills add gitroomhq/postiz-agent`. Ask me for my Postiz API key and set it as the POSTIZ_API_KEY environment variable before using the CLI.', +} as const; + +export const mcpClients = [ 'Claude Code', 'Cursor', 'VS Code / Copilot', @@ -23,64 +39,95 @@ const mcpClients = [ 'Warp', ] as const; -type McpClient = (typeof mcpClients)[number]; +export type RemoteMcpClient = keyof typeof remoteMcpClients; +export type ChatOnlyMcpClient = keyof typeof chatOnlyMcpClients; +export type McpClient = (typeof mcpClients)[number]; +export type AnyMcpClient = RemoteMcpClient | ChatOnlyMcpClient | McpClient; + +// oauth: no API key, the client registers itself (DCR) and the user signs in to Postiz +// apikey: the organization API key, as a Bearer header (or inside the URL for remote clients) +export type McpAuth = 'oauth' | 'apikey'; + +export const getMcpOauthUrl = (mcpBase: string) => + `${mcpBase}/mcp-oauth-dynamic`; + +export const isRemoteMcpClient = (client: string): client is RemoteMcpClient => + client in remoteMcpClients; -const getMcpConfig = ( - client: McpClient, - method: 'header' | 'path', +export const isChatOnlyMcpClient = ( + client: string +): client is ChatOnlyMcpClient => client in chatOnlyMcpClients; + +export const getMcpConfig = ( + client: AnyMcpClient, + auth: McpAuth, mcpBase: string, apiKey: string ): { config: string; hint: string } => { - const urlWithKey = `${mcpBase}/mcp/${apiKey}`; + if (isChatOnlyMcpClient(client)) { + return { + config: chatOnlyMcpClients[client], + hint: 'Paste this into the chat. The agent will ask you for your API key.', + }; + } + if (isRemoteMcpClient(client)) { + return { + config: + auth === 'oauth' ? getMcpOauthUrl(mcpBase) : `${mcpBase}/mcp/${apiKey}`, + hint: remoteMcpClients[client], + }; + } + + const oauthUrl = getMcpOauthUrl(mcpBase); const urlBase = `${mcpBase}/mcp`; const bearer = `Bearer ${apiKey}`; const json = (obj: object) => JSON.stringify(obj, null, 2); - if (method === 'path') { + if (auth === 'oauth') { switch (client) { case 'Claude Code': return { - config: `claude mcp add postiz --transport http "${urlWithKey}"`, + config: `claude mcp add postiz --transport http "${oauthUrl}"`, hint: 'Run this command in your terminal.', }; case 'Cursor': return { - config: json({ mcpServers: { postiz: { url: urlWithKey } } }), + config: json({ mcpServers: { postiz: { url: oauthUrl } } }), hint: 'Add to .cursor/mcp.json in your project root.', }; case 'VS Code / Copilot': return { config: json({ - servers: { postiz: { type: 'http', url: urlWithKey } }, + servers: { postiz: { type: 'http', url: oauthUrl } }, }), hint: 'Add to .vscode/mcp.json in your project root.', }; case 'Windsurf': return { config: json({ - mcpServers: { postiz: { serverUrl: urlWithKey } }, + mcpServers: { postiz: { serverUrl: oauthUrl } }, }), hint: 'Add to ~/.codeium/windsurf/mcp_config.json', }; case 'Amp': return { - config: `amp mcp add postiz ${urlWithKey}`, + config: `amp mcp add postiz ${oauthUrl}`, hint: 'Run this command in your terminal.', }; case 'Codex': return { - config: `# ~/.codex/config.toml\n\n[mcp_servers.postiz]\nurl = "${urlWithKey}"`, - hint: 'Add to ~/.codex/config.toml', + config: `# ~/.codex/config.toml\n\n[mcp_servers.postiz]\nurl = "${oauthUrl}"`, + hint: 'Add to ~/.codex/config.toml, then run: codex mcp login postiz', }; case 'Gemini CLI': return { - config: json({ mcpServers: { postiz: { url: urlWithKey } } }), + config: json({ mcpServers: { postiz: { url: oauthUrl } } }), hint: 'Add to ~/.gemini/settings.json', }; case 'Warp': return { - config: json({ postiz: { url: urlWithKey } }), + config: json({ postiz: { url: oauthUrl } }), hint: 'Settings > MCP Servers > + Add, then paste this config.', }; } @@ -159,7 +206,7 @@ const getMcpConfig = ( } }; -const CopyButton = ({ +export const CopyButton = ({ text, label, }: { @@ -203,27 +250,28 @@ const McpSection = ({ }) => { const t = useT(); const { billingEnabled } = useVariables(); - const [activeClient, setActiveClient] = useState('Claude Code'); - const [method, setMethod] = useState<'header' | 'path'>('header'); + const [activeClient, setActiveClient] = useState('Claude'); + const [auth, setAuth] = useState('oauth'); const [revealed, setRevealed] = useState(false); const { config, hint } = getMcpConfig( activeClient, - method, + auth, mcpBase, user.publicApi ); - const remoteUrl = `${mcpBase}/mcp/${user.publicApi}`; - const cliUrl = `${mcpBase}/mcp`; + const baseUrl = auth === 'oauth' ? getMcpOauthUrl(mcpBase) : `${mcpBase}/mcp`; - const maskedConfig = revealed - ? config - : config.replace(new RegExp(user.publicApi.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '*'.repeat(user.publicApi.length)); + const chatOnly = isChatOnlyMcpClient(activeClient); - const maskedRemoteUrl = revealed - ? remoteUrl - : remoteUrl.replace(user.publicApi, '*'.repeat(user.publicApi.length)); + const maskedConfig = + revealed || auth === 'oauth' || chatOnly + ? config + : config.replace( + new RegExp(user.publicApi.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), + '*'.repeat(user.publicApi.length) + ); return (
@@ -261,108 +309,112 @@ const McpSection = ({
-
-
- {t('auth_method', 'Authentication')} -
-
- {(['header', 'path'] as const).map((m) => ( - - ))} -
-
- {method === 'header' && ( + {!chatOnly && (
- {t('mcp_client', 'Client')} + {t('auth_method', 'Authentication')}
-
- {mcpClients.map((client) => ( +
+ {(['oauth', 'apikey'] as const).map((m) => ( ))}
)} +
+
+ {t('mcp_client', 'Client')} +
+
+ {[ + ...Object.keys(remoteMcpClients), + ...mcpClients, + ...Object.keys(chatOnlyMcpClients), + ].map((client) => ( + + ))} +
+
- {method === 'header' - ? hint - : t( - 'remote_server_url_hint', - 'Paste this URL into your remote MCP client (ChatGPT, Claude, etc.).' - )} + {hint} + {auth === 'oauth' && + !chatOnly && + ` ${t( + 'oauth_sign_in_hint', + 'Your agent will open a browser window to sign in to Postiz.' + )}`}
-            {method === 'header' ? maskedConfig : maskedRemoteUrl}
+            {maskedConfig}
           
- - - {method === 'header' && ( - + + {revealed ? ( + <> + + + + + ) : ( + <> + + + + )} + + {revealed ? t('hide', 'Hide') : t('reveal', 'Reveal')} + + )} + + {!isRemoteMcpClient(activeClient) && !chatOnly && ( + )} - {method === 'path' && billingEnabled && ( + {activeClient === 'Claude' && billingEnabled && ( Developers.", + "sign_in_no_api_key": "Sign in with Postiz (no API key)", + "oauth_sign_in_hint": "Your agent will open a browser window to sign in to Postiz.", + "add_to_cursor": "Add to Cursor", + "cli": "CLI", + "documentation": "Documentation", + "read_the_api_docs": "Read the API docs", + "api_key_onboarding_description": "Send it as the Authorization header on every request", + "api_onboarding_description": "Use the Postiz API from your own code, n8n or any other automation", + "chat": "Chat", + "chat_onboarding_description": "No MCP or CLI settings needed. Paste this into the chat, the agent installs the Postiz CLI and asks you for your API key.", + "connector": "Connector", + "connector_onboarding_description": "The fastest way: add Postiz with one click, you will be asked to sign in", + "mcp_onboarding_description": "Give your agent Postiz tools to create, schedule and manage posts", + "cli_onboarding_description": "Install the Postiz CLI and the skill that teaches your agent how to use it", + "agent_settings_later": "More agents and full instructions are available under Settings > Developers", "watch_tutorial": "Watch Tutorial", "watch_tutorial_title": "Learn How to Use Postiz", "watch_tutorial_description": "Watch this short video to learn how to get the most out of Postiz", "back": "Back", + "continue_skip": "Continue / Skip", "get_started": "Get Started", "kick_select_channel": "Select Channel", "annual": "Annual", From c9382d983df291de0ca0cc69ebb22854ff52d580 Mon Sep 17 00:00:00 2001 From: Nevo David Date: Thu, 3 Sep 2026 15:27:53 +0700 Subject: [PATCH 12/14] feat: other agents --- .../public/icons/third-party/nanoclaw.png | Bin 0 -> 6903 bytes .../onboarding/onboarding.modal.tsx | 43 ++++++++- .../public-api/mcp.client.icons.tsx | 88 ++++++++++++++++++ .../public-api/public.component.tsx | 46 ++++++++- .../translation/locales/en/translation.json | 1 + 5 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 apps/frontend/public/icons/third-party/nanoclaw.png diff --git a/apps/frontend/public/icons/third-party/nanoclaw.png b/apps/frontend/public/icons/third-party/nanoclaw.png new file mode 100644 index 0000000000000000000000000000000000000000..94d7280cc3bd65a59d53360839dd6cc8f0592f49 GIT binary patch literal 6903 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 literal 0 HcmV?d00001 diff --git a/apps/frontend/src/components/onboarding/onboarding.modal.tsx b/apps/frontend/src/components/onboarding/onboarding.modal.tsx index 09ff8d7bd6..2f4bb8db06 100644 --- a/apps/frontend/src/components/onboarding/onboarding.modal.tsx +++ b/apps/frontend/src/components/onboarding/onboarding.modal.tsx @@ -12,12 +12,15 @@ import { useModals } from '@gitroom/frontend/components/layout/new-modal'; import { useUser } from '@gitroom/frontend/components/layout/user.context'; import { useVariables } from '@gitroom/react/helpers/variable.context'; import { + AnyMcpClient, CopyButton, getMcpConfig, getMcpOauthUrl, isChatOnlyMcpClient, localCliSteps, McpAuth, + McpClient, + mcpClients, } from '@gitroom/frontend/components/public-api/public.component'; import { McpClientIcon } from '@gitroom/frontend/components/public-api/mcp.client.icons'; @@ -261,9 +264,15 @@ const onboardingAgents = [ type OnboardingAgent = (typeof onboardingAgents)[number]; +// Every other MCP client, grouped under one tab with its own picker +const otherTab = 'Other agents' as const; +const otherAgents = mcpClients.filter( + (client) => !(onboardingAgents as readonly string[]).includes(client) +); + // Not an agent, a tab showing the raw API key for people integrating by hand const apiTab = 'API' as const; -type OnboardingTab = OnboardingAgent | typeof apiTab; +type OnboardingTab = OnboardingAgent | typeof otherTab | typeof apiTab; const cliCommands = localCliSteps.map((step) => step.code); @@ -292,7 +301,11 @@ const OnboardingStep2: FC<{ onBack: () => void; onNext: () => void }> = ({ const t = useT(); const user = useUser(); const { backendUrl, mcpUrl, billingEnabled } = useVariables(); - const [agent, setAgent] = useState('Claude'); + const [tab, setTab] = useState('Claude'); + const [otherAgent, setOtherAgent] = useState(otherAgents[0]); + // The client the cards describe: the tab itself, or the pick inside "Other agents" + const agent: AnyMcpClient | typeof apiTab = + tab === otherTab ? otherAgent : tab; const [auth, setAuth] = useState('oauth'); const [revealed, setRevealed] = useState(false); const mcpBase = mcpUrl || backendUrl; @@ -554,25 +567,45 @@ const OnboardingStep2: FC<{ onBack: () => void; onNext: () => void }> = ({ {available ? (
- {[...onboardingAgents, apiTab].map((item) => ( + {[...onboardingAgents, otherTab, apiTab].map((item) => ( ))}
+ {tab === otherTab && ( +
+ {otherAgents.map((item) => ( + + ))} +
+ )} {agent === apiTab ? ( apiSection diff --git a/apps/frontend/src/components/public-api/mcp.client.icons.tsx b/apps/frontend/src/components/public-api/mcp.client.icons.tsx index 42daa32787..412cfa0c0d 100644 --- a/apps/frontend/src/components/public-api/mcp.client.icons.tsx +++ b/apps/frontend/src/components/public-api/mcp.client.icons.tsx @@ -423,6 +423,94 @@ const icons: Record> = { ), + // Raster mark, served from /public like the other third-party icons + NanoClaw: ({ size }) => ( + NanoClaw + ), + Hermes: ({ size }) => ( + + + + + ), + OpenClaw: ({ size }) => ( + + + + + + + + + + + + + + + + + + ), + // Not a client: the onboarding "Other agents" tab + 'Other agents': ({ size }) => ( + + + + + + + + + + + + ), // Not a client: the onboarding "API" tab API: ({ size }) => ( MCP Servers > + Add, then paste this config.', }; + case 'Hermes': + return { + config: `# ~/.hermes/config.yaml\n\nmcp_servers:\n postiz:\n url: "${oauthUrl}"\n auth: oauth`, + hint: 'Add to ~/.hermes/config.yaml, then run /reload-mcp in the chat.', + }; + case 'OpenClaw': + return { + config: `openclaw mcp add postiz --url ${oauthUrl} --transport streamable-http --auth oauth && openclaw mcp login postiz`, + hint: 'Run this command in your terminal.', + }; + case 'NanoClaw': + return { + config: `ncl groups config add-mcp-server --id --name postiz --url ${oauthUrl}`, + hint: 'Run this in your terminal, replace with the agent group that should get Postiz.', + }; } } @@ -203,6 +221,32 @@ export const getMcpConfig = ( }), hint: 'Settings > MCP Servers > + Add, then paste this config.', }; + case 'Hermes': + return { + config: `# ~/.hermes/config.yaml\n\nmcp_servers:\n postiz:\n url: "${urlBase}"\n headers:\n Authorization: "${bearer}"`, + hint: 'Add to ~/.hermes/config.yaml, then run /reload-mcp in the chat.', + }; + case 'OpenClaw': + return { + config: json({ + mcp: { + servers: { + postiz: { + url: urlBase, + transport: 'streamable-http', + headers: { Authorization: bearer }, + }, + }, + }, + }), + hint: 'Add to ~/.openclaw/openclaw.json', + }; + case 'NanoClaw': + // No headers flag, the key travels inside the URL like remote clients + return { + config: `ncl groups config add-mcp-server --id --name postiz --url ${mcpBase}/mcp/${apiKey}`, + hint: 'Run this in your terminal, replace with the agent group that should get Postiz.', + }; } }; diff --git a/libraries/react-shared-libraries/src/translation/locales/en/translation.json b/libraries/react-shared-libraries/src/translation/locales/en/translation.json index 912c3af22c..2d9252f525 100644 --- a/libraries/react-shared-libraries/src/translation/locales/en/translation.json +++ b/libraries/react-shared-libraries/src/translation/locales/en/translation.json @@ -704,6 +704,7 @@ "oauth_sign_in_hint": "Your agent will open a browser window to sign in to Postiz.", "add_to_cursor": "Add to Cursor", "cli": "CLI", + "other_agents": "Other agents", "documentation": "Documentation", "read_the_api_docs": "Read the API docs", "api_key_onboarding_description": "Send it as the Authorization header on every request", From 0dfa2f250439076a15f320507d7b28dcd672a450 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:14:38 +0700 Subject: [PATCH 13/14] chore(ci): guard staging-conflicts workflow to upstream repository --- .github/workflows/staging-conflicts.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/staging-conflicts.yml b/.github/workflows/staging-conflicts.yml index 6f44cd2e10..3d58f9b53a 100644 --- a/.github/workflows/staging-conflicts.yml +++ b/.github/workflows/staging-conflicts.yml @@ -21,6 +21,7 @@ concurrency: jobs: resolve: + if: github.repository == 'gitroomhq/postiz-app' runs-on: ubuntu-latest timeout-minutes: 20 permissions: From d838d21ad22d67d3419de311aeaedcc963827c3f Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:53:20 +0700 Subject: [PATCH 14/14] docs(changelog): update changelog with upstream v1.1.2 post workflow and MCP icons --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f464803c2..01df91e8c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **MCP Client Icons & Onboarding Enhancements (Upstream Sync)**: + - Added Nanoclaw and other third-party MCP client icons support in Public API. + - Upgraded onboarding experience and interactive modal walkthroughs. +- **Post Workflow v1.1.2**: + - Enhanced background workflow with automatic retry on heartbeat timeouts when no heartbeat details are present. +- **Frontend & Media Modernization Roadmap**: + - Expanded `ROADMAP.md` with Crove OS visual design standards, workspace switcher overhaul, and R2 direct upload pipeline. + ## [v2.24.0] - 2026-09-03 ### Added