From 18c34b63de8f102caa35985396f1763cc351b754 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 21:34:41 +0200 Subject: [PATCH 1/4] =?UTF-8?q?PBI-55:=20src/lib/push-trigger.ts=20?= =?UTF-8?q?=E2=80=93=20fire-and-forget=20push=20helper=20with=205s=20Abort?= =?UTF-8?q?Controller=20timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 4 ++++ src/lib/push-trigger.ts | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 src/lib/push-trigger.ts diff --git a/.env.example b/.env.example index 6a3e89c..4a99af7 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,7 @@ DATABASE_URL="postgresql://user:pass@host:5432/dbname" # API token from Scrum4Me → /settings/tokens SCRUM4ME_TOKEN="" + +# Internal push endpoint (main-app) for web-push notifications +INTERNAL_PUSH_URL="" +INTERNAL_PUSH_SECRET="" diff --git a/src/lib/push-trigger.ts b/src/lib/push-trigger.ts new file mode 100644 index 0000000..fb0434a --- /dev/null +++ b/src/lib/push-trigger.ts @@ -0,0 +1,22 @@ +export type PushPayload = { title: string; body: string; url: string; tag?: string }; + +export async function triggerPush(userId: string, payload: PushPayload): Promise { + const url = process.env.INTERNAL_PUSH_URL; + const secret = process.env.INTERNAL_PUSH_SECRET; + if (!url || !secret) return; // feature-gated + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${secret}` }, + body: JSON.stringify({ userId, payload }), + signal: controller.signal, + }); + if (!res.ok) console.warn('[push-trigger] non-2xx', res.status); + } catch (err) { + console.error('[push-trigger]', err); + } finally { + clearTimeout(timeout); + } +} From 4c476464ec3284b03971d945b46f78d117f5f120 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 21:37:20 +0200 Subject: [PATCH 2/4] =?UTF-8?q?PBI-55:=20ask-user-question=20=E2=80=93=20t?= =?UTF-8?q?riggerPush=20na=20claudeQuestion.create?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/tools/ask-user-question.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/tools/ask-user-question.ts b/src/tools/ask-user-question.ts index b4d5a59..3618b5e 100644 --- a/src/tools/ask-user-question.ts +++ b/src/tools/ask-user-question.ts @@ -10,6 +10,7 @@ import { prisma } from '../prisma.js' import { requireWriteAccess } from '../auth.js' import { userCanAccessStory, userOwnsIdea } from '../access.js' import { toolError, toolJson, withToolErrors } from '../errors.js' +import { triggerPush } from '../lib/push-trigger.js' const PENDING_TTL_HOURS = 24 const POLL_INTERVAL_MS = 2_000 @@ -127,6 +128,13 @@ export function registerAskUserQuestionTool(server: McpServer) { }, }) + void triggerPush(auth.userId, { + title: 'Claude heeft een vraag', + body: question.slice(0, 120), + url: '/notifications', + tag: `claude-q-${created.id}`, + }) + // Async-mode (default): return direct. if (!wait_seconds || wait_seconds === 0) { return toolJson(summarize(created)) From ab32a72ce055d9ca59a035bceca4f61289584085 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 21:42:19 +0200 Subject: [PATCH 3/4] =?UTF-8?q?PBI-55:=20update-job-status=20=E2=80=93=20N?= =?UTF-8?q?OTIFY=20payload-fix=20(kind/idea=5Fid)=20+=20triggerPush=20on?= =?UTF-8?q?=20done/failed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/tools/update-job-status.ts | 49 +++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/src/tools/update-job-status.ts b/src/tools/update-job-status.ts index 5d35399..8fcb83e 100644 --- a/src/tools/update-job-status.ts +++ b/src/tools/update-job-status.ts @@ -24,6 +24,7 @@ import { pushBranchForJob } from '../git/push.js' import { createPullRequest, markPullRequestReady } from '../git/pr.js' import { cancelPbiOnFailure } from '../cancel/pbi-cascade.js' import { propagateStatusUpwards } from '../lib/tasks-status-update.js' +import { triggerPush } from '../lib/push-trigger.js' import { transition as prFlowTransition } from '../flow/pr-flow.js' import { transition as sprintRunTransition } from '../flow/sprint-run.js' import { executeEffects } from '../flow/effects.js' @@ -887,30 +888,40 @@ export function registerUpdateJobStatusTool(server: McpServer) { try { const pg = new Client({ connectionString: process.env.DATABASE_URL }) await pg.connect() - await pg.query( - `SELECT pg_notify('scrum4me_changes', $1)`, - [ - JSON.stringify({ - type: 'claude_job_status', - job_id: updated.id, - task_id: job.task_id, - user_id: job.user_id, - product_id: job.product_id, - status: actualStatus, - branch: updated.branch ?? undefined, - pushed_at: updated.pushed_at?.toISOString() ?? undefined, - pr_url: updated.pr_url ?? undefined, - verify_result: updated.verify_result?.toLowerCase() ?? undefined, - summary: updated.summary ?? undefined, - error: updated.error ?? undefined, - }), - ], - ) + const notifyPayload: Record = { + type: 'claude_job_status', + job_id: updated.id, + user_id: job.user_id, + product_id: job.product_id, + status: actualStatus, + branch: updated.branch ?? undefined, + pushed_at: updated.pushed_at?.toISOString() ?? undefined, + pr_url: updated.pr_url ?? undefined, + verify_result: updated.verify_result?.toLowerCase() ?? undefined, + summary: updated.summary ?? undefined, + error: updated.error ?? undefined, + } + if (job.task_id) notifyPayload.task_id = job.task_id + if (job.idea_id) { + notifyPayload.idea_id = job.idea_id + notifyPayload.kind = job.kind + } + await pg.query(`SELECT pg_notify('scrum4me_changes', $1)`, [JSON.stringify(notifyPayload)]) await pg.end() } catch { // non-fatal — status is already persisted } + if (actualStatus === 'failed' || actualStatus === 'done') { + const isFailed = actualStatus === 'failed' + void triggerPush(job.user_id, { + title: isFailed ? 'Job gefaald' : 'Job klaar', + body: (updated.summary ?? updated.error ?? `Job ${updated.id}`).slice(0, 120), + url: updated.pr_url ?? '/dashboard', + tag: `job-${updated.id}`, + }) + } + // Best-effort worktree cleanup on terminal transitions (skip if push failed — worktree preserved) if ( (actualStatus === 'done' || actualStatus === 'failed' || actualStatus === 'skipped') && From 6aa43ff7dd19f593283a4cf8385b15606fc9a7dc Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 21:44:41 +0200 Subject: [PATCH 4/4] PBI-55: .env.example descriptive push placeholders + README push-integration section Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 6 ++++-- README.md | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 4a99af7..62b28f7 100644 --- a/.env.example +++ b/.env.example @@ -5,5 +5,7 @@ DATABASE_URL="postgresql://user:pass@host:5432/dbname" SCRUM4ME_TOKEN="" # Internal push endpoint (main-app) for web-push notifications -INTERNAL_PUSH_URL="" -INTERNAL_PUSH_SECRET="" +# Set to the main-app /api/internal/push/send URL; leave empty to disable push (feature-gated). +INTERNAL_PUSH_URL="https://scrum4me.example.com/api/internal/push/send" +# Shared secret (≥32 chars) — must match INTERNAL_PUSH_SECRET in the main-app env. +INTERNAL_PUSH_SECRET="" diff --git a/README.md b/README.md index fb20e38..793cc07 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,10 @@ Minimale agent-prompt (geen CLAUDE.md-context nodig): > *Pak de volgende job uit de Scrum4Me-queue.* +## Web-push integration + +When `INTERNAL_PUSH_URL` and `INTERNAL_PUSH_SECRET` are set, the MCP server fires a fire-and-forget push notification to the main-app's internal endpoint (`/api/internal/push/send`) on two events: when `ask_user_question` creates a new question (tag `claude-q-`), and when `update_job_status` transitions a job to `done` or `failed` (tag `job-`). Both calls are wrapped in a 5 s `AbortController` timeout and a `try/catch` so a push failure never interrupts the tool response. Omitting the env vars disables the feature entirely. The `INTERNAL_PUSH_SECRET` value must match the one configured in the main-app; generate a fresh secret with `openssl rand -hex 32`. + ## Schema sync The Prisma schema is the source of truth in the upstream Scrum4Me