Merge pull request #25 from madhura68/feat/worker-quota-tools

feat(M13): get_worker_settings + worker_heartbeat tools (v0.7.0)
This commit is contained in:
Janpeter Visser 2026-05-06 04:24:47 +02:00 committed by GitHub
commit f5887da1f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 192 additions and 8 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "scrum4me-mcp", "name": "scrum4me-mcp",
"version": "0.6.2", "version": "0.7.0",
"description": "MCP server for Scrum4Me — exposes dev-flow tools and prompts via the Model Context Protocol", "description": "MCP server for Scrum4Me — exposes dev-flow tools and prompts via the Model Context Protocol",
"type": "module", "type": "module",
"bin": { "bin": {

View file

@ -11,6 +11,7 @@ enum Role {
PRODUCT_OWNER PRODUCT_OWNER
SCRUM_MASTER SCRUM_MASTER
DEVELOPER DEVELOPER
ADMIN
} }
enum StoryStatus { enum StoryStatus {
@ -32,6 +33,7 @@ enum ClaudeJobStatus {
DONE DONE
FAILED FAILED
CANCELLED CANCELLED
SKIPPED
} }
enum VerifyResult { enum VerifyResult {
@ -85,6 +87,7 @@ enum ClaudeJobKind {
TASK_IMPLEMENTATION TASK_IMPLEMENTATION
IDEA_GRILL IDEA_GRILL
IDEA_MAKE_PLAN IDEA_MAKE_PLAN
PLAN_CHAT
} }
enum IdeaLogType { enum IdeaLogType {
@ -96,6 +99,11 @@ enum IdeaLogType {
JOB_EVENT JOB_EVENT
} }
enum UserQuestionStatus {
pending
answered
}
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
username String @unique username String @unique
@ -104,10 +112,12 @@ model User {
is_demo Boolean @default(false) is_demo Boolean @default(false)
bio String? @db.VarChar(160) bio String? @db.VarChar(160)
bio_detail String? @db.VarChar(2000) bio_detail String? @db.VarChar(2000)
must_reset_password Boolean @default(false)
avatar_data Bytes? avatar_data Bytes?
active_product_id String? active_product_id String?
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull) active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
idea_code_counter Int @default(0) idea_code_counter Int @default(0)
min_quota_pct Int @default(20)
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
roles UserRole[] roles UserRole[]
@ -175,6 +185,7 @@ model Product {
claude_questions ClaudeQuestion[] claude_questions ClaudeQuestion[]
claude_jobs ClaudeJob[] claude_jobs ClaudeJob[]
ideas Idea[] ideas Idea[]
idea_products IdeaProduct[]
@@unique([user_id, name]) @@unique([user_id, name])
@@unique([user_id, code]) @@unique([user_id, code])
@ -322,6 +333,11 @@ model ClaudeJob {
finished_at DateTime? finished_at DateTime?
pushed_at DateTime? pushed_at DateTime?
verify_result VerifyResult? verify_result VerifyResult?
model_id String?
input_tokens Int?
output_tokens Int?
cache_read_tokens Int?
cache_write_tokens Int?
plan_snapshot String? plan_snapshot String?
branch String? branch String?
pr_url String? pr_url String?
@ -339,15 +355,31 @@ model ClaudeJob {
@@map("claude_jobs") @@map("claude_jobs")
} }
model ModelPrice {
id String @id @default(cuid())
model_id String @unique
input_price_per_1m Decimal @db.Decimal(12, 6)
output_price_per_1m Decimal @db.Decimal(12, 6)
cache_read_price_per_1m Decimal @db.Decimal(12, 6)
cache_write_price_per_1m Decimal @db.Decimal(12, 6)
currency String @default("USD")
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@map("model_prices")
}
model ClaudeWorker { model ClaudeWorker {
id String @id @default(cuid()) id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade) user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String user_id String
token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade) token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade)
token_id String token_id String
product_id String? product_id String?
started_at DateTime @default(now()) started_at DateTime @default(now())
last_seen_at DateTime @default(now()) last_seen_at DateTime @default(now())
last_quota_pct Int?
last_quota_check_at DateTime?
@@unique([token_id]) @@unique([token_id])
@@index([user_id, last_seen_at]) @@index([user_id, last_seen_at])
@ -403,9 +435,11 @@ model Idea {
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
questions ClaudeQuestion[] questions ClaudeQuestion[]
jobs ClaudeJob[] jobs ClaudeJob[]
logs IdeaLog[] logs IdeaLog[]
user_questions UserQuestion[]
secondary_products IdeaProduct[]
@@unique([user_id, code]) @@unique([user_id, code])
@@index([user_id, archived, status]) @@index([user_id, archived, status])
@ -413,6 +447,20 @@ model Idea {
@@map("ideas") @@map("ideas")
} }
model IdeaProduct {
id String @id @default(cuid())
idea_id String
product_id String
created_at DateTime @default(now())
idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade)
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
@@unique([idea_id, product_id])
@@index([product_id])
@@map("idea_products")
}
model IdeaLog { model IdeaLog {
id String @id @default(cuid()) id String @id @default(cuid())
idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade) idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade)
@ -426,6 +474,23 @@ model IdeaLog {
@@map("idea_logs") @@map("idea_logs")
} }
model UserQuestion {
id String @id @default(cuid())
idea_id String
user_id String
question String @db.Text
answer String? @db.Text
status UserQuestionStatus @default(pending)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade)
@@index([idea_id, status])
@@index([user_id])
@@map("user_questions")
}
model LoginPairing { model LoginPairing {
id String @id @default(cuid()) id String @id @default(cuid())
secret_hash String secret_hash String

View file

@ -28,6 +28,8 @@ import { registerGetIdeaContextTool } from './tools/get-idea-context.js'
import { registerUpdateIdeaGrillMdTool } from './tools/update-idea-grill-md.js' import { registerUpdateIdeaGrillMdTool } from './tools/update-idea-grill-md.js'
import { registerUpdateIdeaPlanMdTool } from './tools/update-idea-plan-md.js' import { registerUpdateIdeaPlanMdTool } from './tools/update-idea-plan-md.js'
import { registerLogIdeaDecisionTool } from './tools/log-idea-decision.js' import { registerLogIdeaDecisionTool } from './tools/log-idea-decision.js'
import { registerGetWorkerSettingsTool } from './tools/get-worker-settings.js'
import { registerWorkerHeartbeatTool } from './tools/worker-heartbeat.js'
import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js' import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js'
import { getAuth } from './auth.js' import { getAuth } from './auth.js'
import { registerWorker } from './presence/worker.js' import { registerWorker } from './presence/worker.js'
@ -89,6 +91,9 @@ async function main() {
registerUpdateIdeaGrillMdTool(server) registerUpdateIdeaGrillMdTool(server)
registerUpdateIdeaPlanMdTool(server) registerUpdateIdeaPlanMdTool(server)
registerLogIdeaDecisionTool(server) registerLogIdeaDecisionTool(server)
// M13: worker quota-gate tools
registerGetWorkerSettingsTool(server)
registerWorkerHeartbeatTool(server)
registerImplementNextStoryPrompt(server) registerImplementNextStoryPrompt(server)
// Presence bootstrap MUST run before server.connect — the stdio transport // Presence bootstrap MUST run before server.connect — the stdio transport

View file

@ -0,0 +1,33 @@
// MCP read-tool: lees de worker-instellingen van de geauthenticeerde user.
//
// Worker roept dit aan vóór elke wait_for_job iteratie zodat hij weet
// wanneer hij stand-by moet (pre-flight quota-gate).
//
// Auth: api-token; user_id afgeleid uit token. Demo mag.
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { prisma } from '../prisma.js'
import { getAuth } from '../auth.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
export function registerGetWorkerSettingsTool(server: McpServer) {
server.registerTool(
'get_worker_settings',
{
title: 'Get worker settings',
description:
'Read the authenticated user\'s worker settings (min_quota_pct). Worker should call this each iteration before doing the pre-flight quota probe.',
inputSchema: {},
},
async () =>
withToolErrors(async () => {
const auth = await getAuth()
const user = await prisma.user.findUnique({
where: { id: auth.userId },
select: { min_quota_pct: true },
})
if (!user) return toolError('User not found')
return toolJson({ min_quota_pct: user.min_quota_pct })
}),
)
}

View file

@ -0,0 +1,81 @@
// MCP write-tool: worker rapporteert quota-pct na pre-flight probe.
//
// Aanvulling op de bestaande achtergrond-heartbeat (die alleen last_seen_at
// elke 5s tickt). Deze tool wordt expliciet aangeroepen door de worker
// nadat scripts/worker-quota-probe.sh een quota-meting heeft gedaan.
//
// Updates ClaudeWorker.{last_quota_pct, last_quota_check_at, last_seen_at}
// en emit een pg_notify-event op 'scrum4me_changes' zodat de UI de
// stand-by-badge real-time kan tonen.
//
// Auth: api-token; demo mag niet (worker is geen demo-flow).
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { Client } from 'pg'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { toolError, toolJson, withToolErrors } from '../errors.js'
const inputSchema = z.object({
last_quota_pct: z.number().int().min(0).max(100),
last_quota_check_at: z.string().datetime().optional(),
})
export function registerWorkerHeartbeatTool(server: McpServer) {
server.registerTool(
'worker_heartbeat',
{
title: 'Worker heartbeat with quota',
description:
'Report the worker\'s most recent rate-limit quota percentage to the server. Updates ClaudeWorker.last_quota_pct + last_quota_check_at. Emits a SSE event so the UI can show stand-by status. Forbidden for demo accounts.',
inputSchema,
},
async ({ last_quota_pct, last_quota_check_at }) =>
withToolErrors(async () => {
const auth = await requireWriteAccess()
const checkAt = last_quota_check_at ? new Date(last_quota_check_at) : new Date()
const result = await prisma.claudeWorker.updateMany({
where: { token_id: auth.tokenId },
data: {
last_seen_at: new Date(),
last_quota_pct,
last_quota_check_at: checkAt,
},
})
if (result.count === 0) {
return toolError(
'Worker record not found — call register_worker first or wait for the next heartbeat tick',
)
}
// pg_notify zodat NavBar realtime kan updaten. Failure is non-fatal:
// de DB-write is al gebeurd, alleen de live-update mist dan.
try {
const pg = new Client({ connectionString: process.env.DATABASE_URL })
await pg.connect()
await pg.query('SELECT pg_notify($1, $2)', [
'scrum4me_changes',
JSON.stringify({
type: 'worker_heartbeat',
user_id: auth.userId,
token_id: auth.tokenId,
last_quota_pct,
last_quota_check_at: checkAt.toISOString(),
}),
])
await pg.end()
} catch {
// non-fatal
}
return toolJson({
ok: true,
last_quota_pct,
last_quota_check_at: checkAt.toISOString(),
})
}),
)
}

2
vendor/scrum4me vendored

@ -1 +1 @@
Subproject commit 2893573004cf1df28ff5ad69752ddcf8b66ddb1e Subproject commit 555ed8fe89f0a3c9e52098fa0590ab8ba16e357a