From d4522e8e53116c4e9851de15a386b5d33d357a86 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Sun, 3 May 2026 17:57:17 +0200 Subject: [PATCH] feat: add check_queue_empty tool (v0.3.0) Synchronous, non-blocking count of active ClaudeJobs per product or across all accessible products. Registers check_queue_empty MCP tool with optional product_id scope, productAccessFilter AuthZ, tests, and README docs. --- README.md | 45 +++++++++ __tests__/check-queue-empty.test.ts | 144 ++++++++++++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- src/index.ts | 4 +- src/tools/check-queue-empty.ts | 67 +++++++++++++ 6 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 __tests__/check-queue-empty.test.ts create mode 100644 src/tools/check-queue-empty.ts diff --git a/README.md b/README.md index 62e1462..44bf4b5 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ activity and create todos via native tool calls instead of curl. | `wait_for_job` | Block until a QUEUED ClaudeJob is available, claim it atomically, return full task context with frozen `plan_snapshot`, `worktree_path`, and `branch_name` | no | | `update_job_status` | Report job transition to `running`, `done`, or `failed`; triggers SSE event to UI; cleans up worktree on terminal transitions | no | | `verify_task_against_plan` | Compare frozen `plan_snapshot` against current plan + story logs + commits; returns per-AC ✓/✗/? heuristic and drift-score | yes (read-only) | +| `cleanup_my_worktrees` | Remove stale git worktrees left by crashed or cancelled agent runs | no | +| `check_queue_empty` | Synchronous, non-blocking count of active jobs (QUEUED/CLAIMED/RUNNING); optional `product_id` scope | no | | `set_pbi_pr` | Write `pr_url` on a PBI and clear `pr_merged_at`. Idempotent: re-calling overwrites `pr_url` and resets `pr_merged_at` to null | no | | `mark_pbi_pr_merged` | Set `pr_merged_at = now()` on a PBI. Requires `pr_url` to already be set. Idempotent: re-calling overwrites the timestamp | no | @@ -128,6 +130,49 @@ Records that the linked PR has been merged by setting `pr_merged_at = now()`. Re | `pr_url` not set | `PBI heeft geen gekoppelde PR` | | Demo account | `PERMISSION_DENIED: Demo accounts cannot perform write operations` | +### check_queue_empty + +Synchronous, non-blocking poll that returns how many ClaudeJobs are still active (`QUEUED`, `CLAIMED`, `RUNNING`). No blocking — returns immediately. Use it after the last `update_job_status('done')` in a batch to decide whether to stay in the loop or finalise. + +**Input** + +```json +{ "product_id": "cmoprewcf000q..." } // optional — omit to aggregate all products +``` + +**Output — with product_id** + +```json +{ "empty": false, "remaining": 2 } +``` + +**Output — without product_id** + +```json +{ + "empty": false, + "remaining": 3, + "by_product": { + "cmoprewcf000q...": 2, + "cmohry5yj0001...": 1 + } +} +``` + +**Agent decision rule** + +| `empty` | Action | +|---|---| +| `false` | Stay in loop — call `wait_for_job` again immediately | +| `true` | Finalise — push branch, open PR (if `auto_pr`), recap, exit | + +**Errors** + +| Condition | Message | +|---|---| +| `product_id` provided but not accessible | `Product not found or not accessible` | +| Demo account | `PERMISSION_DENIED: Demo accounts cannot perform write operations` | + ## Prompts - `implement_next_story` — full workflow: fetch context, log plan, walk diff --git a/__tests__/check-queue-empty.test.ts b/__tests__/check-queue-empty.test.ts new file mode 100644 index 0000000..567f1b5 --- /dev/null +++ b/__tests__/check-queue-empty.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + claudeJob: { + count: vi.fn(), + groupBy: vi.fn(), + }, + }, +})) + +vi.mock('../src/auth.js', async (importOriginal) => { + const original = await importOriginal() + return { ...original, requireWriteAccess: vi.fn() } +}) + +vi.mock('../src/access.js', () => ({ + userCanAccessProduct: vi.fn(), +})) + +import { prisma } from '../src/prisma.js' +import { requireWriteAccess, PermissionDeniedError } from '../src/auth.js' +import { userCanAccessProduct } from '../src/access.js' +import { registerCheckQueueEmptyTool } from '../src/tools/check-queue-empty.js' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' + +const mockPrisma = prisma as unknown as { + claudeJob: { + count: ReturnType + groupBy: ReturnType + } +} +const mockRequireWriteAccess = requireWriteAccess as ReturnType +const mockUserCanAccessProduct = userCanAccessProduct as ReturnType + +const USER_ID = 'user-abc' +const PRODUCT_A = 'product-aaa' +const PRODUCT_B = 'product-bbb' + +function makeServer() { + let handler: (args: Record) => Promise + const server = { + registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => { + handler = fn + }), + call: (args: Record) => handler(args), + } + registerCheckQueueEmptyTool(server as unknown as McpServer) + return server +} + +beforeEach(() => { + vi.clearAllMocks() + mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'agent', isDemo: false }) + mockUserCanAccessProduct.mockResolvedValue(true) +}) + +describe('check_queue_empty — no product_id', () => { + it('returns empty:true when no active jobs exist', async () => { + mockPrisma.claudeJob.groupBy.mockResolvedValue([]) + const server = makeServer() + const result = await server.call({}) as { content: { text: string }[] } + const body = JSON.parse(result.content[0].text) + expect(body).toEqual({ empty: true, remaining: 0, by_product: {} }) + }) + + it('returns correct counts for one product with active jobs', async () => { + mockPrisma.claudeJob.groupBy.mockResolvedValue([{ product_id: PRODUCT_A, _count: 3 }]) + const server = makeServer() + const result = await server.call({}) as { content: { text: string }[] } + const body = JSON.parse(result.content[0].text) + expect(body).toEqual({ empty: false, remaining: 3, by_product: { [PRODUCT_A]: 3 } }) + }) + + it('aggregates across two products', async () => { + mockPrisma.claudeJob.groupBy.mockResolvedValue([ + { product_id: PRODUCT_A, _count: 2 }, + { product_id: PRODUCT_B, _count: 1 }, + ]) + const server = makeServer() + const result = await server.call({}) as { content: { text: string }[] } + const body = JSON.parse(result.content[0].text) + expect(body).toEqual({ + empty: false, + remaining: 3, + by_product: { [PRODUCT_A]: 2, [PRODUCT_B]: 1 }, + }) + }) + + it('passes correct where clause to groupBy', async () => { + mockPrisma.claudeJob.groupBy.mockResolvedValue([]) + const server = makeServer() + await server.call({}) + expect(mockPrisma.claudeJob.groupBy).toHaveBeenCalledWith( + expect.objectContaining({ + by: ['product_id'], + where: expect.objectContaining({ + user_id: USER_ID, + status: { in: expect.arrayContaining(['QUEUED', 'CLAIMED', 'RUNNING']) }, + product: expect.objectContaining({ OR: expect.any(Array) }), + }), + _count: true, + }), + ) + }) +}) + +describe('check_queue_empty — with product_id', () => { + it('returns empty:true when product queue is empty', async () => { + mockPrisma.claudeJob.count.mockResolvedValue(0) + const server = makeServer() + const result = await server.call({ product_id: PRODUCT_A }) as { content: { text: string }[] } + const body = JSON.parse(result.content[0].text) + expect(body).toEqual({ empty: true, remaining: 0 }) + expect(body.by_product).toBeUndefined() + }) + + it('returns correct remaining count for a product with jobs', async () => { + mockPrisma.claudeJob.count.mockResolvedValue(2) + const server = makeServer() + const result = await server.call({ product_id: PRODUCT_A }) as { content: { text: string }[] } + const body = JSON.parse(result.content[0].text) + expect(body).toEqual({ empty: false, remaining: 2 }) + }) + + it('returns error when user has no access to the product', async () => { + mockUserCanAccessProduct.mockResolvedValue(false) + const server = makeServer() + const result = await server.call({ product_id: PRODUCT_A }) as { content: { text: string }[]; isError: boolean } + expect(result.isError).toBe(true) + expect(result.content[0].text).toContain('not found or not accessible') + expect(mockPrisma.claudeJob.count).not.toHaveBeenCalled() + }) +}) + +describe('check_queue_empty — demo user', () => { + it('returns PERMISSION_DENIED error for demo accounts', async () => { + mockRequireWriteAccess.mockRejectedValue(new PermissionDeniedError()) + const server = makeServer() + const result = await server.call({}) as { content: { text: string }[]; isError: boolean } + expect(result.isError).toBe(true) + expect(result.content[0].text).toContain('PERMISSION_DENIED') + }) +}) diff --git a/package-lock.json b/package-lock.json index 5dc073b..6a4cd17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scrum4me-mcp", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scrum4me-mcp", - "version": "0.2.0", + "version": "0.3.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index e9e3420..5bcad91 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scrum4me-mcp", - "version": "0.2.0", + "version": "0.3.0", "description": "MCP server for Scrum4Me — exposes dev-flow tools and prompts via the Model Context Protocol", "type": "module", "bin": { diff --git a/src/index.ts b/src/index.ts index b88532c..81dbc91 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ import { registerWaitForJobTool } from './tools/wait-for-job.js' import { registerUpdateJobStatusTool } from './tools/update-job-status.js' import { registerVerifyTaskAgainstPlanTool } from './tools/verify-task-against-plan.js' import { registerCleanupMyWorktreesTool } from './tools/cleanup-my-worktrees.js' +import { registerCheckQueueEmptyTool } from './tools/check-queue-empty.js' import { registerSetPbiPrTool } from './tools/set-pbi-pr.js' import { registerMarkPbiPrMergedTool } from './tools/mark-pbi-pr-merged.js' import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js' @@ -29,7 +30,7 @@ import { registerWorker } from './presence/worker.js' import { startHeartbeat } from './presence/heartbeat.js' import { registerShutdownHandlers } from './presence/shutdown.js' -const VERSION = '0.2.0' +const VERSION = '0.3.0' async function main() { const server = new McpServer( @@ -61,6 +62,7 @@ async function main() { registerUpdateJobStatusTool(server) registerVerifyTaskAgainstPlanTool(server) registerCleanupMyWorktreesTool(server) + registerCheckQueueEmptyTool(server) registerSetPbiPrTool(server) registerMarkPbiPrMergedTool(server) registerImplementNextStoryPrompt(server) diff --git a/src/tools/check-queue-empty.ts b/src/tools/check-queue-empty.ts new file mode 100644 index 0000000..b732696 --- /dev/null +++ b/src/tools/check-queue-empty.ts @@ -0,0 +1,67 @@ +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { prisma } from '../prisma.js' +import { requireWriteAccess } from '../auth.js' +import { userCanAccessProduct } from '../access.js' +import { toolError, toolJson, withToolErrors } from '../errors.js' + +const ACTIVE_STATUSES = ['QUEUED', 'CLAIMED', 'RUNNING'] as const + +const inputSchema = z.object({ + product_id: z.string().min(1).optional(), +}) + +export function registerCheckQueueEmptyTool(server: McpServer) { + server.registerTool( + 'check_queue_empty', + { + title: 'Check queue empty', + description: + 'Synchronous, non-blocking check of how many ClaudeJobs are still active ' + + "(QUEUED, CLAIMED, RUNNING). Optionally scoped to one product via product_id; " + + 'without it, aggregates across all accessible products. ' + + "Use after the last update_job_status('done') in a batch to decide whether to " + + 'keep working or finalize. Forbidden for demo accounts.', + inputSchema, + annotations: { readOnlyHint: true, idempotentHint: true }, + }, + async ({ product_id }) => + withToolErrors(async () => { + const auth = await requireWriteAccess() + const { userId } = auth + + if (product_id) { + if (!(await userCanAccessProduct(product_id, userId))) { + return toolError(`Product ${product_id} not found or not accessible`) + } + const remaining = await prisma.claudeJob.count({ + where: { + user_id: userId, + product_id, + status: { in: [...ACTIVE_STATUSES] }, + }, + }) + return toolJson({ empty: remaining === 0, remaining }) + } + + const groups = await prisma.claudeJob.groupBy({ + by: ['product_id'], + where: { + user_id: userId, + status: { in: [...ACTIVE_STATUSES] }, + product: { + OR: [ + { user_id: userId }, + { members: { some: { user_id: userId } } }, + ], + }, + }, + _count: true, + }) + + const by_product = Object.fromEntries(groups.map((g) => [g.product_id, g._count])) + const remaining = groups.reduce((sum, g) => sum + g._count, 0) + return toolJson({ empty: remaining === 0, remaining, by_product }) + }), + ) +}