diff --git a/.env.example b/.env.example index 6a3e89c..62b28f7 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,9 @@ 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 +# 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 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); + } +} 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)) 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') &&