Compare commits
1 commit
main
...
fix/verify
| Author | SHA1 | Date | |
|---|---|---|---|
| f11081920d |
102 changed files with 599 additions and 10793 deletions
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"hooks": {
|
|
||||||
"PostToolUse": [
|
|
||||||
{
|
|
||||||
"matcher": "mcp__scrum4me__update_job_status",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "tsx \"${SCRUM4ME_MCP_DIR:-$CLAUDE_PROJECT_DIR}/scripts/persist-job-usage.ts\""
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,9 +3,3 @@ DATABASE_URL="postgresql://user:pass@host:5432/dbname"
|
||||||
|
|
||||||
# API token from Scrum4Me → /settings/tokens
|
# API token from Scrum4Me → /settings/tokens
|
||||||
SCRUM4ME_TOKEN=""
|
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="<generate-with: openssl rand -hex 32>"
|
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -12,6 +12,3 @@ prisma/generated
|
||||||
# Editor
|
# Editor
|
||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
# Claude Code worktrees (per-session, never tracked)
|
|
||||||
.claude/worktrees/
|
|
||||||
|
|
|
||||||
35
CHANGELOG.md
35
CHANGELOG.md
|
|
@ -1,35 +0,0 @@
|
||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to scrum4me-mcp.
|
|
||||||
|
|
||||||
## [0.6.0] — 2026-05-04
|
|
||||||
|
|
||||||
Adds support for Scrum4Me M12 (Idea entity + Grill/Plan jobs).
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **`get_idea_context(idea_id)`** — fetch full idea + product + recent logs + open questions for agent context.
|
|
||||||
- **`update_idea_grill_md(idea_id, markdown)`** — save grill-result + transition to GRILLED + IdeaLog{GRILL_RESULT}.
|
|
||||||
- **`update_idea_plan_md(idea_id, markdown)`** — save plan with server-side yaml-frontmatter validation; ok → PLAN_READY, parse-fail → PLAN_FAILED + IdeaLog{JOB_EVENT, errors}.
|
|
||||||
- **`log_idea_decision(idea_id, type, content, metadata?)`** — DECISION/NOTE entries on the idea timeline.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **`ask_user_question`** — now accepts exact one of `story_id` OR `idea_id` (zod xor refine). Idea-questions are user-private (owner-scoped, no productAccessFilter).
|
|
||||||
- **`wait_for_job`** — response now includes `kind: 'TASK_IMPLEMENTATION' | 'IDEA_GRILL' | 'IDEA_MAKE_PLAN'`. For idea-jobs the payload returns `idea`, `product`, `repo_url`, `prompt_text` (embedded prompt from `src/prompts/idea/`) and **no worktree** (agent works in user's existing repo).
|
|
||||||
- **`update_job_status`** — for `failed` on `IDEA_GRILL` / `IDEA_MAKE_PLAN`: idea status auto-transitions to `GRILL_FAILED` / `PLAN_FAILED` + IdeaLog{JOB_EVENT}. Auto-PR + worktree-cleanup skipped for idea-jobs.
|
|
||||||
- **Health version** — now read dynamically from `package.json` at module load (was hardcoded; resolved sync-issues at deploy time).
|
|
||||||
|
|
||||||
### Schema
|
|
||||||
|
|
||||||
- Vendored `prisma/schema.prisma` synced with Scrum4Me M12 (Idea + IdeaLog models, IdeaStatus + ClaudeJobKind + IdeaLogType enums, ClaudeJob.task_id nullable + idea_id + kind, ClaudeQuestion.story_id nullable + idea_id, check-constraints, pg_notify-trigger update).
|
|
||||||
- Pinned to scrum4me commit on branch `feat/m12-ideas` until merged to main.
|
|
||||||
|
|
||||||
### Migration notes
|
|
||||||
|
|
||||||
- Requires Scrum4Me database to have M12 migration applied (`20260504172747_add_ideas_and_grill_jobs`).
|
|
||||||
- Worker runtime: see `vendor/scrum4me/docs/runbooks/mcp-integration.md` — batch-loop now switches on `kind` discriminator.
|
|
||||||
|
|
||||||
## [0.5.0] — earlier
|
|
||||||
|
|
||||||
Version bump (no changelog entry).
|
|
||||||
31
CLAUDE.md
31
CLAUDE.md
|
|
@ -26,16 +26,6 @@ MCP server that exposes the Scrum4Me dev-flow as native tools for Claude Code.
|
||||||
|
|
||||||
A story with 3 sub-tasks lands as **1 branch** with 3 commits and **1 PR** (assuming `auto_pr=true`). Sibling sub-tasks share the same `pr_url` — `maybeCreateAutoPr` reuses an existing PR from a sibling job instead of opening duplicates. Story-level PR title (`<story-code>: <story-title>`) so the GitHub view reads as one logical change rather than per-task fragments.
|
A story with 3 sub-tasks lands as **1 branch** with 3 commits and **1 PR** (assuming `auto_pr=true`). Sibling sub-tasks share the same `pr_url` — `maybeCreateAutoPr` reuses an existing PR from a sibling job instead of opening duplicates. Story-level PR title (`<story-code>: <story-title>`) so the GitHub view reads as one logical change rather than per-task fragments.
|
||||||
|
|
||||||
### PBI fail-cascade
|
|
||||||
|
|
||||||
When a `TASK_IMPLEMENTATION` job ends in `FAILED`, `cancelPbiOnFailure` (`src/cancel/pbi-cascade.ts`) cancels every queued/claimed/running sibling under the **same PBI** (across all stories) and undoes already-pushed commits:
|
|
||||||
|
|
||||||
- **Open PR** → `gh pr close --delete-branch` with a cascade-comment.
|
|
||||||
- **Merged PR** → revert-PR opened against the base branch via `git revert -m 1 <mergeSha>`. **No** auto-merge on the revert PR — review by hand.
|
|
||||||
- **Branch without PR** → best-effort `git push origin --delete <branch>`.
|
|
||||||
|
|
||||||
A trace (cancelled job count, closed/reverted PRs, deleted branches) is written to the original failed job's `error` column. Race-protection: if a parallel worker tries to `update_job_status` on a job that the cascade already set to `CANCELLED`, the call is rejected with a `JOB_CANCELLED` error so the agent discards local work and calls `wait_for_job` again. The cascade is idempotent and never throws — failures become warnings on the failed-job's trace.
|
|
||||||
|
|
||||||
### Required configuration
|
### Required configuration
|
||||||
|
|
||||||
Set env var per product:
|
Set env var per product:
|
||||||
|
|
@ -56,35 +46,18 @@ Or add to `~/.scrum4me-agent-config.json`:
|
||||||
|
|
||||||
If no repo root is found, `wait_for_job` rolls the claim back to QUEUED and returns an error.
|
If no repo root is found, `wait_for_job` rolls the claim back to QUEUED and returns an error.
|
||||||
|
|
||||||
## Token-usage capture (PostToolUse hook)
|
|
||||||
|
|
||||||
`update_job_status` accepts optional fields `model_id`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`. The agent never has to pass them — `scripts/persist-job-usage.ts` runs as a PostToolUse hook, reads the local Claude Code transcript JSONL (no Anthropic API needed), sums per-job usage, and writes directly to `claude_jobs` via Prisma. Window detection: from the most-recent `wait_for_job` tool_use to EOF.
|
|
||||||
|
|
||||||
The hook is registered in `.claude/settings.json` of this repo. **For agent-worker mode** (Claude Code running with cwd inside a product worktree, not scrum4me-mcp), copy the same hook block into your user settings (`~/.claude/settings.json`) and set `SCRUM4ME_MCP_DIR` so the script resolves regardless of cwd:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export SCRUM4ME_MCP_DIR=/absolute/path/to/scrum4me-mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
Pricing rows (`model_prices`) are seeded by Scrum4Me's `prisma/seed.ts`. Unknown `model_id`s leave `cost_usd = NULL` in Insights queries — add a row and re-run `npm run seed` to fill them in.
|
|
||||||
|
|
||||||
Robustness notes:
|
|
||||||
- Subagent (`isSidechain: true`) lines in the main JSONL are skipped to avoid double-counting against `subagents/`-subdirectory transcripts.
|
|
||||||
- Lines are deduplicated on `uuid` because branching/resumption can rewrite the same message into multiple JSONLs.
|
|
||||||
- Known Claude Code bug: auto-updates can silently delete files under `~/.claude/projects/`. If you depend on these numbers for billing/reporting, persist `claude_jobs.input_tokens` etc. immediately on `update_job_status` (already what this hook does) and consider an external backup of `~/.claude/projects/` if you want to retain historical detail.
|
|
||||||
|
|
||||||
## Manual worktree cleanup
|
## Manual worktree cleanup
|
||||||
|
|
||||||
Run `cleanup_my_worktrees` (no arguments) to scan `~/.scrum4me-agent-worktrees/` and remove worktrees for jobs that are in a terminal state (DONE, FAILED, CANCELLED). Worktrees for active jobs (QUEUED, CLAIMED, RUNNING) are left untouched. Returns `{ removed, kept, skipped }`.
|
Run `cleanup_my_worktrees` (no arguments) to scan `~/.scrum4me-agent-worktrees/` and remove worktrees for jobs that are in a terminal state (DONE, FAILED, CANCELLED). Worktrees for active jobs (QUEUED, CLAIMED, RUNNING) are left untouched. Returns `{ removed, kept, skipped }`.
|
||||||
|
|
||||||
## Worker presence
|
## Worker presence
|
||||||
|
|
||||||
Server-startup registers a `ClaudeWorker` record + starts a 10 s heartbeat; SIGTERM/SIGINT cleans it up. The Scrum4Me NavBar counts active workers via `last_seen_at < now() - 15s` — at 10 s interval one missed tick + jitter can flicker the indicator; bump that threshold in Scrum4Me to ≥ 25 s if needed.
|
Server-startup registers a `ClaudeWorker` record + starts a 5 s heartbeat; SIGTERM/SIGINT cleans it up. The Scrum4Me NavBar counts active workers via `last_seen_at < now() - 15s`.
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `src/presence/worker.ts` | `registerWorker` (upsert + pg_notify worker_connected) + `unregisterWorker` |
|
| `src/presence/worker.ts` | `registerWorker` (upsert + pg_notify worker_connected) + `unregisterWorker` |
|
||||||
| `src/presence/heartbeat.ts` | `startHeartbeat` — 10 s interval, self-heals by re-registering when record disappears |
|
| `src/presence/heartbeat.ts` | `startHeartbeat` — 5 s interval, self-heals by re-registering when record disappears |
|
||||||
| `src/presence/shutdown.ts` | `registerShutdownHandlers` — SIGTERM/SIGINT → stop heartbeat + unregister |
|
| `src/presence/shutdown.ts` | `registerShutdownHandlers` — SIGTERM/SIGINT → stop heartbeat + unregister |
|
||||||
| `src/index.ts` | Bootstrap: calls `getAuth` → `registerWorker` → `startHeartbeat` → `registerShutdownHandlers` |
|
| `src/index.ts` | Bootstrap: calls `getAuth` → `registerWorker` → `startHeartbeat` → `registerShutdownHandlers` |
|
||||||
|
|
||||||
|
|
|
||||||
145
README.md
145
README.md
|
|
@ -28,13 +28,6 @@ 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 |
|
| `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 |
|
| `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) |
|
| `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 |
|
|
||||||
| `verify_sprint_task` | SPRINT_IMPLEMENTATION-flow: compare a `SprintTaskExecution`'s frozen `plan_snapshot` against `git diff <base_sha>...HEAD`. Returns `verify_result` + `allowed_for_done`. For `task[1..N]` zonder base_sha vult de tool die in op basis van de head_sha van de vorige DONE-execution | yes (read-only) |
|
|
||||||
| `update_task_execution` | SPRINT_IMPLEMENTATION-flow: mutate `SprintTaskExecution.status` (PENDING/RUNNING/DONE/FAILED/SKIPPED). Token must own the parent SPRINT-job. Idempotent | no |
|
|
||||||
| `job_heartbeat` | Extend `claude_jobs.lease_until` by 5 min. For SPRINT-jobs: response includes `sprint_run_status` + `sprint_run_pause_reason` so the worker can break its task-loop on UI-side cancel/pause | no |
|
|
||||||
|
|
||||||
Demo accounts may read but writes return `PERMISSION_DENIED`.
|
Demo accounts may read but writes return `PERMISSION_DENIED`.
|
||||||
|
|
||||||
|
|
@ -78,110 +71,6 @@ Compares the immutable snapshot captured at claim time against the current state
|
||||||
- Plan_snapshot is NULL voor jobs die zijn geclaimed vóór versie met snapshot-feature — rapport meldt "no baseline"
|
- Plan_snapshot is NULL voor jobs die zijn geclaimed vóór versie met snapshot-feature — rapport meldt "no baseline"
|
||||||
- Gebruik het rapport als startpunt, niet als definitief oordeel; PR-review blijft leidend
|
- Gebruik het rapport als startpunt, niet als definitief oordeel; PR-review blijft leidend
|
||||||
|
|
||||||
### set_pbi_pr
|
|
||||||
|
|
||||||
Links a GitHub Pull Request to a PBI and clears any previous merge timestamp. Safe to call multiple times — idempotent.
|
|
||||||
|
|
||||||
**Input**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "pbi_id": "cmoprewcf000q...", "pr_url": "https://github.com/owner/repo/pull/42" }
|
|
||||||
```
|
|
||||||
|
|
||||||
`pr_url` must match `^https://github\.com/[^/]+/[^/]+/pull/\d+$`. Any other format is rejected with a schema error.
|
|
||||||
|
|
||||||
**Output**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "ok": true, "pbi_id": "cmoprewcf000q...", "pr_url": "https://github.com/owner/repo/pull/42" }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Errors**
|
|
||||||
|
|
||||||
| Condition | Message |
|
|
||||||
|---|---|
|
|
||||||
| PBI not found or inaccessible | `PBI <id> not found or not accessible` |
|
|
||||||
| Demo account | `PERMISSION_DENIED: Demo accounts cannot perform write operations` |
|
|
||||||
| Invalid URL format | `VALIDATION_ERROR: pr_url: Invalid` |
|
|
||||||
|
|
||||||
### mark_pbi_pr_merged
|
|
||||||
|
|
||||||
Records that the linked PR has been merged by setting `pr_merged_at = now()`. Requires `set_pbi_pr` to have been called first. Idempotent: re-calling overwrites the timestamp.
|
|
||||||
|
|
||||||
**Input**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "pbi_id": "cmoprewcf000q..." }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ok": true,
|
|
||||||
"pbi_id": "cmoprewcf000q...",
|
|
||||||
"pr_url": "https://github.com/owner/repo/pull/42",
|
|
||||||
"pr_merged_at": "2026-05-03T12:00:00.000Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Errors**
|
|
||||||
|
|
||||||
| Condition | Message |
|
|
||||||
|---|---|
|
|
||||||
| PBI not found or inaccessible | `PBI <id> not found or not accessible` |
|
|
||||||
| `pr_url` not set | `PBI <id> 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 — empty queue**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "empty": true, "remaining": 0, "by_product": {} }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output — with product_id (non-empty)**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "empty": false, "remaining": 2 }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output — without product_id (per-product split)**
|
|
||||||
|
|
||||||
```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 <id> not found or not accessible` |
|
|
||||||
| Demo account | `PERMISSION_DENIED: Demo accounts cannot perform write operations` |
|
|
||||||
|
|
||||||
## Prompts
|
## Prompts
|
||||||
|
|
||||||
- `implement_next_story` — full workflow: fetch context, log plan, walk
|
- `implement_next_story` — full workflow: fetch context, log plan, walk
|
||||||
|
|
@ -296,10 +185,6 @@ Minimale agent-prompt (geen CLAUDE.md-context nodig):
|
||||||
|
|
||||||
> *Pak de volgende job uit de Scrum4Me-queue.*
|
> *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-<id>`), and when `update_job_status` transitions a job to `done` or `failed` (tag `job-<id>`). 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
|
## Schema sync
|
||||||
|
|
||||||
The Prisma schema is the source of truth in the upstream Scrum4Me
|
The Prisma schema is the source of truth in the upstream Scrum4Me
|
||||||
|
|
@ -342,33 +227,3 @@ npx @modelcontextprotocol/inspector node dist/index.js
|
||||||
- **Production database** — verify against a preview database before
|
- **Production database** — verify against a preview database before
|
||||||
running against prod. The token check enforces user scope but does
|
running against prod. The token check enforces user scope but does
|
||||||
not gate reads of unrelated products you happen to be a member of.
|
not gate reads of unrelated products you happen to be a member of.
|
||||||
|
|
||||||
## Worktrees
|
|
||||||
|
|
||||||
Scrum4Me-mcp uses git worktrees rooted at `~/.scrum4me-agent-worktrees/` (override via `SCRUM4ME_AGENT_WORKTREE_DIR`).
|
|
||||||
|
|
||||||
### Two kinds of worktrees
|
|
||||||
|
|
||||||
- **Per-job task-worktrees** (`<jobId>/`) — one per `TASK_IMPLEMENTATION` job. Created at claim, cleaned up on `DONE`/`FAILED`/`CANCELLED` via `cleanup_my_worktrees`.
|
|
||||||
- **Persistent product-worktrees** (`_products/<productId>/`) — one per product with `repo_url`, used by `IDEA_GRILL` and `IDEA_MAKE_PLAN`. **Detached HEAD on `origin/main`**, hard-reset at every job start. `.scratch/` holds throw-away work and is wiped on each claim.
|
|
||||||
|
|
||||||
### Concurrency: file-locks
|
|
||||||
|
|
||||||
Product-worktrees are serialised via `proper-lockfile` on `_products/<productId>.lock`. Two parallel idea-jobs on the same product wait for each other. For multi-product idea-jobs, locks are acquired in alphabetical order to prevent deadlocks.
|
|
||||||
|
|
||||||
### Single-host invariant
|
|
||||||
|
|
||||||
`proper-lockfile` only works when all MCP-server processes run on the same host. Migrate to Postgres `pg_advisory_lock` when:
|
|
||||||
- multiple MCP instances on different machines serve workers, or
|
|
||||||
- the worktree directory is shared over NFS/CIFS.
|
|
||||||
|
|
||||||
Migration path: replace `acquireFileLock` in `src/git/file-lock.ts` with a `pg_try_advisory_lock(hashtext(path)::bigint)` wrapper via the existing Prisma connection. The API stays identical.
|
|
||||||
|
|
||||||
### Manual cleanup
|
|
||||||
|
|
||||||
`cleanup_my_worktrees` skips `_products/` and `*.lock` automatically. To clean up a product-worktree manually (after archive or repo-rename):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git worktree remove --force ~/.scrum4me-agent-worktrees/_products/<productId>
|
|
||||||
rm ~/.scrum4me-agent-worktrees/_products/<productId>.lock # if still present
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -1,350 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('../src/prisma.js', () => ({
|
|
||||||
prisma: {
|
|
||||||
claudeJob: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
findMany: vi.fn(),
|
|
||||||
updateMany: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../src/tools/wait-for-job.js', async (importOriginal) => {
|
|
||||||
const original = await importOriginal<typeof import('../src/tools/wait-for-job.js')>()
|
|
||||||
return { ...original, resolveRepoRoot: vi.fn() }
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.mock('../src/git/worktree.js', () => ({
|
|
||||||
removeWorktreeForJob: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../src/git/pr.js', () => ({
|
|
||||||
closePullRequest: vi.fn(),
|
|
||||||
getPullRequestState: vi.fn(),
|
|
||||||
createRevertPullRequest: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../src/git/push.js', () => ({
|
|
||||||
deleteRemoteBranch: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '../src/prisma.js'
|
|
||||||
import { resolveRepoRoot } from '../src/tools/wait-for-job.js'
|
|
||||||
import { removeWorktreeForJob } from '../src/git/worktree.js'
|
|
||||||
import {
|
|
||||||
closePullRequest,
|
|
||||||
getPullRequestState,
|
|
||||||
createRevertPullRequest,
|
|
||||||
} from '../src/git/pr.js'
|
|
||||||
import { deleteRemoteBranch } from '../src/git/push.js'
|
|
||||||
import { cancelPbiOnFailure } from '../src/cancel/pbi-cascade.js'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
claudeJob: {
|
|
||||||
findUnique: ReturnType<typeof vi.fn>
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
updateMany: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mockResolveRepoRoot = resolveRepoRoot as ReturnType<typeof vi.fn>
|
|
||||||
const mockRemoveWorktree = removeWorktreeForJob as ReturnType<typeof vi.fn>
|
|
||||||
const mockClosePr = closePullRequest as ReturnType<typeof vi.fn>
|
|
||||||
const mockGetPrState = getPullRequestState as ReturnType<typeof vi.fn>
|
|
||||||
const mockCreateRevertPr = createRevertPullRequest as ReturnType<typeof vi.fn>
|
|
||||||
const mockDeleteBranch = deleteRemoteBranch as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockPrisma.claudeJob.update.mockResolvedValue({})
|
|
||||||
mockPrisma.claudeJob.updateMany.mockResolvedValue({ count: 0 })
|
|
||||||
mockResolveRepoRoot.mockResolvedValue('/repos/proj')
|
|
||||||
mockRemoveWorktree.mockResolvedValue(undefined)
|
|
||||||
// Sensible defaults so an un-stubbed branch in a test doesn't throw on
|
|
||||||
// `result.deleted` / `result.ok` access. Tests that care override these.
|
|
||||||
mockDeleteBranch.mockResolvedValue({ deleted: true })
|
|
||||||
mockClosePr.mockResolvedValue({ ok: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
const FAILED_JOB = {
|
|
||||||
id: 'job-failed',
|
|
||||||
kind: 'TASK_IMPLEMENTATION',
|
|
||||||
product_id: 'prod-1',
|
|
||||||
task_id: 'task-failed',
|
|
||||||
branch: 'feat/story-aaaabbbb',
|
|
||||||
pr_url: null,
|
|
||||||
task: { story: { pbi: { id: 'pbi-1', code: 'PBI-7' } } },
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('cancelPbiOnFailure', () => {
|
|
||||||
it('no-ops for non-TASK_IMPLEMENTATION jobs', async () => {
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({ ...FAILED_JOB, kind: 'IDEA_GRILL' })
|
|
||||||
|
|
||||||
const out = await cancelPbiOnFailure('job-failed')
|
|
||||||
|
|
||||||
expect(out.cancelled_job_ids).toEqual([])
|
|
||||||
expect(mockPrisma.claudeJob.findMany).not.toHaveBeenCalled()
|
|
||||||
expect(mockPrisma.claudeJob.updateMany).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('no-ops when failed job has no PBI parent', async () => {
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({
|
|
||||||
...FAILED_JOB,
|
|
||||||
task: null,
|
|
||||||
})
|
|
||||||
const out = await cancelPbiOnFailure('job-failed')
|
|
||||||
expect(out).toEqual({
|
|
||||||
cancelled_job_ids: [],
|
|
||||||
closed_prs: [],
|
|
||||||
reverted_prs: [],
|
|
||||||
deleted_branches: [],
|
|
||||||
warnings: [],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('cancels eligible siblings and writes a trace to the failed job', async () => {
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue(FAILED_JOB)
|
|
||||||
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
|
||||||
{ id: 'job-sib1', branch: 'feat/story-aaaabbbb', pr_url: null, status: 'QUEUED', task_id: 't2' },
|
|
||||||
{ id: 'job-sib2', branch: 'feat/story-ccccdddd', pr_url: null, status: 'CLAIMED', task_id: 't3' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const out = await cancelPbiOnFailure('job-failed')
|
|
||||||
|
|
||||||
expect(mockPrisma.claudeJob.updateMany).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: { id: { in: ['job-sib1', 'job-sib2'] } },
|
|
||||||
data: expect.objectContaining({
|
|
||||||
status: 'CANCELLED',
|
|
||||||
error: 'cancelled_by_pbi_failure',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
expect(out.cancelled_job_ids).toEqual(['job-sib1', 'job-sib2'])
|
|
||||||
expect(mockPrisma.claudeJob.update).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: { id: 'job-failed' },
|
|
||||||
data: expect.objectContaining({ error: expect.stringContaining('cancelled_by_self') }),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('idempotent: empty eligible set means no updateMany call', async () => {
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue(FAILED_JOB)
|
|
||||||
mockPrisma.claudeJob.findMany.mockResolvedValue([])
|
|
||||||
|
|
||||||
await cancelPbiOnFailure('job-failed')
|
|
||||||
|
|
||||||
expect(mockPrisma.claudeJob.updateMany).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('closes an open PR with the cascade comment', async () => {
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({
|
|
||||||
...FAILED_JOB,
|
|
||||||
pr_url: 'https://github.com/o/r/pull/1',
|
|
||||||
})
|
|
||||||
mockPrisma.claudeJob.findMany.mockResolvedValue([])
|
|
||||||
mockGetPrState.mockResolvedValue({
|
|
||||||
state: 'OPEN',
|
|
||||||
mergeCommit: null,
|
|
||||||
baseRefName: 'main',
|
|
||||||
title: 'feat: x',
|
|
||||||
})
|
|
||||||
mockClosePr.mockResolvedValue({ ok: true })
|
|
||||||
|
|
||||||
const out = await cancelPbiOnFailure('job-failed')
|
|
||||||
|
|
||||||
expect(mockClosePr).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
prUrl: 'https://github.com/o/r/pull/1',
|
|
||||||
comment: expect.stringContaining('PBI PBI-7'),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
expect(out.closed_prs).toEqual(['https://github.com/o/r/pull/1'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates a revert-PR when an affected PR is already merged', async () => {
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({
|
|
||||||
...FAILED_JOB,
|
|
||||||
pr_url: 'https://github.com/o/r/pull/9',
|
|
||||||
})
|
|
||||||
mockPrisma.claudeJob.findMany.mockResolvedValue([])
|
|
||||||
mockGetPrState.mockResolvedValue({
|
|
||||||
state: 'MERGED',
|
|
||||||
mergeCommit: 'abc123def',
|
|
||||||
baseRefName: 'main',
|
|
||||||
title: 'feat: shipped',
|
|
||||||
})
|
|
||||||
mockCreateRevertPr.mockResolvedValue({ url: 'https://github.com/o/r/pull/10' })
|
|
||||||
|
|
||||||
const out = await cancelPbiOnFailure('job-failed')
|
|
||||||
|
|
||||||
expect(mockCreateRevertPr).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
repoRoot: '/repos/proj',
|
|
||||||
mergeSha: 'abc123def',
|
|
||||||
baseRef: 'main',
|
|
||||||
originalTitle: 'feat: shipped',
|
|
||||||
originalBranch: 'feat/story-aaaabbbb',
|
|
||||||
jobId: 'job-failed',
|
|
||||||
pbiCode: 'PBI-7',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
expect(out.reverted_prs).toEqual([
|
|
||||||
{ original: 'https://github.com/o/r/pull/9', revertPr: 'https://github.com/o/r/pull/10' },
|
|
||||||
])
|
|
||||||
expect(mockClosePr).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('skips revert when no repo root is configured + emits a warning', async () => {
|
|
||||||
mockResolveRepoRoot.mockResolvedValue(null)
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({
|
|
||||||
...FAILED_JOB,
|
|
||||||
pr_url: 'https://github.com/o/r/pull/9',
|
|
||||||
})
|
|
||||||
mockPrisma.claudeJob.findMany.mockResolvedValue([])
|
|
||||||
mockGetPrState.mockResolvedValue({
|
|
||||||
state: 'MERGED',
|
|
||||||
mergeCommit: 'abc',
|
|
||||||
baseRefName: 'main',
|
|
||||||
title: 'x',
|
|
||||||
})
|
|
||||||
|
|
||||||
const out = await cancelPbiOnFailure('job-failed')
|
|
||||||
|
|
||||||
expect(mockCreateRevertPr).not.toHaveBeenCalled()
|
|
||||||
expect(out.warnings.some((w) => /no repo root/i.test(w))).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('deletes a remote branch when there is no PR for it', async () => {
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({
|
|
||||||
...FAILED_JOB,
|
|
||||||
pr_url: null,
|
|
||||||
})
|
|
||||||
mockPrisma.claudeJob.findMany.mockResolvedValue([])
|
|
||||||
mockDeleteBranch.mockResolvedValue({ deleted: true })
|
|
||||||
|
|
||||||
const out = await cancelPbiOnFailure('job-failed')
|
|
||||||
|
|
||||||
expect(mockDeleteBranch).toHaveBeenCalledWith({
|
|
||||||
repoRoot: '/repos/proj',
|
|
||||||
branch: 'feat/story-aaaabbbb',
|
|
||||||
})
|
|
||||||
expect(out.deleted_branches).toEqual(['feat/story-aaaabbbb'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('groups siblings sharing a branch so the PR is only closed once', async () => {
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({
|
|
||||||
...FAILED_JOB,
|
|
||||||
branch: 'feat/story-shared',
|
|
||||||
pr_url: 'https://github.com/o/r/pull/1',
|
|
||||||
})
|
|
||||||
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 'job-sib',
|
|
||||||
branch: 'feat/story-shared',
|
|
||||||
pr_url: 'https://github.com/o/r/pull/1',
|
|
||||||
status: 'QUEUED',
|
|
||||||
task_id: 't2',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
mockGetPrState.mockResolvedValue({
|
|
||||||
state: 'OPEN',
|
|
||||||
mergeCommit: null,
|
|
||||||
baseRefName: 'main',
|
|
||||||
title: 't',
|
|
||||||
})
|
|
||||||
mockClosePr.mockResolvedValue({ ok: true })
|
|
||||||
|
|
||||||
await cancelPbiOnFailure('job-failed')
|
|
||||||
|
|
||||||
expect(mockClosePr).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('removes worktrees of cancelled siblings', async () => {
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue(FAILED_JOB)
|
|
||||||
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
|
||||||
{ id: 'job-sib1', branch: null, pr_url: null, status: 'QUEUED', task_id: 't2' },
|
|
||||||
])
|
|
||||||
|
|
||||||
await cancelPbiOnFailure('job-failed')
|
|
||||||
|
|
||||||
expect(mockRemoveWorktree).toHaveBeenCalledWith({
|
|
||||||
repoRoot: '/repos/proj',
|
|
||||||
jobId: 'job-sib1',
|
|
||||||
keepBranch: false,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('never throws — wraps unexpected errors into warnings', async () => {
|
|
||||||
mockPrisma.claudeJob.findUnique.mockRejectedValue(new Error('boom'))
|
|
||||||
|
|
||||||
const out = await cancelPbiOnFailure('job-failed')
|
|
||||||
|
|
||||||
expect(out.warnings.some((w) => w.includes('boom'))).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('no-ops when failed job has status SKIPPED (no-op exit, niet een echte fail)', async () => {
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({ ...FAILED_JOB, status: 'SKIPPED' })
|
|
||||||
|
|
||||||
const out = await cancelPbiOnFailure('job-failed')
|
|
||||||
|
|
||||||
expect(out.cancelled_job_ids).toEqual([])
|
|
||||||
expect(mockPrisma.claudeJob.findMany).not.toHaveBeenCalled()
|
|
||||||
expect(mockPrisma.claudeJob.updateMany).not.toHaveBeenCalled()
|
|
||||||
expect(mockPrisma.claudeJob.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('appends the cascade trace to an existing error (preserves original cause)', async () => {
|
|
||||||
// findUnique wordt twee keer aangeroepen: eerst voor failedJob (status FAILED + originele error),
|
|
||||||
// daarna door de append-trace om de huidige error te lezen vóór update.
|
|
||||||
mockPrisma.claudeJob.findUnique
|
|
||||||
.mockResolvedValueOnce({ ...FAILED_JOB, status: 'FAILED' })
|
|
||||||
.mockResolvedValueOnce({ error: 'timeout: agent died after 5min' })
|
|
||||||
mockPrisma.claudeJob.findMany.mockResolvedValue([])
|
|
||||||
|
|
||||||
await cancelPbiOnFailure('job-failed')
|
|
||||||
|
|
||||||
expect(mockPrisma.claudeJob.update).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: { id: 'job-failed' },
|
|
||||||
data: expect.objectContaining({
|
|
||||||
error: expect.stringMatching(/timeout: agent died after 5min[\s\S]*---[\s\S]*cancelled_by_self/),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to trace-only when there is no existing error', async () => {
|
|
||||||
mockPrisma.claudeJob.findUnique
|
|
||||||
.mockResolvedValueOnce({ ...FAILED_JOB, status: 'FAILED' })
|
|
||||||
.mockResolvedValueOnce({ error: null })
|
|
||||||
mockPrisma.claudeJob.findMany.mockResolvedValue([])
|
|
||||||
|
|
||||||
await cancelPbiOnFailure('job-failed')
|
|
||||||
|
|
||||||
const updateCall = mockPrisma.claudeJob.update.mock.calls[0]?.[0] as
|
|
||||||
| { data: { error: string } }
|
|
||||||
| undefined
|
|
||||||
expect(updateCall?.data.error).toMatch(/^cancelled_by_self/)
|
|
||||||
expect(updateCall?.data.error).not.toContain('---')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('truncates the merged error at 1900 chars while preserving the head of the original', async () => {
|
|
||||||
const longOriginal = 'X'.repeat(1800)
|
|
||||||
mockPrisma.claudeJob.findUnique
|
|
||||||
.mockResolvedValueOnce({ ...FAILED_JOB, status: 'FAILED' })
|
|
||||||
.mockResolvedValueOnce({ error: longOriginal })
|
|
||||||
mockPrisma.claudeJob.findMany.mockResolvedValue([])
|
|
||||||
|
|
||||||
await cancelPbiOnFailure('job-failed')
|
|
||||||
|
|
||||||
const updateCall = mockPrisma.claudeJob.update.mock.calls[0]?.[0] as
|
|
||||||
| { data: { error: string } }
|
|
||||||
| undefined
|
|
||||||
expect(updateCall?.data.error.length).toBeLessThanOrEqual(1900)
|
|
||||||
expect(updateCall?.data.error.startsWith('X')).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
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<typeof import('../src/auth.js')>()
|
|
||||||
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<typeof vi.fn>
|
|
||||||
groupBy: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
|
|
||||||
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
const USER_ID = 'user-abc'
|
|
||||||
const PRODUCT_A = 'product-aaa'
|
|
||||||
const PRODUCT_B = 'product-bbb'
|
|
||||||
|
|
||||||
function makeServer() {
|
|
||||||
let handler: (args: Record<string, unknown>) => Promise<unknown>
|
|
||||||
const server = {
|
|
||||||
registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => {
|
|
||||||
handler = fn
|
|
||||||
}),
|
|
||||||
call: (args: Record<string, unknown>) => 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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -73,17 +73,6 @@ describe('listWorktreeJobIds', () => {
|
||||||
mockReaddir.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
|
mockReaddir.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
|
||||||
expect(await listWorktreeJobIds(WORKTREE_PARENT)).toEqual([])
|
expect(await listWorktreeJobIds(WORKTREE_PARENT)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('skips _products/ system dir and *.lock files (PBI-9)', async () => {
|
|
||||||
mockReaddir.mockResolvedValue([
|
|
||||||
makeDirent('job-aaa'),
|
|
||||||
makeDirent('_products'),
|
|
||||||
makeDirent('product-abc.lock'),
|
|
||||||
makeDirent('job-bbb'),
|
|
||||||
])
|
|
||||||
const ids = await listWorktreeJobIds(WORKTREE_PARENT)
|
|
||||||
expect(ids).toEqual(['job-aaa', 'job-bbb'])
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('cleanupWorktrees', () => {
|
describe('cleanupWorktrees', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { Prisma } from '@prisma/client'
|
|
||||||
|
|
||||||
vi.mock('../src/prisma.js', () => ({
|
|
||||||
prisma: {
|
|
||||||
sprint: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
create: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../src/auth.js', () => ({
|
|
||||||
requireWriteAccess: vi.fn(),
|
|
||||||
PermissionDeniedError: class PermissionDeniedError extends Error {
|
|
||||||
constructor(message = 'Demo accounts cannot perform write operations') {
|
|
||||||
super(message)
|
|
||||||
this.name = 'PermissionDeniedError'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../src/access.js', () => ({
|
|
||||||
userCanAccessProduct: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '../src/prisma.js'
|
|
||||||
import { requireWriteAccess } from '../src/auth.js'
|
|
||||||
import { userCanAccessProduct } from '../src/access.js'
|
|
||||||
import { handleCreateSprint } from '../src/tools/create-sprint.js'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
sprint: {
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
create: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
|
|
||||||
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
const PRODUCT_ID = 'prod-1'
|
|
||||||
const USER_ID = 'user-1'
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false })
|
|
||||||
mockUserCanAccessProduct.mockResolvedValue(true)
|
|
||||||
mockPrisma.sprint.findMany.mockResolvedValue([])
|
|
||||||
})
|
|
||||||
|
|
||||||
function parseResult(result: Awaited<ReturnType<typeof handleCreateSprint>>) {
|
|
||||||
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''
|
|
||||||
try { return JSON.parse(text) } catch { return text }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('handleCreateSprint', () => {
|
|
||||||
it('happy path: creates sprint with auto-generated code', async () => {
|
|
||||||
mockPrisma.sprint.create.mockResolvedValue({
|
|
||||||
id: 'spr-1',
|
|
||||||
code: 'S-2026-05-11-1',
|
|
||||||
sprint_goal: 'My goal',
|
|
||||||
status: 'OPEN',
|
|
||||||
start_date: new Date('2026-05-11'),
|
|
||||||
created_at: new Date('2026-05-11T10:00:00Z'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await handleCreateSprint({
|
|
||||||
product_id: PRODUCT_ID,
|
|
||||||
sprint_goal: 'My goal',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(1)
|
|
||||||
const callArgs = mockPrisma.sprint.create.mock.calls[0][0]
|
|
||||||
expect(callArgs.data.product_id).toBe(PRODUCT_ID)
|
|
||||||
expect(callArgs.data.status).toBe('OPEN')
|
|
||||||
expect(callArgs.data.sprint_goal).toBe('My goal')
|
|
||||||
expect(callArgs.data.code).toMatch(/^S-\d{4}-\d{2}-\d{2}-1$/)
|
|
||||||
expect(callArgs.data.start_date).toBeInstanceOf(Date)
|
|
||||||
|
|
||||||
const parsed = parseResult(result)
|
|
||||||
expect(parsed.id).toBe('spr-1')
|
|
||||||
expect(parsed.status).toBe('OPEN')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses user-provided code when given', async () => {
|
|
||||||
mockPrisma.sprint.create.mockResolvedValue({
|
|
||||||
id: 'spr-2',
|
|
||||||
code: 'CUSTOM-CODE',
|
|
||||||
sprint_goal: 'g',
|
|
||||||
status: 'OPEN',
|
|
||||||
start_date: new Date(),
|
|
||||||
created_at: new Date(),
|
|
||||||
})
|
|
||||||
|
|
||||||
await handleCreateSprint({
|
|
||||||
product_id: PRODUCT_ID,
|
|
||||||
code: 'CUSTOM-CODE',
|
|
||||||
sprint_goal: 'g',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(1)
|
|
||||||
expect(mockPrisma.sprint.findMany).not.toHaveBeenCalled()
|
|
||||||
expect(mockPrisma.sprint.create.mock.calls[0][0].data.code).toBe('CUSTOM-CODE')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('auto-code increments past existing same-day sprints', async () => {
|
|
||||||
// Codes moeten relatief aan "vandaag" zijn: generateNextSprintCode telt
|
|
||||||
// alleen same-day sprints. Hardcoded datums maakten deze test datum-flaky.
|
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
|
||||||
mockPrisma.sprint.findMany.mockResolvedValue([
|
|
||||||
{ code: `S-${today}-1` },
|
|
||||||
{ code: `S-${today}-3` },
|
|
||||||
{ code: 'S-2020-01-01-7' },
|
|
||||||
])
|
|
||||||
mockPrisma.sprint.create.mockResolvedValue({
|
|
||||||
id: 'spr-3', code: 'X', sprint_goal: 'g', status: 'OPEN', start_date: new Date(), created_at: new Date(),
|
|
||||||
})
|
|
||||||
|
|
||||||
await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' })
|
|
||||||
|
|
||||||
expect(mockPrisma.sprint.create.mock.calls[0][0].data.code).toBe(`S-${today}-4`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('retries on P2002 unique conflict', async () => {
|
|
||||||
const conflict = new Prisma.PrismaClientKnownRequestError('unique', {
|
|
||||||
code: 'P2002', clientVersion: 'x', meta: { target: ['product_id', 'code'] },
|
|
||||||
})
|
|
||||||
mockPrisma.sprint.create
|
|
||||||
.mockRejectedValueOnce(conflict)
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
id: 'spr-r', code: 'S-2026-05-11-2', sprint_goal: 'g', status: 'OPEN',
|
|
||||||
start_date: new Date(), created_at: new Date(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' })
|
|
||||||
|
|
||||||
expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(2)
|
|
||||||
expect(parseResult(result).id).toBe('spr-r')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns error when user cannot access product', async () => {
|
|
||||||
mockUserCanAccessProduct.mockResolvedValue(false)
|
|
||||||
const result = await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' })
|
|
||||||
|
|
||||||
expect(mockPrisma.sprint.create).not.toHaveBeenCalled()
|
|
||||||
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''
|
|
||||||
expect(text).toMatch(/not found or not accessible/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses provided start_date when given', async () => {
|
|
||||||
mockPrisma.sprint.create.mockResolvedValue({
|
|
||||||
id: 'spr-d', code: 'X', sprint_goal: 'g', status: 'OPEN',
|
|
||||||
start_date: new Date('2026-01-01'), created_at: new Date(),
|
|
||||||
})
|
|
||||||
|
|
||||||
await handleCreateSprint({
|
|
||||||
product_id: PRODUCT_ID,
|
|
||||||
sprint_goal: 'g',
|
|
||||||
start_date: '2026-01-01',
|
|
||||||
})
|
|
||||||
|
|
||||||
const callArgs = mockPrisma.sprint.create.mock.calls[0][0]
|
|
||||||
expect(callArgs.data.start_date.toISOString().slice(0, 10)).toBe('2026-01-01')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('../src/prisma.js', () => ({
|
|
||||||
prisma: {
|
|
||||||
pbi: { findUnique: vi.fn() },
|
|
||||||
sprint: { findUnique: vi.fn() },
|
|
||||||
story: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
findMany: vi.fn(),
|
|
||||||
create: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../src/auth.js', () => ({
|
|
||||||
requireWriteAccess: vi.fn(),
|
|
||||||
PermissionDeniedError: class PermissionDeniedError extends Error {
|
|
||||||
constructor(message = 'Demo accounts cannot perform write operations') {
|
|
||||||
super(message)
|
|
||||||
this.name = 'PermissionDeniedError'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../src/access.js', () => ({
|
|
||||||
userCanAccessProduct: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '../src/prisma.js'
|
|
||||||
import { requireWriteAccess } from '../src/auth.js'
|
|
||||||
import { userCanAccessProduct } from '../src/access.js'
|
|
||||||
import { handleCreateStory } from '../src/tools/create-story.js'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
pbi: { findUnique: ReturnType<typeof vi.fn> }
|
|
||||||
sprint: { findUnique: ReturnType<typeof vi.fn> }
|
|
||||||
story: {
|
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
create: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
|
|
||||||
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
const PRODUCT_ID = 'prod-1'
|
|
||||||
const PBI_ID = 'pbi-1'
|
|
||||||
const SPRINT_ID = 'spr-1'
|
|
||||||
const USER_ID = 'user-1'
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false })
|
|
||||||
mockUserCanAccessProduct.mockResolvedValue(true)
|
|
||||||
mockPrisma.pbi.findUnique.mockResolvedValue({ product_id: PRODUCT_ID })
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([])
|
|
||||||
mockPrisma.story.findFirst.mockResolvedValue(null)
|
|
||||||
mockPrisma.story.create.mockImplementation((args: { data: Record<string, unknown> }) =>
|
|
||||||
Promise.resolve({ id: 'story-1', created_at: new Date('2026-05-14T10:00:00Z'), ...args.data }),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
function parseResult(result: Awaited<ReturnType<typeof handleCreateStory>>) {
|
|
||||||
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''
|
|
||||||
try { return JSON.parse(text) } catch { return text }
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorText(result: Awaited<ReturnType<typeof handleCreateStory>>): string {
|
|
||||||
return result.content?.[0]?.type === 'text' ? result.content[0].text : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('handleCreateStory', () => {
|
|
||||||
it('without sprint_id: creates story with status OPEN and no sprint', async () => {
|
|
||||||
const result = await handleCreateStory({ pbi_id: PBI_ID, title: 'A story', priority: 2 })
|
|
||||||
|
|
||||||
expect(mockPrisma.sprint.findUnique).not.toHaveBeenCalled()
|
|
||||||
const data = mockPrisma.story.create.mock.calls[0][0].data
|
|
||||||
expect(data.status).toBe('OPEN')
|
|
||||||
expect(data.sprint_id).toBeNull()
|
|
||||||
expect(data.product_id).toBe(PRODUCT_ID)
|
|
||||||
expect(parseResult(result).status).toBe('OPEN')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('with valid sprint_id: links story to sprint with status IN_SPRINT', async () => {
|
|
||||||
mockPrisma.sprint.findUnique.mockResolvedValue({ product_id: PRODUCT_ID })
|
|
||||||
|
|
||||||
const result = await handleCreateStory({
|
|
||||||
pbi_id: PBI_ID,
|
|
||||||
title: 'A story',
|
|
||||||
priority: 2,
|
|
||||||
sprint_id: SPRINT_ID,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockPrisma.sprint.findUnique).toHaveBeenCalledWith({
|
|
||||||
where: { id: SPRINT_ID },
|
|
||||||
select: { product_id: true },
|
|
||||||
})
|
|
||||||
const data = mockPrisma.story.create.mock.calls[0][0].data
|
|
||||||
expect(data.status).toBe('IN_SPRINT')
|
|
||||||
expect(data.sprint_id).toBe(SPRINT_ID)
|
|
||||||
expect(parseResult(result).sprint_id).toBe(SPRINT_ID)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects a non-existent sprint_id', async () => {
|
|
||||||
mockPrisma.sprint.findUnique.mockResolvedValue(null)
|
|
||||||
|
|
||||||
const result = await handleCreateStory({
|
|
||||||
pbi_id: PBI_ID,
|
|
||||||
title: 'A story',
|
|
||||||
priority: 2,
|
|
||||||
sprint_id: 'missing',
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockPrisma.story.create).not.toHaveBeenCalled()
|
|
||||||
expect(errorText(result)).toMatch(/Sprint missing not found/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects a sprint from a different product', async () => {
|
|
||||||
mockPrisma.sprint.findUnique.mockResolvedValue({ product_id: 'other-product' })
|
|
||||||
|
|
||||||
const result = await handleCreateStory({
|
|
||||||
pbi_id: PBI_ID,
|
|
||||||
title: 'A story',
|
|
||||||
priority: 2,
|
|
||||||
sprint_id: SPRINT_ID,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockPrisma.story.create).not.toHaveBeenCalled()
|
|
||||||
expect(errorText(result)).toMatch(/different product/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns error when PBI not found', async () => {
|
|
||||||
mockPrisma.pbi.findUnique.mockResolvedValue(null)
|
|
||||||
|
|
||||||
const result = await handleCreateStory({ pbi_id: 'missing', title: 'A story', priority: 2 })
|
|
||||||
|
|
||||||
expect(mockPrisma.sprint.findUnique).not.toHaveBeenCalled()
|
|
||||||
expect(mockPrisma.story.create).not.toHaveBeenCalled()
|
|
||||||
expect(errorText(result)).toMatch(/PBI missing not found/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { executeEffects } from '../../src/flow/effects.js'
|
|
||||||
|
|
||||||
describe('effects executor', () => {
|
|
||||||
it('RELEASE_WORKTREE_LOCKS for unknown jobId is a no-op (no throw)', async () => {
|
|
||||||
const out = await executeEffects([{ type: 'RELEASE_WORKTREE_LOCKS', jobId: 'no-such-job' }])
|
|
||||||
expect(out).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('multiple effects execute in order; failure in one is logged but does not abort', async () => {
|
|
||||||
const out = await executeEffects([
|
|
||||||
{ type: 'RELEASE_WORKTREE_LOCKS', jobId: 'a' },
|
|
||||||
{ type: 'RELEASE_WORKTREE_LOCKS', jobId: 'b' },
|
|
||||||
])
|
|
||||||
expect(out).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('empty effects array returns empty outcomes', async () => {
|
|
||||||
const out = await executeEffects([])
|
|
||||||
expect(out).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { transition, type PrFlowState } from '../../src/flow/pr-flow.js'
|
|
||||||
|
|
||||||
describe('pr-flow STORY-mode 3-tasks scenario', () => {
|
|
||||||
it('opens PR early; auto-merge only fires on the last task', () => {
|
|
||||||
let state: PrFlowState = { kind: 'none', strategy: 'STORY' }
|
|
||||||
const allEffects: Array<Record<string, unknown>> = []
|
|
||||||
|
|
||||||
// Task 1 DONE → PR_CREATED
|
|
||||||
let r = transition(state, { type: 'PR_CREATED', prUrl: 'https://github.com/o/r/pull/1' })
|
|
||||||
state = r.nextState
|
|
||||||
allEffects.push(...r.effects)
|
|
||||||
expect(state.kind).toBe('pr_opened')
|
|
||||||
expect(allEffects.filter((e) => e.type === 'ENABLE_AUTO_MERGE')).toHaveLength(0)
|
|
||||||
|
|
||||||
// Task 2 DONE → no STORY_COMPLETED yet, no transition emitted
|
|
||||||
r = transition(state, { type: 'TASK_DONE', taskId: 't2', headSha: 'abc123' })
|
|
||||||
state = r.nextState
|
|
||||||
allEffects.push(...r.effects)
|
|
||||||
expect(state.kind).toBe('pr_opened')
|
|
||||||
expect(allEffects.filter((e) => e.type === 'ENABLE_AUTO_MERGE')).toHaveLength(0)
|
|
||||||
|
|
||||||
// Task 3 DONE = STORY_COMPLETED → ENABLE_AUTO_MERGE with head guard
|
|
||||||
r = transition(state, { type: 'STORY_COMPLETED', storyId: 's1', headSha: 'def456' })
|
|
||||||
state = r.nextState
|
|
||||||
allEffects.push(...r.effects)
|
|
||||||
expect(state.kind).toBe('waiting_for_checks')
|
|
||||||
const enableEffects = allEffects.filter((e) => e.type === 'ENABLE_AUTO_MERGE')
|
|
||||||
expect(enableEffects).toHaveLength(1)
|
|
||||||
expect(enableEffects[0]).toMatchObject({ expectedHeadSha: 'def456' })
|
|
||||||
|
|
||||||
// CI green + merge OK
|
|
||||||
r = transition(state, { type: 'MERGE_RESULT' })
|
|
||||||
state = r.nextState
|
|
||||||
expect(state.kind).toBe('auto_merge_enabled')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('CHECKS_FAILED → checks_failed (no pause)', () => {
|
|
||||||
const state: PrFlowState = {
|
|
||||||
kind: 'waiting_for_checks',
|
|
||||||
strategy: 'STORY',
|
|
||||||
prUrl: 'x',
|
|
||||||
headSha: 'y',
|
|
||||||
}
|
|
||||||
const r = transition(state, { type: 'MERGE_RESULT', reason: 'CHECKS_FAILED' })
|
|
||||||
expect(r.nextState.kind).toBe('checks_failed')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('MERGE_CONFLICT → merge_conflict_paused', () => {
|
|
||||||
const state: PrFlowState = {
|
|
||||||
kind: 'waiting_for_checks',
|
|
||||||
strategy: 'STORY',
|
|
||||||
prUrl: 'x',
|
|
||||||
headSha: 'y',
|
|
||||||
}
|
|
||||||
const r = transition(state, { type: 'MERGE_RESULT', reason: 'MERGE_CONFLICT' })
|
|
||||||
expect(r.nextState.kind).toBe('merge_conflict_paused')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('pr-flow SPRINT-mode', () => {
|
|
||||||
it('draft stays draft until SPRINT_COMPLETED → MARK_PR_READY effect', () => {
|
|
||||||
let state: PrFlowState = { kind: 'none', strategy: 'SPRINT' }
|
|
||||||
let r = transition(state, { type: 'PR_CREATED', prUrl: 'x' })
|
|
||||||
expect(r.nextState.kind).toBe('draft_opened')
|
|
||||||
expect(r.effects).toHaveLength(0)
|
|
||||||
|
|
||||||
state = r.nextState
|
|
||||||
r = transition(state, { type: 'TASK_DONE', taskId: 't1', headSha: 'a' })
|
|
||||||
expect(r.nextState.kind).toBe('draft_opened')
|
|
||||||
expect(r.effects).toHaveLength(0)
|
|
||||||
|
|
||||||
state = r.nextState
|
|
||||||
r = transition(state, { type: 'SPRINT_COMPLETED', sprintRunId: 'sr1' })
|
|
||||||
expect(r.nextState.kind).toBe('ready_for_review')
|
|
||||||
expect(r.effects.filter((e) => e.type === 'MARK_PR_READY')).toHaveLength(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { transition, type SprintRunState } from '../../src/flow/sprint-run.js'
|
|
||||||
|
|
||||||
describe('sprint-run pure transitions', () => {
|
|
||||||
it('queued + CLAIM_FIRST_JOB → running with SET_SPRINT_RUN_STATUS effect', () => {
|
|
||||||
const state: SprintRunState = { kind: 'queued', sprintRunId: 'sr1' }
|
|
||||||
const r = transition(state, { type: 'CLAIM_FIRST_JOB' })
|
|
||||||
expect(r.nextState.kind).toBe('running')
|
|
||||||
expect(r.effects).toEqual([
|
|
||||||
{ type: 'SET_SPRINT_RUN_STATUS', sprintRunId: 'sr1', status: 'RUNNING' },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('running + MERGE_CONFLICT → paused_merge_conflict + 2 effects in order', () => {
|
|
||||||
const state: SprintRunState = { kind: 'running', sprintRunId: 'sr1' }
|
|
||||||
const r = transition(state, {
|
|
||||||
type: 'MERGE_CONFLICT',
|
|
||||||
prUrl: 'https://github.com/o/r/pull/1',
|
|
||||||
prHeadSha: 'abc123',
|
|
||||||
conflictFiles: ['a.ts', 'b.ts'],
|
|
||||||
resumeInstructions: 'Resolve and push',
|
|
||||||
})
|
|
||||||
expect(r.nextState.kind).toBe('paused_merge_conflict')
|
|
||||||
expect(r.effects).toHaveLength(2)
|
|
||||||
expect(r.effects[0].type).toBe('CREATE_CLAUDE_QUESTION')
|
|
||||||
expect(r.effects[1].type).toBe('SET_SPRINT_RUN_STATUS')
|
|
||||||
if (r.effects[1].type === 'SET_SPRINT_RUN_STATUS') {
|
|
||||||
expect(r.effects[1].status).toBe('PAUSED')
|
|
||||||
expect(r.effects[1].pauseContextDraft).toMatchObject({
|
|
||||||
pause_reason: 'MERGE_CONFLICT',
|
|
||||||
pr_url: 'https://github.com/o/r/pull/1',
|
|
||||||
pr_head_sha: 'abc123',
|
|
||||||
conflict_files: ['a.ts', 'b.ts'],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('paused + USER_RESUMED → running + CLOSE_CLAUDE_QUESTION + clear pause_context', () => {
|
|
||||||
const state: SprintRunState = {
|
|
||||||
kind: 'paused_merge_conflict',
|
|
||||||
sprintRunId: 'sr1',
|
|
||||||
pauseContext: {
|
|
||||||
pause_reason: 'MERGE_CONFLICT',
|
|
||||||
pr_url: 'x',
|
|
||||||
pr_head_sha: 'y',
|
|
||||||
conflict_files: [],
|
|
||||||
claude_question_id: 'q1',
|
|
||||||
resume_instructions: 'r',
|
|
||||||
paused_at: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const r = transition(state, { type: 'USER_RESUMED' })
|
|
||||||
expect(r.nextState.kind).toBe('running')
|
|
||||||
expect(r.effects[0]).toEqual({ type: 'CLOSE_CLAUDE_QUESTION', questionId: 'q1' })
|
|
||||||
expect(r.effects[1]).toMatchObject({
|
|
||||||
type: 'SET_SPRINT_RUN_STATUS',
|
|
||||||
status: 'RUNNING',
|
|
||||||
clearPauseContext: true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('running + TASK_FAILED → failed (no PAUSE)', () => {
|
|
||||||
const state: SprintRunState = { kind: 'running', sprintRunId: 'sr1' }
|
|
||||||
const r = transition(state, { type: 'TASK_FAILED', taskId: 't1', error: 'CI red' })
|
|
||||||
expect(r.nextState.kind).toBe('failed')
|
|
||||||
expect(r.effects[0]).toMatchObject({ status: 'FAILED' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('running + ALL_DONE → done + SET_SPRINT_RUN_STATUS DONE', () => {
|
|
||||||
const state: SprintRunState = { kind: 'running', sprintRunId: 'sr1' }
|
|
||||||
const r = transition(state, { type: 'ALL_DONE' })
|
|
||||||
expect(r.nextState.kind).toBe('done')
|
|
||||||
expect(r.effects[0]).toMatchObject({ status: 'DONE' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('forbidden transition (running + CLAIM_FIRST_JOB) keeps state and emits no effects', () => {
|
|
||||||
const state: SprintRunState = { kind: 'running', sprintRunId: 'sr1' }
|
|
||||||
const r = transition(state, { type: 'CLAIM_FIRST_JOB' })
|
|
||||||
expect(r.nextState).toEqual(state)
|
|
||||||
expect(r.effects).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { transition, type WorktreeLeaseState } from '../../src/flow/worktree-lease.js'
|
|
||||||
|
|
||||||
describe('worktree-lease pure transitions', () => {
|
|
||||||
it('idle + JOB_CLAIMED → acquiring_lock, no effects', () => {
|
|
||||||
const r = transition({ kind: 'idle' }, { type: 'JOB_CLAIMED', jobId: 'j1', productIds: ['p1'] })
|
|
||||||
expect(r.nextState.kind).toBe('acquiring_lock')
|
|
||||||
expect(r.effects).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('acquiring_lock + LOCK_ACQUIRED → creating_or_reusing', () => {
|
|
||||||
const state: WorktreeLeaseState = {
|
|
||||||
kind: 'acquiring_lock',
|
|
||||||
jobId: 'j1',
|
|
||||||
productIds: ['p1'],
|
|
||||||
}
|
|
||||||
const r = transition(state, { type: 'LOCK_ACQUIRED' })
|
|
||||||
expect(r.nextState.kind).toBe('creating_or_reusing')
|
|
||||||
expect(r.effects).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('acquiring_lock + LOCK_TIMEOUT → lock_timeout', () => {
|
|
||||||
const state: WorktreeLeaseState = {
|
|
||||||
kind: 'acquiring_lock',
|
|
||||||
jobId: 'j1',
|
|
||||||
productIds: ['p1'],
|
|
||||||
}
|
|
||||||
const r = transition(state, { type: 'LOCK_TIMEOUT' })
|
|
||||||
expect(r.nextState.kind).toBe('lock_timeout')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creating_or_reusing + WORKTREE_READY → syncing', () => {
|
|
||||||
const r = transition(
|
|
||||||
{ kind: 'creating_or_reusing', jobId: 'j1', productIds: ['p1'] },
|
|
||||||
{ type: 'WORKTREE_READY' },
|
|
||||||
)
|
|
||||||
expect(r.nextState.kind).toBe('syncing')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('syncing + SYNC_DONE → ready (no release effect yet)', () => {
|
|
||||||
const r = transition(
|
|
||||||
{ kind: 'syncing', jobId: 'j1', productIds: ['p1'] },
|
|
||||||
{ type: 'SYNC_DONE' },
|
|
||||||
)
|
|
||||||
expect(r.nextState.kind).toBe('ready')
|
|
||||||
expect(r.effects).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('syncing + SYNC_FAILED → sync_failed + RELEASE_WORKTREE_LOCKS effect', () => {
|
|
||||||
const r = transition(
|
|
||||||
{ kind: 'syncing', jobId: 'j1', productIds: ['p1'] },
|
|
||||||
{ type: 'SYNC_FAILED', error: 'boom' },
|
|
||||||
)
|
|
||||||
expect(r.nextState.kind).toBe('sync_failed')
|
|
||||||
expect(r.effects).toEqual([{ type: 'RELEASE_WORKTREE_LOCKS', jobId: 'j1' }])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ready + JOB_TERMINAL → releasing + RELEASE_WORKTREE_LOCKS effect', () => {
|
|
||||||
const r = transition(
|
|
||||||
{ kind: 'ready', jobId: 'j1', productIds: ['p1'] },
|
|
||||||
{ type: 'JOB_TERMINAL', jobId: 'j1' },
|
|
||||||
)
|
|
||||||
expect(r.nextState.kind).toBe('releasing')
|
|
||||||
expect(r.effects).toEqual([{ type: 'RELEASE_WORKTREE_LOCKS', jobId: 'j1' }])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ready + STALE_RESET → stale_released + RELEASE_WORKTREE_LOCKS effect', () => {
|
|
||||||
const r = transition(
|
|
||||||
{ kind: 'ready', jobId: 'j1', productIds: ['p1'] },
|
|
||||||
{ type: 'STALE_RESET', jobId: 'j1' },
|
|
||||||
)
|
|
||||||
expect(r.nextState.kind).toBe('stale_released')
|
|
||||||
expect(r.effects).toEqual([{ type: 'RELEASE_WORKTREE_LOCKS', jobId: 'j1' }])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('forbidden transition (idle + LOCK_ACQUIRED) keeps state, no effects', () => {
|
|
||||||
const state: WorktreeLeaseState = { kind: 'idle' }
|
|
||||||
const r = transition(state, { type: 'LOCK_ACQUIRED' })
|
|
||||||
expect(r.nextState).toEqual(state)
|
|
||||||
expect(r.effects).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -4,12 +4,12 @@ const {
|
||||||
mockProductFindFirst,
|
mockProductFindFirst,
|
||||||
mockSprintFindFirst,
|
mockSprintFindFirst,
|
||||||
mockStoryFindFirst,
|
mockStoryFindFirst,
|
||||||
mockIdeaFindMany,
|
mockTodoFindMany,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
mockProductFindFirst: vi.fn(),
|
mockProductFindFirst: vi.fn(),
|
||||||
mockSprintFindFirst: vi.fn(),
|
mockSprintFindFirst: vi.fn(),
|
||||||
mockStoryFindFirst: vi.fn(),
|
mockStoryFindFirst: vi.fn(),
|
||||||
mockIdeaFindMany: vi.fn(),
|
mockTodoFindMany: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../src/auth.js', () => ({
|
vi.mock('../src/auth.js', () => ({
|
||||||
|
|
@ -21,7 +21,7 @@ vi.mock('../src/prisma.js', () => ({
|
||||||
product: { findFirst: mockProductFindFirst },
|
product: { findFirst: mockProductFindFirst },
|
||||||
sprint: { findFirst: mockSprintFindFirst },
|
sprint: { findFirst: mockSprintFindFirst },
|
||||||
story: { findFirst: mockStoryFindFirst },
|
story: { findFirst: mockStoryFindFirst },
|
||||||
idea: { findMany: mockIdeaFindMany },
|
todo: { findMany: mockTodoFindMany },
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -55,7 +55,7 @@ beforeEach(() => {
|
||||||
})
|
})
|
||||||
mockSprintFindFirst.mockResolvedValue({ id: 'sprint-1', sprint_goal: 'Goal', status: 'ACTIVE' })
|
mockSprintFindFirst.mockResolvedValue({ id: 'sprint-1', sprint_goal: 'Goal', status: 'ACTIVE' })
|
||||||
mockStoryFindFirst.mockResolvedValue(null)
|
mockStoryFindFirst.mockResolvedValue(null)
|
||||||
mockIdeaFindMany.mockResolvedValue([])
|
mockTodoFindMany.mockResolvedValue([])
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('get_claude_context safety-net filter', () => {
|
describe('get_claude_context safety-net filter', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
||||||
import * as fs from 'node:fs/promises'
|
|
||||||
import * as os from 'node:os'
|
|
||||||
import * as path from 'node:path'
|
|
||||||
import { acquireFileLock, acquireFileLocksOrdered } from '../../src/git/file-lock.js'
|
|
||||||
|
|
||||||
describe('file-lock', () => {
|
|
||||||
let tmpDir: string
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'file-lock-'))
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('acquires and releases a lock; lockfile is gone after release', async () => {
|
|
||||||
const lockPath = path.join(tmpDir, 'a.lock')
|
|
||||||
const release = await acquireFileLock(lockPath)
|
|
||||||
// proper-lockfile creates a directory at <lockPath>.lock for the actual lock
|
|
||||||
const stat = await fs.stat(`${lockPath}.lock`).catch(() => null)
|
|
||||||
expect(stat).not.toBeNull()
|
|
||||||
await release()
|
|
||||||
// After release, the .lock dir should be gone
|
|
||||||
const after = await fs.stat(`${lockPath}.lock`).catch(() => null)
|
|
||||||
expect(after).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('release is idempotent (second call is no-op)', async () => {
|
|
||||||
const lockPath = path.join(tmpDir, 'b.lock')
|
|
||||||
const release = await acquireFileLock(lockPath)
|
|
||||||
await release()
|
|
||||||
await expect(release()).resolves.toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('second acquire blocks until first release', async () => {
|
|
||||||
const lockPath = path.join(tmpDir, 'c.lock')
|
|
||||||
const release1 = await acquireFileLock(lockPath)
|
|
||||||
|
|
||||||
let secondAcquired = false
|
|
||||||
const second = acquireFileLock(lockPath).then((r) => {
|
|
||||||
secondAcquired = true
|
|
||||||
return r
|
|
||||||
})
|
|
||||||
|
|
||||||
// Give the second acquire a moment to attempt
|
|
||||||
await new Promise((r) => setTimeout(r, 200))
|
|
||||||
expect(secondAcquired).toBe(false)
|
|
||||||
|
|
||||||
await release1()
|
|
||||||
const release2 = await second
|
|
||||||
expect(secondAcquired).toBe(true)
|
|
||||||
await release2()
|
|
||||||
}, 10_000)
|
|
||||||
|
|
||||||
it('acquireFileLocksOrdered sorts paths alphabetically (deadlock-free for crossed sets)', async () => {
|
|
||||||
const a = path.join(tmpDir, 'A.lock')
|
|
||||||
const b = path.join(tmpDir, 'B.lock')
|
|
||||||
|
|
||||||
// Two concurrent multi-locks with crossed orders both sort to [A, B]
|
|
||||||
const r1Promise = acquireFileLocksOrdered([b, a])
|
|
||||||
|
|
||||||
// First should grab both since paths sort the same
|
|
||||||
const r1 = await r1Promise
|
|
||||||
|
|
||||||
let secondAcquired = false
|
|
||||||
const r2Promise = acquireFileLocksOrdered([a, b]).then((r) => {
|
|
||||||
secondAcquired = true
|
|
||||||
return r
|
|
||||||
})
|
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 200))
|
|
||||||
expect(secondAcquired).toBe(false)
|
|
||||||
|
|
||||||
await r1()
|
|
||||||
const r2 = await r2Promise
|
|
||||||
expect(secondAcquired).toBe(true)
|
|
||||||
await r2()
|
|
||||||
}, 15_000)
|
|
||||||
|
|
||||||
it('partial failure releases held locks', async () => {
|
|
||||||
// Force the second acquire to fail by writing a regular file at the lockfile
|
|
||||||
// location proper-lockfile wants to create as a directory.
|
|
||||||
const a = path.join(tmpDir, 'A.lock')
|
|
||||||
const bPath = path.join(tmpDir, 'B.lock')
|
|
||||||
// Create a regular file at `${bPath}.lock` so proper-lockfile's mkdir fails with EEXIST
|
|
||||||
await fs.writeFile(`${bPath}.lock`, 'blocked')
|
|
||||||
|
|
||||||
await expect(acquireFileLocksOrdered([a, bPath])).rejects.toThrow()
|
|
||||||
|
|
||||||
// After failure, A's lock should be released — re-acquire immediately
|
|
||||||
const r = await acquireFileLock(a)
|
|
||||||
await r()
|
|
||||||
}, 90_000)
|
|
||||||
})
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
||||||
import * as fs from 'node:fs/promises'
|
|
||||||
import * as os from 'node:os'
|
|
||||||
import * as path from 'node:path'
|
|
||||||
import { execFile } from 'node:child_process'
|
|
||||||
import { promisify } from 'node:util'
|
|
||||||
import {
|
|
||||||
registerJobLockReleases,
|
|
||||||
releaseLocksOnTerminal,
|
|
||||||
setupProductWorktrees,
|
|
||||||
_resetJobReleasesForTest,
|
|
||||||
} from '../../src/git/job-locks.js'
|
|
||||||
|
|
||||||
const exec = promisify(execFile)
|
|
||||||
|
|
||||||
describe('job-locks: registerJobLockReleases + releaseLocksOnTerminal', () => {
|
|
||||||
beforeEach(() => _resetJobReleasesForTest())
|
|
||||||
|
|
||||||
it('releaseLocksOnTerminal for unknown job is a no-op', async () => {
|
|
||||||
await expect(releaseLocksOnTerminal('nonexistent')).resolves.toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('runs registered releases and clears the entry', async () => {
|
|
||||||
const release = vi.fn().mockResolvedValue(undefined)
|
|
||||||
registerJobLockReleases('job-1', [release])
|
|
||||||
await releaseLocksOnTerminal('job-1')
|
|
||||||
expect(release).toHaveBeenCalledTimes(1)
|
|
||||||
// Second call → no-op (cleared)
|
|
||||||
await releaseLocksOnTerminal('job-1')
|
|
||||||
expect(release).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('failures in one release do not abort others', async () => {
|
|
||||||
const r1 = vi.fn().mockRejectedValue(new Error('boom'))
|
|
||||||
const r2 = vi.fn().mockResolvedValue(undefined)
|
|
||||||
registerJobLockReleases('job-2', [r1, r2])
|
|
||||||
await expect(releaseLocksOnTerminal('job-2')).resolves.toBeUndefined()
|
|
||||||
expect(r1).toHaveBeenCalled()
|
|
||||||
expect(r2).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('append-mode: multiple registers accumulate', async () => {
|
|
||||||
const r1 = vi.fn().mockResolvedValue(undefined)
|
|
||||||
const r2 = vi.fn().mockResolvedValue(undefined)
|
|
||||||
registerJobLockReleases('job-3', [r1])
|
|
||||||
registerJobLockReleases('job-3', [r2])
|
|
||||||
await releaseLocksOnTerminal('job-3')
|
|
||||||
expect(r1).toHaveBeenCalledTimes(1)
|
|
||||||
expect(r2).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('job-locks: setupProductWorktrees', () => {
|
|
||||||
let tmpRoot: string
|
|
||||||
let originalEnv: string | undefined
|
|
||||||
let bareRepo: string
|
|
||||||
let originRepo: string
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
_resetJobReleasesForTest()
|
|
||||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'job-locks-'))
|
|
||||||
originalEnv = process.env.SCRUM4ME_AGENT_WORKTREE_DIR
|
|
||||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = path.join(tmpRoot, 'agent-worktrees')
|
|
||||||
|
|
||||||
// Set up a bare repo as origin and a clone with origin/main
|
|
||||||
bareRepo = path.join(tmpRoot, 'origin.git')
|
|
||||||
await exec('git', ['init', '--bare', '-b', 'main', bareRepo])
|
|
||||||
|
|
||||||
originRepo = path.join(tmpRoot, 'work')
|
|
||||||
await exec('git', ['init', '-b', 'main', originRepo])
|
|
||||||
await exec('git', ['config', 'user.email', 't@t.local'], { cwd: originRepo })
|
|
||||||
await exec('git', ['config', 'user.name', 'Test'], { cwd: originRepo })
|
|
||||||
await exec('git', ['remote', 'add', 'origin', bareRepo], { cwd: originRepo })
|
|
||||||
await fs.writeFile(path.join(originRepo, 'README.md'), '# init\n')
|
|
||||||
await exec('git', ['add', '-A'], { cwd: originRepo })
|
|
||||||
await exec('git', ['commit', '-m', 'init'], { cwd: originRepo })
|
|
||||||
await exec('git', ['push', '-u', 'origin', 'main'], { cwd: originRepo })
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
if (originalEnv) process.env.SCRUM4ME_AGENT_WORKTREE_DIR = originalEnv
|
|
||||||
else delete process.env.SCRUM4ME_AGENT_WORKTREE_DIR
|
|
||||||
await fs.rm(tmpRoot, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty when productIds is empty', async () => {
|
|
||||||
const result = await setupProductWorktrees('j1', [], async () => null)
|
|
||||||
expect(result).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates a product-worktree, registers a lock-release, and releases it', async () => {
|
|
||||||
const result = await setupProductWorktrees('j2', ['prod-a'], async () => originRepo)
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
expect(result[0].productId).toBe('prod-a')
|
|
||||||
expect(result[0].worktreePath).toContain('_products/prod-a')
|
|
||||||
|
|
||||||
// Worktree dir exists with detached HEAD on origin/main
|
|
||||||
const stat = await fs.stat(result[0].worktreePath)
|
|
||||||
expect(stat.isDirectory()).toBe(true)
|
|
||||||
|
|
||||||
// Lockfile is held during the job (proper-lockfile creates a .lock dir)
|
|
||||||
const lockDir = path.join(
|
|
||||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR!,
|
|
||||||
'_products',
|
|
||||||
'prod-a.lock.lock',
|
|
||||||
)
|
|
||||||
const lockStat = await fs.stat(lockDir).catch(() => null)
|
|
||||||
expect(lockStat).not.toBeNull()
|
|
||||||
|
|
||||||
await releaseLocksOnTerminal('j2')
|
|
||||||
const lockAfter = await fs.stat(lockDir).catch(() => null)
|
|
||||||
expect(lockAfter).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('skips products where resolveRepoRoot returns null', async () => {
|
|
||||||
const result = await setupProductWorktrees('j3', ['no-repo'], async () => null)
|
|
||||||
expect(result).toEqual([])
|
|
||||||
// Lock was still acquired and registered — release cleans up
|
|
||||||
await releaseLocksOnTerminal('j3')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('output preserves input order regardless of alphabetical lock-acquire order', async () => {
|
|
||||||
// 'z-primary' sorts AFTER 'a-secondary' alphabetically, but caller passes
|
|
||||||
// primary first → output[0] must be 'z-primary' so wait_for_job's
|
|
||||||
// primary_worktree_path = worktrees[0]?.worktreePath points at the right repo.
|
|
||||||
const result = await setupProductWorktrees(
|
|
||||||
'j4',
|
|
||||||
['z-primary', 'a-secondary'],
|
|
||||||
async () => originRepo,
|
|
||||||
)
|
|
||||||
expect(result).toHaveLength(2)
|
|
||||||
expect(result[0].productId).toBe('z-primary')
|
|
||||||
expect(result[1].productId).toBe('a-secondary')
|
|
||||||
await releaseLocksOnTerminal('j4')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
// Mock node:child_process before importing the module under test
|
|
||||||
vi.mock('node:child_process', () => ({
|
|
||||||
execFile: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { execFile } from 'node:child_process'
|
|
||||||
import { enableAutoMergeOnPr } from '../../src/git/pr.js'
|
|
||||||
|
|
||||||
const mockExecFile = vi.mocked(execFile) as unknown as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
function mockGhFailure(stderr: string) {
|
|
||||||
mockExecFile.mockImplementation(((_cmd: string, _args: string[], _opts: unknown, cb: any) => {
|
|
||||||
cb(Object.assign(new Error('gh exit'), { stderr }))
|
|
||||||
}) as never)
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockGhSuccess() {
|
|
||||||
mockExecFile.mockImplementation(((_cmd: string, _args: string[], _opts: unknown, cb: any) => {
|
|
||||||
cb(null, { stdout: '', stderr: '' })
|
|
||||||
}) as never)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('enableAutoMergeOnPr — typed errors (PBI-47 C2 layer 1)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockExecFile.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns ok:true on green merge', async () => {
|
|
||||||
mockGhSuccess()
|
|
||||||
const result = await enableAutoMergeOnPr({ prUrl: 'x', expectedHeadSha: 'sha1' })
|
|
||||||
expect(result.ok).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('classifies GH_AUTH_ERROR for 401/403 / permission strings', async () => {
|
|
||||||
mockGhFailure('gh: HTTP 403: permission denied')
|
|
||||||
const result = await enableAutoMergeOnPr({ prUrl: 'x', expectedHeadSha: 'sha1' })
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
if (!result.ok) expect(result.reason).toBe('GH_AUTH_ERROR')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('classifies AUTO_MERGE_NOT_ALLOWED for repo-setting refusal', async () => {
|
|
||||||
mockGhFailure('auto-merge is not allowed for this repository')
|
|
||||||
const result = await enableAutoMergeOnPr({ prUrl: 'x', expectedHeadSha: 'sha1' })
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
if (!result.ok) expect(result.reason).toBe('AUTO_MERGE_NOT_ALLOWED')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('classifies MERGE_CONFLICT for dirty merge state', async () => {
|
|
||||||
mockGhFailure('pull request is not in a mergeable state (dirty)')
|
|
||||||
const result = await enableAutoMergeOnPr({ prUrl: 'x', expectedHeadSha: 'sha1' })
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
if (!result.ok) expect(result.reason).toBe('MERGE_CONFLICT')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('classifies UNKNOWN for unrecognised stderr', async () => {
|
|
||||||
mockGhFailure('unexpected gh error')
|
|
||||||
const result = await enableAutoMergeOnPr({ prUrl: 'x', expectedHeadSha: 'sha1' })
|
|
||||||
expect(result.ok).toBe(false)
|
|
||||||
if (!result.ok) expect(result.reason).toBe('UNKNOWN')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('passes --match-head-commit when expectedHeadSha provided', async () => {
|
|
||||||
mockGhSuccess()
|
|
||||||
await enableAutoMergeOnPr({ prUrl: 'pr-url', expectedHeadSha: 'abc123' })
|
|
||||||
const callArgs = mockExecFile.mock.calls[0]
|
|
||||||
expect(callArgs[0]).toBe('gh')
|
|
||||||
const args = callArgs[1] as string[]
|
|
||||||
expect(args).toContain('--match-head-commit')
|
|
||||||
expect(args).toContain('abc123')
|
|
||||||
expect(args).toContain('--auto')
|
|
||||||
expect(args).toContain('--squash')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -12,7 +12,7 @@ vi.mock('node:util', () => ({
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { createPullRequest, markPullRequestReady } from '../../src/git/pr.js'
|
import { createPullRequest } from '../../src/git/pr.js'
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
@ -66,80 +66,4 @@ describe('createPullRequest', () => {
|
||||||
|
|
||||||
expect(result).toMatchObject({ error: expect.stringContaining('gh pr create failed') })
|
expect(result).toMatchObject({ error: expect.stringContaining('gh pr create failed') })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('passes --draft when draft=true en slaat auto-merge over', async () => {
|
|
||||||
const calls: string[][] = []
|
|
||||||
mockExecFile.mockImplementation(
|
|
||||||
(
|
|
||||||
_cmd: string,
|
|
||||||
args: string[],
|
|
||||||
_opts: unknown,
|
|
||||||
cb: (err: null, res: { stdout: string; stderr: string }) => void,
|
|
||||||
) => {
|
|
||||||
calls.push(args)
|
|
||||||
cb(null, {
|
|
||||||
stdout: 'Creating draft pull request...\nhttps://github.com/org/repo/pull/100\n',
|
|
||||||
stderr: '',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = await createPullRequest({
|
|
||||||
worktreePath: '/wt/sprint-1',
|
|
||||||
branchName: 'feat/sprint-12345678',
|
|
||||||
title: 'Sprint: Cascade-flow live',
|
|
||||||
body: 'Sprint draft',
|
|
||||||
draft: true,
|
|
||||||
enableAutoMerge: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result).toEqual({ url: 'https://github.com/org/repo/pull/100' })
|
|
||||||
expect(calls.some((a) => a.includes('--draft'))).toBe(true)
|
|
||||||
// gh pr merge --auto mag NIET gestart zijn voor draft + auto-merge=false
|
|
||||||
expect(calls.some((a) => a[0] === 'pr' && a[1] === 'merge')).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('markPullRequestReady', () => {
|
|
||||||
it('roept gh pr ready aan met de PR-URL', async () => {
|
|
||||||
const calls: string[][] = []
|
|
||||||
mockExecFile.mockImplementation(
|
|
||||||
(
|
|
||||||
_cmd: string,
|
|
||||||
args: string[],
|
|
||||||
_opts: unknown,
|
|
||||||
cb: (err: null, res: { stdout: string; stderr: string }) => void,
|
|
||||||
) => {
|
|
||||||
calls.push(args)
|
|
||||||
cb(null, { stdout: '', stderr: '' })
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = await markPullRequestReady({ prUrl: 'https://github.com/org/repo/pull/100' })
|
|
||||||
|
|
||||||
expect(result).toEqual({ ok: true })
|
|
||||||
expect(calls[0]).toEqual(['pr', 'ready', 'https://github.com/org/repo/pull/100'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('behandelt "already ready" als success', async () => {
|
|
||||||
mockExecFile.mockImplementation(
|
|
||||||
(_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) =>
|
|
||||||
cb(Object.assign(new Error(''), { stderr: 'Pull request is not in draft state' })),
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = await markPullRequestReady({ prUrl: 'https://github.com/org/repo/pull/100' })
|
|
||||||
|
|
||||||
expect(result).toEqual({ ok: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('retourneert error op onverwachte gh-fout', async () => {
|
|
||||||
mockExecFile.mockImplementation(
|
|
||||||
(_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) =>
|
|
||||||
cb(new Error('rate limit exceeded')),
|
|
||||||
)
|
|
||||||
|
|
||||||
const result = await markPullRequestReady({ prUrl: 'https://github.com/org/repo/pull/100' })
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ error: expect.stringContaining('gh pr ready failed') })
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -74,12 +74,11 @@ describe('createWorktreeForJob', () => {
|
||||||
expect(result.worktreePath).toBe(path.join(wtParent, 'job-001'))
|
expect(result.worktreePath).toBe(path.join(wtParent, 'job-001'))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('removes orphan branch and reuses the predictable name when no worktree owns it', async () => {
|
it('suffixes branch name with timestamp when branch already exists', async () => {
|
||||||
const { repoDir, originDir } = await setupRepo()
|
const { repoDir, originDir } = await setupRepo()
|
||||||
tmpDirs.push(repoDir, originDir)
|
tmpDirs.push(repoDir, originDir)
|
||||||
await makeWorktreeParent()
|
await makeWorktreeParent()
|
||||||
|
|
||||||
// Pre-create an orphan branch (no worktree attached)
|
|
||||||
await git(['branch', 'feat/job-002'], repoDir)
|
await git(['branch', 'feat/job-002'], repoDir)
|
||||||
|
|
||||||
const result = await createWorktreeForJob({
|
const result = await createWorktreeForJob({
|
||||||
|
|
@ -89,11 +88,10 @@ describe('createWorktreeForJob', () => {
|
||||||
baseRef: 'origin/main',
|
baseRef: 'origin/main',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Orphan was deleted → predictable name reused, no timestamp suffix
|
expect(result.branchName).toMatch(/^feat\/job-002-\d+$/)
|
||||||
expect(result.branchName).toBe('feat/job-002')
|
|
||||||
|
|
||||||
const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath)
|
const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath)
|
||||||
expect(stdout.trim()).toBe('feat/job-002')
|
expect(stdout.trim()).toBe(result.branchName)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects when worktree path already exists', async () => {
|
it('rejects when worktree path already exists', async () => {
|
||||||
|
|
@ -113,71 +111,6 @@ describe('createWorktreeForJob', () => {
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow('Worktree path already exists')
|
).rejects.toThrow('Worktree path already exists')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reuseBranch: reuses an existing local branch', async () => {
|
|
||||||
const { repoDir, originDir } = await setupRepo()
|
|
||||||
tmpDirs.push(repoDir, originDir)
|
|
||||||
await makeWorktreeParent()
|
|
||||||
|
|
||||||
// Sibling already created the branch locally.
|
|
||||||
await git(['branch', 'feat/sprint-abc', 'origin/main'], repoDir)
|
|
||||||
|
|
||||||
const result = await createWorktreeForJob({
|
|
||||||
repoRoot: repoDir,
|
|
||||||
jobId: 'job-reuse-local',
|
|
||||||
branchName: 'feat/sprint-abc',
|
|
||||||
baseRef: 'origin/main',
|
|
||||||
reuseBranch: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath)
|
|
||||||
expect(stdout.trim()).toBe('feat/sprint-abc')
|
|
||||||
expect(result.branchName).toBe('feat/sprint-abc')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reuseBranch: recreates a local branch from origin when only the remote has it', async () => {
|
|
||||||
const { repoDir, originDir } = await setupRepo()
|
|
||||||
tmpDirs.push(repoDir, originDir)
|
|
||||||
await makeWorktreeParent()
|
|
||||||
|
|
||||||
// Branch exists on origin (a sibling pushed it, or the container was
|
|
||||||
// recreated and the local clone is fresh) but not as a local branch.
|
|
||||||
await git(['branch', 'feat/sprint-xyz', 'origin/main'], repoDir)
|
|
||||||
await git(['push', 'origin', 'feat/sprint-xyz'], repoDir)
|
|
||||||
await git(['branch', '-D', 'feat/sprint-xyz'], repoDir)
|
|
||||||
|
|
||||||
const result = await createWorktreeForJob({
|
|
||||||
repoRoot: repoDir,
|
|
||||||
jobId: 'job-reuse-origin',
|
|
||||||
branchName: 'feat/sprint-xyz',
|
|
||||||
baseRef: 'origin/main',
|
|
||||||
reuseBranch: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath)
|
|
||||||
expect(stdout.trim()).toBe('feat/sprint-xyz')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reuseBranch: falls back to a fresh branch when it exists nowhere (cross-repo sprint)', async () => {
|
|
||||||
const { repoDir, originDir } = await setupRepo()
|
|
||||||
tmpDirs.push(repoDir, originDir)
|
|
||||||
await makeWorktreeParent()
|
|
||||||
|
|
||||||
// reuseBranch is decided sprint-wide; for the first job targeting THIS
|
|
||||||
// repo the branch exists neither locally nor on origin. Must not throw
|
|
||||||
// "invalid reference" — should create it fresh from baseRef.
|
|
||||||
const result = await createWorktreeForJob({
|
|
||||||
repoRoot: repoDir,
|
|
||||||
jobId: 'job-reuse-fresh',
|
|
||||||
branchName: 'feat/sprint-newrepo',
|
|
||||||
baseRef: 'origin/main',
|
|
||||||
reuseBranch: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath)
|
|
||||||
expect(stdout.trim()).toBe('feat/sprint-newrepo')
|
|
||||||
expect(result.branchName).toBe('feat/sprint-newrepo')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('removeWorktreeForJob', () => {
|
describe('removeWorktreeForJob', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { getKindDefault, resolveJobConfig, mapBudgetToEffort } from '../src/lib/job-config.js'
|
|
||||||
|
|
||||||
const KIND_EXPECTED = {
|
|
||||||
IDEA_GRILL: { model: 'claude-sonnet-4-6', thinking_budget: 12000, permission_mode: 'acceptEdits', max_turns: 15 },
|
|
||||||
IDEA_MAKE_PLAN: { model: 'claude-opus-4-7', thinking_budget: 24000, permission_mode: 'acceptEdits', max_turns: 20 },
|
|
||||||
PLAN_CHAT: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'acceptEdits', max_turns: 5 },
|
|
||||||
TASK_IMPLEMENTATION: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: 50 },
|
|
||||||
SPRINT_IMPLEMENTATION: { model: 'claude-sonnet-4-6', thinking_budget: 6000, permission_mode: 'bypassPermissions', max_turns: null },
|
|
||||||
} as const
|
|
||||||
|
|
||||||
describe('getKindDefault', () => {
|
|
||||||
for (const [kind, expected] of Object.entries(KIND_EXPECTED)) {
|
|
||||||
it(`returnt de juiste defaults voor ${kind}`, () => {
|
|
||||||
const cfg = getKindDefault(kind)
|
|
||||||
expect(cfg.model).toBe(expected.model)
|
|
||||||
expect(cfg.thinking_budget).toBe(expected.thinking_budget)
|
|
||||||
expect(cfg.permission_mode).toBe(expected.permission_mode)
|
|
||||||
expect(cfg.max_turns).toBe(expected.max_turns)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
it('valt terug op een veilige fallback voor onbekende kinds', () => {
|
|
||||||
const cfg = getKindDefault('SOMETHING_NEW')
|
|
||||||
expect(cfg.model).toBe('claude-sonnet-4-6')
|
|
||||||
expect(cfg.permission_mode).toBe('default')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('resolveJobConfig — geen overrides', () => {
|
|
||||||
for (const kind of Object.keys(KIND_EXPECTED)) {
|
|
||||||
it(`returnt kind-default voor ${kind} zonder overrides`, () => {
|
|
||||||
const cfg = resolveJobConfig({ kind }, {})
|
|
||||||
expect(cfg).toEqual(getKindDefault(kind))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('resolveJobConfig — cascade', () => {
|
|
||||||
it('product.preferred_model overrult kind-default', () => {
|
|
||||||
const cfg = resolveJobConfig({ kind: 'TASK_IMPLEMENTATION' }, { preferred_model: 'claude-haiku-4-5-20251001' })
|
|
||||||
expect(cfg.model).toBe('claude-haiku-4-5-20251001')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('job.requested_model overrult product.preferred_model', () => {
|
|
||||||
const cfg = resolveJobConfig(
|
|
||||||
{ kind: 'TASK_IMPLEMENTATION', requested_model: 'claude-opus-4-7' },
|
|
||||||
{ preferred_model: 'claude-haiku-4-5-20251001' },
|
|
||||||
)
|
|
||||||
expect(cfg.model).toBe('claude-opus-4-7')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('task.requires_opus overrult product.preferred_model', () => {
|
|
||||||
const cfg = resolveJobConfig(
|
|
||||||
{ kind: 'TASK_IMPLEMENTATION' },
|
|
||||||
{ preferred_model: 'claude-sonnet-4-6' },
|
|
||||||
{ requires_opus: true },
|
|
||||||
)
|
|
||||||
expect(cfg.model).toBe('claude-opus-4-7')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('task.requires_opus overrult ook job.requested_model = haiku', () => {
|
|
||||||
const cfg = resolveJobConfig(
|
|
||||||
{ kind: 'TASK_IMPLEMENTATION', requested_model: 'claude-haiku-4-5-20251001' },
|
|
||||||
{},
|
|
||||||
{ requires_opus: true },
|
|
||||||
)
|
|
||||||
expect(cfg.model).toBe('claude-opus-4-7')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('job.requested_thinking_budget overrult kind-default', () => {
|
|
||||||
const cfg = resolveJobConfig({ kind: 'PLAN_CHAT', requested_thinking_budget: 1024 }, {})
|
|
||||||
expect(cfg.thinking_budget).toBe(1024)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('product.thinking_budget_default overrult kind-default', () => {
|
|
||||||
const cfg = resolveJobConfig({ kind: 'IDEA_GRILL' }, { thinking_budget_default: 0 })
|
|
||||||
expect(cfg.thinking_budget).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('product.preferred_permission_mode = acceptEdits overrult bypassPermissions voor TASK_IMPLEMENTATION', () => {
|
|
||||||
const cfg = resolveJobConfig(
|
|
||||||
{ kind: 'TASK_IMPLEMENTATION' },
|
|
||||||
{ preferred_permission_mode: 'acceptEdits' },
|
|
||||||
)
|
|
||||||
expect(cfg.permission_mode).toBe('acceptEdits')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('max_turns blijft kind-default ook met product- en job-overrides (geen V1-cascade)', () => {
|
|
||||||
const cfg = resolveJobConfig(
|
|
||||||
{ kind: 'IDEA_GRILL', requested_model: 'claude-haiku-4-5-20251001' },
|
|
||||||
{ preferred_model: 'claude-sonnet-4-6' },
|
|
||||||
)
|
|
||||||
expect(cfg.max_turns).toBe(15)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('KIND_DEFAULTS.allowed_tools', () => {
|
|
||||||
it('TASK_IMPLEMENTATION bevat geen claim-tools', () => {
|
|
||||||
const cfg = getKindDefault('TASK_IMPLEMENTATION')
|
|
||||||
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job')
|
|
||||||
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__check_queue_empty')
|
|
||||||
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__get_idea_context')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('TASK_IMPLEMENTATION bevat de essentiële task-tools', () => {
|
|
||||||
const cfg = getKindDefault('TASK_IMPLEMENTATION')
|
|
||||||
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_task_status')
|
|
||||||
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_job_status')
|
|
||||||
expect(cfg.allowed_tools).toContain('mcp__scrum4me__verify_task_against_plan')
|
|
||||||
expect(cfg.allowed_tools).toContain('Bash')
|
|
||||||
expect(cfg.allowed_tools).toContain('Edit')
|
|
||||||
expect(cfg.allowed_tools).toContain('Write')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('SPRINT_IMPLEMENTATION bevat sprint-specifieke tools maar GEEN job_heartbeat (runner doet die)', () => {
|
|
||||||
const cfg = getKindDefault('SPRINT_IMPLEMENTATION')
|
|
||||||
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_task_execution')
|
|
||||||
expect(cfg.allowed_tools).toContain('mcp__scrum4me__verify_sprint_task')
|
|
||||||
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__job_heartbeat')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('IDEA_GRILL bevat update_idea_grill_md en geen wait_for_job', () => {
|
|
||||||
const cfg = getKindDefault('IDEA_GRILL')
|
|
||||||
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_idea_grill_md')
|
|
||||||
expect(cfg.allowed_tools).toContain('mcp__scrum4me__log_idea_decision')
|
|
||||||
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_job_status')
|
|
||||||
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('IDEA_MAKE_PLAN bevat update_idea_plan_md en geen wait_for_job', () => {
|
|
||||||
const cfg = getKindDefault('IDEA_MAKE_PLAN')
|
|
||||||
expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_idea_plan_md')
|
|
||||||
expect(cfg.allowed_tools).toContain('mcp__scrum4me__log_idea_decision')
|
|
||||||
expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('alle kinds hebben non-null allowed_tools', () => {
|
|
||||||
for (const kind of ['IDEA_GRILL', 'IDEA_MAKE_PLAN', 'PLAN_CHAT', 'TASK_IMPLEMENTATION', 'SPRINT_IMPLEMENTATION']) {
|
|
||||||
const cfg = getKindDefault(kind)
|
|
||||||
expect(cfg.allowed_tools).not.toBeNull()
|
|
||||||
expect(Array.isArray(cfg.allowed_tools)).toBe(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('mapBudgetToEffort', () => {
|
|
||||||
it.each([
|
|
||||||
[0, null],
|
|
||||||
[-1, null],
|
|
||||||
[1, 'medium'],
|
|
||||||
[3000, 'medium'],
|
|
||||||
[6000, 'medium'],
|
|
||||||
[6001, 'high'],
|
|
||||||
[9000, 'high'],
|
|
||||||
[12000, 'high'],
|
|
||||||
[12001, 'xhigh'],
|
|
||||||
[18000, 'xhigh'],
|
|
||||||
[24000, 'xhigh'],
|
|
||||||
[24001, 'max'],
|
|
||||||
[50000, 'max'],
|
|
||||||
[100000, 'max'],
|
|
||||||
])('budget %i → %s', (budget, expected) => {
|
|
||||||
expect(mapBudgetToEffort(budget)).toBe(expected)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('../src/prisma.js', () => ({
|
|
||||||
prisma: {
|
|
||||||
$queryRaw: vi.fn(),
|
|
||||||
sprintRun: { findUnique: vi.fn() },
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../src/auth.js', async (importOriginal) => {
|
|
||||||
const original = await importOriginal<typeof import('../src/auth.js')>()
|
|
||||||
return { ...original, requireWriteAccess: vi.fn() }
|
|
||||||
})
|
|
||||||
|
|
||||||
import { prisma } from '../src/prisma.js'
|
|
||||||
import { requireWriteAccess } from '../src/auth.js'
|
|
||||||
import { registerJobHeartbeatTool } from '../src/tools/job-heartbeat.js'
|
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
$queryRaw: ReturnType<typeof vi.fn>
|
|
||||||
sprintRun: { findUnique: ReturnType<typeof vi.fn> }
|
|
||||||
}
|
|
||||||
const mockAuth = requireWriteAccess as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
const TOKEN_ID = 'tok-owner'
|
|
||||||
|
|
||||||
function makeServer() {
|
|
||||||
let handler: (args: Record<string, unknown>) => Promise<unknown>
|
|
||||||
const server = {
|
|
||||||
registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => {
|
|
||||||
handler = fn
|
|
||||||
}),
|
|
||||||
call: (args: Record<string, unknown>) => handler(args),
|
|
||||||
}
|
|
||||||
registerJobHeartbeatTool(server as unknown as McpServer)
|
|
||||||
return server
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockAuth.mockResolvedValue({
|
|
||||||
userId: 'u-1',
|
|
||||||
tokenId: TOKEN_ID,
|
|
||||||
username: 'agent',
|
|
||||||
isDemo: false,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('job_heartbeat', () => {
|
|
||||||
it('returns 403-style error when no row matched (token mismatch / terminal)', async () => {
|
|
||||||
mockPrisma.$queryRaw.mockResolvedValue([])
|
|
||||||
const server = makeServer()
|
|
||||||
const result = (await server.call({ job_id: 'job-x' })) as {
|
|
||||||
content: { text: string }[]
|
|
||||||
isError?: boolean
|
|
||||||
}
|
|
||||||
expect(result.isError).toBe(true)
|
|
||||||
expect(result.content[0].text).toMatch(/not found|terminal|claimed_by/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('non-SPRINT job returns ok + lease_until without sprint fields', async () => {
|
|
||||||
const lease = new Date()
|
|
||||||
mockPrisma.$queryRaw.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 'job-1',
|
|
||||||
lease_until: lease,
|
|
||||||
kind: 'TASK_IMPLEMENTATION',
|
|
||||||
sprint_run_id: null,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
const server = makeServer()
|
|
||||||
const result = (await server.call({ job_id: 'job-1' })) as {
|
|
||||||
content: { text: string }[]
|
|
||||||
}
|
|
||||||
const body = JSON.parse(result.content[0].text)
|
|
||||||
expect(body).toEqual({
|
|
||||||
ok: true,
|
|
||||||
job_id: 'job-1',
|
|
||||||
lease_until: lease.toISOString(),
|
|
||||||
sprint_run_status: null,
|
|
||||||
sprint_run_pause_reason: null,
|
|
||||||
})
|
|
||||||
expect(mockPrisma.sprintRun.findUnique).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('SPRINT job returns sprint_run_status from sibling lookup', async () => {
|
|
||||||
const lease = new Date()
|
|
||||||
mockPrisma.$queryRaw.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 'job-2',
|
|
||||||
lease_until: lease,
|
|
||||||
kind: 'SPRINT_IMPLEMENTATION',
|
|
||||||
sprint_run_id: 'sr-1',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
mockPrisma.sprintRun.findUnique.mockResolvedValue({
|
|
||||||
status: 'PAUSED',
|
|
||||||
pause_context: { pause_reason: 'QUOTA_DEPLETED' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const server = makeServer()
|
|
||||||
const result = (await server.call({ job_id: 'job-2' })) as {
|
|
||||||
content: { text: string }[]
|
|
||||||
}
|
|
||||||
const body = JSON.parse(result.content[0].text)
|
|
||||||
expect(body).toMatchObject({
|
|
||||||
ok: true,
|
|
||||||
sprint_run_status: 'PAUSED',
|
|
||||||
sprint_run_pause_reason: 'QUOTA_DEPLETED',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('SPRINT job tolerates missing pause_context', async () => {
|
|
||||||
const lease = new Date()
|
|
||||||
mockPrisma.$queryRaw.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 'job-3',
|
|
||||||
lease_until: lease,
|
|
||||||
kind: 'SPRINT_IMPLEMENTATION',
|
|
||||||
sprint_run_id: 'sr-2',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
mockPrisma.sprintRun.findUnique.mockResolvedValue({
|
|
||||||
status: 'RUNNING',
|
|
||||||
pause_context: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const server = makeServer()
|
|
||||||
const result = (await server.call({ job_id: 'job-3' })) as {
|
|
||||||
content: { text: string }[]
|
|
||||||
}
|
|
||||||
const body = JSON.parse(result.content[0].text)
|
|
||||||
expect(body.sprint_run_status).toBe('RUNNING')
|
|
||||||
expect(body.sprint_run_pause_reason).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import type { ClaudeJobKind } from '@prisma/client'
|
|
||||||
import { getKindPromptText, getIdeaPromptText } from '../src/lib/kind-prompts.js'
|
|
||||||
|
|
||||||
const KINDS: ClaudeJobKind[] = [
|
|
||||||
'IDEA_GRILL',
|
|
||||||
'IDEA_MAKE_PLAN',
|
|
||||||
'TASK_IMPLEMENTATION',
|
|
||||||
'SPRINT_IMPLEMENTATION',
|
|
||||||
'PLAN_CHAT',
|
|
||||||
]
|
|
||||||
|
|
||||||
describe('getKindPromptText', () => {
|
|
||||||
it.each(KINDS)('returnt non-empty content voor %s', (kind) => {
|
|
||||||
const text = getKindPromptText(kind)
|
|
||||||
expect(text.length).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('TASK_IMPLEMENTATION-prompt verbiedt wait_for_job', () => {
|
|
||||||
const text = getKindPromptText('TASK_IMPLEMENTATION')
|
|
||||||
expect(text).toMatch(/GEEN.*wait_for_job/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('SPRINT_IMPLEMENTATION-prompt verbiedt job_heartbeat', () => {
|
|
||||||
const text = getKindPromptText('SPRINT_IMPLEMENTATION')
|
|
||||||
expect(text).toMatch(/GEEN.*job_heartbeat/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.each(KINDS)(
|
|
||||||
'%s-prompt noemt $PAYLOAD_PATH als variabele (alle kinds — runner doet substitution)',
|
|
||||||
(kind) => {
|
|
||||||
const text = getKindPromptText(kind)
|
|
||||||
expect(text).toContain('$PAYLOAD_PATH')
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
it.each(['IDEA_GRILL', 'IDEA_MAKE_PLAN'] as const)(
|
|
||||||
'%s-prompt verwijst niet meer naar wait_for_job (refactor: runner claimt)',
|
|
||||||
(kind) => {
|
|
||||||
const text = getKindPromptText(kind)
|
|
||||||
expect(text).not.toContain('wait_for_job')
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
it.each(['IDEA_GRILL', 'IDEA_MAKE_PLAN'] as const)(
|
|
||||||
'%s-prompt bevat geen onvervangen {idea_*} placeholders',
|
|
||||||
(kind) => {
|
|
||||||
const text = getKindPromptText(kind)
|
|
||||||
expect(text).not.toMatch(/\{idea_code\}|\{idea_title\}/)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('getIdeaPromptText (back-compat)', () => {
|
|
||||||
it('returnt content voor IDEA_GRILL', () => {
|
|
||||||
expect(getIdeaPromptText('IDEA_GRILL').length).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
it('returnt content voor IDEA_MAKE_PLAN', () => {
|
|
||||||
expect(getIdeaPromptText('IDEA_MAKE_PLAN').length).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
it('returnt empty string voor non-idea kind', () => {
|
|
||||||
expect(getIdeaPromptText('TASK_IMPLEMENTATION')).toBe('')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('../src/prisma.js', () => ({
|
|
||||||
prisma: {
|
|
||||||
pbi: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../src/auth.js', () => ({
|
|
||||||
requireWriteAccess: vi.fn(),
|
|
||||||
PermissionDeniedError: class PermissionDeniedError extends Error {
|
|
||||||
constructor(message = 'Demo accounts cannot perform write operations') {
|
|
||||||
super(message)
|
|
||||||
this.name = 'PermissionDeniedError'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
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 { handleMarkPbiPrMerged } from '../src/tools/mark-pbi-pr-merged.js'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
pbi: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
|
||||||
}
|
|
||||||
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
|
|
||||||
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
const PBI_ID = 'pbi-abc123'
|
|
||||||
const PR_URL = 'https://github.com/owner/repo/pull/42'
|
|
||||||
const MERGED_AT = new Date('2026-05-03T12:00:00Z')
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockRequireWriteAccess.mockResolvedValue({ userId: 'user-1', tokenId: 'tok-1', username: 'alice', isDemo: false })
|
|
||||||
mockPrisma.pbi.findUnique.mockResolvedValue({ product_id: 'prod-1', pr_url: PR_URL })
|
|
||||||
mockUserCanAccessProduct.mockResolvedValue(true)
|
|
||||||
mockPrisma.pbi.update.mockResolvedValue({ id: PBI_ID, pr_url: PR_URL, pr_merged_at: MERGED_AT })
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('handleMarkPbiPrMerged', () => {
|
|
||||||
it('happy path: sets pr_merged_at and returns ok', async () => {
|
|
||||||
const result = await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
|
|
||||||
|
|
||||||
expect(result.isError).toBeFalsy()
|
|
||||||
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: PBI_ID },
|
|
||||||
data: { pr_merged_at: expect.any(Date) },
|
|
||||||
select: { id: true, pr_url: true, pr_merged_at: true },
|
|
||||||
})
|
|
||||||
const text = result.content[0].type === 'text' ? result.content[0].text : ''
|
|
||||||
const parsed = JSON.parse(text)
|
|
||||||
expect(parsed.ok).toBe(true)
|
|
||||||
expect(parsed.pbi_id).toBe(PBI_ID)
|
|
||||||
expect(parsed.pr_url).toBe(PR_URL)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns error when PBI has no pr_url', async () => {
|
|
||||||
mockPrisma.pbi.findUnique.mockResolvedValue({ product_id: 'prod-1', pr_url: null })
|
|
||||||
|
|
||||||
const result = await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
|
|
||||||
|
|
||||||
expect(result.isError).toBe(true)
|
|
||||||
const text = result.content[0].type === 'text' ? result.content[0].text : ''
|
|
||||||
expect(text).toMatch(/geen gekoppelde PR/)
|
|
||||||
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('idempotent: re-calling overwrites pr_merged_at timestamp', async () => {
|
|
||||||
await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
|
|
||||||
await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
|
|
||||||
|
|
||||||
expect(mockPrisma.pbi.update).toHaveBeenCalledTimes(2)
|
|
||||||
expect(mockPrisma.pbi.update.mock.calls[0][0].data.pr_merged_at).toBeInstanceOf(Date)
|
|
||||||
expect(mockPrisma.pbi.update.mock.calls[1][0].data.pr_merged_at).toBeInstanceOf(Date)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns error when user has no access', async () => {
|
|
||||||
mockUserCanAccessProduct.mockResolvedValue(false)
|
|
||||||
|
|
||||||
const result = await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
|
|
||||||
|
|
||||||
expect(result.isError).toBe(true)
|
|
||||||
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns error when PBI not found', async () => {
|
|
||||||
mockPrisma.pbi.findUnique.mockResolvedValue(null)
|
|
||||||
|
|
||||||
const result = await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
|
|
||||||
|
|
||||||
expect(result.isError).toBe(true)
|
|
||||||
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns PERMISSION_DENIED for demo accounts', async () => {
|
|
||||||
mockRequireWriteAccess.mockRejectedValue(new PermissionDeniedError())
|
|
||||||
|
|
||||||
const result = await handleMarkPbiPrMerged({ pbi_id: PBI_ID })
|
|
||||||
|
|
||||||
expect(result.isError).toBe(true)
|
|
||||||
const text = result.content[0].type === 'text' ? result.content[0].text : ''
|
|
||||||
expect(text).toMatch(/PERMISSION_DENIED/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,287 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
|
||||||
import { tmpdir } from 'node:os'
|
|
||||||
import { join } from 'node:path'
|
|
||||||
|
|
||||||
vi.mock('../../src/prisma.js', () => ({
|
|
||||||
prisma: {
|
|
||||||
claudeJob: { update: vi.fn() },
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '../../src/prisma.js'
|
|
||||||
import {
|
|
||||||
parseTranscript,
|
|
||||||
computeUsageFromTranscript,
|
|
||||||
normalizeModelId,
|
|
||||||
persistJobUsage,
|
|
||||||
} from '../../scripts/persist-job-usage.js'
|
|
||||||
|
|
||||||
const mockUpdate = (prisma as unknown as { claudeJob: { update: ReturnType<typeof vi.fn> } })
|
|
||||||
.claudeJob.update
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockUpdate.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
function assistantLine(opts: {
|
|
||||||
model?: string
|
|
||||||
usage?: {
|
|
||||||
input_tokens?: number
|
|
||||||
output_tokens?: number
|
|
||||||
cache_creation_input_tokens?: number
|
|
||||||
cache_read_input_tokens?: number
|
|
||||||
}
|
|
||||||
toolUseName?: string
|
|
||||||
isSidechain?: boolean
|
|
||||||
uuid?: string
|
|
||||||
}) {
|
|
||||||
const content: Array<{ type: string; name?: string }> = []
|
|
||||||
if (opts.toolUseName) content.push({ type: 'tool_use', name: opts.toolUseName })
|
|
||||||
return JSON.stringify({
|
|
||||||
type: 'assistant',
|
|
||||||
uuid: opts.uuid,
|
|
||||||
isSidechain: opts.isSidechain ?? false,
|
|
||||||
message: {
|
|
||||||
role: 'assistant',
|
|
||||||
model: opts.model ?? 'claude-sonnet-4-6',
|
|
||||||
content,
|
|
||||||
usage: opts.usage,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('normalizeModelId', () => {
|
|
||||||
it('strips bracket suffix', () => {
|
|
||||||
expect(normalizeModelId('claude-opus-4-7[1m]')).toBe('claude-opus-4-7-1m')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('passes through plain ids', () => {
|
|
||||||
expect(normalizeModelId('claude-sonnet-4-6')).toBe('claude-sonnet-4-6')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('parseTranscript', () => {
|
|
||||||
it('skips malformed lines', () => {
|
|
||||||
const raw = `${assistantLine({})}\nnot-json\n${assistantLine({})}\n`
|
|
||||||
expect(parseTranscript(raw)).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles trailing newline + empty lines', () => {
|
|
||||||
expect(parseTranscript('\n\n')).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('dedups on uuid (branching/resumption)', () => {
|
|
||||||
const a = assistantLine({ uuid: 'u1', usage: { input_tokens: 5, output_tokens: 5 } })
|
|
||||||
const b = assistantLine({ uuid: 'u1', usage: { input_tokens: 99, output_tokens: 99 } })
|
|
||||||
const c = assistantLine({ uuid: 'u2', usage: { input_tokens: 1, output_tokens: 1 } })
|
|
||||||
const lines = parseTranscript([a, b, c].join('\n'))
|
|
||||||
expect(lines).toHaveLength(2)
|
|
||||||
expect(lines[0].uuid).toBe('u1')
|
|
||||||
expect(lines[1].uuid).toBe('u2')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('computeUsageFromTranscript', () => {
|
|
||||||
it('sums assistant usage after wait_for_job marker', () => {
|
|
||||||
const lines = parseTranscript(
|
|
||||||
[
|
|
||||||
assistantLine({
|
|
||||||
toolUseName: 'mcp__scrum4me__wait_for_job',
|
|
||||||
usage: { input_tokens: 999, output_tokens: 999 },
|
|
||||||
}),
|
|
||||||
assistantLine({
|
|
||||||
usage: {
|
|
||||||
input_tokens: 10,
|
|
||||||
output_tokens: 20,
|
|
||||||
cache_creation_input_tokens: 30,
|
|
||||||
cache_read_input_tokens: 40,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
assistantLine({
|
|
||||||
usage: {
|
|
||||||
input_tokens: 1,
|
|
||||||
output_tokens: 2,
|
|
||||||
cache_creation_input_tokens: 3,
|
|
||||||
cache_read_input_tokens: 4,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
assistantLine({ toolUseName: 'mcp__scrum4me__update_job_status' }),
|
|
||||||
].join('\n'),
|
|
||||||
)
|
|
||||||
|
|
||||||
const usage = computeUsageFromTranscript(lines)
|
|
||||||
expect(usage.input_tokens).toBe(11)
|
|
||||||
expect(usage.output_tokens).toBe(22)
|
|
||||||
expect(usage.cache_write_tokens).toBe(33)
|
|
||||||
expect(usage.cache_read_tokens).toBe(44)
|
|
||||||
expect(usage.model_id).toBe('claude-sonnet-4-6')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sums whole session when no wait_for_job marker', () => {
|
|
||||||
const lines = parseTranscript(
|
|
||||||
[
|
|
||||||
assistantLine({ usage: { input_tokens: 5, output_tokens: 6 } }),
|
|
||||||
assistantLine({ usage: { input_tokens: 7, output_tokens: 8 } }),
|
|
||||||
].join('\n'),
|
|
||||||
)
|
|
||||||
|
|
||||||
const usage = computeUsageFromTranscript(lines)
|
|
||||||
expect(usage.input_tokens).toBe(12)
|
|
||||||
expect(usage.output_tokens).toBe(14)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ignores non-assistant lines', () => {
|
|
||||||
const userLine = JSON.stringify({
|
|
||||||
type: 'user',
|
|
||||||
message: { role: 'user', content: [] },
|
|
||||||
})
|
|
||||||
const lines = parseTranscript(
|
|
||||||
[
|
|
||||||
assistantLine({ toolUseName: 'mcp__scrum4me__wait_for_job' }),
|
|
||||||
userLine,
|
|
||||||
assistantLine({ usage: { input_tokens: 100, output_tokens: 200 } }),
|
|
||||||
].join('\n'),
|
|
||||||
)
|
|
||||||
|
|
||||||
const usage = computeUsageFromTranscript(lines)
|
|
||||||
expect(usage.input_tokens).toBe(100)
|
|
||||||
expect(usage.output_tokens).toBe(200)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns last model_id and normalizes [1m]-suffix', () => {
|
|
||||||
const lines = parseTranscript(
|
|
||||||
[
|
|
||||||
assistantLine({ model: 'claude-sonnet-4-6', usage: { input_tokens: 1, output_tokens: 1 } }),
|
|
||||||
assistantLine({ model: 'claude-opus-4-7[1m]', usage: { input_tokens: 1, output_tokens: 1 } }),
|
|
||||||
].join('\n'),
|
|
||||||
)
|
|
||||||
const usage = computeUsageFromTranscript(lines)
|
|
||||||
expect(usage.model_id).toBe('claude-opus-4-7-1m')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null model_id when transcript is empty', () => {
|
|
||||||
expect(computeUsageFromTranscript([]).model_id).toBe(null)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('skips sidechain (subagent) lines to avoid double-counting', () => {
|
|
||||||
const lines = parseTranscript(
|
|
||||||
[
|
|
||||||
assistantLine({
|
|
||||||
toolUseName: 'mcp__scrum4me__wait_for_job',
|
|
||||||
uuid: 'main-1',
|
|
||||||
}),
|
|
||||||
assistantLine({
|
|
||||||
isSidechain: true,
|
|
||||||
uuid: 'sub-1',
|
|
||||||
usage: { input_tokens: 9999, output_tokens: 9999 },
|
|
||||||
}),
|
|
||||||
assistantLine({
|
|
||||||
uuid: 'main-2',
|
|
||||||
usage: { input_tokens: 50, output_tokens: 60 },
|
|
||||||
}),
|
|
||||||
].join('\n'),
|
|
||||||
)
|
|
||||||
|
|
||||||
const usage = computeUsageFromTranscript(lines)
|
|
||||||
expect(usage.input_tokens).toBe(50)
|
|
||||||
expect(usage.output_tokens).toBe(60)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('persistJobUsage', () => {
|
|
||||||
let tmpDir: string
|
|
||||||
let transcriptPath: string
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = mkdtempSync(join(tmpdir(), 'persist-job-usage-test-'))
|
|
||||||
transcriptPath = join(tmpDir, 'session.jsonl')
|
|
||||||
})
|
|
||||||
|
|
||||||
function cleanup() {
|
|
||||||
rmSync(tmpDir, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
it('skips when tool_name is not update_job_status', async () => {
|
|
||||||
const result = await persistJobUsage({
|
|
||||||
tool_name: 'mcp__scrum4me__create_task',
|
|
||||||
tool_input: { job_id: 'j1', status: 'done' },
|
|
||||||
transcript_path: transcriptPath,
|
|
||||||
})
|
|
||||||
expect(result).toBe('skipped')
|
|
||||||
expect(mockUpdate).not.toHaveBeenCalled()
|
|
||||||
cleanup()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('skips on status=running', async () => {
|
|
||||||
const result = await persistJobUsage({
|
|
||||||
tool_name: 'mcp__scrum4me__update_job_status',
|
|
||||||
tool_input: { job_id: 'j1', status: 'running' },
|
|
||||||
transcript_path: transcriptPath,
|
|
||||||
})
|
|
||||||
expect(result).toBe('skipped')
|
|
||||||
expect(mockUpdate).not.toHaveBeenCalled()
|
|
||||||
cleanup()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('skips when transcript missing', async () => {
|
|
||||||
const result = await persistJobUsage({
|
|
||||||
tool_name: 'mcp__scrum4me__update_job_status',
|
|
||||||
tool_input: { job_id: 'j1', status: 'done' },
|
|
||||||
transcript_path: '/no/such/file.jsonl',
|
|
||||||
})
|
|
||||||
expect(result).toBe('skipped')
|
|
||||||
expect(mockUpdate).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('writes computed usage on success', async () => {
|
|
||||||
writeFileSync(
|
|
||||||
transcriptPath,
|
|
||||||
[
|
|
||||||
assistantLine({ toolUseName: 'mcp__scrum4me__wait_for_job' }),
|
|
||||||
assistantLine({
|
|
||||||
model: 'claude-sonnet-4-6',
|
|
||||||
usage: {
|
|
||||||
input_tokens: 10,
|
|
||||||
output_tokens: 20,
|
|
||||||
cache_creation_input_tokens: 30,
|
|
||||||
cache_read_input_tokens: 40,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
assistantLine({ toolUseName: 'mcp__scrum4me__update_job_status' }),
|
|
||||||
].join('\n'),
|
|
||||||
)
|
|
||||||
|
|
||||||
mockUpdate.mockResolvedValue({})
|
|
||||||
const result = await persistJobUsage({
|
|
||||||
tool_name: 'mcp__scrum4me__update_job_status',
|
|
||||||
tool_input: { job_id: 'job-123', status: 'done' },
|
|
||||||
transcript_path: transcriptPath,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result).toBe('written')
|
|
||||||
expect(mockUpdate).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'job-123' },
|
|
||||||
data: {
|
|
||||||
model_id: 'claude-sonnet-4-6',
|
|
||||||
input_tokens: 10,
|
|
||||||
output_tokens: 20,
|
|
||||||
cache_read_tokens: 40,
|
|
||||||
cache_write_tokens: 30,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
cleanup()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns noop when transcript has no usage', async () => {
|
|
||||||
writeFileSync(transcriptPath, '')
|
|
||||||
const result = await persistJobUsage({
|
|
||||||
tool_name: 'mcp__scrum4me__update_job_status',
|
|
||||||
tool_input: { job_id: 'job-123', status: 'failed' },
|
|
||||||
transcript_path: transcriptPath,
|
|
||||||
})
|
|
||||||
expect(result).toBe('noop')
|
|
||||||
expect(mockUpdate).not.toHaveBeenCalled()
|
|
||||||
cleanup()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('../src/prisma.js', () => ({
|
|
||||||
prisma: {
|
|
||||||
pbi: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../src/auth.js', () => ({
|
|
||||||
requireWriteAccess: vi.fn(),
|
|
||||||
PermissionDeniedError: class PermissionDeniedError extends Error {
|
|
||||||
constructor(message = 'Demo accounts cannot perform write operations') {
|
|
||||||
super(message)
|
|
||||||
this.name = 'PermissionDeniedError'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
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 { handleSetPbiPr, inputSchema } from '../src/tools/set-pbi-pr.js'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
pbi: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
|
||||||
}
|
|
||||||
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
|
|
||||||
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
const VALID_PR_URL = 'https://github.com/owner/repo/pull/42'
|
|
||||||
const PBI_ID = 'pbi-abc123'
|
|
||||||
const USER_ID = 'user-1'
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false })
|
|
||||||
mockPrisma.pbi.findUnique.mockResolvedValue({ product_id: 'prod-1' })
|
|
||||||
mockUserCanAccessProduct.mockResolvedValue(true)
|
|
||||||
mockPrisma.pbi.update.mockResolvedValue({})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('handleSetPbiPr', () => {
|
|
||||||
it('happy path: updates pr_url and clears pr_merged_at', async () => {
|
|
||||||
const result = await handleSetPbiPr({ pbi_id: PBI_ID, pr_url: VALID_PR_URL })
|
|
||||||
|
|
||||||
expect(result.isError).toBeFalsy()
|
|
||||||
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: PBI_ID },
|
|
||||||
data: { pr_url: VALID_PR_URL, pr_merged_at: null },
|
|
||||||
})
|
|
||||||
const text = result.content[0].type === 'text' ? result.content[0].text : ''
|
|
||||||
const parsed = JSON.parse(text)
|
|
||||||
expect(parsed).toEqual({ ok: true, pbi_id: PBI_ID, pr_url: VALID_PR_URL })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('idempotent: second call with different url overwrites', async () => {
|
|
||||||
const newUrl = 'https://github.com/owner/repo/pull/99'
|
|
||||||
await handleSetPbiPr({ pbi_id: PBI_ID, pr_url: newUrl })
|
|
||||||
|
|
||||||
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: PBI_ID },
|
|
||||||
data: { pr_url: newUrl, pr_merged_at: null },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns error when PBI not found', async () => {
|
|
||||||
mockPrisma.pbi.findUnique.mockResolvedValue(null)
|
|
||||||
|
|
||||||
const result = await handleSetPbiPr({ pbi_id: PBI_ID, pr_url: VALID_PR_URL })
|
|
||||||
|
|
||||||
expect(result.isError).toBe(true)
|
|
||||||
const text = result.content[0].type === 'text' ? result.content[0].text : ''
|
|
||||||
expect(text).toMatch(PBI_ID)
|
|
||||||
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns error when user has no access to the product', async () => {
|
|
||||||
mockUserCanAccessProduct.mockResolvedValue(false)
|
|
||||||
|
|
||||||
const result = await handleSetPbiPr({ pbi_id: PBI_ID, pr_url: VALID_PR_URL })
|
|
||||||
|
|
||||||
expect(result.isError).toBe(true)
|
|
||||||
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns PERMISSION_DENIED for demo accounts', async () => {
|
|
||||||
mockRequireWriteAccess.mockRejectedValue(new PermissionDeniedError())
|
|
||||||
|
|
||||||
const result = await handleSetPbiPr({ pbi_id: PBI_ID, pr_url: VALID_PR_URL })
|
|
||||||
|
|
||||||
expect(result.isError).toBe(true)
|
|
||||||
const text = result.content[0].type === 'text' ? result.content[0].text : ''
|
|
||||||
expect(text).toMatch(/PERMISSION_DENIED/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('inputSchema validation', () => {
|
|
||||||
it('accepts a valid GitHub PR URL', () => {
|
|
||||||
const r = inputSchema.safeParse({ pbi_id: PBI_ID, pr_url: VALID_PR_URL })
|
|
||||||
expect(r.success).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects a URL pointing to an issue instead of a pull', () => {
|
|
||||||
const r = inputSchema.safeParse({ pbi_id: PBI_ID, pr_url: 'https://github.com/owner/repo/issues/42' })
|
|
||||||
expect(r.success).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects a non-GitHub URL', () => {
|
|
||||||
const r = inputSchema.safeParse({ pbi_id: PBI_ID, pr_url: 'https://gitlab.com/owner/repo/pull/42' })
|
|
||||||
expect(r.success).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects a URL without a numeric PR number', () => {
|
|
||||||
const r = inputSchema.safeParse({ pbi_id: PBI_ID, pr_url: 'https://github.com/owner/repo/pull/abc' })
|
|
||||||
expect(r.success).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects an empty pbi_id', () => {
|
|
||||||
const r = inputSchema.safeParse({ pbi_id: '', pr_url: VALID_PR_URL })
|
|
||||||
expect(r.success).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -8,24 +8,6 @@ vi.mock('../src/prisma.js', () => ({
|
||||||
},
|
},
|
||||||
story: {
|
story: {
|
||||||
findUniqueOrThrow: vi.fn(),
|
findUniqueOrThrow: vi.fn(),
|
||||||
findMany: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
pbi: {
|
|
||||||
findUniqueOrThrow: vi.fn(),
|
|
||||||
findMany: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
sprint: {
|
|
||||||
findUniqueOrThrow: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
claudeJob: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
updateMany: vi.fn(),
|
|
||||||
},
|
|
||||||
sprintRun: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
},
|
},
|
||||||
$transaction: vi.fn(),
|
$transaction: vi.fn(),
|
||||||
|
|
@ -33,47 +15,14 @@ vi.mock('../src/prisma.js', () => ({
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { prisma } from '../src/prisma.js'
|
import { prisma } from '../src/prisma.js'
|
||||||
import {
|
import { updateTaskStatusWithStoryPromotion } from '../src/lib/tasks-status-update.js'
|
||||||
propagateStatusUpwards,
|
|
||||||
updateTaskStatusWithStoryPromotion,
|
|
||||||
} from '../src/lib/tasks-status-update.js'
|
|
||||||
|
|
||||||
type MockedPrisma = {
|
const mockPrisma = prisma as unknown as {
|
||||||
task: { update: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn> }
|
task: { update: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn> }
|
||||||
story: {
|
story: { findUniqueOrThrow: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
||||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
pbi: {
|
|
||||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
sprint: {
|
|
||||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
claudeJob: {
|
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
|
||||||
updateMany: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
sprintRun: {
|
|
||||||
findUnique: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
$transaction: ReturnType<typeof vi.fn>
|
$transaction: ReturnType<typeof vi.fn>
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as MockedPrisma
|
|
||||||
|
|
||||||
const TASK_BASE = {
|
|
||||||
id: 'task-1',
|
|
||||||
title: 'Task',
|
|
||||||
story_id: 'story-1',
|
|
||||||
implementation_plan: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockPrisma.$transaction.mockImplementation(
|
mockPrisma.$transaction.mockImplementation(
|
||||||
|
|
@ -81,181 +30,107 @@ beforeEach(() => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('propagateStatusUpwards — story-niveau', () => {
|
const TASK_BASE = {
|
||||||
it('zet story op DONE wanneer alle siblings DONE zijn', async () => {
|
id: 'task-1',
|
||||||
|
title: 'Task',
|
||||||
|
story_id: 'story-1',
|
||||||
|
implementation_plan: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('updateTaskStatusWithStoryPromotion', () => {
|
||||||
|
it('promotes story to DONE when last sibling task transitions to DONE', async () => {
|
||||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
|
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
|
||||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }])
|
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }])
|
||||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
|
||||||
id: 'story-1',
|
|
||||||
status: 'IN_SPRINT',
|
|
||||||
pbi_id: 'pbi-1',
|
|
||||||
sprint_id: null,
|
|
||||||
})
|
|
||||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
|
|
||||||
|
|
||||||
const result = await propagateStatusUpwards('task-1', 'DONE')
|
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE')
|
||||||
|
|
||||||
expect(result.storyChanged).toBe(true)
|
expect(result.storyStatusChange).toBe('promoted')
|
||||||
|
expect(result.storyId).toBe('story-1')
|
||||||
expect(mockPrisma.story.update).toHaveBeenCalledWith({
|
expect(mockPrisma.story.update).toHaveBeenCalledWith({
|
||||||
where: { id: 'story-1' },
|
where: { id: 'story-1' },
|
||||||
data: { status: 'DONE' },
|
data: { status: 'DONE' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('zet story op FAILED wanneer een task FAILED is, ongeacht andere tasks', async () => {
|
it('does not promote when story is already DONE (idempotent)', async () => {
|
||||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'FAILED' })
|
|
||||||
mockPrisma.task.findMany.mockResolvedValue([
|
|
||||||
{ status: 'FAILED' },
|
|
||||||
{ status: 'DONE' },
|
|
||||||
{ status: 'TO_DO' },
|
|
||||||
])
|
|
||||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
|
||||||
id: 'story-1',
|
|
||||||
status: 'IN_SPRINT',
|
|
||||||
pbi_id: 'pbi-1',
|
|
||||||
sprint_id: null,
|
|
||||||
})
|
|
||||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'FAILED' }])
|
|
||||||
|
|
||||||
const result = await propagateStatusUpwards('task-1', 'FAILED')
|
|
||||||
|
|
||||||
expect(result.storyChanged).toBe(true)
|
|
||||||
expect(mockPrisma.story.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'story-1' },
|
|
||||||
data: { status: 'FAILED' },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('propagateStatusUpwards — PBI BLOCKED met rust laten', () => {
|
|
||||||
it('overschrijft een handmatig BLOCKED PBI niet', async () => {
|
|
||||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
|
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
|
||||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
|
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' })
|
||||||
id: 'story-1',
|
|
||||||
status: 'IN_SPRINT',
|
|
||||||
pbi_id: 'pbi-1',
|
|
||||||
sprint_id: null,
|
|
||||||
})
|
|
||||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'BLOCKED' })
|
|
||||||
|
|
||||||
const result = await propagateStatusUpwards('task-1', 'DONE')
|
|
||||||
|
|
||||||
expect(result.pbiChanged).toBe(false)
|
|
||||||
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('propagateStatusUpwards — sprint cascade tot SprintRun', () => {
|
|
||||||
it('zet bij FAILED de hele keten op FAILED en cancelt sibling-jobs', async () => {
|
|
||||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'FAILED' })
|
|
||||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'FAILED' }, { status: 'DONE' }])
|
|
||||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
|
||||||
id: 'story-1',
|
|
||||||
status: 'IN_SPRINT',
|
|
||||||
pbi_id: 'pbi-1',
|
|
||||||
sprint_id: 'sprint-1',
|
|
||||||
})
|
|
||||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
|
||||||
mockPrisma.story.findMany.mockImplementation(
|
|
||||||
async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => {
|
|
||||||
if (args.where?.pbi_id) return [{ status: 'FAILED' }]
|
|
||||||
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
|
|
||||||
return []
|
|
||||||
},
|
|
||||||
)
|
|
||||||
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' })
|
|
||||||
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue([{ status: 'FAILED' }])
|
|
||||||
mockPrisma.claudeJob.findFirst.mockResolvedValue({ id: 'job-1', sprint_run_id: 'run-1' })
|
|
||||||
mockPrisma.sprintRun.findUnique.mockResolvedValue({ id: 'run-1', status: 'RUNNING' })
|
|
||||||
|
|
||||||
const result = await propagateStatusUpwards('task-1', 'FAILED')
|
|
||||||
|
|
||||||
expect(result.sprintChanged).toBe(true)
|
|
||||||
expect(result.sprintRunChanged).toBe(true)
|
|
||||||
expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: { id: 'run-1' },
|
|
||||||
data: expect.objectContaining({ status: 'FAILED', failed_task_id: 'task-1' }),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
expect(mockPrisma.claudeJob.updateMany).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: expect.objectContaining({
|
|
||||||
sprint_run_id: 'run-1',
|
|
||||||
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
|
|
||||||
id: { not: 'job-1' },
|
|
||||||
}),
|
|
||||||
data: expect.objectContaining({ status: 'CANCELLED' }),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('updateTaskStatusWithStoryPromotion (BC-wrapper)', () => {
|
|
||||||
it('mapt storyChanged + DONE-newStatus naar storyStatusChange="promoted"', async () => {
|
|
||||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
|
|
||||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
|
|
||||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
|
||||||
id: 'story-1',
|
|
||||||
status: 'IN_SPRINT',
|
|
||||||
pbi_id: 'pbi-1',
|
|
||||||
sprint_id: null,
|
|
||||||
})
|
|
||||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
|
|
||||||
|
|
||||||
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE')
|
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE')
|
||||||
|
|
||||||
expect(result.storyStatusChange).toBe('promoted')
|
expect(result.storyStatusChange).toBe(null)
|
||||||
expect(result.storyId).toBe('story-1')
|
expect(mockPrisma.story.update).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('mapt storyChanged + non-DONE naar storyStatusChange="demoted"', async () => {
|
it('does not promote when not all siblings are DONE', async () => {
|
||||||
|
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
|
||||||
|
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'IN_PROGRESS' }])
|
||||||
|
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
|
||||||
|
|
||||||
|
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE')
|
||||||
|
|
||||||
|
expect(result.storyStatusChange).toBe(null)
|
||||||
|
expect(mockPrisma.story.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('demotes story to IN_SPRINT when a task moves out of DONE on a DONE story', async () => {
|
||||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
|
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
|
||||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }, { status: 'DONE' }])
|
mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }, { status: 'DONE' }])
|
||||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' })
|
||||||
id: 'story-1',
|
|
||||||
status: 'DONE',
|
|
||||||
pbi_id: 'pbi-1',
|
|
||||||
sprint_id: null,
|
|
||||||
})
|
|
||||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' })
|
|
||||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'OPEN' }])
|
|
||||||
|
|
||||||
const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS')
|
const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS')
|
||||||
|
|
||||||
expect(result.storyStatusChange).toBe('demoted')
|
expect(result.storyStatusChange).toBe('demoted')
|
||||||
|
expect(mockPrisma.story.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'story-1' },
|
||||||
|
data: { status: 'IN_SPRINT' },
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('null wanneer story niet verandert', async () => {
|
it('does not demote when story is not DONE', async () => {
|
||||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
|
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
|
||||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }, { status: 'TO_DO' }])
|
mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }])
|
||||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
|
||||||
id: 'story-1',
|
|
||||||
status: 'IN_SPRINT',
|
|
||||||
pbi_id: 'pbi-1',
|
|
||||||
sprint_id: 'sprint-1',
|
|
||||||
})
|
|
||||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
|
||||||
mockPrisma.story.findMany.mockImplementation(
|
|
||||||
async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => {
|
|
||||||
if (args.where?.pbi_id) return [{ status: 'IN_SPRINT' }]
|
|
||||||
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
|
|
||||||
return []
|
|
||||||
},
|
|
||||||
)
|
|
||||||
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' })
|
|
||||||
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue([{ status: 'READY' }])
|
|
||||||
|
|
||||||
const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS')
|
const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS')
|
||||||
|
|
||||||
expect(result.storyStatusChange).toBe(null)
|
expect(result.storyStatusChange).toBe(null)
|
||||||
|
expect(mockPrisma.story.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates the task regardless of story-status change', async () => {
|
||||||
|
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
|
||||||
|
mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }])
|
||||||
|
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
|
||||||
|
|
||||||
|
await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS')
|
||||||
|
|
||||||
|
expect(mockPrisma.task.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'task-1' },
|
||||||
|
data: { status: 'IN_PROGRESS' },
|
||||||
|
select: expect.any(Object),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the provided transaction client when passed', async () => {
|
||||||
|
const tx = {
|
||||||
|
task: { update: vi.fn(), findMany: vi.fn() },
|
||||||
|
story: { findUniqueOrThrow: vi.fn(), update: vi.fn() },
|
||||||
|
}
|
||||||
|
tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
|
||||||
|
tx.task.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||||
|
tx.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE', tx as any)
|
||||||
|
|
||||||
|
expect(result.storyStatusChange).toBe('promoted')
|
||||||
|
expect(mockPrisma.$transaction).not.toHaveBeenCalled()
|
||||||
|
expect(tx.story.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'story-1' },
|
||||||
|
data: { status: 'DONE' },
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('../src/prisma.js', () => ({
|
|
||||||
prisma: {
|
|
||||||
idea: { update: vi.fn() },
|
|
||||||
ideaLog: { create: vi.fn() },
|
|
||||||
$transaction: vi.fn(),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../src/auth.js', () => ({
|
|
||||||
requireWriteAccess: vi.fn(),
|
|
||||||
PermissionDeniedError: class PermissionDeniedError extends Error {
|
|
||||||
constructor(message = 'Demo accounts cannot perform write operations') {
|
|
||||||
super(message)
|
|
||||||
this.name = 'PermissionDeniedError'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../src/access.js', () => ({
|
|
||||||
userOwnsIdea: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '../src/prisma.js'
|
|
||||||
import { requireWriteAccess } from '../src/auth.js'
|
|
||||||
import { userOwnsIdea } from '../src/access.js'
|
|
||||||
import { handleUpdateIdeaPlanReviewed } from '../src/tools/update-idea-plan-reviewed.js'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
idea: { update: ReturnType<typeof vi.fn> }
|
|
||||||
ideaLog: { create: ReturnType<typeof vi.fn> }
|
|
||||||
$transaction: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
|
|
||||||
const mockUserOwnsIdea = userOwnsIdea as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
const IDEA_ID = 'idea-1'
|
|
||||||
const USER_ID = 'user-1'
|
|
||||||
const REVIEW_LOG = {
|
|
||||||
rounds: [{ score: 88 }],
|
|
||||||
convergence: { stable_at_round: 2 },
|
|
||||||
approval: { status: 'approved' },
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockRequireWriteAccess.mockResolvedValue({
|
|
||||||
userId: USER_ID,
|
|
||||||
tokenId: 'tok-1',
|
|
||||||
username: 'alice',
|
|
||||||
isDemo: false,
|
|
||||||
})
|
|
||||||
mockUserOwnsIdea.mockResolvedValue(true)
|
|
||||||
// $transaction returns the array of its two operations' results; the handler
|
|
||||||
// only reads result[0] (the idea.update result).
|
|
||||||
mockPrisma.$transaction.mockImplementation(async () => [
|
|
||||||
{ id: IDEA_ID, status: 'PLACEHOLDER', code: 'IDEA-1' },
|
|
||||||
{},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
function parseResult(result: Awaited<ReturnType<typeof handleUpdateIdeaPlanReviewed>>) {
|
|
||||||
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''
|
|
||||||
try {
|
|
||||||
return JSON.parse(text)
|
|
||||||
} catch {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The handler builds `data.status` inside the idea.update call passed to
|
|
||||||
// $transaction. We capture it by inspecting the prisma.idea.update mock args.
|
|
||||||
function statusPassedToUpdate(): string | undefined {
|
|
||||||
const call = mockPrisma.idea.update.mock.calls[0]
|
|
||||||
return call?.[0]?.data?.status
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('handleUpdateIdeaPlanReviewed — status transition', () => {
|
|
||||||
it('approval_status="approved" → PLAN_REVIEWED', async () => {
|
|
||||||
await handleUpdateIdeaPlanReviewed({
|
|
||||||
idea_id: IDEA_ID,
|
|
||||||
review_log: REVIEW_LOG,
|
|
||||||
approval_status: 'approved',
|
|
||||||
})
|
|
||||||
expect(statusPassedToUpdate()).toBe('PLAN_REVIEWED')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('approval_status="rejected" → PLAN_REVIEW_FAILED', async () => {
|
|
||||||
await handleUpdateIdeaPlanReviewed({
|
|
||||||
idea_id: IDEA_ID,
|
|
||||||
review_log: REVIEW_LOG,
|
|
||||||
approval_status: 'rejected',
|
|
||||||
})
|
|
||||||
expect(statusPassedToUpdate()).toBe('PLAN_REVIEW_FAILED')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('approval_status="pending" → PLAN_REVIEW_FAILED (needs manual approval, never silently approved)', async () => {
|
|
||||||
await handleUpdateIdeaPlanReviewed({
|
|
||||||
idea_id: IDEA_ID,
|
|
||||||
review_log: REVIEW_LOG,
|
|
||||||
approval_status: 'pending',
|
|
||||||
})
|
|
||||||
expect(statusPassedToUpdate()).toBe('PLAN_REVIEW_FAILED')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('omitted approval_status → PLAN_REVIEW_FAILED (safe default, not PLAN_REVIEWED)', async () => {
|
|
||||||
await handleUpdateIdeaPlanReviewed({
|
|
||||||
idea_id: IDEA_ID,
|
|
||||||
review_log: REVIEW_LOG,
|
|
||||||
})
|
|
||||||
expect(statusPassedToUpdate()).toBe('PLAN_REVIEW_FAILED')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns "Idea not found" when the user does not own the idea', async () => {
|
|
||||||
mockUserOwnsIdea.mockResolvedValue(false)
|
|
||||||
const result = await handleUpdateIdeaPlanReviewed({
|
|
||||||
idea_id: IDEA_ID,
|
|
||||||
review_log: REVIEW_LOG,
|
|
||||||
approval_status: 'approved',
|
|
||||||
})
|
|
||||||
expect(parseResult(result)).toContain('Idea not found')
|
|
||||||
expect(mockPrisma.idea.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('persists review_log + reviewed_at and logs a PLAN_REVIEW_RESULT entry', async () => {
|
|
||||||
await handleUpdateIdeaPlanReviewed({
|
|
||||||
idea_id: IDEA_ID,
|
|
||||||
review_log: REVIEW_LOG,
|
|
||||||
approval_status: 'approved',
|
|
||||||
})
|
|
||||||
const updateArg = mockPrisma.idea.update.mock.calls[0]?.[0]
|
|
||||||
expect(updateArg?.data?.plan_review_log).toEqual(REVIEW_LOG)
|
|
||||||
expect(updateArg?.data?.reviewed_at).toBeInstanceOf(Date)
|
|
||||||
|
|
||||||
const logArg = mockPrisma.ideaLog.create.mock.calls[0]?.[0]
|
|
||||||
expect(logArg?.data?.type).toBe('PLAN_REVIEW_RESULT')
|
|
||||||
expect(logArg?.data?.idea_id).toBe(IDEA_ID)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -4,13 +4,12 @@ vi.mock('../src/prisma.js', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
product: { findUnique: vi.fn() },
|
product: { findUnique: vi.fn() },
|
||||||
task: { findUnique: vi.fn() },
|
task: { findUnique: vi.fn() },
|
||||||
claudeJob: { findFirst: vi.fn(), findMany: vi.fn(), findUnique: vi.fn() },
|
claudeJob: { findFirst: vi.fn() },
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../src/git/pr.js', () => ({
|
vi.mock('../src/git/pr.js', () => ({
|
||||||
createPullRequest: vi.fn(),
|
createPullRequest: vi.fn(),
|
||||||
markPullRequestReady: vi.fn(),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { prisma } from '../src/prisma.js'
|
import { prisma } from '../src/prisma.js'
|
||||||
|
|
@ -20,11 +19,7 @@ import { maybeCreateAutoPr } from '../src/tools/update-job-status.js'
|
||||||
const mockPrisma = prisma as unknown as {
|
const mockPrisma = prisma as unknown as {
|
||||||
product: { findUnique: ReturnType<typeof vi.fn> }
|
product: { findUnique: ReturnType<typeof vi.fn> }
|
||||||
task: { findUnique: ReturnType<typeof vi.fn> }
|
task: { findUnique: ReturnType<typeof vi.fn> }
|
||||||
claudeJob: {
|
claudeJob: { findFirst: ReturnType<typeof vi.fn> }
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
|
||||||
findMany: ReturnType<typeof vi.fn>
|
|
||||||
findUnique: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const mockCreatePr = createPullRequest as ReturnType<typeof vi.fn>
|
const mockCreatePr = createPullRequest as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
|
@ -42,12 +37,9 @@ beforeEach(() => {
|
||||||
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: true })
|
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: true })
|
||||||
mockPrisma.task.findUnique.mockResolvedValue({
|
mockPrisma.task.findUnique.mockResolvedValue({
|
||||||
title: 'Add feature',
|
title: 'Add feature',
|
||||||
repo_url: null,
|
|
||||||
story: { id: 'story-1', code: 'SCRUM-42', title: 'Story title' },
|
story: { id: 'story-1', code: 'SCRUM-42', title: 'Story title' },
|
||||||
})
|
})
|
||||||
mockPrisma.claudeJob.findMany.mockResolvedValue([]) // no sibling PRs by default
|
mockPrisma.claudeJob.findFirst.mockResolvedValue(null) // no sibling PR by default
|
||||||
// Default: legacy job zonder sprint_run (STORY-mode pad).
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({ sprint_run_id: null, sprint_run: null })
|
|
||||||
mockCreatePr.mockResolvedValue({ url: 'https://github.com/org/repo/pull/99' })
|
mockCreatePr.mockResolvedValue({ url: 'https://github.com/org/repo/pull/99' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -64,27 +56,12 @@ describe('maybeCreateAutoPr', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reuses sibling pr_url when another job in same story already opened a PR', async () => {
|
it('reuses sibling pr_url when another job in same story already opened a PR', async () => {
|
||||||
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
mockPrisma.claudeJob.findFirst.mockResolvedValue({ pr_url: 'https://github.com/org/repo/pull/77' })
|
||||||
{ pr_url: 'https://github.com/org/repo/pull/77', task: { repo_url: null } },
|
|
||||||
])
|
|
||||||
const url = await maybeCreateAutoPr(BASE_OPTS)
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
||||||
expect(url).toBe('https://github.com/org/repo/pull/77')
|
expect(url).toBe('https://github.com/org/repo/pull/77')
|
||||||
expect(mockCreatePr).not.toHaveBeenCalled()
|
expect(mockCreatePr).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does NOT reuse a sibling PR from a different repo (cross-repo story)', async () => {
|
|
||||||
// Sibling targeted another repo via task.repo_url — its PR must not leak in.
|
|
||||||
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
|
||||||
{
|
|
||||||
pr_url: 'https://github.com/org/other-repo/pull/12',
|
|
||||||
task: { repo_url: 'https://github.com/org/other-repo' },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
const url = await maybeCreateAutoPr(BASE_OPTS)
|
|
||||||
expect(url).toBe('https://github.com/org/repo/pull/99') // fresh PR, not the sibling's
|
|
||||||
expect(mockCreatePr).toHaveBeenCalledOnce()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null when auto_pr=false', async () => {
|
it('returns null when auto_pr=false', async () => {
|
||||||
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: false })
|
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: false })
|
||||||
const url = await maybeCreateAutoPr(BASE_OPTS)
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
||||||
|
|
@ -95,7 +72,6 @@ describe('maybeCreateAutoPr', () => {
|
||||||
it('uses story title without code prefix when story has no code', async () => {
|
it('uses story title without code prefix when story has no code', async () => {
|
||||||
mockPrisma.task.findUnique.mockResolvedValue({
|
mockPrisma.task.findUnique.mockResolvedValue({
|
||||||
title: 'Add feature',
|
title: 'Add feature',
|
||||||
repo_url: null,
|
|
||||||
story: { id: 'story-1', code: null, title: 'Story title' },
|
story: { id: 'story-1', code: null, title: 'Story title' },
|
||||||
})
|
})
|
||||||
await maybeCreateAutoPr(BASE_OPTS)
|
await maybeCreateAutoPr(BASE_OPTS)
|
||||||
|
|
@ -104,66 +80,6 @@ describe('maybeCreateAutoPr', () => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('SPRINT-mode: maakt een draft-PR aan met sprint-titel, geen auto-merge', async () => {
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({
|
|
||||||
sprint_run_id: 'run-1',
|
|
||||||
sprint_run: {
|
|
||||||
id: 'run-1',
|
|
||||||
pr_strategy: 'SPRINT',
|
|
||||||
sprint: { sprint_goal: 'Cascade-flow live' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const url = await maybeCreateAutoPr(BASE_OPTS)
|
|
||||||
|
|
||||||
expect(url).toBe('https://github.com/org/repo/pull/99')
|
|
||||||
expect(mockCreatePr).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
title: 'Sprint: Cascade-flow live',
|
|
||||||
draft: true,
|
|
||||||
enableAutoMerge: false,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('SPRINT-mode: hergebruikt sibling-PR binnen dezelfde SprintRun', async () => {
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({
|
|
||||||
sprint_run_id: 'run-1',
|
|
||||||
sprint_run: { id: 'run-1', pr_strategy: 'SPRINT', sprint: { sprint_goal: 'Goal' } },
|
|
||||||
})
|
|
||||||
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
|
||||||
{ pr_url: 'https://github.com/org/repo/pull/55', task: { repo_url: null } },
|
|
||||||
])
|
|
||||||
|
|
||||||
const url = await maybeCreateAutoPr(BASE_OPTS)
|
|
||||||
|
|
||||||
expect(url).toBe('https://github.com/org/repo/pull/55')
|
|
||||||
expect(mockCreatePr).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('SPRINT-mode: cross-repo — sibling-PR van ander repo wordt niet hergebruikt', async () => {
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({
|
|
||||||
sprint_run_id: 'run-1',
|
|
||||||
sprint_run: { id: 'run-1', pr_strategy: 'SPRINT', sprint: { sprint_goal: 'Goal' } },
|
|
||||||
})
|
|
||||||
// Deze job target een ander repo via task.repo_url.
|
|
||||||
mockPrisma.task.findUnique.mockResolvedValue({
|
|
||||||
title: 'MCP-taak',
|
|
||||||
repo_url: 'https://github.com/org/scrum4me-mcp',
|
|
||||||
story: { id: 'story-1', code: 'SCRUM-9', title: 'Story title' },
|
|
||||||
})
|
|
||||||
// Sibling met pr_url hoort bij het product-repo (repo_url null) → andere bucket.
|
|
||||||
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
|
||||||
{ pr_url: 'https://github.com/org/repo/pull/201', task: { repo_url: null } },
|
|
||||||
])
|
|
||||||
|
|
||||||
const url = await maybeCreateAutoPr(BASE_OPTS)
|
|
||||||
|
|
||||||
// Geen hergebruik van de product-repo PR → eigen draft-PR voor het mcp-repo.
|
|
||||||
expect(url).toBe('https://github.com/org/repo/pull/99')
|
|
||||||
expect(mockCreatePr).toHaveBeenCalledOnce()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null and does not throw when gh fails', async () => {
|
it('returns null and does not throw when gh fails', async () => {
|
||||||
mockCreatePr.mockResolvedValue({ error: 'gh CLI not found' })
|
mockCreatePr.mockResolvedValue({ error: 'gh CLI not found' })
|
||||||
const url = await maybeCreateAutoPr(BASE_OPTS)
|
const url = await maybeCreateAutoPr(BASE_OPTS)
|
||||||
|
|
|
||||||
|
|
@ -5,26 +5,13 @@ vi.mock('../src/git/push.js', () => ({
|
||||||
pushBranchForJob: vi.fn(),
|
pushBranchForJob: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../src/prisma.js', () => ({
|
|
||||||
prisma: {
|
|
||||||
claudeJob: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { pushBranchForJob } from '../src/git/push.js'
|
import { pushBranchForJob } from '../src/git/push.js'
|
||||||
import { prisma } from '../src/prisma.js'
|
|
||||||
import { prepareDoneUpdate } from '../src/tools/update-job-status.js'
|
import { prepareDoneUpdate } from '../src/tools/update-job-status.js'
|
||||||
|
|
||||||
const mockPush = pushBranchForJob as ReturnType<typeof vi.fn>
|
const mockPush = pushBranchForJob as ReturnType<typeof vi.fn>
|
||||||
const mockFindUnique = (prisma as unknown as {
|
|
||||||
claudeJob: { findUnique: ReturnType<typeof vi.fn> }
|
|
||||||
}).claudeJob.findUnique
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockFindUnique.mockResolvedValue(null)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('prepareDoneUpdate', () => {
|
describe('prepareDoneUpdate', () => {
|
||||||
|
|
@ -52,25 +39,8 @@ describe('prepareDoneUpdate', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reads branchName from DB (claudeJob.branch) when branch arg is undefined', async () => {
|
it('derives branchName from jobId when branch is undefined', async () => {
|
||||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt'
|
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt'
|
||||||
mockFindUnique.mockResolvedValue({ branch: 'feat/sprint-fvy30lvv' })
|
|
||||||
mockPush.mockResolvedValue({ pushed: true, remoteRef: 'refs/heads/feat/sprint-fvy30lvv' })
|
|
||||||
|
|
||||||
await prepareDoneUpdate('job-abc12345', undefined)
|
|
||||||
|
|
||||||
expect(mockFindUnique).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'job-abc12345' },
|
|
||||||
select: { branch: true },
|
|
||||||
})
|
|
||||||
expect(mockPush).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ branchName: 'feat/sprint-fvy30lvv' }),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to feat/job-<8> when neither branch arg nor DB.branch is set', async () => {
|
|
||||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt'
|
|
||||||
mockFindUnique.mockResolvedValue({ branch: null })
|
|
||||||
mockPush.mockResolvedValue({ pushed: true, remoteRef: 'refs/heads/feat/job-abc12345' })
|
mockPush.mockResolvedValue({ pushed: true, remoteRef: 'refs/heads/feat/job-abc12345' })
|
||||||
|
|
||||||
await prepareDoneUpdate('job-abc12345', undefined)
|
await prepareDoneUpdate('job-abc12345', undefined)
|
||||||
|
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
// Unit-tests voor de no-op SKIPPED exit-route in update_job_status (PBI-57 ST-1273).
|
|
||||||
// Volle handler-integratie wordt niet hier getest — die hangt aan tientallen
|
|
||||||
// MCP/Prisma-mocks. Wel testen we de geëxporteerde helpers die expliciet
|
|
||||||
// SKIPPED-aware zijn gemaakt: resolveNextAction en cleanupWorktreeForTerminalStatus.
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('../src/prisma.js', () => ({
|
|
||||||
prisma: {
|
|
||||||
claudeJob: { findUnique: vi.fn(), count: vi.fn() },
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../src/git/worktree.js', () => ({
|
|
||||||
removeWorktreeForJob: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../src/tools/wait-for-job.js', async (importOriginal) => {
|
|
||||||
const original = await importOriginal<typeof import('../src/tools/wait-for-job.js')>()
|
|
||||||
return {
|
|
||||||
...original,
|
|
||||||
resolveRepoRoot: vi.fn(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
import { prisma } from '../src/prisma.js'
|
|
||||||
import { removeWorktreeForJob } from '../src/git/worktree.js'
|
|
||||||
import { resolveRepoRoot } from '../src/tools/wait-for-job.js'
|
|
||||||
import {
|
|
||||||
cleanupWorktreeForTerminalStatus,
|
|
||||||
resolveNextAction,
|
|
||||||
} from '../src/tools/update-job-status.js'
|
|
||||||
|
|
||||||
const mockRemove = removeWorktreeForJob as ReturnType<typeof vi.fn>
|
|
||||||
const mockResolve = resolveRepoRoot as ReturnType<typeof vi.fn>
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
claudeJob: {
|
|
||||||
findUnique: ReturnType<typeof vi.fn>
|
|
||||||
count: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({ task: { story_id: 'story-default' } })
|
|
||||||
mockPrisma.claudeJob.count.mockResolvedValue(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('resolveNextAction — skipped pad', () => {
|
|
||||||
it('returns wait_for_job_again when queue has jobs after skipped', () => {
|
|
||||||
expect(resolveNextAction(2, 'skipped')).toBe('wait_for_job_again')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns queue_empty when queue is empty after skipped', () => {
|
|
||||||
expect(resolveNextAction(0, 'skipped')).toBe('queue_empty')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('cleanupWorktreeForTerminalStatus — skipped pad', () => {
|
|
||||||
it('calls removeWorktreeForJob with keepBranch=false when skipped (no push happened)', async () => {
|
|
||||||
mockResolve.mockResolvedValue('/repos/my-project')
|
|
||||||
mockRemove.mockResolvedValue({ removed: true })
|
|
||||||
|
|
||||||
await cleanupWorktreeForTerminalStatus('prod-001', 'job-skip', 'skipped', undefined)
|
|
||||||
|
|
||||||
expect(mockRemove).toHaveBeenCalledWith({
|
|
||||||
repoRoot: '/repos/my-project',
|
|
||||||
jobId: 'job-skip',
|
|
||||||
keepBranch: false,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps keepBranch=false when skipped even if a branch is reported', async () => {
|
|
||||||
mockResolve.mockResolvedValue('/repos/my-project')
|
|
||||||
mockRemove.mockResolvedValue({ removed: true })
|
|
||||||
|
|
||||||
await cleanupWorktreeForTerminalStatus('prod-001', 'job-skip', 'skipped', 'feat/job-skip')
|
|
||||||
|
|
||||||
expect(mockRemove).toHaveBeenCalledWith({
|
|
||||||
repoRoot: '/repos/my-project',
|
|
||||||
jobId: 'job-skip',
|
|
||||||
keepBranch: false,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('defers cleanup when sibling jobs in same story are still active (skipped path)', async () => {
|
|
||||||
mockResolve.mockResolvedValue('/repos/my-project')
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({ task: { story_id: 'story-shared' } })
|
|
||||||
mockPrisma.claudeJob.count.mockResolvedValue(1)
|
|
||||||
|
|
||||||
await cleanupWorktreeForTerminalStatus('prod-001', 'job-skip', 'skipped', undefined)
|
|
||||||
|
|
||||||
expect(mockRemove).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,192 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('../src/prisma.js', () => ({
|
|
||||||
prisma: {
|
|
||||||
sprintTaskExecution: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
},
|
|
||||||
sprintRun: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
story: {
|
|
||||||
count: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '../src/prisma.js'
|
|
||||||
import {
|
|
||||||
checkSprintVerifyGate,
|
|
||||||
finalizeSprintRunOnDone,
|
|
||||||
} from '../src/tools/update-job-status.js'
|
|
||||||
|
|
||||||
type MockedPrisma = {
|
|
||||||
sprintTaskExecution: { findMany: ReturnType<typeof vi.fn> }
|
|
||||||
sprintRun: {
|
|
||||||
findUnique: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
story: { count: ReturnType<typeof vi.fn> }
|
|
||||||
}
|
|
||||||
|
|
||||||
const mocked = prisma as unknown as MockedPrisma
|
|
||||||
|
|
||||||
const LONG_SUMMARY = 'Refactor touched extra files for type narrowing.'
|
|
||||||
|
|
||||||
function execRow(overrides: Record<string, unknown>) {
|
|
||||||
return {
|
|
||||||
id: 'exec-' + Math.random().toString(36).slice(2, 8),
|
|
||||||
task_id: 't1',
|
|
||||||
order: 0,
|
|
||||||
status: 'DONE',
|
|
||||||
verify_result: 'ALIGNED',
|
|
||||||
verify_summary: null,
|
|
||||||
verify_required_snapshot: 'ALIGNED_OR_PARTIAL',
|
|
||||||
verify_only_snapshot: false,
|
|
||||||
task: { code: 'TASK-1', title: 'Sample task' },
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('checkSprintVerifyGate', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects when no executions exist (claim-bug)', async () => {
|
|
||||||
mocked.sprintTaskExecution.findMany.mockResolvedValue([])
|
|
||||||
const r = await checkSprintVerifyGate('job-x')
|
|
||||||
expect(r.allowed).toBe(false)
|
|
||||||
if (!r.allowed) expect(r.error).toMatch(/geen SprintTaskExecution-rows/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks PENDING/RUNNING executions', async () => {
|
|
||||||
mocked.sprintTaskExecution.findMany.mockResolvedValue([
|
|
||||||
execRow({ status: 'PENDING' }),
|
|
||||||
execRow({ status: 'RUNNING' }),
|
|
||||||
])
|
|
||||||
const r = await checkSprintVerifyGate('job-x')
|
|
||||||
expect(r.allowed).toBe(false)
|
|
||||||
if (!r.allowed) {
|
|
||||||
expect(r.error).toMatch(/PENDING/)
|
|
||||||
expect(r.error).toMatch(/RUNNING/)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks FAILED executions', async () => {
|
|
||||||
mocked.sprintTaskExecution.findMany.mockResolvedValue([
|
|
||||||
execRow({ status: 'FAILED' }),
|
|
||||||
])
|
|
||||||
const r = await checkSprintVerifyGate('job-x')
|
|
||||||
expect(r.allowed).toBe(false)
|
|
||||||
if (!r.allowed) expect(r.error).toMatch(/FAILED/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks SKIPPED unless verify_required_snapshot=ANY', async () => {
|
|
||||||
mocked.sprintTaskExecution.findMany.mockResolvedValue([
|
|
||||||
execRow({ status: 'SKIPPED', verify_required_snapshot: 'ALIGNED' }),
|
|
||||||
])
|
|
||||||
const r = await checkSprintVerifyGate('job-x')
|
|
||||||
expect(r.allowed).toBe(false)
|
|
||||||
if (!r.allowed) expect(r.error).toMatch(/SKIPPED/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('allows SKIPPED when verify_required_snapshot=ANY', async () => {
|
|
||||||
mocked.sprintTaskExecution.findMany.mockResolvedValue([
|
|
||||||
execRow({ status: 'SKIPPED', verify_required_snapshot: 'ANY' }),
|
|
||||||
])
|
|
||||||
expect((await checkSprintVerifyGate('job-x')).allowed).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('runs per-row gate for DONE executions', async () => {
|
|
||||||
// PARTIAL zonder summary onder ALIGNED_OR_PARTIAL → blocker
|
|
||||||
mocked.sprintTaskExecution.findMany.mockResolvedValue([
|
|
||||||
execRow({
|
|
||||||
status: 'DONE',
|
|
||||||
verify_result: 'PARTIAL',
|
|
||||||
verify_summary: null,
|
|
||||||
verify_required_snapshot: 'ALIGNED_OR_PARTIAL',
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
const r = await checkSprintVerifyGate('job-x')
|
|
||||||
expect(r.allowed).toBe(false)
|
|
||||||
if (!r.allowed) expect(r.error).toMatch(/DONE-gate/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('passes when all DONE rows pass per-row gate', async () => {
|
|
||||||
mocked.sprintTaskExecution.findMany.mockResolvedValue([
|
|
||||||
execRow({ verify_result: 'ALIGNED' }),
|
|
||||||
execRow({
|
|
||||||
verify_result: 'PARTIAL',
|
|
||||||
verify_summary: LONG_SUMMARY,
|
|
||||||
verify_required_snapshot: 'ALIGNED_OR_PARTIAL',
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
expect((await checkSprintVerifyGate('job-x')).allowed).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('aggregates multiple blockers in one error message', async () => {
|
|
||||||
mocked.sprintTaskExecution.findMany.mockResolvedValue([
|
|
||||||
execRow({ status: 'FAILED', task: { code: 'A', title: 'a' } }),
|
|
||||||
execRow({ status: 'PENDING', task: { code: 'B', title: 'b' } }),
|
|
||||||
])
|
|
||||||
const r = await checkSprintVerifyGate('job-x')
|
|
||||||
expect(r.allowed).toBe(false)
|
|
||||||
if (!r.allowed) {
|
|
||||||
expect(r.error).toMatch(/2 task\(s\) blokkeren/)
|
|
||||||
expect(r.error).toMatch(/A: a/)
|
|
||||||
expect(r.error).toMatch(/B: b/)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('finalizeSprintRunOnDone', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('no-op when SprintRun already DONE (idempotent)', async () => {
|
|
||||||
mocked.sprintRun.findUnique.mockResolvedValue({
|
|
||||||
id: 'sr-1',
|
|
||||||
status: 'DONE',
|
|
||||||
sprint_id: 's1',
|
|
||||||
})
|
|
||||||
await finalizeSprintRunOnDone('sr-1')
|
|
||||||
expect(mocked.sprintRun.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('no-op when SprintRun does not exist', async () => {
|
|
||||||
mocked.sprintRun.findUnique.mockResolvedValue(null)
|
|
||||||
await finalizeSprintRunOnDone('sr-x')
|
|
||||||
expect(mocked.sprintRun.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('no-op when stories still open', async () => {
|
|
||||||
mocked.sprintRun.findUnique.mockResolvedValue({
|
|
||||||
id: 'sr-1',
|
|
||||||
status: 'RUNNING',
|
|
||||||
sprint_id: 's1',
|
|
||||||
})
|
|
||||||
mocked.story.count.mockResolvedValue(2)
|
|
||||||
await finalizeSprintRunOnDone('sr-1')
|
|
||||||
expect(mocked.sprintRun.update).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sets SprintRun → DONE when all stories DONE/FAILED', async () => {
|
|
||||||
mocked.sprintRun.findUnique.mockResolvedValue({
|
|
||||||
id: 'sr-1',
|
|
||||||
status: 'RUNNING',
|
|
||||||
sprint_id: 's1',
|
|
||||||
})
|
|
||||||
mocked.story.count.mockResolvedValue(0)
|
|
||||||
await finalizeSprintRunOnDone('sr-1')
|
|
||||||
expect(mocked.sprintRun.update).toHaveBeenCalledWith({
|
|
||||||
where: { id: 'sr-1' },
|
|
||||||
data: expect.objectContaining({
|
|
||||||
status: 'DONE',
|
|
||||||
finished_at: expect.any(Date),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
// Unit-tests voor resolveJobTimestamps — de status-gedreven timestamp-helper
|
|
||||||
// van update_job_status. Pure functie, geen mocks (zoals update-job-status-gate).
|
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { resolveJobTimestamps } from '../src/tools/update-job-status.js'
|
|
||||||
|
|
||||||
const NOW = new Date('2026-05-14T12:00:00.000Z')
|
|
||||||
const EARLIER = new Date('2026-05-14T11:00:00.000Z')
|
|
||||||
|
|
||||||
describe('resolveJobTimestamps', () => {
|
|
||||||
describe('running', () => {
|
|
||||||
it('sets started_at when not yet set, no finished_at', () => {
|
|
||||||
const r = resolveJobTimestamps('running', { claimed_at: EARLIER, started_at: null }, NOW)
|
|
||||||
expect(r.started_at).toBe(NOW)
|
|
||||||
expect(r.finished_at).toBeUndefined()
|
|
||||||
expect(r.claimed_at).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('is set-once: does not re-stamp started_at when already set', () => {
|
|
||||||
const r = resolveJobTimestamps('running', { claimed_at: EARLIER, started_at: EARLIER }, NOW)
|
|
||||||
expect(r.started_at).toBeUndefined()
|
|
||||||
expect(r.finished_at).toBeUndefined()
|
|
||||||
expect(r.claimed_at).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('terminal transitions (done/failed/skipped)', () => {
|
|
||||||
it.each(['done', 'failed', 'skipped'] as const)(
|
|
||||||
'backfills started_at and sets finished_at for %s when started_at is null',
|
|
||||||
(status) => {
|
|
||||||
const r = resolveJobTimestamps(status, { claimed_at: EARLIER, started_at: null }, NOW)
|
|
||||||
expect(r.started_at).toBe(NOW)
|
|
||||||
expect(r.finished_at).toBe(NOW)
|
|
||||||
expect(r.claimed_at).toBeUndefined()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
it('only sets finished_at when started_at is already set', () => {
|
|
||||||
const r = resolveJobTimestamps('done', { claimed_at: EARLIER, started_at: EARLIER }, NOW)
|
|
||||||
expect(r.started_at).toBeUndefined()
|
|
||||||
expect(r.finished_at).toBe(NOW)
|
|
||||||
expect(r.claimed_at).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('claimed_at backfill', () => {
|
|
||||||
it.each(['running', 'done', 'failed', 'skipped'] as const)(
|
|
||||||
'backfills claimed_at for %s when it is null',
|
|
||||||
(status) => {
|
|
||||||
const r = resolveJobTimestamps(status, { claimed_at: null, started_at: null }, NOW)
|
|
||||||
expect(r.claimed_at).toBe(NOW)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
it('never returns claimed_at when it is already set', () => {
|
|
||||||
const r = resolveJobTimestamps('done', { claimed_at: EARLIER, started_at: EARLIER }, NOW)
|
|
||||||
expect(r.claimed_at).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns only finished_at when all timestamps are already set and status is terminal', () => {
|
|
||||||
const r = resolveJobTimestamps('failed', { claimed_at: EARLIER, started_at: EARLIER }, NOW)
|
|
||||||
expect(r).toEqual({ finished_at: NOW })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('defaults now to a fresh Date when omitted', () => {
|
|
||||||
const before = Date.now()
|
|
||||||
const r = resolveJobTimestamps('running', { claimed_at: EARLIER, started_at: null })
|
|
||||||
const after = Date.now()
|
|
||||||
expect(r.started_at).toBeInstanceOf(Date)
|
|
||||||
expect(r.started_at!.getTime()).toBeGreaterThanOrEqual(before)
|
|
||||||
expect(r.started_at!.getTime()).toBeLessThanOrEqual(after)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('../src/prisma.js', () => ({
|
|
||||||
prisma: {
|
|
||||||
sprint: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../src/auth.js', () => ({
|
|
||||||
requireWriteAccess: vi.fn(),
|
|
||||||
PermissionDeniedError: class PermissionDeniedError extends Error {
|
|
||||||
constructor(message = 'Demo accounts cannot perform write operations') {
|
|
||||||
super(message)
|
|
||||||
this.name = 'PermissionDeniedError'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../src/access.js', () => ({
|
|
||||||
userCanAccessProduct: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '../src/prisma.js'
|
|
||||||
import { requireWriteAccess } from '../src/auth.js'
|
|
||||||
import { userCanAccessProduct } from '../src/access.js'
|
|
||||||
import { handleUpdateSprint } from '../src/tools/update-sprint.js'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
sprint: {
|
|
||||||
findUnique: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
|
|
||||||
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
const SPRINT_ID = 'spr-1'
|
|
||||||
const PRODUCT_ID = 'prod-1'
|
|
||||||
const USER_ID = 'user-1'
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false })
|
|
||||||
mockUserCanAccessProduct.mockResolvedValue(true)
|
|
||||||
mockPrisma.sprint.findUnique.mockResolvedValue({ id: SPRINT_ID, product_id: PRODUCT_ID })
|
|
||||||
mockPrisma.sprint.update.mockResolvedValue({
|
|
||||||
id: SPRINT_ID,
|
|
||||||
code: 'S-2026-05-11-1',
|
|
||||||
sprint_goal: 'g',
|
|
||||||
status: 'OPEN',
|
|
||||||
start_date: new Date('2026-05-11'),
|
|
||||||
end_date: null,
|
|
||||||
completed_at: null,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
function getText(result: Awaited<ReturnType<typeof handleUpdateSprint>>) {
|
|
||||||
return result.content?.[0]?.type === 'text' ? result.content[0].text : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('handleUpdateSprint', () => {
|
|
||||||
it('returns error when no fields provided', async () => {
|
|
||||||
const result = await handleUpdateSprint({ sprint_id: SPRINT_ID })
|
|
||||||
|
|
||||||
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
|
|
||||||
expect(getText(result)).toMatch(/Minstens één veld vereist/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates status only', async () => {
|
|
||||||
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' })
|
|
||||||
|
|
||||||
expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(1)
|
|
||||||
const args = mockPrisma.sprint.update.mock.calls[0][0]
|
|
||||||
expect(args.where).toEqual({ id: SPRINT_ID })
|
|
||||||
expect(args.data).toEqual({ status: 'OPEN' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('auto-sets end_date AND completed_at when status → CLOSED without explicit end_date', async () => {
|
|
||||||
const before = Date.now()
|
|
||||||
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' })
|
|
||||||
const after = Date.now()
|
|
||||||
|
|
||||||
const args = mockPrisma.sprint.update.mock.calls[0][0]
|
|
||||||
expect(args.data.status).toBe('CLOSED')
|
|
||||||
expect(args.data.end_date).toBeInstanceOf(Date)
|
|
||||||
expect(args.data.end_date.getTime()).toBeGreaterThanOrEqual(before)
|
|
||||||
expect(args.data.end_date.getTime()).toBeLessThanOrEqual(after)
|
|
||||||
expect(args.data.completed_at).toBeInstanceOf(Date)
|
|
||||||
expect(args.data.completed_at.getTime()).toBeGreaterThanOrEqual(before)
|
|
||||||
expect(args.data.completed_at.getTime()).toBeLessThanOrEqual(after)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('auto-sets end_date when status → FAILED, but does NOT set completed_at', async () => {
|
|
||||||
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'FAILED' })
|
|
||||||
|
|
||||||
const args = mockPrisma.sprint.update.mock.calls[0][0]
|
|
||||||
expect(args.data.end_date).toBeInstanceOf(Date)
|
|
||||||
expect(args.data.completed_at).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('auto-sets end_date when status → ARCHIVED, but does NOT set completed_at', async () => {
|
|
||||||
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'ARCHIVED' })
|
|
||||||
|
|
||||||
const args = mockPrisma.sprint.update.mock.calls[0][0]
|
|
||||||
expect(args.data.end_date).toBeInstanceOf(Date)
|
|
||||||
expect(args.data.completed_at).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('still sets completed_at when status → CLOSED even with explicit end_date', async () => {
|
|
||||||
await handleUpdateSprint({
|
|
||||||
sprint_id: SPRINT_ID,
|
|
||||||
status: 'CLOSED',
|
|
||||||
end_date: '2025-12-31',
|
|
||||||
})
|
|
||||||
|
|
||||||
const args = mockPrisma.sprint.update.mock.calls[0][0]
|
|
||||||
expect(args.data.end_date.toISOString().slice(0, 10)).toBe('2025-12-31')
|
|
||||||
expect(args.data.completed_at).toBeInstanceOf(Date)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does NOT auto-set end_date or completed_at when status → OPEN', async () => {
|
|
||||||
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' })
|
|
||||||
|
|
||||||
const args = mockPrisma.sprint.update.mock.calls[0][0]
|
|
||||||
expect(args.data.end_date).toBeUndefined()
|
|
||||||
expect(args.data.completed_at).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates multiple fields at once', async () => {
|
|
||||||
await handleUpdateSprint({
|
|
||||||
sprint_id: SPRINT_ID,
|
|
||||||
sprint_goal: 'New goal',
|
|
||||||
start_date: '2026-05-15',
|
|
||||||
})
|
|
||||||
|
|
||||||
const args = mockPrisma.sprint.update.mock.calls[0][0]
|
|
||||||
expect(args.data.sprint_goal).toBe('New goal')
|
|
||||||
expect(args.data.start_date.toISOString().slice(0, 10)).toBe('2026-05-15')
|
|
||||||
expect(args.data.status).toBeUndefined()
|
|
||||||
expect(args.data.end_date).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns error when sprint not found', async () => {
|
|
||||||
mockPrisma.sprint.findUnique.mockResolvedValue(null)
|
|
||||||
|
|
||||||
const result = await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' })
|
|
||||||
|
|
||||||
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
|
|
||||||
expect(getText(result)).toMatch(/not found/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns error when user cannot access sprint product', async () => {
|
|
||||||
mockUserCanAccessProduct.mockResolvedValue(false)
|
|
||||||
|
|
||||||
const result = await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' })
|
|
||||||
|
|
||||||
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
|
|
||||||
expect(getText(result)).toMatch(/not accessible/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('allows any status transition (no state-machine)', async () => {
|
|
||||||
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' })
|
|
||||||
expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' })
|
|
||||||
expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(2)
|
|
||||||
|
|
||||||
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' })
|
|
||||||
expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(3)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,199 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('../src/prisma.js', () => ({
|
|
||||||
prisma: {
|
|
||||||
sprintTaskExecution: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../src/auth.js', async (importOriginal) => {
|
|
||||||
const original = await importOriginal<typeof import('../src/auth.js')>()
|
|
||||||
return { ...original, requireWriteAccess: vi.fn() }
|
|
||||||
})
|
|
||||||
|
|
||||||
import { prisma } from '../src/prisma.js'
|
|
||||||
import { requireWriteAccess } from '../src/auth.js'
|
|
||||||
import { registerUpdateTaskExecutionTool } from '../src/tools/update-task-execution.js'
|
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
sprintTaskExecution: {
|
|
||||||
findUnique: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mockAuth = requireWriteAccess as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
const TOKEN_ID = 'tok-owner'
|
|
||||||
|
|
||||||
function makeServer() {
|
|
||||||
let handler: (args: Record<string, unknown>) => Promise<unknown>
|
|
||||||
const server = {
|
|
||||||
registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => {
|
|
||||||
handler = fn
|
|
||||||
}),
|
|
||||||
call: (args: Record<string, unknown>) => handler(args),
|
|
||||||
}
|
|
||||||
registerUpdateTaskExecutionTool(server as unknown as McpServer)
|
|
||||||
return server
|
|
||||||
}
|
|
||||||
|
|
||||||
function execRecord(overrides: Record<string, unknown> = {}) {
|
|
||||||
return {
|
|
||||||
id: 'exec-1',
|
|
||||||
sprint_job_id: 'job-1',
|
|
||||||
sprint_job: {
|
|
||||||
claimed_by_token_id: TOKEN_ID,
|
|
||||||
status: 'CLAIMED',
|
|
||||||
kind: 'SPRINT_IMPLEMENTATION',
|
|
||||||
},
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockAuth.mockResolvedValue({
|
|
||||||
userId: 'u-1',
|
|
||||||
tokenId: TOKEN_ID,
|
|
||||||
username: 'agent',
|
|
||||||
isDemo: false,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('update_task_execution', () => {
|
|
||||||
it('rejects when execution not found', async () => {
|
|
||||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(null)
|
|
||||||
const server = makeServer()
|
|
||||||
const result = (await server.call({
|
|
||||||
execution_id: 'missing',
|
|
||||||
status: 'RUNNING',
|
|
||||||
})) as { content: { text: string }[]; isError?: boolean }
|
|
||||||
expect(result.isError).toBe(true)
|
|
||||||
expect(result.content[0].text).toMatch(/not found/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects wrong job-kind', async () => {
|
|
||||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
|
|
||||||
execRecord({
|
|
||||||
sprint_job: { claimed_by_token_id: TOKEN_ID, status: 'CLAIMED', kind: 'TASK_IMPLEMENTATION' },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const server = makeServer()
|
|
||||||
const result = (await server.call({
|
|
||||||
execution_id: 'exec-1',
|
|
||||||
status: 'RUNNING',
|
|
||||||
})) as { content: { text: string }[]; isError?: boolean }
|
|
||||||
expect(result.isError).toBe(true)
|
|
||||||
expect(result.content[0].text).toMatch(/SPRINT_IMPLEMENTATION/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects when token does not own the job', async () => {
|
|
||||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
|
|
||||||
execRecord({
|
|
||||||
sprint_job: { claimed_by_token_id: 'other-token', status: 'CLAIMED', kind: 'SPRINT_IMPLEMENTATION' },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const server = makeServer()
|
|
||||||
const result = (await server.call({
|
|
||||||
execution_id: 'exec-1',
|
|
||||||
status: 'RUNNING',
|
|
||||||
})) as { content: { text: string }[]; isError?: boolean }
|
|
||||||
expect(result.isError).toBe(true)
|
|
||||||
expect(result.content[0].text).toMatch(/Forbidden/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects when job is in terminal state', async () => {
|
|
||||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
|
|
||||||
execRecord({
|
|
||||||
sprint_job: { claimed_by_token_id: TOKEN_ID, status: 'DONE', kind: 'SPRINT_IMPLEMENTATION' },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const server = makeServer()
|
|
||||||
const result = (await server.call({
|
|
||||||
execution_id: 'exec-1',
|
|
||||||
status: 'DONE',
|
|
||||||
})) as { content: { text: string }[]; isError?: boolean }
|
|
||||||
expect(result.isError).toBe(true)
|
|
||||||
expect(result.content[0].text).toMatch(/terminal/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('writes started_at on RUNNING', async () => {
|
|
||||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord())
|
|
||||||
mockPrisma.sprintTaskExecution.update.mockResolvedValue({
|
|
||||||
id: 'exec-1',
|
|
||||||
status: 'RUNNING',
|
|
||||||
base_sha: null,
|
|
||||||
head_sha: null,
|
|
||||||
verify_result: null,
|
|
||||||
verify_summary: null,
|
|
||||||
skip_reason: null,
|
|
||||||
started_at: new Date(),
|
|
||||||
finished_at: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const server = makeServer()
|
|
||||||
await server.call({ execution_id: 'exec-1', status: 'RUNNING' })
|
|
||||||
|
|
||||||
const updateCall = mockPrisma.sprintTaskExecution.update.mock.calls[0][0]
|
|
||||||
expect(updateCall.data.status).toBe('RUNNING')
|
|
||||||
expect(updateCall.data.started_at).toBeInstanceOf(Date)
|
|
||||||
expect(updateCall.data.finished_at).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('writes finished_at on DONE/FAILED/SKIPPED', async () => {
|
|
||||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord())
|
|
||||||
mockPrisma.sprintTaskExecution.update.mockResolvedValue({
|
|
||||||
id: 'exec-1',
|
|
||||||
status: 'DONE',
|
|
||||||
base_sha: 'sha-base',
|
|
||||||
head_sha: 'sha-head',
|
|
||||||
verify_result: null,
|
|
||||||
verify_summary: null,
|
|
||||||
skip_reason: null,
|
|
||||||
started_at: new Date(),
|
|
||||||
finished_at: new Date(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const server = makeServer()
|
|
||||||
await server.call({
|
|
||||||
execution_id: 'exec-1',
|
|
||||||
status: 'DONE',
|
|
||||||
head_sha: 'sha-head',
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateCall = mockPrisma.sprintTaskExecution.update.mock.calls[0][0]
|
|
||||||
expect(updateCall.data.status).toBe('DONE')
|
|
||||||
expect(updateCall.data.finished_at).toBeInstanceOf(Date)
|
|
||||||
expect(updateCall.data.head_sha).toBe('sha-head')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('persists skip_reason on SKIPPED', async () => {
|
|
||||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord())
|
|
||||||
mockPrisma.sprintTaskExecution.update.mockResolvedValue({
|
|
||||||
id: 'exec-1',
|
|
||||||
status: 'SKIPPED',
|
|
||||||
base_sha: null,
|
|
||||||
head_sha: null,
|
|
||||||
verify_result: null,
|
|
||||||
verify_summary: null,
|
|
||||||
skip_reason: 'no-op task',
|
|
||||||
started_at: null,
|
|
||||||
finished_at: new Date(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const server = makeServer()
|
|
||||||
await server.call({
|
|
||||||
execution_id: 'exec-1',
|
|
||||||
status: 'SKIPPED',
|
|
||||||
skip_reason: 'no-op task',
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateCall = mockPrisma.sprintTaskExecution.update.mock.calls[0][0]
|
|
||||||
expect(updateCall.data.skip_reason).toBe('no-op task')
|
|
||||||
expect(updateCall.data.finished_at).toBeInstanceOf(Date)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('../src/prisma.js', () => ({
|
|
||||||
prisma: {
|
|
||||||
sprintTaskExecution: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../src/auth.js', async (importOriginal) => {
|
|
||||||
const original = await importOriginal<typeof import('../src/auth.js')>()
|
|
||||||
return { ...original, requireWriteAccess: vi.fn() }
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.mock('../src/verify/classify.js', () => ({
|
|
||||||
classifyDiffAgainstPlan: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('node:child_process', () => ({
|
|
||||||
execFile: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '../src/prisma.js'
|
|
||||||
import { requireWriteAccess } from '../src/auth.js'
|
|
||||||
import { classifyDiffAgainstPlan } from '../src/verify/classify.js'
|
|
||||||
import { execFile } from 'node:child_process'
|
|
||||||
import { registerVerifySprintTaskTool } from '../src/tools/verify-sprint-task.js'
|
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
sprintTaskExecution: {
|
|
||||||
findUnique: ReturnType<typeof vi.fn>
|
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
|
||||||
update: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const mockAuth = requireWriteAccess as ReturnType<typeof vi.fn>
|
|
||||||
const mockClassify = classifyDiffAgainstPlan as ReturnType<typeof vi.fn>
|
|
||||||
const mockExecFile = execFile as unknown as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
const TOKEN_ID = 'tok-owner'
|
|
||||||
|
|
||||||
function makeServer() {
|
|
||||||
let handler: (args: Record<string, unknown>) => Promise<unknown>
|
|
||||||
const server = {
|
|
||||||
registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => {
|
|
||||||
handler = fn
|
|
||||||
}),
|
|
||||||
call: (args: Record<string, unknown>) => handler(args),
|
|
||||||
}
|
|
||||||
registerVerifySprintTaskTool(server as unknown as McpServer)
|
|
||||||
return server
|
|
||||||
}
|
|
||||||
|
|
||||||
function stubGitDiff(stdout: string) {
|
|
||||||
// promisify(execFile) calls (cmd, args, opts, cb)
|
|
||||||
mockExecFile.mockImplementation(
|
|
||||||
(
|
|
||||||
_cmd: string,
|
|
||||||
_args: string[],
|
|
||||||
_opts: unknown,
|
|
||||||
cb: (err: null, result: { stdout: string; stderr: string }) => void,
|
|
||||||
) => {
|
|
||||||
cb(null, { stdout, stderr: '' })
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function execRecord(overrides: Record<string, unknown> = {}) {
|
|
||||||
return {
|
|
||||||
id: 'exec-1',
|
|
||||||
sprint_job_id: 'job-1',
|
|
||||||
order: 0,
|
|
||||||
base_sha: 'sha-base',
|
|
||||||
plan_snapshot: 'frozen plan',
|
|
||||||
verify_required_snapshot: 'ALIGNED_OR_PARTIAL',
|
|
||||||
verify_only_snapshot: false,
|
|
||||||
sprint_job: {
|
|
||||||
claimed_by_token_id: TOKEN_ID,
|
|
||||||
status: 'CLAIMED',
|
|
||||||
kind: 'SPRINT_IMPLEMENTATION',
|
|
||||||
},
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockAuth.mockResolvedValue({
|
|
||||||
userId: 'u-1',
|
|
||||||
tokenId: TOKEN_ID,
|
|
||||||
username: 'agent',
|
|
||||||
isDemo: false,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('verify_sprint_task', () => {
|
|
||||||
it('rejects when execution not found', async () => {
|
|
||||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(null)
|
|
||||||
const server = makeServer()
|
|
||||||
const result = (await server.call({
|
|
||||||
execution_id: 'missing',
|
|
||||||
worktree_path: '/tmp/wt',
|
|
||||||
})) as { content: { text: string }[]; isError?: boolean }
|
|
||||||
expect(result.isError).toBe(true)
|
|
||||||
expect(result.content[0].text).toMatch(/not found/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects wrong token', async () => {
|
|
||||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
|
|
||||||
execRecord({
|
|
||||||
sprint_job: { claimed_by_token_id: 'other', status: 'CLAIMED', kind: 'SPRINT_IMPLEMENTATION' },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const server = makeServer()
|
|
||||||
const result = (await server.call({
|
|
||||||
execution_id: 'exec-1',
|
|
||||||
worktree_path: '/tmp/wt',
|
|
||||||
})) as { content: { text: string }[]; isError?: boolean }
|
|
||||||
expect(result.isError).toBe(true)
|
|
||||||
expect(result.content[0].text).toMatch(/Forbidden/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('PARTIAL with summary returns allowed_for_done=true under ALIGNED_OR_PARTIAL', async () => {
|
|
||||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord())
|
|
||||||
stubGitDiff('diff --git a/x b/x\n+ change\n')
|
|
||||||
mockClassify.mockReturnValue({ result: 'PARTIAL', reasoning: 'extra files' })
|
|
||||||
|
|
||||||
const server = makeServer()
|
|
||||||
const result = (await server.call({
|
|
||||||
execution_id: 'exec-1',
|
|
||||||
worktree_path: '/tmp/wt',
|
|
||||||
summary: 'Refactor touched extra files for type narrowing.',
|
|
||||||
})) as { content: { text: string }[] }
|
|
||||||
const body = JSON.parse(result.content[0].text)
|
|
||||||
expect(body.result).toBe('partial')
|
|
||||||
expect(body.allowed_for_done).toBe(true)
|
|
||||||
expect(body.reason).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('PARTIAL without summary returns allowed_for_done=false', async () => {
|
|
||||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord())
|
|
||||||
stubGitDiff('diff --git a/x b/x\n')
|
|
||||||
mockClassify.mockReturnValue({ result: 'PARTIAL', reasoning: 'r' })
|
|
||||||
|
|
||||||
const server = makeServer()
|
|
||||||
const result = (await server.call({
|
|
||||||
execution_id: 'exec-1',
|
|
||||||
worktree_path: '/tmp/wt',
|
|
||||||
})) as { content: { text: string }[] }
|
|
||||||
const body = JSON.parse(result.content[0].text)
|
|
||||||
expect(body.result).toBe('partial')
|
|
||||||
expect(body.allowed_for_done).toBe(false)
|
|
||||||
expect(body.reason).toMatch(/summary/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('DIVERGENT with strict ALIGNED returns allowed_for_done=false', async () => {
|
|
||||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
|
|
||||||
execRecord({ verify_required_snapshot: 'ALIGNED' }),
|
|
||||||
)
|
|
||||||
stubGitDiff('diff --git a/x b/x\n')
|
|
||||||
mockClassify.mockReturnValue({ result: 'DIVERGENT', reasoning: 'r' })
|
|
||||||
|
|
||||||
const server = makeServer()
|
|
||||||
const result = (await server.call({
|
|
||||||
execution_id: 'exec-1',
|
|
||||||
worktree_path: '/tmp/wt',
|
|
||||||
summary: 'Long enough summary describing the deviation rationale clearly.',
|
|
||||||
})) as { content: { text: string }[] }
|
|
||||||
const body = JSON.parse(result.content[0].text)
|
|
||||||
expect(body.allowed_for_done).toBe(false)
|
|
||||||
expect(body.reason).toMatch(/ALIGNED/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('auto-fills base_sha from previous DONE execution head_sha', async () => {
|
|
||||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
|
|
||||||
execRecord({ order: 1, base_sha: null }),
|
|
||||||
)
|
|
||||||
mockPrisma.sprintTaskExecution.findFirst.mockResolvedValue({
|
|
||||||
head_sha: 'prev-head-sha',
|
|
||||||
})
|
|
||||||
stubGitDiff('diff\n')
|
|
||||||
mockClassify.mockReturnValue({ result: 'ALIGNED', reasoning: 'ok' })
|
|
||||||
|
|
||||||
const server = makeServer()
|
|
||||||
const result = (await server.call({
|
|
||||||
execution_id: 'exec-1',
|
|
||||||
worktree_path: '/tmp/wt',
|
|
||||||
})) as { content: { text: string }[] }
|
|
||||||
const body = JSON.parse(result.content[0].text)
|
|
||||||
expect(body.base_sha).toBe('prev-head-sha')
|
|
||||||
|
|
||||||
// Persisted back to row
|
|
||||||
const updateCalls = mockPrisma.sprintTaskExecution.update.mock.calls
|
|
||||||
const baseShaPersist = updateCalls.find((c) => c[0].data.base_sha === 'prev-head-sha')
|
|
||||||
expect(baseShaPersist).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('errors when base_sha cannot be derived (no prior DONE)', async () => {
|
|
||||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
|
|
||||||
execRecord({ order: 2, base_sha: null }),
|
|
||||||
)
|
|
||||||
mockPrisma.sprintTaskExecution.findFirst.mockResolvedValue(null)
|
|
||||||
|
|
||||||
const server = makeServer()
|
|
||||||
const result = (await server.call({
|
|
||||||
execution_id: 'exec-1',
|
|
||||||
worktree_path: '/tmp/wt',
|
|
||||||
})) as { content: { text: string }[]; isError?: boolean }
|
|
||||||
expect(result.isError).toBe(true)
|
|
||||||
expect(result.content[0].text).toMatch(/MISSING_BASE_SHA/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { classifyDiffAgainstPlan } from '../../src/verify/classify.js'
|
|
||||||
|
|
||||||
describe('classify — delete-only commits (PBI-47 C5)', () => {
|
|
||||||
it('returns ALIGNED when the deleted path is in the plan', () => {
|
|
||||||
const diff = `diff --git a/app/todos/page.tsx b/app/todos/page.tsx
|
|
||||||
deleted file mode 100644
|
|
||||||
index 1234567..0000000
|
|
||||||
--- a/app/todos/page.tsx
|
|
||||||
+++ /dev/null
|
|
||||||
@@ -1,3 +0,0 @@
|
|
||||||
-export default function TodosPage() {
|
|
||||||
- return null
|
|
||||||
-}`
|
|
||||||
|
|
||||||
const plan = '- Verwijder `app/todos/page.tsx`\n- Verwijder gerelateerde imports'
|
|
||||||
|
|
||||||
const result = classifyDiffAgainstPlan({ diff, plan })
|
|
||||||
expect(result.result).toBe('ALIGNED')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns ALIGNED for multi-file delete-only when both paths in plan', () => {
|
|
||||||
const diff = `diff --git a/app/todos/page.tsx b/app/todos/page.tsx
|
|
||||||
deleted file mode 100644
|
|
||||||
--- a/app/todos/page.tsx
|
|
||||||
+++ /dev/null
|
|
||||||
@@ -1,2 +0,0 @@
|
|
||||||
-line 1
|
|
||||||
-line 2
|
|
||||||
diff --git a/components/todo-list.tsx b/components/todo-list.tsx
|
|
||||||
deleted file mode 100644
|
|
||||||
--- a/components/todo-list.tsx
|
|
||||||
+++ /dev/null
|
|
||||||
@@ -1,1 +0,0 @@
|
|
||||||
-line`
|
|
||||||
|
|
||||||
const plan = '- `app/todos/page.tsx`\n- `components/todo-list.tsx`'
|
|
||||||
const result = classifyDiffAgainstPlan({ diff, plan })
|
|
||||||
expect(result.result).toBe('ALIGNED')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns PARTIAL when only some plan deletes appear in the diff', () => {
|
|
||||||
const diff = `diff --git a/a.ts b/a.ts
|
|
||||||
deleted file mode 100644
|
|
||||||
--- a/a.ts
|
|
||||||
+++ /dev/null
|
|
||||||
@@ -1,1 +0,0 @@
|
|
||||||
-x`
|
|
||||||
|
|
||||||
const plan = '- `a.ts`\n- `b.ts`' // b.ts missing
|
|
||||||
const result = classifyDiffAgainstPlan({ diff, plan })
|
|
||||||
expect(result.result).toBe('PARTIAL')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns EMPTY for a no-op diff', () => {
|
|
||||||
const result = classifyDiffAgainstPlan({ diff: '', plan: 'irrelevant' })
|
|
||||||
expect(result.result).toBe('EMPTY')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -124,92 +124,3 @@ describe('classifyDiffAgainstPlan — DIVERGENT (scope creep)', () => {
|
||||||
expect(r.reasoning).toMatch(/extra/i)
|
expect(r.reasoning).toMatch(/extra/i)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Helper voor pure-delete diffs: +++ /dev/null betekent dat het bestand
|
|
||||||
// volledig verwijderd is. Pad zit alleen nog in de "--- a/<path>" regel.
|
|
||||||
function makeDeleteDiff(files: string[], linesPerFile = 5): string {
|
|
||||||
return files
|
|
||||||
.map(
|
|
||||||
(f) =>
|
|
||||||
`diff --git a/${f} b/${f}\ndeleted file mode 100644\n--- a/${f}\n+++ /dev/null\n` +
|
|
||||||
Array.from({ length: linesPerFile }, (_, i) => `-removed line ${i}`).join('\n'),
|
|
||||||
)
|
|
||||||
.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('classifyDiffAgainstPlan — delete-only commits', () => {
|
|
||||||
it('herkent delete-only diff (geen +++ b/, wel --- a/) als ALIGNED bij matchend plan', () => {
|
|
||||||
const plan = 'Verwijder `src/old-helper.ts` — niet meer gebruikt.'
|
|
||||||
const diff = makeDeleteDiff(['src/old-helper.ts'])
|
|
||||||
const r = classifyDiffAgainstPlan({ diff, plan })
|
|
||||||
expect(r.result).toBe('ALIGNED')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('retourneert PARTIAL wanneer plan meer paden noemt dan zijn verwijderd', () => {
|
|
||||||
const plan = 'Verwijder `src/a.ts` en `src/b.ts`.'
|
|
||||||
const diff = makeDeleteDiff(['src/a.ts'])
|
|
||||||
const r = classifyDiffAgainstPlan({ diff, plan })
|
|
||||||
expect(r.result).toBe('PARTIAL')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('retourneert ALIGNED voor delete-only diff zonder plan-baseline', () => {
|
|
||||||
const diff = makeDeleteDiff(['src/old.ts'])
|
|
||||||
const r = classifyDiffAgainstPlan({ diff, plan: null })
|
|
||||||
expect(r.result).toBe('ALIGNED')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('retourneert nog steeds EMPTY voor echt lege diff', () => {
|
|
||||||
const r = classifyDiffAgainstPlan({ diff: '', plan: 'Verwijder `src/x.ts`.' })
|
|
||||||
expect(r.result).toBe('EMPTY')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Pseudo-paths in plans (code-snippets, attribute-syntax, ellipses) moeten
|
|
||||||
// niet als plan-paden meetellen — anders krijg je PARTIAL terwijl het werk
|
|
||||||
// volledig gedaan is. Regression-guard voor T-815-incident (sprint
|
|
||||||
// cmoyiu4yd000zf917acq9twtr, 2026-05-09).
|
|
||||||
describe('classifyDiffAgainstPlan — plan met pseudo-paths', () => {
|
|
||||||
it('negeert `data-debug-label="..."` als pseudo-pad en classificeert ALIGNED', () => {
|
|
||||||
const plan = [
|
|
||||||
'Verwijder alle voorkomens van `data-debug-label="..."` uit:',
|
|
||||||
'',
|
|
||||||
'- `app/components/shared/status-bar.tsx`',
|
|
||||||
'- `app/components/shared/header.tsx`',
|
|
||||||
].join('\n')
|
|
||||||
const diff = makeDiff([
|
|
||||||
'app/components/shared/status-bar.tsx',
|
|
||||||
'app/components/shared/header.tsx',
|
|
||||||
])
|
|
||||||
const r = classifyDiffAgainstPlan({ diff, plan })
|
|
||||||
expect(r.result).toBe('ALIGNED')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('negeert ellipsis-tokens (drie of meer dots) als pad', () => {
|
|
||||||
const plan = 'Refactor `foo(...)` naar `bar()`. Files: `src/a.ts`.'
|
|
||||||
const diff = makeDiff(['src/a.ts'])
|
|
||||||
const r = classifyDiffAgainstPlan({ diff, plan })
|
|
||||||
expect(r.result).toBe('ALIGNED')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('negeert tokens met operators/quotes als pad', () => {
|
|
||||||
const plan = 'Wijzig `props={x: 1}` en `useState<string>()` in `src/c.tsx`.'
|
|
||||||
const diff = makeDiff(['src/c.tsx'])
|
|
||||||
const r = classifyDiffAgainstPlan({ diff, plan })
|
|
||||||
expect(r.result).toBe('ALIGNED')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepteert package.json en andere extension-only paths', () => {
|
|
||||||
const plan = 'Update `package.json` en `tsconfig.json`.'
|
|
||||||
const diff = makeDiff(['package.json', 'tsconfig.json'])
|
|
||||||
const r = classifyDiffAgainstPlan({ diff, plan })
|
|
||||||
expect(r.result).toBe('ALIGNED')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blijft PARTIAL retourneren wanneer een echt plan-pad ontbreekt', () => {
|
|
||||||
const plan = 'Wijzig `src/foo.ts` en `src/bar.ts`. Verwijder `data-x="..."`.'
|
|
||||||
const diff = makeDiff(['src/foo.ts'])
|
|
||||||
const r = classifyDiffAgainstPlan({ diff, plan })
|
|
||||||
expect(r.result).toBe('PARTIAL')
|
|
||||||
expect(r.reasoning).toMatch(/bar\.ts/)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
||||||
import * as fs from 'node:fs/promises'
|
|
||||||
import * as path from 'node:path'
|
|
||||||
import * as os from 'node:os'
|
|
||||||
import { execFile } from 'node:child_process'
|
|
||||||
import { promisify } from 'node:util'
|
|
||||||
import { getDiffInWorktree } from '../../src/tools/verify-task-against-plan.js'
|
|
||||||
|
|
||||||
const exec = promisify(execFile)
|
|
||||||
|
|
||||||
describe('verify scope per-job (PBI-47 P0)', () => {
|
|
||||||
let tmpRepo: string
|
|
||||||
let baseSha: string
|
|
||||||
let task1Sha: string
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
tmpRepo = await fs.mkdtemp(path.join(os.tmpdir(), 'verify-scope-'))
|
|
||||||
await exec('git', ['init', '-b', 'main'], { cwd: tmpRepo })
|
|
||||||
await exec('git', ['config', 'user.email', 't@t.local'], { cwd: tmpRepo })
|
|
||||||
await exec('git', ['config', 'user.name', 'Test'], { cwd: tmpRepo })
|
|
||||||
await fs.writeFile(path.join(tmpRepo, 'README.md'), '# init\n')
|
|
||||||
await exec('git', ['add', '-A'], { cwd: tmpRepo })
|
|
||||||
await exec('git', ['commit', '-m', 'init'], { cwd: tmpRepo })
|
|
||||||
const baseRev = await exec('git', ['rev-parse', 'HEAD'], { cwd: tmpRepo })
|
|
||||||
baseSha = baseRev.stdout.trim()
|
|
||||||
|
|
||||||
// Simulate task 1: add a.ts
|
|
||||||
await fs.writeFile(path.join(tmpRepo, 'a.ts'), 'task 1\n')
|
|
||||||
await exec('git', ['add', '-A'], { cwd: tmpRepo })
|
|
||||||
await exec('git', ['commit', '-m', 'task 1'], { cwd: tmpRepo })
|
|
||||||
const t1Rev = await exec('git', ['rev-parse', 'HEAD'], { cwd: tmpRepo })
|
|
||||||
task1Sha = t1Rev.stdout.trim()
|
|
||||||
|
|
||||||
// Simulate task 2: add b.ts
|
|
||||||
await fs.writeFile(path.join(tmpRepo, 'b.ts'), 'task 2\n')
|
|
||||||
await exec('git', ['add', '-A'], { cwd: tmpRepo })
|
|
||||||
await exec('git', ['commit', '-m', 'task 2'], { cwd: tmpRepo })
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await fs.rm(tmpRepo, { recursive: true, force: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('diff vs base = origin/main → both task 1 and task 2 visible', async () => {
|
|
||||||
const diff = await getDiffInWorktree(tmpRepo, baseSha)
|
|
||||||
expect(diff).toContain('a.ts')
|
|
||||||
expect(diff).toContain('b.ts')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('diff vs base = task1_sha → only task 2 visible', async () => {
|
|
||||||
const diff = await getDiffInWorktree(tmpRepo, task1Sha)
|
|
||||||
expect(diff).not.toContain('a.ts')
|
|
||||||
expect(diff).toContain('b.ts')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('../src/prisma.js', () => ({
|
|
||||||
prisma: {
|
|
||||||
claudeJob: {
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { prisma } from '../src/prisma.js'
|
|
||||||
import { resolveBranchForJob } from '../src/tools/wait-for-job.js'
|
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
|
||||||
claudeJob: {
|
|
||||||
findUnique: ReturnType<typeof vi.fn>
|
|
||||||
findFirst: ReturnType<typeof vi.fn>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('resolveBranchForJob — sprint-aware', () => {
|
|
||||||
it('SPRINT-mode: kiest feat/sprint-<id-suffix> en marks reused=false bij eerste task', async () => {
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({
|
|
||||||
sprint_run_id: 'run-cuid-12345678',
|
|
||||||
sprint_run: { id: 'run-cuid-12345678', pr_strategy: 'SPRINT' },
|
|
||||||
})
|
|
||||||
mockPrisma.claudeJob.findFirst.mockResolvedValue(null)
|
|
||||||
|
|
||||||
const result = await resolveBranchForJob('job-1', 'story-anything')
|
|
||||||
|
|
||||||
expect(result.branchName).toBe('feat/sprint-12345678')
|
|
||||||
expect(result.reused).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('SPRINT-mode: marks reused=true wanneer sibling al de branch gebruikt', async () => {
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({
|
|
||||||
sprint_run_id: 'run-cuid-12345678',
|
|
||||||
sprint_run: { id: 'run-cuid-12345678', pr_strategy: 'SPRINT' },
|
|
||||||
})
|
|
||||||
mockPrisma.claudeJob.findFirst.mockResolvedValue({ branch: 'feat/sprint-12345678' })
|
|
||||||
|
|
||||||
const result = await resolveBranchForJob('job-2', 'story-anything')
|
|
||||||
|
|
||||||
expect(result.branchName).toBe('feat/sprint-12345678')
|
|
||||||
expect(result.reused).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('STORY-mode (sprint-flow): valt terug op story-branch via legacy-pad', async () => {
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({
|
|
||||||
sprint_run_id: 'run-cuid-12345678',
|
|
||||||
sprint_run: { id: 'run-cuid-12345678', pr_strategy: 'STORY' },
|
|
||||||
})
|
|
||||||
mockPrisma.claudeJob.findFirst.mockResolvedValue(null)
|
|
||||||
|
|
||||||
const result = await resolveBranchForJob('job-1', 'story-cuid-87654321')
|
|
||||||
|
|
||||||
expect(result.branchName).toBe('feat/story-87654321')
|
|
||||||
expect(result.reused).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Legacy (geen sprint_run): bestaand gedrag — feat/story-<id-suffix>', async () => {
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({
|
|
||||||
sprint_run_id: null,
|
|
||||||
sprint_run: null,
|
|
||||||
})
|
|
||||||
mockPrisma.claudeJob.findFirst.mockResolvedValue(null)
|
|
||||||
|
|
||||||
const result = await resolveBranchForJob('job-1', 'story-cuid-87654321')
|
|
||||||
|
|
||||||
expect(result.branchName).toBe('feat/story-87654321')
|
|
||||||
expect(result.reused).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Legacy: hergebruik branch wanneer sibling-job in dezelfde story al een branch heeft', async () => {
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({
|
|
||||||
sprint_run_id: null,
|
|
||||||
sprint_run: null,
|
|
||||||
})
|
|
||||||
mockPrisma.claudeJob.findFirst.mockResolvedValue({ branch: 'feat/story-87654321' })
|
|
||||||
|
|
||||||
const result = await resolveBranchForJob('job-2', 'story-cuid-87654321')
|
|
||||||
|
|
||||||
expect(result.branchName).toBe('feat/story-87654321')
|
|
||||||
expect(result.reused).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -6,7 +6,7 @@ import * as fs from 'node:fs/promises'
|
||||||
vi.mock('../src/prisma.js', () => ({
|
vi.mock('../src/prisma.js', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
$executeRaw: vi.fn(),
|
$executeRaw: vi.fn(),
|
||||||
claudeJob: { findFirst: vi.fn(), findUnique: vi.fn(), update: vi.fn() },
|
claudeJob: { findFirst: vi.fn() },
|
||||||
product: { findUnique: vi.fn() },
|
product: { findUnique: vi.fn() },
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
@ -21,15 +21,13 @@ import { resolveRepoRoot, rollbackClaim, attachWorktreeToJob } from '../src/tool
|
||||||
|
|
||||||
const mockPrisma = prisma as unknown as {
|
const mockPrisma = prisma as unknown as {
|
||||||
$executeRaw: ReturnType<typeof vi.fn>
|
$executeRaw: ReturnType<typeof vi.fn>
|
||||||
claudeJob: { findFirst: ReturnType<typeof vi.fn>; findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
claudeJob: { findFirst: ReturnType<typeof vi.fn> }
|
||||||
product: { findUnique: ReturnType<typeof vi.fn> }
|
product: { findUnique: ReturnType<typeof vi.fn> }
|
||||||
}
|
}
|
||||||
const mockCreateWorktree = createWorktreeForJob as ReturnType<typeof vi.fn>
|
const mockCreateWorktree = createWorktreeForJob as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
// Default: legacy job zonder sprint_run (oude flow).
|
|
||||||
mockPrisma.claudeJob.findUnique.mockResolvedValue({ sprint_run_id: null, sprint_run: null })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('resolveRepoRoot', () => {
|
describe('resolveRepoRoot', () => {
|
||||||
|
|
|
||||||
71
package-lock.json
generated
71
package-lock.json
generated
|
|
@ -1,22 +1,19 @@
|
||||||
{
|
{
|
||||||
"name": "scrum4me-mcp",
|
"name": "scrum4me-mcp",
|
||||||
"version": "0.8.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "scrum4me-mcp",
|
"name": "scrum4me-mcp",
|
||||||
"version": "0.8.0",
|
"version": "0.1.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@prisma/adapter-pg": "^7.8.0",
|
"@prisma/adapter-pg": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
"@types/proper-lockfile": "^4.1.4",
|
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"proper-lockfile": "^4.1.2",
|
|
||||||
"yaml": "^2.8.4",
|
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -1095,6 +1092,9 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1112,6 +1112,9 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1129,6 +1132,9 @@
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1146,6 +1152,9 @@
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1163,6 +1172,9 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1180,6 +1192,9 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1329,15 +1344,6 @@
|
||||||
"pg-types": "^2.2.0"
|
"pg-types": "^2.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/proper-lockfile": {
|
|
||||||
"version": "4.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz",
|
|
||||||
"integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/retry": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
|
|
@ -1349,12 +1355,6 @@
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/retry": {
|
|
||||||
"version": "0.12.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz",
|
|
||||||
"integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
|
||||||
|
|
@ -2349,6 +2349,7 @@
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/grammex": {
|
"node_modules/grammex": {
|
||||||
|
|
@ -2658,6 +2659,9 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2679,6 +2683,9 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2700,6 +2707,9 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -2721,6 +2731,9 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -3293,6 +3306,7 @@
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
||||||
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
|
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.4",
|
"graceful-fs": "^4.2.4",
|
||||||
|
|
@ -3304,6 +3318,7 @@
|
||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
|
|
@ -3458,6 +3473,7 @@
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||||
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
|
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
|
|
@ -4119,21 +4135,6 @@
|
||||||
"node": ">=0.4"
|
"node": ">=0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
|
||||||
"version": "2.8.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
|
|
||||||
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
|
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
|
||||||
"yaml": "bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/eemeli"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/zeptomatch": {
|
"node_modules/zeptomatch": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "scrum4me-mcp",
|
"name": "scrum4me-mcp",
|
||||||
"version": "0.8.0",
|
"version": "0.1.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": {
|
||||||
|
|
@ -32,10 +32,7 @@
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@prisma/adapter-pg": "^7.8.0",
|
"@prisma/adapter-pg": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
"@types/proper-lockfile": "^4.1.4",
|
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"proper-lockfile": "^4.1.2",
|
|
||||||
"yaml": "^2.8.4",
|
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
}
|
}
|
||||||
|
|
@ -10,20 +11,17 @@ enum Role {
|
||||||
PRODUCT_OWNER
|
PRODUCT_OWNER
|
||||||
SCRUM_MASTER
|
SCRUM_MASTER
|
||||||
DEVELOPER
|
DEVELOPER
|
||||||
ADMIN
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum StoryStatus {
|
enum StoryStatus {
|
||||||
OPEN
|
OPEN
|
||||||
IN_SPRINT
|
IN_SPRINT
|
||||||
DONE
|
DONE
|
||||||
FAILED
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PbiStatus {
|
enum PbiStatus {
|
||||||
READY
|
READY
|
||||||
BLOCKED
|
BLOCKED
|
||||||
FAILED
|
|
||||||
DONE
|
DONE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,7 +32,6 @@ enum ClaudeJobStatus {
|
||||||
DONE
|
DONE
|
||||||
FAILED
|
FAILED
|
||||||
CANCELLED
|
CANCELLED
|
||||||
SKIPPED
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum VerifyResult {
|
enum VerifyResult {
|
||||||
|
|
@ -55,8 +52,6 @@ enum TaskStatus {
|
||||||
IN_PROGRESS
|
IN_PROGRESS
|
||||||
REVIEW
|
REVIEW
|
||||||
DONE
|
DONE
|
||||||
FAILED
|
|
||||||
EXCLUDED
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LogType {
|
enum LogType {
|
||||||
|
|
@ -71,94 +66,27 @@ enum TestStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SprintStatus {
|
enum SprintStatus {
|
||||||
OPEN
|
ACTIVE
|
||||||
CLOSED
|
COMPLETED
|
||||||
ARCHIVED
|
|
||||||
FAILED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SprintRunStatus {
|
|
||||||
QUEUED
|
|
||||||
RUNNING
|
|
||||||
PAUSED
|
|
||||||
DONE
|
|
||||||
FAILED
|
|
||||||
CANCELLED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PrStrategy {
|
|
||||||
SPRINT
|
|
||||||
STORY
|
|
||||||
SPRINT_BATCH
|
|
||||||
}
|
|
||||||
|
|
||||||
enum IdeaStatus {
|
|
||||||
DRAFT
|
|
||||||
GRILLING
|
|
||||||
GRILL_FAILED
|
|
||||||
GRILLED
|
|
||||||
PLANNING
|
|
||||||
PLAN_FAILED
|
|
||||||
PLAN_READY
|
|
||||||
REVIEWING_PLAN
|
|
||||||
PLAN_REVIEW_FAILED
|
|
||||||
PLAN_REVIEWED
|
|
||||||
PLANNED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ClaudeJobKind {
|
|
||||||
TASK_IMPLEMENTATION
|
|
||||||
IDEA_GRILL
|
|
||||||
IDEA_MAKE_PLAN
|
|
||||||
IDEA_REVIEW_PLAN
|
|
||||||
PLAN_CHAT
|
|
||||||
SPRINT_IMPLEMENTATION
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SprintTaskExecutionStatus {
|
|
||||||
PENDING
|
|
||||||
RUNNING
|
|
||||||
DONE
|
|
||||||
FAILED
|
|
||||||
SKIPPED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum IdeaLogType {
|
|
||||||
DECISION
|
|
||||||
NOTE
|
|
||||||
GRILL_RESULT
|
|
||||||
PLAN_RESULT
|
|
||||||
PLAN_REVIEW_RESULT
|
|
||||||
STATUS_CHANGE
|
|
||||||
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
|
||||||
email String? @unique
|
email String? @unique
|
||||||
password_hash String
|
password_hash String
|
||||||
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)
|
created_at DateTime @default(now())
|
||||||
idea_code_counter Int @default(0)
|
updated_at DateTime @updatedAt
|
||||||
min_quota_pct Int @default(20)
|
roles UserRole[]
|
||||||
settings Json @default("{}")
|
|
||||||
created_at DateTime @default(now())
|
|
||||||
updated_at DateTime @updatedAt
|
|
||||||
roles UserRole[]
|
|
||||||
api_tokens ApiToken[]
|
api_tokens ApiToken[]
|
||||||
products Product[]
|
products Product[]
|
||||||
ideas Idea[]
|
todos Todo[]
|
||||||
product_members ProductMember[]
|
product_members ProductMember[]
|
||||||
assigned_stories Story[] @relation("StoryAssignee")
|
assigned_stories Story[] @relation("StoryAssignee")
|
||||||
login_pairings LoginPairing[]
|
login_pairings LoginPairing[]
|
||||||
|
|
@ -166,8 +94,6 @@ model User {
|
||||||
answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer")
|
answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer")
|
||||||
claude_jobs ClaudeJob[]
|
claude_jobs ClaudeJob[]
|
||||||
claude_workers ClaudeWorker[]
|
claude_workers ClaudeWorker[]
|
||||||
started_sprint_runs SprintRun[] @relation("SprintRunStartedBy")
|
|
||||||
push_subscriptions PushSubscription[]
|
|
||||||
|
|
||||||
@@index([active_product_id])
|
@@index([active_product_id])
|
||||||
@@map("users")
|
@@map("users")
|
||||||
|
|
@ -184,47 +110,41 @@ model UserRole {
|
||||||
}
|
}
|
||||||
|
|
||||||
model ApiToken {
|
model ApiToken {
|
||||||
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_hash String @unique
|
token_hash String @unique
|
||||||
label String?
|
label String?
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
revoked_at DateTime?
|
revoked_at DateTime?
|
||||||
claimed_jobs ClaudeJob[]
|
claimed_jobs ClaudeJob[]
|
||||||
claude_worker ClaudeWorker?
|
claude_worker ClaudeWorker?
|
||||||
|
|
||||||
@@index([token_hash])
|
@@index([token_hash])
|
||||||
@@map("api_tokens")
|
@@map("api_tokens")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Product {
|
model Product {
|
||||||
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
|
||||||
name String
|
name String
|
||||||
code String? @db.VarChar(30)
|
code String? @db.VarChar(30)
|
||||||
description String?
|
description String?
|
||||||
repo_url String?
|
repo_url String?
|
||||||
definition_of_done String
|
definition_of_done String
|
||||||
auto_pr Boolean @default(false)
|
auto_pr Boolean @default(false)
|
||||||
pr_strategy PrStrategy @default(SPRINT)
|
archived Boolean @default(false)
|
||||||
preferred_model String?
|
created_at DateTime @default(now())
|
||||||
thinking_budget_default Int?
|
updated_at DateTime @updatedAt
|
||||||
preferred_permission_mode String?
|
|
||||||
archived Boolean @default(false)
|
|
||||||
created_at DateTime @default(now())
|
|
||||||
updated_at DateTime @updatedAt
|
|
||||||
pbis Pbi[]
|
pbis Pbi[]
|
||||||
sprints Sprint[]
|
sprints Sprint[]
|
||||||
stories Story[]
|
stories Story[]
|
||||||
tasks Task[]
|
todos Todo[]
|
||||||
members ProductMember[]
|
members ProductMember[]
|
||||||
active_for_users User[] @relation("UserActiveProduct")
|
active_for_users User[] @relation("UserActiveProduct")
|
||||||
claude_questions ClaudeQuestion[]
|
claude_questions ClaudeQuestion[]
|
||||||
claude_jobs ClaudeJob[]
|
claude_jobs ClaudeJob[]
|
||||||
ideas Idea[]
|
|
||||||
idea_products IdeaProduct[]
|
|
||||||
|
|
||||||
@@unique([user_id, name])
|
@@unique([user_id, name])
|
||||||
@@unique([user_id, code])
|
@@unique([user_id, code])
|
||||||
|
|
@ -233,21 +153,18 @@ model Product {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Pbi {
|
model Pbi {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||||
product_id String
|
product_id String
|
||||||
code String @db.VarChar(30)
|
code String? @db.VarChar(30)
|
||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
priority Int
|
priority Int
|
||||||
sort_order Float
|
sort_order Float
|
||||||
status PbiStatus @default(READY)
|
status PbiStatus @default(READY)
|
||||||
pr_url String?
|
created_at DateTime @default(now())
|
||||||
pr_merged_at DateTime?
|
updated_at DateTime @updatedAt
|
||||||
created_at DateTime @default(now())
|
stories Story[]
|
||||||
updated_at DateTime @updatedAt
|
|
||||||
stories Story[]
|
|
||||||
idea Idea?
|
|
||||||
|
|
||||||
@@unique([product_id, code])
|
@@unique([product_id, code])
|
||||||
@@index([product_id, priority, sort_order])
|
@@index([product_id, priority, sort_order])
|
||||||
|
|
@ -256,24 +173,24 @@ model Pbi {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Story {
|
model Story {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade)
|
pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade)
|
||||||
pbi_id String
|
pbi_id String
|
||||||
product Product @relation(fields: [product_id], references: [id])
|
product Product @relation(fields: [product_id], references: [id])
|
||||||
product_id String
|
product_id String
|
||||||
sprint Sprint? @relation(fields: [sprint_id], references: [id])
|
sprint Sprint? @relation(fields: [sprint_id], references: [id])
|
||||||
sprint_id String?
|
sprint_id String?
|
||||||
assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull)
|
assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull)
|
||||||
assignee_id String?
|
assignee_id String?
|
||||||
code String @db.VarChar(30)
|
code String? @db.VarChar(30)
|
||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
acceptance_criteria String?
|
acceptance_criteria String?
|
||||||
priority Int
|
priority Int
|
||||||
sort_order Float
|
sort_order Float
|
||||||
status StoryStatus @default(OPEN)
|
status StoryStatus @default(OPEN)
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
logs StoryLog[]
|
logs StoryLog[]
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
claude_questions ClaudeQuestion[]
|
claude_questions ClaudeQuestion[]
|
||||||
|
|
@ -306,196 +223,84 @@ model Sprint {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||||
product_id String
|
product_id String
|
||||||
code String @db.VarChar(30)
|
|
||||||
sprint_goal String
|
sprint_goal String
|
||||||
status SprintStatus @default(OPEN)
|
status SprintStatus @default(ACTIVE)
|
||||||
start_date DateTime? @db.Date
|
start_date DateTime? @db.Date
|
||||||
end_date DateTime? @db.Date
|
end_date DateTime? @db.Date
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
completed_at DateTime?
|
completed_at DateTime?
|
||||||
stories Story[]
|
stories Story[]
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
sprint_runs SprintRun[]
|
|
||||||
|
|
||||||
@@unique([product_id, code])
|
|
||||||
@@index([product_id, status])
|
@@index([product_id, status])
|
||||||
@@map("sprints")
|
@@map("sprints")
|
||||||
}
|
}
|
||||||
|
|
||||||
model SprintRun {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
sprint Sprint @relation(fields: [sprint_id], references: [id], onDelete: Cascade)
|
|
||||||
sprint_id String
|
|
||||||
started_by User @relation("SprintRunStartedBy", fields: [started_by_id], references: [id])
|
|
||||||
started_by_id String
|
|
||||||
status SprintRunStatus @default(QUEUED)
|
|
||||||
pr_strategy PrStrategy
|
|
||||||
branch String?
|
|
||||||
pr_url String?
|
|
||||||
started_at DateTime?
|
|
||||||
finished_at DateTime?
|
|
||||||
failure_reason String?
|
|
||||||
failed_task Task? @relation("SprintRunFailedTask", fields: [failed_task_id], references: [id], onDelete: SetNull)
|
|
||||||
failed_task_id String?
|
|
||||||
pause_context Json?
|
|
||||||
previous_run_id String? @unique
|
|
||||||
previous_run SprintRun? @relation("SprintRunChain", fields: [previous_run_id], references: [id], onDelete: SetNull)
|
|
||||||
next_run SprintRun? @relation("SprintRunChain")
|
|
||||||
created_at DateTime @default(now())
|
|
||||||
updated_at DateTime @updatedAt
|
|
||||||
jobs ClaudeJob[]
|
|
||||||
|
|
||||||
@@index([sprint_id, status])
|
|
||||||
@@index([started_by_id, status])
|
|
||||||
@@map("sprint_runs")
|
|
||||||
}
|
|
||||||
|
|
||||||
model Task {
|
model Task {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
||||||
story_id String
|
story_id String
|
||||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
sprint Sprint? @relation(fields: [sprint_id], references: [id])
|
||||||
product_id String
|
sprint_id String?
|
||||||
sprint Sprint? @relation(fields: [sprint_id], references: [id])
|
title String
|
||||||
sprint_id String?
|
description String?
|
||||||
code String @db.VarChar(30)
|
implementation_plan String?
|
||||||
title String
|
priority Int
|
||||||
description String?
|
sort_order Float
|
||||||
implementation_plan String?
|
status TaskStatus @default(TO_DO)
|
||||||
priority Int
|
verify_only Boolean @default(false)
|
||||||
sort_order Float
|
verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL)
|
||||||
status TaskStatus @default(TO_DO)
|
created_at DateTime @default(now())
|
||||||
verify_only Boolean @default(false)
|
updated_at DateTime @updatedAt
|
||||||
verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL)
|
claude_questions ClaudeQuestion[]
|
||||||
requires_opus Boolean @default(false)
|
claude_jobs ClaudeJob[]
|
||||||
// Override product.repo_url for branch/worktree/push purposes. Set when
|
|
||||||
// a task targets a different repo than its parent product (e.g. an
|
|
||||||
// MCP-server task tracked under the main product's PBI). Falls back to
|
|
||||||
// product.repo_url when null.
|
|
||||||
repo_url String?
|
|
||||||
created_at DateTime @default(now())
|
|
||||||
updated_at DateTime @updatedAt
|
|
||||||
claude_questions ClaudeQuestion[]
|
|
||||||
claude_jobs ClaudeJob[]
|
|
||||||
sprint_run_failures SprintRun[] @relation("SprintRunFailedTask")
|
|
||||||
sprint_task_executions SprintTaskExecution[]
|
|
||||||
|
|
||||||
@@unique([product_id, code])
|
|
||||||
@@index([story_id, priority, sort_order])
|
@@index([story_id, priority, sort_order])
|
||||||
@@index([sprint_id, status])
|
@@index([sprint_id, status])
|
||||||
@@index([product_id])
|
|
||||||
@@map("tasks")
|
@@map("tasks")
|
||||||
}
|
}
|
||||||
|
|
||||||
model ClaudeJob {
|
model ClaudeJob {
|
||||||
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
|
||||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||||
product_id String
|
product_id String
|
||||||
task Task? @relation(fields: [task_id], references: [id], onDelete: Cascade)
|
task Task @relation(fields: [task_id], references: [id], onDelete: Cascade)
|
||||||
task_id String?
|
task_id String
|
||||||
idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade)
|
status ClaudeJobStatus @default(QUEUED)
|
||||||
idea_id String?
|
claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull)
|
||||||
sprint_run SprintRun? @relation(fields: [sprint_run_id], references: [id], onDelete: SetNull)
|
|
||||||
sprint_run_id String?
|
|
||||||
kind ClaudeJobKind @default(TASK_IMPLEMENTATION)
|
|
||||||
status ClaudeJobStatus @default(QUEUED)
|
|
||||||
claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull)
|
|
||||||
claimed_by_token_id String?
|
claimed_by_token_id String?
|
||||||
claimed_at DateTime?
|
claimed_at DateTime?
|
||||||
started_at DateTime?
|
started_at DateTime?
|
||||||
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?
|
|
||||||
requested_model String?
|
|
||||||
requested_thinking_budget Int?
|
|
||||||
requested_permission_mode String?
|
|
||||||
actual_thinking_tokens Int?
|
|
||||||
plan_snapshot String?
|
plan_snapshot String?
|
||||||
base_sha String?
|
|
||||||
head_sha String?
|
|
||||||
branch String?
|
branch String?
|
||||||
pr_url String?
|
pr_url String?
|
||||||
summary String?
|
summary String?
|
||||||
error String?
|
error String?
|
||||||
retry_count Int @default(0)
|
retry_count Int @default(0)
|
||||||
lease_until DateTime?
|
created_at DateTime @default(now())
|
||||||
task_executions SprintTaskExecution[] @relation("SprintJobExecutions")
|
updated_at DateTime @updatedAt
|
||||||
created_at DateTime @default(now())
|
|
||||||
updated_at DateTime @updatedAt
|
|
||||||
|
|
||||||
@@index([user_id, status])
|
@@index([user_id, status])
|
||||||
@@index([task_id, status])
|
@@index([task_id, status])
|
||||||
@@index([idea_id, status])
|
|
||||||
@@index([sprint_run_id, status])
|
|
||||||
@@index([status, claimed_at])
|
@@index([status, claimed_at])
|
||||||
@@index([status, finished_at])
|
@@index([status, finished_at])
|
||||||
@@index([status, lease_until])
|
|
||||||
@@map("claude_jobs")
|
@@map("claude_jobs")
|
||||||
}
|
}
|
||||||
|
|
||||||
// PBI-50: frozen scope-snapshot per SPRINT_IMPLEMENTATION-claim. Bij claim
|
|
||||||
// wordt voor elke TO_DO-task in scope één PENDING-record gemaakt met
|
|
||||||
// implementation_plan + verify_required gesnapshot. Worker en gate werken
|
|
||||||
// uitsluitend op deze rows; latere wijzigingen aan Task hebben geen
|
|
||||||
// invloed op de lopende batch.
|
|
||||||
model SprintTaskExecution {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
sprint_job ClaudeJob @relation("SprintJobExecutions", fields: [sprint_job_id], references: [id], onDelete: Cascade)
|
|
||||||
sprint_job_id String
|
|
||||||
task Task @relation(fields: [task_id], references: [id], onDelete: Cascade)
|
|
||||||
task_id String
|
|
||||||
order Int
|
|
||||||
plan_snapshot String @db.Text
|
|
||||||
verify_required_snapshot VerifyRequired
|
|
||||||
verify_only_snapshot Boolean @default(false)
|
|
||||||
base_sha String?
|
|
||||||
head_sha String?
|
|
||||||
status SprintTaskExecutionStatus @default(PENDING)
|
|
||||||
verify_result VerifyResult?
|
|
||||||
verify_summary String? @db.Text
|
|
||||||
skip_reason String? @db.Text
|
|
||||||
started_at DateTime?
|
|
||||||
finished_at DateTime?
|
|
||||||
created_at DateTime @default(now())
|
|
||||||
updated_at DateTime @updatedAt
|
|
||||||
|
|
||||||
@@unique([sprint_job_id, task_id])
|
|
||||||
@@index([sprint_job_id, order])
|
|
||||||
@@map("sprint_task_executions")
|
|
||||||
}
|
|
||||||
|
|
||||||
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])
|
||||||
|
|
@ -515,80 +320,22 @@ model ProductMember {
|
||||||
@@map("product_members")
|
@@map("product_members")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Idea {
|
model Todo {
|
||||||
id String @id @default(cuid())
|
|
||||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
|
||||||
user_id String
|
|
||||||
product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull)
|
|
||||||
product_id String?
|
|
||||||
code String @db.VarChar(30)
|
|
||||||
title String
|
|
||||||
description String? @db.VarChar(4000)
|
|
||||||
grill_md String? @db.Text
|
|
||||||
plan_md String? @db.Text
|
|
||||||
plan_review_log Json? // ReviewLog from orchestrator (all rounds, convergence metrics, approval status)
|
|
||||||
reviewed_at DateTime? // When last reviewed
|
|
||||||
pbi Pbi? @relation(fields: [pbi_id], references: [id], onDelete: SetNull)
|
|
||||||
pbi_id String? @unique
|
|
||||||
status IdeaStatus @default(DRAFT)
|
|
||||||
archived Boolean @default(false)
|
|
||||||
created_at DateTime @default(now())
|
|
||||||
updated_at DateTime @updatedAt
|
|
||||||
|
|
||||||
questions ClaudeQuestion[]
|
|
||||||
jobs ClaudeJob[]
|
|
||||||
logs IdeaLog[]
|
|
||||||
user_questions UserQuestion[]
|
|
||||||
secondary_products IdeaProduct[]
|
|
||||||
|
|
||||||
@@unique([user_id, code])
|
|
||||||
@@index([user_id, archived, status])
|
|
||||||
@@index([user_id, product_id])
|
|
||||||
@@map("ideas")
|
|
||||||
}
|
|
||||||
|
|
||||||
model IdeaProduct {
|
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
idea_id String
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
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 {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade)
|
|
||||||
idea_id String
|
|
||||||
type IdeaLogType
|
|
||||||
content String @db.Text
|
|
||||||
metadata Json?
|
|
||||||
created_at DateTime @default(now())
|
|
||||||
|
|
||||||
@@index([idea_id, created_at])
|
|
||||||
@@map("idea_logs")
|
|
||||||
}
|
|
||||||
|
|
||||||
model UserQuestion {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
idea_id String
|
|
||||||
user_id String
|
user_id String
|
||||||
question String @db.Text
|
product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull)
|
||||||
answer String? @db.Text
|
product_id String?
|
||||||
status UserQuestionStatus @default(pending)
|
title String
|
||||||
created_at DateTime @default(now())
|
description String? @db.VarChar(2000)
|
||||||
updated_at DateTime @updatedAt
|
done Boolean @default(false)
|
||||||
|
archived Boolean @default(false)
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade)
|
@@index([user_id, done, archived])
|
||||||
|
@@index([user_id, product_id])
|
||||||
@@index([idea_id, status])
|
@@map("todos")
|
||||||
@@index([user_id])
|
|
||||||
@@map("user_questions")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model LoginPairing {
|
model LoginPairing {
|
||||||
|
|
@ -611,45 +358,27 @@ model LoginPairing {
|
||||||
}
|
}
|
||||||
|
|
||||||
model ClaudeQuestion {
|
model ClaudeQuestion {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
story Story? @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
||||||
story_id String?
|
story_id String
|
||||||
task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull)
|
task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull)
|
||||||
task_id String?
|
task_id String?
|
||||||
idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade)
|
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||||
idea_id String?
|
product_id String // gedenormaliseerd uit story.product_id voor SSE-filter
|
||||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id])
|
||||||
product_id String // gedenormaliseerd uit story.product_id voor SSE-filter
|
asked_by String // user_id van token-houder (= Claude-token)
|
||||||
asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id])
|
question String @db.Text
|
||||||
asked_by String // user_id van token-houder (= Claude-token)
|
options Json? // string[] voor multi-choice; null voor free-text
|
||||||
question String @db.Text
|
status String // 'open' | 'answered' | 'cancelled' | 'expired'
|
||||||
options Json? // string[] voor multi-choice; null voor free-text
|
answer String? @db.Text
|
||||||
status String // 'open' | 'answered' | 'cancelled' | 'expired'
|
answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id])
|
||||||
answer String? @db.Text
|
answered_by String?
|
||||||
answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id])
|
answered_at DateTime?
|
||||||
answered_by String?
|
created_at DateTime @default(now())
|
||||||
answered_at DateTime?
|
expires_at DateTime // ingesteld door MCP-tool, default now() + 24h
|
||||||
created_at DateTime @default(now())
|
|
||||||
expires_at DateTime // ingesteld door MCP-tool, default now() + 24h
|
|
||||||
|
|
||||||
@@index([story_id, status])
|
@@index([story_id, status])
|
||||||
@@index([idea_id, status])
|
|
||||||
@@index([product_id, status])
|
@@index([product_id, status])
|
||||||
@@index([status, expires_at])
|
@@index([status, expires_at])
|
||||||
@@map("claude_questions")
|
@@map("claude_questions")
|
||||||
}
|
}
|
||||||
|
|
||||||
model PushSubscription {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
|
||||||
user_id String
|
|
||||||
endpoint String @unique
|
|
||||||
p256dh String
|
|
||||||
auth String
|
|
||||||
user_agent String?
|
|
||||||
created_at DateTime @default(now())
|
|
||||||
last_used_at DateTime @default(now())
|
|
||||||
|
|
||||||
@@index([user_id])
|
|
||||||
@@map("push_subscriptions")
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,229 +0,0 @@
|
||||||
// PostToolUse hook for mcp__scrum4me__update_job_status.
|
|
||||||
//
|
|
||||||
// Reads the local Claude Code transcript (no Anthropic API needed) and writes
|
|
||||||
// per-job token usage + model_id to claude_jobs. The hook receives a JSON
|
|
||||||
// payload on stdin with { session_id, transcript_path, tool_name, tool_input }.
|
|
||||||
//
|
|
||||||
// Window detection: the most-recent assistant message before EOF that issued a
|
|
||||||
// `mcp__scrum4me__wait_for_job` tool_use marks the job's start. All assistant
|
|
||||||
// messages after that index, up to and including the one that just called
|
|
||||||
// update_job_status, are summed.
|
|
||||||
//
|
|
||||||
// Idempotent — running twice for the same job overwrites with the same values.
|
|
||||||
// Designed to never block the agent: any failure logs a warning and exits 0.
|
|
||||||
|
|
||||||
import { readFile } from 'node:fs/promises'
|
|
||||||
import { prisma } from '../src/prisma.js'
|
|
||||||
|
|
||||||
export type HookInput = {
|
|
||||||
session_id?: string
|
|
||||||
transcript_path?: string
|
|
||||||
tool_name?: string
|
|
||||||
tool_input?: { job_id?: string; status?: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
type Usage = {
|
|
||||||
input_tokens?: number
|
|
||||||
output_tokens?: number
|
|
||||||
cache_creation_input_tokens?: number
|
|
||||||
cache_read_input_tokens?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type ContentBlock = { type?: string; name?: string }
|
|
||||||
|
|
||||||
type TranscriptLine = {
|
|
||||||
type?: string
|
|
||||||
uuid?: string
|
|
||||||
isSidechain?: boolean
|
|
||||||
message?: {
|
|
||||||
role?: string
|
|
||||||
model?: string
|
|
||||||
content?: ContentBlock[]
|
|
||||||
usage?: Usage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ComputedUsage = {
|
|
||||||
model_id: string | null
|
|
||||||
input_tokens: number
|
|
||||||
output_tokens: number
|
|
||||||
cache_read_tokens: number
|
|
||||||
cache_write_tokens: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const WAIT_TOOL_NAME = 'mcp__scrum4me__wait_for_job'
|
|
||||||
const UPDATE_TOOL_NAME = 'mcp__scrum4me__update_job_status'
|
|
||||||
|
|
||||||
export function parseTranscript(raw: string): TranscriptLine[] {
|
|
||||||
const lines = raw.split('\n')
|
|
||||||
const out: TranscriptLine[] = []
|
|
||||||
const seenUuids = new Set<string>()
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line) continue
|
|
||||||
let parsed: TranscriptLine
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(line) as TranscriptLine
|
|
||||||
} catch {
|
|
||||||
continue // skip malformed lines — transcript may be partially written
|
|
||||||
}
|
|
||||||
// Dedup on uuid: branching/resumption can re-write the same message into
|
|
||||||
// multiple JSONLs. Keep first occurrence.
|
|
||||||
if (parsed.uuid) {
|
|
||||||
if (seenUuids.has(parsed.uuid)) continue
|
|
||||||
seenUuids.add(parsed.uuid)
|
|
||||||
}
|
|
||||||
out.push(parsed)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasToolUse(line: TranscriptLine, toolName: string): boolean {
|
|
||||||
const content = line.message?.content
|
|
||||||
if (!Array.isArray(content)) return false
|
|
||||||
return content.some((c) => c.type === 'tool_use' && c.name === toolName)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeUsageFromTranscript(lines: TranscriptLine[]): ComputedUsage {
|
|
||||||
// Skip subagent (sidechain) lines: token usage attributed to subagent work
|
|
||||||
// is reported in the main transcript via assistant messages of the parent
|
|
||||||
// agent. Counting sidechain lines as well risks double-attribution because
|
|
||||||
// those same units of work also appear in `subagents/`-subdirectory files.
|
|
||||||
const main = lines.filter((l) => !l.isSidechain)
|
|
||||||
|
|
||||||
// Find the last main-agent assistant message that called wait_for_job.
|
|
||||||
let startIdx = -1
|
|
||||||
for (let i = main.length - 1; i >= 0; i--) {
|
|
||||||
if (hasToolUse(main[i], WAIT_TOOL_NAME)) {
|
|
||||||
startIdx = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Window = (startIdx, end]. If no wait_for_job found, sum the whole session.
|
|
||||||
const from = startIdx + 1
|
|
||||||
const window = main.slice(from)
|
|
||||||
|
|
||||||
let input = 0
|
|
||||||
let output = 0
|
|
||||||
let cacheRead = 0
|
|
||||||
let cacheWrite = 0
|
|
||||||
let model: string | null = null
|
|
||||||
const modelsSeen = new Set<string>()
|
|
||||||
|
|
||||||
for (const line of window) {
|
|
||||||
if (line.type !== 'assistant') continue
|
|
||||||
const msg = line.message
|
|
||||||
if (!msg || msg.role !== 'assistant') continue
|
|
||||||
const u = msg.usage
|
|
||||||
if (u) {
|
|
||||||
input += u.input_tokens ?? 0
|
|
||||||
output += u.output_tokens ?? 0
|
|
||||||
cacheRead += u.cache_read_input_tokens ?? 0
|
|
||||||
cacheWrite += u.cache_creation_input_tokens ?? 0
|
|
||||||
}
|
|
||||||
if (msg.model) {
|
|
||||||
modelsSeen.add(msg.model)
|
|
||||||
model = msg.model // keep last
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modelsSeen.size > 1) {
|
|
||||||
console.warn(
|
|
||||||
`[persist-job-usage] multiple models in window: ${[...modelsSeen].join(', ')} — using last (${model})`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
model_id: model ? normalizeModelId(model) : null,
|
|
||||||
input_tokens: input,
|
|
||||||
output_tokens: output,
|
|
||||||
cache_read_tokens: cacheRead,
|
|
||||||
cache_write_tokens: cacheWrite,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip wrapping brackets so [1m]-suffix maps cleanly to a model_prices row.
|
|
||||||
// Example: 'claude-opus-4-7[1m]' → 'claude-opus-4-7-1m'.
|
|
||||||
export function normalizeModelId(raw: string): string {
|
|
||||||
return raw.replace(/\[(.*?)\]/g, '-$1')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readHookInput(): Promise<HookInput> {
|
|
||||||
const chunks: Buffer[] = []
|
|
||||||
for await (const chunk of process.stdin) {
|
|
||||||
chunks.push(chunk as Buffer)
|
|
||||||
}
|
|
||||||
const raw = Buffer.concat(chunks).toString('utf8').trim()
|
|
||||||
if (!raw) return {}
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw) as HookInput
|
|
||||||
} catch {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function persistJobUsage(input: HookInput): Promise<'skipped' | 'written' | 'noop'> {
|
|
||||||
if (input.tool_name !== UPDATE_TOOL_NAME) return 'skipped'
|
|
||||||
const status = input.tool_input?.status
|
|
||||||
if (status !== 'done' && status !== 'failed') return 'skipped'
|
|
||||||
const jobId = input.tool_input?.job_id
|
|
||||||
if (!jobId) return 'skipped'
|
|
||||||
const transcriptPath = input.transcript_path
|
|
||||||
if (!transcriptPath) return 'skipped'
|
|
||||||
|
|
||||||
let raw: string
|
|
||||||
try {
|
|
||||||
raw = await readFile(transcriptPath, 'utf8')
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[persist-job-usage] cannot read transcript ${transcriptPath}:`, err)
|
|
||||||
return 'skipped'
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = parseTranscript(raw)
|
|
||||||
const usage = computeUsageFromTranscript(lines)
|
|
||||||
|
|
||||||
// Skip pure no-op: no usage data and no model — nothing meaningful to persist.
|
|
||||||
if (
|
|
||||||
usage.model_id === null &&
|
|
||||||
usage.input_tokens === 0 &&
|
|
||||||
usage.output_tokens === 0 &&
|
|
||||||
usage.cache_read_tokens === 0 &&
|
|
||||||
usage.cache_write_tokens === 0
|
|
||||||
) {
|
|
||||||
return 'noop'
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.claudeJob.update({
|
|
||||||
where: { id: jobId },
|
|
||||||
data: {
|
|
||||||
...(usage.model_id !== null ? { model_id: usage.model_id } : {}),
|
|
||||||
input_tokens: usage.input_tokens,
|
|
||||||
output_tokens: usage.output_tokens,
|
|
||||||
cache_read_tokens: usage.cache_read_tokens,
|
|
||||||
cache_write_tokens: usage.cache_write_tokens,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return 'written'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const input = await readHookInput()
|
|
||||||
const result = await persistJobUsage(input)
|
|
||||||
if (result === 'written') {
|
|
||||||
console.log(`[persist-job-usage] persisted usage for job=${input.tool_input?.job_id}`)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[persist-job-usage] error:', err)
|
|
||||||
} finally {
|
|
||||||
// Ensure clean exit even if Prisma keeps a connection pool alive.
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDirect =
|
|
||||||
import.meta.url === `file://${process.argv[1]}` ||
|
|
||||||
process.argv[1]?.endsWith('persist-job-usage.ts')
|
|
||||||
if (isDirect) {
|
|
||||||
void main()
|
|
||||||
}
|
|
||||||
|
|
@ -28,13 +28,3 @@ export async function userCanAccessStory(storyId: string, userId: string): Promi
|
||||||
if (!story) return false
|
if (!story) return false
|
||||||
return userCanAccessProduct(story.product_id, userId)
|
return userCanAccessProduct(story.product_id, userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// M12: idee is strikt user_id-only (geen productAccessFilter — Q8).
|
|
||||||
// Idea-questions, idea-jobs, en idea-md-mutaties scopen op de eigenaar.
|
|
||||||
export async function userOwnsIdea(ideaId: string, userId: string): Promise<boolean> {
|
|
||||||
const idea = await prisma.idea.findUnique({
|
|
||||||
where: { id: ideaId },
|
|
||||||
select: { user_id: true },
|
|
||||||
})
|
|
||||||
return idea !== null && idea.user_id === userId
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,253 +0,0 @@
|
||||||
// PBI fail-cascade — wanneer een TASK_IMPLEMENTATION-job FAILED wordt,
|
|
||||||
// cancellen we alle queued/claimed/running siblings binnen dezelfde PBI
|
|
||||||
// en draaien we eerder gepushte commits ongedaan via PR-close of een
|
|
||||||
// auto-revert-PR. Idempotent en non-blocking: elke fout wordt gelogd in
|
|
||||||
// het error-veld van de oorspronkelijke failed-job en stopt de cascade niet.
|
|
||||||
|
|
||||||
import { prisma } from '../prisma.js'
|
|
||||||
import { resolveRepoRoot } from '../tools/wait-for-job.js'
|
|
||||||
import { removeWorktreeForJob } from '../git/worktree.js'
|
|
||||||
import {
|
|
||||||
closePullRequest,
|
|
||||||
createRevertPullRequest,
|
|
||||||
getPullRequestState,
|
|
||||||
} from '../git/pr.js'
|
|
||||||
import { deleteRemoteBranch } from '../git/push.js'
|
|
||||||
import { releaseLocksOnTerminal } from '../git/job-locks.js'
|
|
||||||
|
|
||||||
export type CascadeOutcome = {
|
|
||||||
cancelled_job_ids: string[]
|
|
||||||
closed_prs: string[]
|
|
||||||
reverted_prs: { original: string; revertPr: string }[]
|
|
||||||
deleted_branches: string[]
|
|
||||||
warnings: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY: CascadeOutcome = {
|
|
||||||
cancelled_job_ids: [],
|
|
||||||
closed_prs: [],
|
|
||||||
reverted_prs: [],
|
|
||||||
deleted_branches: [],
|
|
||||||
warnings: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public entry. Always returns; never throws.
|
|
||||||
export async function cancelPbiOnFailure(failedJobId: string): Promise<CascadeOutcome> {
|
|
||||||
try {
|
|
||||||
return await runCascade(failedJobId)
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[pbi-cascade] unexpected error for failedJob=${failedJobId}:`, err)
|
|
||||||
return { ...EMPTY, warnings: [`unexpected: ${(err as Error).message}`] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runCascade(failedJobId: string): Promise<CascadeOutcome> {
|
|
||||||
const failedJob = await prisma.claudeJob.findUnique({
|
|
||||||
where: { id: failedJobId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
kind: true,
|
|
||||||
status: true,
|
|
||||||
product_id: true,
|
|
||||||
task_id: true,
|
|
||||||
branch: true,
|
|
||||||
pr_url: true,
|
|
||||||
task: {
|
|
||||||
select: {
|
|
||||||
story: {
|
|
||||||
select: {
|
|
||||||
pbi: { select: { id: true, code: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!failedJob) return EMPTY
|
|
||||||
if (failedJob.kind !== 'TASK_IMPLEMENTATION') return EMPTY
|
|
||||||
// SKIPPED is een no-op exit (zie update_job_status). Geen cascade naar siblings.
|
|
||||||
if (failedJob.status === 'SKIPPED') return EMPTY
|
|
||||||
const pbi = failedJob.task?.story?.pbi
|
|
||||||
if (!pbi) return EMPTY
|
|
||||||
|
|
||||||
// 1. Atomic cascade: select + updateMany. Race-window between SELECT
|
|
||||||
// and UPDATE is harmless because the cascade is idempotent — a second
|
|
||||||
// invocation simply finds zero rows.
|
|
||||||
const eligible = await prisma.claudeJob.findMany({
|
|
||||||
where: {
|
|
||||||
id: { not: failedJobId },
|
|
||||||
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
|
|
||||||
task: { story: { pbi_id: pbi.id } },
|
|
||||||
},
|
|
||||||
select: { id: true, branch: true, pr_url: true, status: true, task_id: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (eligible.length > 0) {
|
|
||||||
await prisma.claudeJob.updateMany({
|
|
||||||
where: { id: { in: eligible.map((j) => j.id) } },
|
|
||||||
data: {
|
|
||||||
status: 'CANCELLED',
|
|
||||||
finished_at: new Date(),
|
|
||||||
error: 'cancelled_by_pbi_failure',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
// PBI-9: release product-worktree locks for cancelled jobs.
|
|
||||||
// No-op for jobs without registered locks (TASK_IMPLEMENTATION).
|
|
||||||
for (const j of eligible) await releaseLocksOnTerminal(j.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const outcome: CascadeOutcome = {
|
|
||||||
cancelled_job_ids: eligible.map((j) => j.id),
|
|
||||||
closed_prs: [],
|
|
||||||
reverted_prs: [],
|
|
||||||
deleted_branches: [],
|
|
||||||
warnings: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Group affected jobs (cascade-set ∪ failed) by branch to avoid
|
|
||||||
// closing the same PR twice for siblings sharing a story-branch.
|
|
||||||
const branchSet = new Map<string, { prUrl: string | null }>()
|
|
||||||
const all = [...eligible, { branch: failedJob.branch, pr_url: failedJob.pr_url }]
|
|
||||||
for (const j of all) {
|
|
||||||
if (!j.branch) continue
|
|
||||||
const existing = branchSet.get(j.branch)
|
|
||||||
// Prefer a non-null pr_url if any sibling has one.
|
|
||||||
if (!existing) {
|
|
||||||
branchSet.set(j.branch, { prUrl: j.pr_url ?? null })
|
|
||||||
} else if (!existing.prUrl && j.pr_url) {
|
|
||||||
branchSet.set(j.branch, { prUrl: j.pr_url })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const repoRoot = await resolveRepoRoot(failedJob.product_id)
|
|
||||||
const cascadeComment = `PBI ${pbi.code ?? pbi.id} cascaded fail — see job ${failedJobId}`
|
|
||||||
|
|
||||||
for (const [branch, { prUrl }] of branchSet) {
|
|
||||||
if (prUrl) {
|
|
||||||
const info = await getPullRequestState({ prUrl, cwd: repoRoot ?? undefined })
|
|
||||||
if ('error' in info) {
|
|
||||||
outcome.warnings.push(`gh pr view ${prUrl}: ${info.error}`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (info.state === 'CLOSED') {
|
|
||||||
// Already closed; nothing to do for the PR. Branch may still exist.
|
|
||||||
if (repoRoot) await tryDeleteBranch(repoRoot, branch, outcome)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (info.state === 'OPEN') {
|
|
||||||
const closed = await closePullRequest({
|
|
||||||
prUrl,
|
|
||||||
comment: cascadeComment,
|
|
||||||
cwd: repoRoot ?? undefined,
|
|
||||||
})
|
|
||||||
if ('error' in closed) {
|
|
||||||
outcome.warnings.push(`close ${prUrl}: ${closed.error}`)
|
|
||||||
} else {
|
|
||||||
outcome.closed_prs.push(prUrl)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (info.state === 'MERGED') {
|
|
||||||
if (!repoRoot) {
|
|
||||||
outcome.warnings.push(
|
|
||||||
`merged PR ${prUrl} not reverted: no repo root configured for product ${failedJob.product_id}`,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (!info.mergeCommit) {
|
|
||||||
outcome.warnings.push(`merged PR ${prUrl} has no mergeCommit — skipping revert`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const revert = await createRevertPullRequest({
|
|
||||||
repoRoot,
|
|
||||||
mergeSha: info.mergeCommit,
|
|
||||||
baseRef: info.baseRefName,
|
|
||||||
originalTitle: info.title,
|
|
||||||
originalBranch: branch,
|
|
||||||
jobId: failedJobId,
|
|
||||||
pbiCode: pbi.code,
|
|
||||||
})
|
|
||||||
if ('error' in revert) {
|
|
||||||
outcome.warnings.push(`revert ${prUrl}: ${revert.error}`)
|
|
||||||
} else {
|
|
||||||
outcome.reverted_prs.push({ original: prUrl, revertPr: revert.url })
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Branch without PR: best-effort delete on remote.
|
|
||||||
if (repoRoot) await tryDeleteBranch(repoRoot, branch, outcome)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Worktree cleanup for every cancelled job (and the failed job itself
|
|
||||||
// is handled elsewhere by cleanupWorktreeForTerminalStatus). For
|
|
||||||
// cancelled jobs we always discard the branch locally — they did not
|
|
||||||
// succeed.
|
|
||||||
if (repoRoot) {
|
|
||||||
for (const j of eligible) {
|
|
||||||
try {
|
|
||||||
await removeWorktreeForJob({ repoRoot, jobId: j.id, keepBranch: false })
|
|
||||||
} catch (err) {
|
|
||||||
outcome.warnings.push(`worktree cleanup for ${j.id}: ${(err as Error).message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Persist a trace on the failed-job's error field so the operator can
|
|
||||||
// follow up. Use a structured one-liner to keep the column readable.
|
|
||||||
// Append to the existing error (separated by '\n---\n') so the original
|
|
||||||
// failure reason is preserved instead of being overwritten by the trace.
|
|
||||||
const trace = formatTrace(outcome)
|
|
||||||
if (trace) {
|
|
||||||
try {
|
|
||||||
const fresh = await prisma.claudeJob.findUnique({
|
|
||||||
where: { id: failedJobId },
|
|
||||||
select: { error: true },
|
|
||||||
})
|
|
||||||
const merged = fresh?.error
|
|
||||||
? `${fresh.error}\n---\n${trace}`.slice(0, 1900)
|
|
||||||
: trace.slice(0, 1900)
|
|
||||||
await prisma.claudeJob.update({
|
|
||||||
where: { id: failedJobId },
|
|
||||||
data: { error: merged },
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[pbi-cascade] failed to persist trace for ${failedJobId}:`, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return outcome
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tryDeleteBranch(
|
|
||||||
repoRoot: string,
|
|
||||||
branch: string,
|
|
||||||
outcome: CascadeOutcome,
|
|
||||||
): Promise<void> {
|
|
||||||
const result = await deleteRemoteBranch({ repoRoot, branch })
|
|
||||||
if (result.deleted) {
|
|
||||||
outcome.deleted_branches.push(branch)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (result.reason === 'not-found') {
|
|
||||||
// Already gone — silent no-op.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
outcome.warnings.push(
|
|
||||||
`delete-branch ${branch} (${result.reason}): ${result.stderr.slice(0, 120)}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTrace(o: CascadeOutcome): string {
|
|
||||||
const parts: string[] = ['cancelled_by_self']
|
|
||||||
if (o.cancelled_job_ids.length) parts.push(`siblings_cancelled=${o.cancelled_job_ids.length}`)
|
|
||||||
if (o.closed_prs.length) parts.push(`closed=${o.closed_prs.join(',')}`)
|
|
||||||
if (o.reverted_prs.length) {
|
|
||||||
parts.push(`reverted=${o.reverted_prs.map((r) => `${r.original}->${r.revertPr}`).join(';')}`)
|
|
||||||
}
|
|
||||||
if (o.deleted_branches.length) parts.push(`branches_deleted=${o.deleted_branches.join(',')}`)
|
|
||||||
if (o.warnings.length) parts.push(`warnings=${o.warnings.length}`)
|
|
||||||
return parts.join('; ')
|
|
||||||
}
|
|
||||||
|
|
@ -1,192 +0,0 @@
|
||||||
// PBI-9 + PBI-47: declarative effects produced by pure transitions.
|
|
||||||
// Executor handles each effect idempotently; failures are logged, not thrown.
|
|
||||||
|
|
||||||
export type PauseContext = {
|
|
||||||
pause_reason: 'MERGE_CONFLICT'
|
|
||||||
pr_url: string
|
|
||||||
pr_head_sha: string
|
|
||||||
conflict_files: string[]
|
|
||||||
claude_question_id: string
|
|
||||||
resume_instructions: string
|
|
||||||
paused_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FlowEffect =
|
|
||||||
| { type: 'RELEASE_WORKTREE_LOCKS'; jobId: string }
|
|
||||||
| { type: 'ENABLE_AUTO_MERGE'; prUrl: string; expectedHeadSha: string }
|
|
||||||
| { type: 'MARK_PR_READY'; prUrl: string }
|
|
||||||
| {
|
|
||||||
type: 'CREATE_CLAUDE_QUESTION'
|
|
||||||
sprintRunId: string
|
|
||||||
prUrl: string
|
|
||||||
files: string[]
|
|
||||||
}
|
|
||||||
| { type: 'CLOSE_CLAUDE_QUESTION'; questionId: string }
|
|
||||||
| {
|
|
||||||
type: 'SET_SPRINT_RUN_STATUS'
|
|
||||||
sprintRunId: string
|
|
||||||
status: 'QUEUED' | 'RUNNING' | 'PAUSED' | 'DONE' | 'FAILED' | 'CANCELLED'
|
|
||||||
pauseContextDraft?: Omit<PauseContext, 'claude_question_id'>
|
|
||||||
clearPauseContext?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AutoMergeOutcome =
|
|
||||||
| { effect: 'ENABLE_AUTO_MERGE'; ok: true }
|
|
||||||
| {
|
|
||||||
effect: 'ENABLE_AUTO_MERGE'
|
|
||||||
ok: false
|
|
||||||
reason: 'CHECKS_FAILED' | 'MERGE_CONFLICT' | 'GH_AUTH_ERROR' | 'AUTO_MERGE_NOT_ALLOWED' | 'UNKNOWN'
|
|
||||||
stderr: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a list of effects in order. Returns outcome objects only for
|
|
||||||
* effects whose result the caller needs to react to (auto-merge fail
|
|
||||||
* triggers MERGE_CONFLICT-event in update-job-status). Other failures
|
|
||||||
* are logged but swallowed.
|
|
||||||
*
|
|
||||||
* CREATE_CLAUDE_QUESTION → SET_SPRINT_RUN_STATUS chains: the question_id
|
|
||||||
* created in the first effect is injected into the pause_context of the
|
|
||||||
* second.
|
|
||||||
*/
|
|
||||||
export async function executeEffects(
|
|
||||||
effects: FlowEffect[],
|
|
||||||
): Promise<AutoMergeOutcome[]> {
|
|
||||||
const outcomes: AutoMergeOutcome[] = []
|
|
||||||
let lastQuestionId: string | undefined
|
|
||||||
for (const effect of effects) {
|
|
||||||
try {
|
|
||||||
if (effect.type === 'CREATE_CLAUDE_QUESTION') {
|
|
||||||
lastQuestionId = await createOrReuseClaudeQuestion(effect)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (effect.type === 'SET_SPRINT_RUN_STATUS') {
|
|
||||||
await applySprintRunStatus(effect, lastQuestionId)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const outcome = await executeEffect(effect)
|
|
||||||
if (outcome) outcomes.push(outcome)
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[effects] effect ${effect.type} failed (idempotent skip):`, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return outcomes
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeEffect(effect: FlowEffect): Promise<AutoMergeOutcome | undefined> {
|
|
||||||
switch (effect.type) {
|
|
||||||
case 'RELEASE_WORKTREE_LOCKS': {
|
|
||||||
const { releaseLocksOnTerminal } = await import('../git/job-locks.js')
|
|
||||||
await releaseLocksOnTerminal(effect.jobId)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
case 'ENABLE_AUTO_MERGE': {
|
|
||||||
const { enableAutoMergeOnPr } = await import('../git/pr.js')
|
|
||||||
const result = await enableAutoMergeOnPr({
|
|
||||||
prUrl: effect.prUrl,
|
|
||||||
expectedHeadSha: effect.expectedHeadSha,
|
|
||||||
})
|
|
||||||
if (result.ok) return { effect: 'ENABLE_AUTO_MERGE', ok: true }
|
|
||||||
return { effect: 'ENABLE_AUTO_MERGE', ok: false, reason: result.reason, stderr: result.stderr }
|
|
||||||
}
|
|
||||||
case 'MARK_PR_READY': {
|
|
||||||
const { markPullRequestReady } = await import('../git/pr.js')
|
|
||||||
const result = await markPullRequestReady({ prUrl: effect.prUrl })
|
|
||||||
if ('error' in result) {
|
|
||||||
console.warn(`[effects] MARK_PR_READY failed for ${effect.prUrl}: ${result.error}`)
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
case 'CLOSE_CLAUDE_QUESTION': {
|
|
||||||
const { prisma } = await import('../prisma.js')
|
|
||||||
await prisma.claudeQuestion.updateMany({
|
|
||||||
where: { id: effect.questionId, status: 'open' },
|
|
||||||
data: { status: 'closed' },
|
|
||||||
})
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
// CREATE_CLAUDE_QUESTION + SET_SPRINT_RUN_STATUS handled in executeEffects.
|
|
||||||
case 'CREATE_CLAUDE_QUESTION':
|
|
||||||
case 'SET_SPRINT_RUN_STATUS':
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createOrReuseClaudeQuestion(effect: {
|
|
||||||
sprintRunId: string
|
|
||||||
prUrl: string
|
|
||||||
files: string[]
|
|
||||||
}): Promise<string> {
|
|
||||||
const { prisma } = await import('../prisma.js')
|
|
||||||
|
|
||||||
// Reuse existing open question for the same SprintRun + PR if present.
|
|
||||||
const existing = await prisma.claudeQuestion.findFirst({
|
|
||||||
where: {
|
|
||||||
status: 'open',
|
|
||||||
options: { path: ['sprint_run_id'], equals: effect.sprintRunId } as never,
|
|
||||||
},
|
|
||||||
orderBy: { created_at: 'desc' },
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
if (existing) return existing.id
|
|
||||||
|
|
||||||
// Need product_id + asker (user) to create. Resolve via SprintRun.
|
|
||||||
const sprintRun = await prisma.sprintRun.findUnique({
|
|
||||||
where: { id: effect.sprintRunId },
|
|
||||||
select: {
|
|
||||||
started_by_id: true,
|
|
||||||
sprint: { select: { product_id: true } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (!sprintRun) {
|
|
||||||
throw new Error(`SprintRun ${effect.sprintRunId} not found`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileList =
|
|
||||||
effect.files.length === 0
|
|
||||||
? '(unknown files — check the PR)'
|
|
||||||
: effect.files.slice(0, 5).join(', ')
|
|
||||||
+ (effect.files.length > 5 ? ` + ${effect.files.length - 5} more` : '')
|
|
||||||
|
|
||||||
const created = await prisma.claudeQuestion.create({
|
|
||||||
data: {
|
|
||||||
product_id: sprintRun.sprint.product_id,
|
|
||||||
asked_by: sprintRun.started_by_id,
|
|
||||||
question:
|
|
||||||
`Merge-conflict on ${effect.prUrl}. Conflict files: ${fileList}. `
|
|
||||||
+ `Resolve on the branch and push, then resume the sprint.`,
|
|
||||||
options: {
|
|
||||||
sprint_run_id: effect.sprintRunId,
|
|
||||||
pr_url: effect.prUrl,
|
|
||||||
conflict_files: effect.files,
|
|
||||||
},
|
|
||||||
status: 'open',
|
|
||||||
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
return created.id
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applySprintRunStatus(
|
|
||||||
effect: Extract<FlowEffect, { type: 'SET_SPRINT_RUN_STATUS' }>,
|
|
||||||
lastQuestionId: string | undefined,
|
|
||||||
): Promise<void> {
|
|
||||||
const { prisma, Prisma } = await (async () => {
|
|
||||||
const mod = await import('../prisma.js')
|
|
||||||
const prismaPkg = await import('@prisma/client')
|
|
||||||
return { prisma: mod.prisma, Prisma: prismaPkg.Prisma }
|
|
||||||
})()
|
|
||||||
|
|
||||||
const data: Record<string, unknown> = { status: effect.status }
|
|
||||||
if (effect.pauseContextDraft && lastQuestionId) {
|
|
||||||
data.pause_context = {
|
|
||||||
...effect.pauseContextDraft,
|
|
||||||
claude_question_id: lastQuestionId,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (effect.clearPauseContext) {
|
|
||||||
data.pause_context = Prisma.JsonNull
|
|
||||||
}
|
|
||||||
await prisma.sprintRun.update({ where: { id: effect.sprintRunId }, data })
|
|
||||||
}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
import type { FlowEffect } from './effects.js'
|
|
||||||
import type { AutoMergeFailReason } from '../git/pr.js'
|
|
||||||
|
|
||||||
export type PrStrategy = 'STORY' | 'SPRINT'
|
|
||||||
|
|
||||||
export type PrFlowState =
|
|
||||||
| { kind: 'none'; strategy: PrStrategy }
|
|
||||||
| { kind: 'branch_pushed'; strategy: PrStrategy; prUrl?: string }
|
|
||||||
| { kind: 'pr_opened'; strategy: 'STORY'; prUrl: string }
|
|
||||||
| { kind: 'draft_opened'; strategy: 'SPRINT'; prUrl: string }
|
|
||||||
| { kind: 'waiting_for_checks'; strategy: 'STORY'; prUrl: string; headSha: string }
|
|
||||||
| { kind: 'auto_merge_enabled'; strategy: 'STORY'; prUrl: string; headSha: string }
|
|
||||||
| { kind: 'ready_for_review'; strategy: 'SPRINT'; prUrl: string }
|
|
||||||
| { kind: 'merged'; strategy: PrStrategy; prUrl: string }
|
|
||||||
| { kind: 'checks_failed'; strategy: PrStrategy; prUrl: string }
|
|
||||||
| { kind: 'merge_conflict_paused'; strategy: PrStrategy; prUrl: string; headSha: string }
|
|
||||||
|
|
||||||
export type PrFlowEvent =
|
|
||||||
| { type: 'PR_CREATED'; prUrl: string }
|
|
||||||
| { type: 'TASK_DONE'; taskId: string; headSha: string }
|
|
||||||
| { type: 'STORY_COMPLETED'; storyId: string; headSha: string }
|
|
||||||
| { type: 'SPRINT_COMPLETED'; sprintRunId: string }
|
|
||||||
| { type: 'MERGE_RESULT'; reason?: AutoMergeFailReason }
|
|
||||||
|
|
||||||
export type TransitionResult = { nextState: PrFlowState; effects: FlowEffect[] }
|
|
||||||
|
|
||||||
export function transition(state: PrFlowState, event: PrFlowEvent): TransitionResult {
|
|
||||||
if (state.strategy === 'STORY') {
|
|
||||||
switch (state.kind) {
|
|
||||||
case 'none':
|
|
||||||
case 'branch_pushed':
|
|
||||||
if (event.type === 'PR_CREATED') {
|
|
||||||
return {
|
|
||||||
nextState: { kind: 'pr_opened', strategy: 'STORY', prUrl: event.prUrl },
|
|
||||||
effects: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'pr_opened':
|
|
||||||
if (event.type === 'STORY_COMPLETED') {
|
|
||||||
return {
|
|
||||||
nextState: {
|
|
||||||
kind: 'waiting_for_checks',
|
|
||||||
strategy: 'STORY',
|
|
||||||
prUrl: state.prUrl,
|
|
||||||
headSha: event.headSha,
|
|
||||||
},
|
|
||||||
effects: [
|
|
||||||
{ type: 'ENABLE_AUTO_MERGE', prUrl: state.prUrl, expectedHeadSha: event.headSha },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'waiting_for_checks':
|
|
||||||
if (event.type === 'MERGE_RESULT' && !event.reason) {
|
|
||||||
return {
|
|
||||||
nextState: {
|
|
||||||
kind: 'auto_merge_enabled',
|
|
||||||
strategy: 'STORY',
|
|
||||||
prUrl: state.prUrl,
|
|
||||||
headSha: state.headSha,
|
|
||||||
},
|
|
||||||
effects: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (event.type === 'MERGE_RESULT' && event.reason === 'MERGE_CONFLICT') {
|
|
||||||
return {
|
|
||||||
nextState: {
|
|
||||||
kind: 'merge_conflict_paused',
|
|
||||||
strategy: 'STORY',
|
|
||||||
prUrl: state.prUrl,
|
|
||||||
headSha: state.headSha,
|
|
||||||
},
|
|
||||||
effects: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (event.type === 'MERGE_RESULT' && event.reason === 'CHECKS_FAILED') {
|
|
||||||
return {
|
|
||||||
nextState: { kind: 'checks_failed', strategy: 'STORY', prUrl: state.prUrl },
|
|
||||||
effects: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.strategy === 'SPRINT') {
|
|
||||||
switch (state.kind) {
|
|
||||||
case 'none':
|
|
||||||
case 'branch_pushed':
|
|
||||||
if (event.type === 'PR_CREATED') {
|
|
||||||
return {
|
|
||||||
nextState: { kind: 'draft_opened', strategy: 'SPRINT', prUrl: event.prUrl },
|
|
||||||
effects: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'draft_opened':
|
|
||||||
if (event.type === 'SPRINT_COMPLETED') {
|
|
||||||
return {
|
|
||||||
nextState: { kind: 'ready_for_review', strategy: 'SPRINT', prUrl: state.prUrl },
|
|
||||||
effects: [{ type: 'MARK_PR_READY', prUrl: state.prUrl }],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { nextState: state, effects: [] }
|
|
||||||
}
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
import type { FlowEffect, PauseContext } from './effects.js'
|
|
||||||
|
|
||||||
export type SprintRunStateKind =
|
|
||||||
| 'queued'
|
|
||||||
| 'running'
|
|
||||||
| 'paused_merge_conflict'
|
|
||||||
| 'done'
|
|
||||||
| 'failed'
|
|
||||||
| 'cancelled'
|
|
||||||
|
|
||||||
export type SprintRunState = {
|
|
||||||
kind: SprintRunStateKind
|
|
||||||
sprintRunId: string
|
|
||||||
pauseContext?: PauseContext
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SprintRunEvent =
|
|
||||||
| { type: 'CLAIM_FIRST_JOB' }
|
|
||||||
| { type: 'TASK_DONE'; taskId: string }
|
|
||||||
| { type: 'TASK_FAILED'; taskId: string; error: string }
|
|
||||||
| {
|
|
||||||
type: 'MERGE_CONFLICT'
|
|
||||||
prUrl: string
|
|
||||||
prHeadSha: string
|
|
||||||
conflictFiles: string[]
|
|
||||||
resumeInstructions: string
|
|
||||||
}
|
|
||||||
| { type: 'USER_RESUMED' }
|
|
||||||
| { type: 'USER_CANCELLED' }
|
|
||||||
| { type: 'ALL_DONE' }
|
|
||||||
|
|
||||||
export type TransitionResult = { nextState: SprintRunState; effects: FlowEffect[] }
|
|
||||||
|
|
||||||
export function transition(state: SprintRunState, event: SprintRunEvent): TransitionResult {
|
|
||||||
switch (state.kind) {
|
|
||||||
case 'queued':
|
|
||||||
if (event.type === 'CLAIM_FIRST_JOB') {
|
|
||||||
return {
|
|
||||||
nextState: { ...state, kind: 'running' },
|
|
||||||
effects: [
|
|
||||||
{ type: 'SET_SPRINT_RUN_STATUS', sprintRunId: state.sprintRunId, status: 'RUNNING' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'running':
|
|
||||||
if (event.type === 'TASK_DONE') {
|
|
||||||
return { nextState: state, effects: [] }
|
|
||||||
}
|
|
||||||
if (event.type === 'TASK_FAILED') {
|
|
||||||
return {
|
|
||||||
nextState: { ...state, kind: 'failed' },
|
|
||||||
effects: [
|
|
||||||
{ type: 'SET_SPRINT_RUN_STATUS', sprintRunId: state.sprintRunId, status: 'FAILED' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (event.type === 'ALL_DONE') {
|
|
||||||
return {
|
|
||||||
nextState: { ...state, kind: 'done' },
|
|
||||||
effects: [
|
|
||||||
{ type: 'SET_SPRINT_RUN_STATUS', sprintRunId: state.sprintRunId, status: 'DONE' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (event.type === 'MERGE_CONFLICT') {
|
|
||||||
const pauseContextDraft: Omit<PauseContext, 'claude_question_id'> = {
|
|
||||||
pause_reason: 'MERGE_CONFLICT',
|
|
||||||
pr_url: event.prUrl,
|
|
||||||
pr_head_sha: event.prHeadSha,
|
|
||||||
conflict_files: event.conflictFiles,
|
|
||||||
resume_instructions: event.resumeInstructions,
|
|
||||||
paused_at: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
nextState: { ...state, kind: 'paused_merge_conflict' },
|
|
||||||
effects: [
|
|
||||||
{
|
|
||||||
type: 'CREATE_CLAUDE_QUESTION',
|
|
||||||
sprintRunId: state.sprintRunId,
|
|
||||||
prUrl: event.prUrl,
|
|
||||||
files: event.conflictFiles,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'SET_SPRINT_RUN_STATUS',
|
|
||||||
sprintRunId: state.sprintRunId,
|
|
||||||
status: 'PAUSED',
|
|
||||||
pauseContextDraft,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (event.type === 'USER_CANCELLED') {
|
|
||||||
return {
|
|
||||||
nextState: { ...state, kind: 'cancelled' },
|
|
||||||
effects: [
|
|
||||||
{ type: 'SET_SPRINT_RUN_STATUS', sprintRunId: state.sprintRunId, status: 'CANCELLED' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'paused_merge_conflict':
|
|
||||||
if (event.type === 'USER_RESUMED') {
|
|
||||||
const closeQuestionEffects: FlowEffect[] = state.pauseContext
|
|
||||||
? [{ type: 'CLOSE_CLAUDE_QUESTION', questionId: state.pauseContext.claude_question_id }]
|
|
||||||
: []
|
|
||||||
return {
|
|
||||||
nextState: { ...state, kind: 'running', pauseContext: undefined },
|
|
||||||
effects: [
|
|
||||||
...closeQuestionEffects,
|
|
||||||
{
|
|
||||||
type: 'SET_SPRINT_RUN_STATUS',
|
|
||||||
sprintRunId: state.sprintRunId,
|
|
||||||
status: 'RUNNING',
|
|
||||||
clearPauseContext: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (event.type === 'USER_CANCELLED') {
|
|
||||||
return {
|
|
||||||
nextState: { ...state, kind: 'cancelled', pauseContext: undefined },
|
|
||||||
effects: [
|
|
||||||
{
|
|
||||||
type: 'SET_SPRINT_RUN_STATUS',
|
|
||||||
sprintRunId: state.sprintRunId,
|
|
||||||
status: 'CANCELLED',
|
|
||||||
clearPauseContext: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
return { nextState: state, effects: [] }
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import type { FlowEffect } from './effects.js'
|
|
||||||
|
|
||||||
export type WorktreeLeaseState =
|
|
||||||
| { kind: 'idle' }
|
|
||||||
| { kind: 'acquiring_lock'; jobId: string; productIds: string[] }
|
|
||||||
| { kind: 'creating_or_reusing'; jobId: string; productIds: string[] }
|
|
||||||
| { kind: 'syncing'; jobId: string; productIds: string[] }
|
|
||||||
| { kind: 'ready'; jobId: string; productIds: string[] }
|
|
||||||
| { kind: 'releasing'; jobId: string }
|
|
||||||
| { kind: 'released'; jobId: string }
|
|
||||||
| { kind: 'lock_timeout'; jobId: string; productIds: string[] }
|
|
||||||
| { kind: 'sync_failed'; jobId: string; productIds: string[]; error: string }
|
|
||||||
| { kind: 'stale_released'; jobId: string }
|
|
||||||
|
|
||||||
export type WorktreeLeaseEvent =
|
|
||||||
| { type: 'JOB_CLAIMED'; jobId: string; productIds: string[] }
|
|
||||||
| { type: 'LOCK_ACQUIRED' }
|
|
||||||
| { type: 'LOCK_TIMEOUT' }
|
|
||||||
| { type: 'WORKTREE_READY' }
|
|
||||||
| { type: 'SYNC_DONE' }
|
|
||||||
| { type: 'SYNC_FAILED'; error: string }
|
|
||||||
| { type: 'JOB_TERMINAL'; jobId: string }
|
|
||||||
| { type: 'STALE_RESET'; jobId: string }
|
|
||||||
|
|
||||||
export type TransitionResult = {
|
|
||||||
nextState: WorktreeLeaseState
|
|
||||||
effects: FlowEffect[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function transition(
|
|
||||||
state: WorktreeLeaseState,
|
|
||||||
event: WorktreeLeaseEvent,
|
|
||||||
): TransitionResult {
|
|
||||||
switch (state.kind) {
|
|
||||||
case 'idle':
|
|
||||||
if (event.type === 'JOB_CLAIMED') {
|
|
||||||
return {
|
|
||||||
nextState: { kind: 'acquiring_lock', jobId: event.jobId, productIds: event.productIds },
|
|
||||||
effects: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'acquiring_lock':
|
|
||||||
if (event.type === 'LOCK_ACQUIRED') {
|
|
||||||
return {
|
|
||||||
nextState: { kind: 'creating_or_reusing', jobId: state.jobId, productIds: state.productIds },
|
|
||||||
effects: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (event.type === 'LOCK_TIMEOUT') {
|
|
||||||
return {
|
|
||||||
nextState: { kind: 'lock_timeout', jobId: state.jobId, productIds: state.productIds },
|
|
||||||
effects: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'creating_or_reusing':
|
|
||||||
if (event.type === 'WORKTREE_READY') {
|
|
||||||
return {
|
|
||||||
nextState: { kind: 'syncing', jobId: state.jobId, productIds: state.productIds },
|
|
||||||
effects: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'syncing':
|
|
||||||
if (event.type === 'SYNC_DONE') {
|
|
||||||
return {
|
|
||||||
nextState: { kind: 'ready', jobId: state.jobId, productIds: state.productIds },
|
|
||||||
effects: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (event.type === 'SYNC_FAILED') {
|
|
||||||
return {
|
|
||||||
nextState: {
|
|
||||||
kind: 'sync_failed',
|
|
||||||
jobId: state.jobId,
|
|
||||||
productIds: state.productIds,
|
|
||||||
error: event.error,
|
|
||||||
},
|
|
||||||
effects: [{ type: 'RELEASE_WORKTREE_LOCKS', jobId: state.jobId }],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'ready':
|
|
||||||
if (event.type === 'JOB_TERMINAL') {
|
|
||||||
return {
|
|
||||||
nextState: { kind: 'releasing', jobId: state.jobId },
|
|
||||||
effects: [{ type: 'RELEASE_WORKTREE_LOCKS', jobId: state.jobId }],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (event.type === 'STALE_RESET') {
|
|
||||||
return {
|
|
||||||
nextState: { kind: 'stale_released', jobId: state.jobId },
|
|
||||||
effects: [{ type: 'RELEASE_WORKTREE_LOCKS', jobId: state.jobId }],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'releasing':
|
|
||||||
return { nextState: { kind: 'released', jobId: state.jobId }, effects: [] }
|
|
||||||
}
|
|
||||||
// Unknown or forbidden transition — keep current state, no effects
|
|
||||||
return { nextState: state, effects: [] }
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import lockfile from 'proper-lockfile'
|
|
||||||
|
|
||||||
export async function acquireFileLock(lockPath: string): Promise<() => Promise<void>> {
|
|
||||||
const release = await lockfile.lock(lockPath, {
|
|
||||||
realpath: false,
|
|
||||||
stale: 30_000,
|
|
||||||
update: 5_000,
|
|
||||||
retries: { retries: 60, factor: 1, minTimeout: 1_000, maxTimeout: 1_000 },
|
|
||||||
})
|
|
||||||
let released = false
|
|
||||||
return async () => {
|
|
||||||
if (released) return
|
|
||||||
released = true
|
|
||||||
await release()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function acquireFileLocksOrdered(
|
|
||||||
lockPaths: string[],
|
|
||||||
): Promise<() => Promise<void>> {
|
|
||||||
const sorted = [...lockPaths].sort()
|
|
||||||
const releases: Array<() => Promise<void>> = []
|
|
||||||
try {
|
|
||||||
for (const p of sorted) {
|
|
||||||
releases.push(await acquireFileLock(p))
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
for (const r of releases.reverse()) {
|
|
||||||
await r().catch(() => {})
|
|
||||||
}
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
return async () => {
|
|
||||||
for (const r of releases.reverse()) {
|
|
||||||
await r().catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import * as fs from 'node:fs/promises'
|
|
||||||
import * as path from 'node:path'
|
|
||||||
import { acquireFileLocksOrdered } from './file-lock.js'
|
|
||||||
import {
|
|
||||||
getProductWorktreeLockPath,
|
|
||||||
getWorktreeRoot,
|
|
||||||
} from './worktree-paths.js'
|
|
||||||
import {
|
|
||||||
getOrCreateProductWorktree,
|
|
||||||
syncProductWorktree,
|
|
||||||
} from './product-worktree.js'
|
|
||||||
|
|
||||||
type JobReleases = Map<string, Array<() => Promise<void>>>
|
|
||||||
const jobReleases: JobReleases = new Map()
|
|
||||||
|
|
||||||
export async function setupProductWorktrees(
|
|
||||||
jobId: string,
|
|
||||||
productIds: string[],
|
|
||||||
resolveRepoRoot: (productId: string) => Promise<string | null>,
|
|
||||||
): Promise<Array<{ productId: string; worktreePath: string }>> {
|
|
||||||
if (productIds.length === 0) return []
|
|
||||||
|
|
||||||
// Ensure parent dir exists so lockfile creation succeeds
|
|
||||||
await fs.mkdir(path.join(getWorktreeRoot(), '_products'), { recursive: true })
|
|
||||||
|
|
||||||
// Lock-first, alphabetically sorted (deadlock prevention for multi-product idea-jobs).
|
|
||||||
// Locks acquired in sorted order; output preserves caller's input order so that
|
|
||||||
// worktrees[0] is the primary product (Idea.product_id), regardless of how its
|
|
||||||
// id sorts alphabetically against secondary products.
|
|
||||||
const sorted = [...productIds].sort()
|
|
||||||
const lockPaths = sorted.map(getProductWorktreeLockPath)
|
|
||||||
const releaseAll = await acquireFileLocksOrdered(lockPaths)
|
|
||||||
registerJobLockReleases(jobId, [releaseAll])
|
|
||||||
|
|
||||||
// After lock-acquire, create/reuse worktrees and sync — iterate input order
|
|
||||||
// so callers get back [primary, ...secondaries] in their original sequence.
|
|
||||||
const out: Array<{ productId: string; worktreePath: string }> = []
|
|
||||||
for (const productId of productIds) {
|
|
||||||
const repoRoot = await resolveRepoRoot(productId)
|
|
||||||
if (!repoRoot) continue
|
|
||||||
const { worktreePath } = await getOrCreateProductWorktree({ repoRoot, productId })
|
|
||||||
await syncProductWorktree({ worktreePath })
|
|
||||||
out.push({ productId, worktreePath })
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerJobLockReleases(
|
|
||||||
jobId: string,
|
|
||||||
releases: Array<() => Promise<void>>,
|
|
||||||
): void {
|
|
||||||
const existing = jobReleases.get(jobId) ?? []
|
|
||||||
jobReleases.set(jobId, [...existing, ...releases])
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function releaseLocksOnTerminal(jobId: string): Promise<void> {
|
|
||||||
const releases = jobReleases.get(jobId)
|
|
||||||
if (!releases) return // idempotent — already released or never locked
|
|
||||||
jobReleases.delete(jobId)
|
|
||||||
for (const release of releases) {
|
|
||||||
try {
|
|
||||||
await release()
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[job-locks] release failed for job ${jobId}:`, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For tests
|
|
||||||
export function _resetJobReleasesForTest(): void {
|
|
||||||
jobReleases.clear()
|
|
||||||
}
|
|
||||||
271
src/git/pr.ts
271
src/git/pr.ts
|
|
@ -1,7 +1,5 @@
|
||||||
import { execFile } from 'node:child_process'
|
import { execFile } from 'node:child_process'
|
||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
import * as path from 'node:path'
|
|
||||||
import { getWorktreeRoot } from './worktree-paths.js'
|
|
||||||
|
|
||||||
const exec = promisify(execFile)
|
const exec = promisify(execFile)
|
||||||
|
|
||||||
|
|
@ -10,31 +8,22 @@ export async function createPullRequest(opts: {
|
||||||
branchName: string
|
branchName: string
|
||||||
title: string
|
title: string
|
||||||
body: string
|
body: string
|
||||||
/** Open as draft PR (mens moet 'm later ready-for-review zetten). Default false. */
|
|
||||||
draft?: boolean
|
|
||||||
/**
|
|
||||||
* PBI-47 (P0): default changed to false. Auto-merge is now enabled
|
|
||||||
* separately via `enableAutoMergeOnPr` only on the **last task** of a
|
|
||||||
* STORY-mode story, with a head-SHA guard to prevent racing earlier
|
|
||||||
* task merges. Callers may still pass `true` for one-off PRs that
|
|
||||||
* are immediately ready to merge; in that case we use the new typed
|
|
||||||
* helper rather than the previous fire-and-forget gh call.
|
|
||||||
*/
|
|
||||||
enableAutoMerge?: boolean
|
|
||||||
}): Promise<{ url: string } | { error: string }> {
|
}): Promise<{ url: string } | { error: string }> {
|
||||||
const { worktreePath, branchName, title, body, draft = false, enableAutoMerge = false } = opts
|
const { worktreePath, branchName, title, body } = opts
|
||||||
|
|
||||||
let url: string
|
|
||||||
try {
|
try {
|
||||||
const args = ['pr', 'create', '--title', title, '--body', body, '--head', branchName]
|
const { stdout } = await exec(
|
||||||
if (draft) args.push('--draft')
|
'gh',
|
||||||
const { stdout } = await exec('gh', args, { cwd: worktreePath })
|
['pr', 'create', '--title', title, '--body', body, '--head', branchName],
|
||||||
|
{ cwd: worktreePath },
|
||||||
|
)
|
||||||
// gh prints the PR URL as the last non-empty line
|
// gh prints the PR URL as the last non-empty line
|
||||||
const lines = stdout.trim().split('\n').filter(Boolean)
|
const lines = stdout.trim().split('\n').filter(Boolean)
|
||||||
url = lines[lines.length - 1]?.trim() ?? ''
|
const url = lines[lines.length - 1]?.trim() ?? ''
|
||||||
if (!url.startsWith('http')) {
|
if (!url.startsWith('http')) {
|
||||||
return { error: `gh pr create produced unexpected output: ${stdout.slice(0, 200)}` }
|
return { error: `gh pr create produced unexpected output: ${stdout.slice(0, 200)}` }
|
||||||
}
|
}
|
||||||
|
return { url }
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = (err as { message?: string }).message ?? String(err)
|
const msg = (err as { message?: string }).message ?? String(err)
|
||||||
const isNotFound =
|
const isNotFound =
|
||||||
|
|
@ -46,248 +35,4 @@ export async function createPullRequest(opts: {
|
||||||
}
|
}
|
||||||
return { error: `gh pr create failed: ${msg.slice(0, 300)}` }
|
return { error: `gh pr create failed: ${msg.slice(0, 300)}` }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy opt-in: enableAutoMerge=true and not draft → fire the new typed
|
|
||||||
// helper without head-SHA guard (caller didn't supply one). Result is
|
|
||||||
// logged but not propagated — same shape as before.
|
|
||||||
if (enableAutoMerge && !draft) {
|
|
||||||
const result = await enableAutoMergeOnPr({ prUrl: url, cwd: worktreePath })
|
|
||||||
if (!result.ok) {
|
|
||||||
console.warn(
|
|
||||||
`[createPullRequest] auto-merge enable failed for ${url}: ${result.reason} ${result.stderr.slice(0, 200)}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { url }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AutoMergeFailReason =
|
|
||||||
| 'CHECKS_FAILED'
|
|
||||||
| 'MERGE_CONFLICT'
|
|
||||||
| 'GH_AUTH_ERROR'
|
|
||||||
| 'AUTO_MERGE_NOT_ALLOWED'
|
|
||||||
| 'UNKNOWN'
|
|
||||||
|
|
||||||
export type EnableAutoMergeResult =
|
|
||||||
| { ok: true }
|
|
||||||
| { ok: false; reason: AutoMergeFailReason; stderr: string }
|
|
||||||
|
|
||||||
function classifyAutoMergeError(stderr: string): AutoMergeFailReason {
|
|
||||||
if (/conflict|not in mergeable state|dirty/i.test(stderr)) return 'MERGE_CONFLICT'
|
|
||||||
if (/checks? failed|status check|required check/i.test(stderr)) return 'CHECKS_FAILED'
|
|
||||||
if (/authentication|HTTP 401|HTTP 403|permission|gh auth/i.test(stderr)) return 'GH_AUTH_ERROR'
|
|
||||||
if (/auto-?merge.*not.*allowed|auto-?merge.*disabled/i.test(stderr)) return 'AUTO_MERGE_NOT_ALLOWED'
|
|
||||||
return 'UNKNOWN'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable auto-merge (squash) on a PR with an optional head-SHA guard.
|
|
||||||
*
|
|
||||||
* PBI-47 (P0): when `expectedHeadSha` is provided we pass `--match-head-commit`
|
|
||||||
* so GitHub only activates auto-merge if the remote head still matches the
|
|
||||||
* SHA the caller observed. This prevents racing late pushes from another
|
|
||||||
* worker triggering a merge of a different commit set.
|
|
||||||
*/
|
|
||||||
export async function enableAutoMergeOnPr(opts: {
|
|
||||||
prUrl: string
|
|
||||||
expectedHeadSha?: string
|
|
||||||
cwd?: string
|
|
||||||
}): Promise<EnableAutoMergeResult> {
|
|
||||||
try {
|
|
||||||
const args = ['pr', 'merge', '--auto', '--squash']
|
|
||||||
if (opts.expectedHeadSha) args.push('--match-head-commit', opts.expectedHeadSha)
|
|
||||||
args.push(opts.prUrl)
|
|
||||||
await exec('gh', args, opts.cwd ? { cwd: opts.cwd } : {})
|
|
||||||
return { ok: true }
|
|
||||||
} catch (err) {
|
|
||||||
const stderr =
|
|
||||||
(err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
|
|
||||||
return { ok: false, reason: classifyAutoMergeError(stderr), stderr: stderr.slice(0, 500) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zet een draft-PR over naar "ready for review". Gebruikt bij sprint-mode
|
|
||||||
// wanneer alle stories in de SprintRun DONE zijn — mens reviewt en mergt zelf.
|
|
||||||
export async function markPullRequestReady(opts: {
|
|
||||||
prUrl: string
|
|
||||||
cwd?: string
|
|
||||||
}): Promise<{ ok: true } | { error: string }> {
|
|
||||||
try {
|
|
||||||
await exec('gh', ['pr', 'ready', opts.prUrl], opts.cwd ? { cwd: opts.cwd } : {})
|
|
||||||
return { ok: true }
|
|
||||||
} catch (err) {
|
|
||||||
const msg = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
|
|
||||||
// gh-CLI fout "Pull request is not in draft state" is benign wanneer de
|
|
||||||
// PR al ready was (bv. handmatig ready gezet of een tweede call).
|
|
||||||
if (/not in draft state|already in ready/i.test(msg)) return { ok: true }
|
|
||||||
return { error: `gh pr ready failed: ${msg.slice(0, 300)}` }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PrState = 'OPEN' | 'MERGED' | 'CLOSED'
|
|
||||||
|
|
||||||
export type PrInfo = {
|
|
||||||
state: PrState
|
|
||||||
mergeCommit: string | null
|
|
||||||
baseRefName: string
|
|
||||||
title: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPullRequestState(opts: {
|
|
||||||
prUrl: string
|
|
||||||
cwd?: string
|
|
||||||
}): Promise<PrInfo | { error: string }> {
|
|
||||||
const { prUrl } = opts
|
|
||||||
try {
|
|
||||||
const { stdout } = await exec(
|
|
||||||
'gh',
|
|
||||||
['pr', 'view', prUrl, '--json', 'state,mergeCommit,baseRefName,title'],
|
|
||||||
opts.cwd ? { cwd: opts.cwd } : {},
|
|
||||||
)
|
|
||||||
const parsed = JSON.parse(stdout) as {
|
|
||||||
state: string
|
|
||||||
mergeCommit: { oid: string } | null
|
|
||||||
baseRefName: string
|
|
||||||
title: string
|
|
||||||
}
|
|
||||||
const state = parsed.state.toUpperCase() as PrState
|
|
||||||
if (state !== 'OPEN' && state !== 'MERGED' && state !== 'CLOSED') {
|
|
||||||
return { error: `unexpected PR state: ${parsed.state}` }
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
state,
|
|
||||||
mergeCommit: parsed.mergeCommit?.oid ?? null,
|
|
||||||
baseRefName: parsed.baseRefName,
|
|
||||||
title: parsed.title,
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const msg = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
|
|
||||||
return { error: `gh pr view failed: ${msg.slice(0, 300)}` }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closePullRequest(opts: {
|
|
||||||
prUrl: string
|
|
||||||
comment: string
|
|
||||||
cwd?: string
|
|
||||||
}): Promise<{ ok: true } | { error: string }> {
|
|
||||||
try {
|
|
||||||
await exec(
|
|
||||||
'gh',
|
|
||||||
['pr', 'close', opts.prUrl, '--delete-branch', '--comment', opts.comment],
|
|
||||||
opts.cwd ? { cwd: opts.cwd } : {},
|
|
||||||
)
|
|
||||||
return { ok: true }
|
|
||||||
} catch (err) {
|
|
||||||
const msg = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
|
|
||||||
return { error: `gh pr close failed: ${msg.slice(0, 300)}` }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Creates a revert-PR for a merged PR. Uses an isolated worktree so it
|
|
||||||
// never touches the user's main checkout. Returns the new PR URL or an
|
|
||||||
// error string. The revert PR is opened WITHOUT auto-merge — the user
|
|
||||||
// must review + merge it manually so an unintended cascade can be undone.
|
|
||||||
export async function createRevertPullRequest(opts: {
|
|
||||||
repoRoot: string
|
|
||||||
mergeSha: string
|
|
||||||
baseRef: string
|
|
||||||
originalTitle: string
|
|
||||||
originalBranch: string
|
|
||||||
jobId: string
|
|
||||||
pbiCode: string | null
|
|
||||||
}): Promise<{ url: string } | { error: string }> {
|
|
||||||
const {
|
|
||||||
repoRoot,
|
|
||||||
mergeSha,
|
|
||||||
baseRef,
|
|
||||||
originalTitle,
|
|
||||||
originalBranch,
|
|
||||||
jobId,
|
|
||||||
pbiCode,
|
|
||||||
} = opts
|
|
||||||
|
|
||||||
const worktreeDir = getWorktreeRoot()
|
|
||||||
const wtPath = path.join(worktreeDir, `revert-${jobId}`)
|
|
||||||
const revertBranch = `revert/${originalBranch}-${jobId.slice(-8)}`
|
|
||||||
|
|
||||||
const run = async (cmd: string, args: string[], cwd: string) => {
|
|
||||||
await exec(cmd, args, { cwd })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup helper, best-effort
|
|
||||||
const cleanup = async () => {
|
|
||||||
try {
|
|
||||||
await exec('git', ['worktree', 'remove', '--force', wtPath], { cwd: repoRoot })
|
|
||||||
} catch {
|
|
||||||
// ignore — worktree may not exist if creation failed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await run('git', ['fetch', 'origin', baseRef, mergeSha], repoRoot)
|
|
||||||
await run('git', ['worktree', 'add', '-b', revertBranch, wtPath, `origin/${baseRef}`], repoRoot)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await run('git', ['revert', '-m', '1', mergeSha, '--no-edit'], wtPath)
|
|
||||||
} catch (err) {
|
|
||||||
await cleanup()
|
|
||||||
const msg = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
|
|
||||||
if (/conflict/i.test(msg)) {
|
|
||||||
return { error: `git revert conflicts on ${mergeSha}: ${msg.slice(0, 200)}` }
|
|
||||||
}
|
|
||||||
return { error: `git revert failed: ${msg.slice(0, 200)}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
await run('git', ['push', '-u', 'origin', revertBranch], wtPath)
|
|
||||||
|
|
||||||
const pbiTag = pbiCode ? `PBI ${pbiCode}` : 'PBI'
|
|
||||||
const title = `Revert: ${originalTitle}`
|
|
||||||
const body = [
|
|
||||||
`Auto-revert by Scrum4Me agent.`,
|
|
||||||
``,
|
|
||||||
`Reason: ${pbiTag} failed (cascade from job \`${jobId}\`).`,
|
|
||||||
`Reverts merge commit \`${mergeSha}\`.`,
|
|
||||||
``,
|
|
||||||
`**Review carefully before merging** — auto-merge is intentionally NOT enabled on revert PRs.`,
|
|
||||||
].join('\n')
|
|
||||||
|
|
||||||
let prUrl: string
|
|
||||||
try {
|
|
||||||
const { stdout } = await exec(
|
|
||||||
'gh',
|
|
||||||
[
|
|
||||||
'pr',
|
|
||||||
'create',
|
|
||||||
'--base',
|
|
||||||
baseRef,
|
|
||||||
'--head',
|
|
||||||
revertBranch,
|
|
||||||
'--title',
|
|
||||||
title,
|
|
||||||
'--body',
|
|
||||||
body,
|
|
||||||
],
|
|
||||||
{ cwd: wtPath },
|
|
||||||
)
|
|
||||||
const lines = stdout.trim().split('\n').filter(Boolean)
|
|
||||||
prUrl = lines[lines.length - 1]?.trim() ?? ''
|
|
||||||
if (!prUrl.startsWith('http')) {
|
|
||||||
await cleanup()
|
|
||||||
return { error: `gh pr create produced unexpected output: ${stdout.slice(0, 200)}` }
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
await cleanup()
|
|
||||||
const msg = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
|
|
||||||
return { error: `gh pr create (revert) failed: ${msg.slice(0, 300)}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
await cleanup()
|
|
||||||
return { url: prUrl }
|
|
||||||
} catch (err) {
|
|
||||||
await cleanup()
|
|
||||||
const msg = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
|
|
||||||
return { error: `revert worktree setup failed: ${msg.slice(0, 300)}` }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
import { execFile } from 'node:child_process'
|
|
||||||
import { promisify } from 'node:util'
|
|
||||||
import * as fs from 'node:fs/promises'
|
|
||||||
import * as path from 'node:path'
|
|
||||||
import { getProductWorktreePath } from './worktree-paths.js'
|
|
||||||
|
|
||||||
const exec = promisify(execFile)
|
|
||||||
|
|
||||||
export async function getOrCreateProductWorktree(opts: {
|
|
||||||
repoRoot: string
|
|
||||||
productId: string
|
|
||||||
}): Promise<{ worktreePath: string; created: boolean }> {
|
|
||||||
const worktreePath = getProductWorktreePath(opts.productId)
|
|
||||||
await fs.mkdir(path.dirname(worktreePath), { recursive: true })
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fs.access(worktreePath)
|
|
||||||
return { worktreePath, created: false }
|
|
||||||
} catch {
|
|
||||||
// Path bestaat niet — aanmaken
|
|
||||||
}
|
|
||||||
|
|
||||||
await exec('git', ['fetch', 'origin', '--prune'], { cwd: opts.repoRoot })
|
|
||||||
await exec('git', ['worktree', 'add', '--detach', worktreePath, 'origin/main'], {
|
|
||||||
cwd: opts.repoRoot,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Resolve REAL exclude-pad (linked worktree heeft .git als file, niet directory)
|
|
||||||
const { stdout } = await exec('git', ['rev-parse', '--git-path', 'info/exclude'], {
|
|
||||||
cwd: worktreePath,
|
|
||||||
})
|
|
||||||
const excludePath = path.resolve(worktreePath, stdout.trim())
|
|
||||||
const existing = await fs.readFile(excludePath, 'utf8').catch(() => '')
|
|
||||||
if (!existing.split('\n').includes('.scratch/')) {
|
|
||||||
const sep = existing === '' || existing.endsWith('\n') ? '' : '\n'
|
|
||||||
await fs.appendFile(excludePath, `${sep}.scratch/\n`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { worktreePath, created: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function syncProductWorktree(opts: { worktreePath: string }): Promise<void> {
|
|
||||||
const { worktreePath } = opts
|
|
||||||
await exec('git', ['fetch', 'origin', '--prune'], { cwd: worktreePath })
|
|
||||||
await exec('git', ['reset', '--hard', 'origin/main'], { cwd: worktreePath })
|
|
||||||
await exec('git', ['clean', '-fd', '-e', '.scratch/'], { cwd: worktreePath })
|
|
||||||
// Wis .scratch/ inhoud, behoud de map
|
|
||||||
const scratch = path.join(worktreePath, '.scratch')
|
|
||||||
await fs.rm(scratch, { recursive: true, force: true })
|
|
||||||
await fs.mkdir(scratch, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeProductWorktree(opts: {
|
|
||||||
repoRoot: string
|
|
||||||
productId: string
|
|
||||||
}): Promise<{ removed: boolean }> {
|
|
||||||
const worktreePath = getProductWorktreePath(opts.productId)
|
|
||||||
try {
|
|
||||||
await exec('git', ['worktree', 'remove', '--force', worktreePath], {
|
|
||||||
cwd: opts.repoRoot,
|
|
||||||
})
|
|
||||||
return { removed: true }
|
|
||||||
} catch {
|
|
||||||
return { removed: false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -51,27 +51,3 @@ export async function pushBranchForJob(opts: {
|
||||||
return { pushed: false, reason: 'unknown', stderr }
|
return { pushed: false, reason: 'unknown', stderr }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeleteRemoteResult =
|
|
||||||
| { deleted: true }
|
|
||||||
| { deleted: false; reason: 'not-found' | 'no-credentials' | 'unknown'; stderr: string }
|
|
||||||
|
|
||||||
export async function deleteRemoteBranch(opts: {
|
|
||||||
repoRoot: string
|
|
||||||
branch: string
|
|
||||||
}): Promise<DeleteRemoteResult> {
|
|
||||||
const { repoRoot, branch } = opts
|
|
||||||
try {
|
|
||||||
await exec('git', ['push', 'origin', '--delete', branch], { cwd: repoRoot })
|
|
||||||
return { deleted: true }
|
|
||||||
} catch (err) {
|
|
||||||
const stderr = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
|
|
||||||
if (/remote ref does not exist|unable to delete .* remote ref does not exist/i.test(stderr)) {
|
|
||||||
return { deleted: false, reason: 'not-found', stderr }
|
|
||||||
}
|
|
||||||
if (/Authentication failed|could not read Username/i.test(stderr)) {
|
|
||||||
return { deleted: false, reason: 'no-credentials', stderr }
|
|
||||||
}
|
|
||||||
return { deleted: false, reason: 'unknown', stderr }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import * as os from 'node:os'
|
|
||||||
import * as path from 'node:path'
|
|
||||||
|
|
||||||
export const SYSTEM_WORKTREE_DIRS = new Set(['_products'])
|
|
||||||
|
|
||||||
export function getWorktreeRoot(): string {
|
|
||||||
return (
|
|
||||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR
|
|
||||||
?? path.join(os.homedir(), '.scrum4me-agent-worktrees')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProductWorktreePath(productId: string): string {
|
|
||||||
return path.join(getWorktreeRoot(), '_products', productId)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProductWorktreeLockPath(productId: string): string {
|
|
||||||
return path.join(getWorktreeRoot(), '_products', `${productId}.lock`)
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { execFile } from 'node:child_process'
|
import { execFile } from 'node:child_process'
|
||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
|
import * as os from 'node:os'
|
||||||
import * as fs from 'node:fs/promises'
|
import * as fs from 'node:fs/promises'
|
||||||
import { getWorktreeRoot } from './worktree-paths.js'
|
|
||||||
|
|
||||||
const exec = promisify(execFile)
|
const exec = promisify(execFile)
|
||||||
|
|
||||||
|
|
@ -15,19 +15,6 @@ async function branchExists(repoRoot: string, name: string): Promise<boolean> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remoteBranchExists(repoRoot: string, name: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await exec(
|
|
||||||
'git',
|
|
||||||
['show-ref', '--verify', '--quiet', `refs/remotes/origin/${name}`],
|
|
||||||
{ cwd: repoRoot },
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findWorktreeForBranch(
|
async function findWorktreeForBranch(
|
||||||
repoRoot: string,
|
repoRoot: string,
|
||||||
branchName: string,
|
branchName: string,
|
||||||
|
|
@ -63,7 +50,9 @@ export async function createWorktreeForJob(opts: {
|
||||||
const { repoRoot, jobId, baseRef = 'origin/main', reuseBranch = false } = opts
|
const { repoRoot, jobId, baseRef = 'origin/main', reuseBranch = false } = opts
|
||||||
let { branchName } = opts
|
let { branchName } = opts
|
||||||
|
|
||||||
const parent = getWorktreeRoot()
|
const parent =
|
||||||
|
process.env.SCRUM4ME_AGENT_WORKTREE_DIR ??
|
||||||
|
path.join(os.homedir(), '.scrum4me-agent-worktrees')
|
||||||
|
|
||||||
await fs.mkdir(parent, { recursive: true })
|
await fs.mkdir(parent, { recursive: true })
|
||||||
|
|
||||||
|
|
@ -88,54 +77,13 @@ export async function createWorktreeForJob(opts: {
|
||||||
if (occupant) {
|
if (occupant) {
|
||||||
await exec('git', ['worktree', 'remove', '--force', occupant], { cwd: repoRoot })
|
await exec('git', ['worktree', 'remove', '--force', occupant], { cwd: repoRoot })
|
||||||
}
|
}
|
||||||
// reuseBranch is decided sprint-wide, but git branches are per-repo. For a
|
await exec('git', ['worktree', 'add', worktreePath, branchName], { cwd: repoRoot })
|
||||||
// cross-repo sprint the first job targeting THIS repo gets reuseBranch=true
|
|
||||||
// even though the branch was never created here; a container recreate also
|
|
||||||
// wipes the local clone. Fall back gracefully instead of failing with
|
|
||||||
// "invalid reference":
|
|
||||||
// - local branch exists → reuse it
|
|
||||||
// - exists on origin only → recreate the local branch tracking origin
|
|
||||||
// - nowhere → create it fresh from baseRef
|
|
||||||
if (await branchExists(repoRoot, branchName)) {
|
|
||||||
await exec('git', ['worktree', 'add', worktreePath, branchName], { cwd: repoRoot })
|
|
||||||
} else if (await remoteBranchExists(repoRoot, branchName)) {
|
|
||||||
await exec(
|
|
||||||
'git',
|
|
||||||
['worktree', 'add', '-b', branchName, worktreePath, `origin/${branchName}`],
|
|
||||||
{ cwd: repoRoot },
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
await exec('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], {
|
|
||||||
cwd: repoRoot,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return { worktreePath, branchName }
|
return { worktreePath, branchName }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fresh branch: if a local branch with this name already exists, it is an
|
// Fresh branch: suffix with timestamp when name collision occurs
|
||||||
// orphan from a prior failed run (the agent didn't push or branch was
|
|
||||||
// never tied to a worktree). Remove the orphan so the new worktree gets
|
|
||||||
// the predictable `feat/story-<id>`-name; this prevents the kind of
|
|
||||||
// 2-May-2026 failure where the agent inherited an unrelated suffix and
|
|
||||||
// pushed to a non-existent remote ref.
|
|
||||||
if (await branchExists(repoRoot, branchName)) {
|
if (await branchExists(repoRoot, branchName)) {
|
||||||
const occupant = await findWorktreeForBranch(repoRoot, branchName)
|
branchName = `${branchName}-${Date.now()}`
|
||||||
if (occupant) {
|
|
||||||
// Branch is currently checked out elsewhere — likely a sibling worktree
|
|
||||||
// that should have been cleaned up. Remove it before reusing the name.
|
|
||||||
try {
|
|
||||||
await exec('git', ['worktree', 'remove', '--force', occupant], { cwd: repoRoot })
|
|
||||||
} catch {
|
|
||||||
// ignore — fall through to deletion below
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await exec('git', ['branch', '-D', branchName], { cwd: repoRoot })
|
|
||||||
console.warn(`[createWorktreeForJob] removed orphan branch ${branchName} before recreate`)
|
|
||||||
} catch {
|
|
||||||
// last resort: timestamp-suffix to avoid collision rather than fail
|
|
||||||
branchName = `${branchName}-${Date.now()}`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await exec('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], {
|
await exec('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], {
|
||||||
|
|
@ -152,7 +100,9 @@ export async function removeWorktreeForJob(opts: {
|
||||||
}): Promise<{ removed: boolean }> {
|
}): Promise<{ removed: boolean }> {
|
||||||
const { repoRoot, jobId, keepBranch = false } = opts
|
const { repoRoot, jobId, keepBranch = false } = opts
|
||||||
|
|
||||||
const parent = getWorktreeRoot()
|
const parent =
|
||||||
|
process.env.SCRUM4ME_AGENT_WORKTREE_DIR ??
|
||||||
|
path.join(os.homedir(), '.scrum4me-agent-worktrees')
|
||||||
|
|
||||||
const worktreePath = path.join(parent, jobId)
|
const worktreePath = path.join(parent, jobId)
|
||||||
|
|
||||||
|
|
|
||||||
54
src/index.ts
54
src/index.ts
|
|
@ -9,11 +9,10 @@ import { registerUpdateTaskPlanTool } from './tools/update-task-plan.js'
|
||||||
import { registerLogImplementationTool } from './tools/log-implementation.js'
|
import { registerLogImplementationTool } from './tools/log-implementation.js'
|
||||||
import { registerLogTestResultTool } from './tools/log-test-result.js'
|
import { registerLogTestResultTool } from './tools/log-test-result.js'
|
||||||
import { registerLogCommitTool } from './tools/log-commit.js'
|
import { registerLogCommitTool } from './tools/log-commit.js'
|
||||||
|
import { registerCreateTodoTool } from './tools/create-todo.js'
|
||||||
import { registerCreatePbiTool } from './tools/create-pbi.js'
|
import { registerCreatePbiTool } from './tools/create-pbi.js'
|
||||||
import { registerCreateStoryTool } from './tools/create-story.js'
|
import { registerCreateStoryTool } from './tools/create-story.js'
|
||||||
import { registerCreateTaskTool } from './tools/create-task.js'
|
import { registerCreateTaskTool } from './tools/create-task.js'
|
||||||
import { registerCreateSprintTool } from './tools/create-sprint.js'
|
|
||||||
import { registerUpdateSprintTool } from './tools/update-sprint.js'
|
|
||||||
import { registerAskUserQuestionTool } from './tools/ask-user-question.js'
|
import { registerAskUserQuestionTool } from './tools/ask-user-question.js'
|
||||||
import { registerGetQuestionAnswerTool } from './tools/get-question-answer.js'
|
import { registerGetQuestionAnswerTool } from './tools/get-question-answer.js'
|
||||||
import { registerListOpenQuestionsTool } from './tools/list-open-questions.js'
|
import { registerListOpenQuestionsTool } from './tools/list-open-questions.js'
|
||||||
|
|
@ -22,42 +21,13 @@ import { registerWaitForJobTool } from './tools/wait-for-job.js'
|
||||||
import { registerUpdateJobStatusTool } from './tools/update-job-status.js'
|
import { registerUpdateJobStatusTool } from './tools/update-job-status.js'
|
||||||
import { registerVerifyTaskAgainstPlanTool } from './tools/verify-task-against-plan.js'
|
import { registerVerifyTaskAgainstPlanTool } from './tools/verify-task-against-plan.js'
|
||||||
import { registerCleanupMyWorktreesTool } from './tools/cleanup-my-worktrees.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 { registerGetIdeaContextTool } from './tools/get-idea-context.js'
|
|
||||||
import { registerUpdateIdeaGrillMdTool } from './tools/update-idea-grill-md.js'
|
|
||||||
import { registerUpdateIdeaPlanMdTool } from './tools/update-idea-plan-md.js'
|
|
||||||
import { registerUpdateIdeaPlanReviewedTool } from './tools/update-idea-plan-reviewed.js'
|
|
||||||
import { registerLogIdeaDecisionTool } from './tools/log-idea-decision.js'
|
|
||||||
import { registerGetWorkerSettingsTool } from './tools/get-worker-settings.js'
|
|
||||||
import { registerWorkerHeartbeatTool } from './tools/worker-heartbeat.js'
|
|
||||||
// PBI-50: SPRINT_IMPLEMENTATION-tools
|
|
||||||
import { registerVerifySprintTaskTool } from './tools/verify-sprint-task.js'
|
|
||||||
import { registerUpdateTaskExecutionTool } from './tools/update-task-execution.js'
|
|
||||||
import { registerJobHeartbeatTool } from './tools/job-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'
|
||||||
import { startHeartbeat } from './presence/heartbeat.js'
|
import { startHeartbeat } from './presence/heartbeat.js'
|
||||||
import { registerShutdownHandlers } from './presence/shutdown.js'
|
import { registerShutdownHandlers } from './presence/shutdown.js'
|
||||||
|
|
||||||
import { readFileSync } from 'node:fs'
|
const VERSION = '0.1.0'
|
||||||
import { dirname, join } from 'node:path'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
|
|
||||||
// Read version dynamically from package.json — voorheen hardcoded en
|
|
||||||
// veroorzaakte sync-issues bij deployment. Lees op module-load.
|
|
||||||
function readPkgVersion(): string {
|
|
||||||
try {
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url))
|
|
||||||
const pkgPath = join(here, '..', 'package.json')
|
|
||||||
return (JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: string }).version ?? '0.0.0'
|
|
||||||
} catch {
|
|
||||||
return '0.0.0'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const VERSION = readPkgVersion()
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const server = new McpServer(
|
const server = new McpServer(
|
||||||
|
|
@ -77,12 +47,10 @@ async function main() {
|
||||||
registerLogImplementationTool(server)
|
registerLogImplementationTool(server)
|
||||||
registerLogTestResultTool(server)
|
registerLogTestResultTool(server)
|
||||||
registerLogCommitTool(server)
|
registerLogCommitTool(server)
|
||||||
|
registerCreateTodoTool(server)
|
||||||
registerCreatePbiTool(server)
|
registerCreatePbiTool(server)
|
||||||
registerCreateStoryTool(server)
|
registerCreateStoryTool(server)
|
||||||
registerCreateTaskTool(server)
|
registerCreateTaskTool(server)
|
||||||
// PBI-12: sprint lifecycle tools
|
|
||||||
registerCreateSprintTool(server)
|
|
||||||
registerUpdateSprintTool(server)
|
|
||||||
registerAskUserQuestionTool(server)
|
registerAskUserQuestionTool(server)
|
||||||
registerGetQuestionAnswerTool(server)
|
registerGetQuestionAnswerTool(server)
|
||||||
registerListOpenQuestionsTool(server)
|
registerListOpenQuestionsTool(server)
|
||||||
|
|
@ -91,22 +59,6 @@ async function main() {
|
||||||
registerUpdateJobStatusTool(server)
|
registerUpdateJobStatusTool(server)
|
||||||
registerVerifyTaskAgainstPlanTool(server)
|
registerVerifyTaskAgainstPlanTool(server)
|
||||||
registerCleanupMyWorktreesTool(server)
|
registerCleanupMyWorktreesTool(server)
|
||||||
registerCheckQueueEmptyTool(server)
|
|
||||||
registerSetPbiPrTool(server)
|
|
||||||
registerMarkPbiPrMergedTool(server)
|
|
||||||
// M12: idee-job tools
|
|
||||||
registerGetIdeaContextTool(server)
|
|
||||||
registerUpdateIdeaGrillMdTool(server)
|
|
||||||
registerUpdateIdeaPlanMdTool(server)
|
|
||||||
registerUpdateIdeaPlanReviewedTool(server)
|
|
||||||
registerLogIdeaDecisionTool(server)
|
|
||||||
// M13: worker quota-gate tools
|
|
||||||
registerGetWorkerSettingsTool(server)
|
|
||||||
registerWorkerHeartbeatTool(server)
|
|
||||||
// PBI-50: SPRINT_IMPLEMENTATION-tools
|
|
||||||
registerVerifySprintTaskTool(server)
|
|
||||||
registerUpdateTaskExecutionTool(server)
|
|
||||||
registerJobHeartbeatTool(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
|
||||||
|
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
// MCP-side port van scrum4me/lib/idea-plan-parser.ts (M12).
|
|
||||||
//
|
|
||||||
// Parser voor de plan_md die make-plan-job produceert: yaml-frontmatter
|
|
||||||
// (structuur) + markdown-body (vrije reasoning). Gebruikt door
|
|
||||||
// update_idea_plan_md voor server-side validatie vóór persistentie.
|
|
||||||
//
|
|
||||||
// LET OP: deze code is BEWUST een duplicaat van de Scrum4Me-parser om
|
|
||||||
// drift-detectie te krijgen via de vendor/scrum4me schema-watchdog. Houd
|
|
||||||
// het schema (zod-shape) in sync met scrum4me/lib/schemas/idea.ts.
|
|
||||||
|
|
||||||
import { parse as parseYaml, YAMLParseError } from 'yaml'
|
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
const verifyRequiredEnum = z.enum(['ALIGNED', 'ALIGNED_OR_PARTIAL', 'ANY'])
|
|
||||||
|
|
||||||
const planTaskSchema = z.object({
|
|
||||||
title: z.string().min(1).max(200),
|
|
||||||
description: z.string().max(4000).optional(),
|
|
||||||
implementation_plan: z.string().max(8000).optional(),
|
|
||||||
priority: z.number().int().min(1).max(4),
|
|
||||||
verify_required: verifyRequiredEnum.optional(),
|
|
||||||
verify_only: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const planStorySchema = z.object({
|
|
||||||
title: z.string().min(1).max(200),
|
|
||||||
description: z.string().max(4000).optional(),
|
|
||||||
acceptance_criteria: z.string().max(4000).optional(),
|
|
||||||
priority: z.number().int().min(1).max(4),
|
|
||||||
tasks: z.array(planTaskSchema).min(1, 'Story moet minimaal 1 taak hebben'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const planPbiSchema = z.object({
|
|
||||||
title: z.string().min(1).max(200),
|
|
||||||
description: z.string().max(4000).optional(),
|
|
||||||
priority: z.number().int().min(1).max(4),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ideaPlanMdFrontmatterSchema = z.object({
|
|
||||||
pbi: planPbiSchema,
|
|
||||||
stories: z.array(planStorySchema).min(1, 'Plan moet minimaal 1 story bevatten'),
|
|
||||||
})
|
|
||||||
|
|
||||||
export type IdeaPlanFrontmatter = z.infer<typeof ideaPlanMdFrontmatterSchema>
|
|
||||||
|
|
||||||
export type PlanParseError = { line?: number; message: string }
|
|
||||||
|
|
||||||
export type PlanParseResult =
|
|
||||||
| { ok: true; plan: IdeaPlanFrontmatter; body: string }
|
|
||||||
| { ok: false; errors: PlanParseError[] }
|
|
||||||
|
|
||||||
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
|
|
||||||
|
|
||||||
export function parsePlanMd(md: string): PlanParseResult {
|
|
||||||
const match = md.match(FRONTMATTER_RE)
|
|
||||||
if (!match) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
line: 1,
|
|
||||||
message: 'Plan ontbreekt yaml-frontmatter. Verwacht eerste regel: ---',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [, frontmatterRaw, body] = match
|
|
||||||
|
|
||||||
let parsed: unknown
|
|
||||||
try {
|
|
||||||
parsed = parseYaml(frontmatterRaw)
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof YAMLParseError) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
errors: [{ line: err.linePos?.[0]?.line, message: err.message }],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
errors: [{ message: err instanceof Error ? err.message : String(err) }],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const validation = ideaPlanMdFrontmatterSchema.safeParse(parsed)
|
|
||||||
if (!validation.success) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
errors: validation.error.issues.map((iss) => ({
|
|
||||||
message: `${iss.path.join('.') || '<root>'}: ${iss.message}`,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true, plan: validation.data, body: body.trimStart() }
|
|
||||||
}
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
// PBI-67: model + mode-selectie per ClaudeJob-kind.
|
|
||||||
//
|
|
||||||
// Sync met Scrum4Me/lib/job-config.ts — als je hier een veld aanpast,
|
|
||||||
// doe hetzelfde aan de webapp-kant. Bewust duplicate (geen gedeeld
|
|
||||||
// package) om de MCP-server eigenstandig te houden.
|
|
||||||
//
|
|
||||||
// Override-cascade (eerste match wint):
|
|
||||||
// 1. task.requires_opus === true → forceer Opus
|
|
||||||
// 2. job.requested_* (snapshot bij enqueue)
|
|
||||||
// 3. product.preferred_*
|
|
||||||
// 4. KIND_DEFAULTS hieronder
|
|
||||||
//
|
|
||||||
// CLI-flag-mapping (Claude CLI 2.1.x):
|
|
||||||
// - thinking_budget (number) → mapBudgetToEffort() → --effort {low,medium,high,xhigh,max}
|
|
||||||
// (de CLI heeft geen --thinking-budget flag — alleen --effort)
|
|
||||||
// - max_turns blijft audit-only: de CLI heeft géén --max-turns flag.
|
|
||||||
// De waarde wordt gesnapshot voor cost-attribution maar niet doorgegeven.
|
|
||||||
// - allowed_tools → --allowedTools (komma-gescheiden lijst)
|
|
||||||
|
|
||||||
export type ClaudeModel =
|
|
||||||
| 'claude-opus-4-7'
|
|
||||||
| 'claude-sonnet-4-6'
|
|
||||||
| 'claude-haiku-4-5-20251001'
|
|
||||||
|
|
||||||
export type PermissionMode = 'plan' | 'default' | 'acceptEdits' | 'bypassPermissions'
|
|
||||||
|
|
||||||
export type JobConfig = {
|
|
||||||
model: ClaudeModel
|
|
||||||
thinking_budget: number // 0 = uit
|
|
||||||
permission_mode: PermissionMode
|
|
||||||
max_turns: number | null // null = onbegrensd
|
|
||||||
allowed_tools: string[] | null // null = alle
|
|
||||||
}
|
|
||||||
|
|
||||||
export type JobInput = {
|
|
||||||
kind: string
|
|
||||||
requested_model?: string | null
|
|
||||||
requested_thinking_budget?: number | null
|
|
||||||
requested_permission_mode?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProductInput = {
|
|
||||||
preferred_model?: string | null
|
|
||||||
thinking_budget_default?: number | null
|
|
||||||
preferred_permission_mode?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TaskInput = {
|
|
||||||
requires_opus?: boolean | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tool-allowlists per kind. Bewust géén `wait_for_job`, `check_queue_empty`
|
|
||||||
// of `get_idea_context` — de runner (scrum4me-docker/bin/run-one-job.ts)
|
|
||||||
// claimt voor Claude. Vangrail tegen recursieve claims binnen één invocation.
|
|
||||||
const TASK_TOOLS = [
|
|
||||||
'Read', 'Edit', 'Write', 'Bash', 'Grep', 'Glob',
|
|
||||||
'mcp__scrum4me__get_claude_context',
|
|
||||||
'mcp__scrum4me__update_task_status',
|
|
||||||
'mcp__scrum4me__update_task_plan',
|
|
||||||
'mcp__scrum4me__log_implementation',
|
|
||||||
'mcp__scrum4me__log_test_result',
|
|
||||||
'mcp__scrum4me__log_commit',
|
|
||||||
'mcp__scrum4me__verify_task_against_plan',
|
|
||||||
'mcp__scrum4me__update_job_status',
|
|
||||||
'mcp__scrum4me__ask_user_question',
|
|
||||||
'mcp__scrum4me__get_question_answer',
|
|
||||||
'mcp__scrum4me__list_open_questions',
|
|
||||||
'mcp__scrum4me__cancel_question',
|
|
||||||
'mcp__scrum4me__worker_heartbeat',
|
|
||||||
]
|
|
||||||
|
|
||||||
const KIND_DEFAULTS: Record<string, JobConfig> = {
|
|
||||||
// Idea-kinds en PLAN_CHAT draaien in `acceptEdits` (niet `plan`):
|
|
||||||
// `plan`-mode wacht op human-approval na elke planning-fase, wat in een
|
|
||||||
// autonome runner-context betekent dat Claude geen `update_job_status`
|
|
||||||
// aanroept en de job na lease-expiry FAILED'd. De `allowed_tools`-lijst
|
|
||||||
// doet de echte sandboxing (geen Bash, geen Edit, alleen Read/Grep/etc).
|
|
||||||
IDEA_GRILL: {
|
|
||||||
model: 'claude-sonnet-4-6',
|
|
||||||
thinking_budget: 12000,
|
|
||||||
permission_mode: 'acceptEdits',
|
|
||||||
max_turns: 15,
|
|
||||||
allowed_tools: [
|
|
||||||
'Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion',
|
|
||||||
'mcp__scrum4me__update_idea_grill_md',
|
|
||||||
'mcp__scrum4me__log_idea_decision',
|
|
||||||
'mcp__scrum4me__update_job_status',
|
|
||||||
'mcp__scrum4me__ask_user_question',
|
|
||||||
'mcp__scrum4me__get_question_answer',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
IDEA_MAKE_PLAN: {
|
|
||||||
model: 'claude-opus-4-7',
|
|
||||||
thinking_budget: 24000,
|
|
||||||
permission_mode: 'acceptEdits',
|
|
||||||
max_turns: 20,
|
|
||||||
allowed_tools: [
|
|
||||||
'Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion', 'Write',
|
|
||||||
'mcp__scrum4me__update_idea_plan_md',
|
|
||||||
'mcp__scrum4me__log_idea_decision',
|
|
||||||
'mcp__scrum4me__update_job_status',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
IDEA_REVIEW_PLAN: {
|
|
||||||
model: 'claude-opus-4-7',
|
|
||||||
thinking_budget: 6000,
|
|
||||||
permission_mode: 'acceptEdits',
|
|
||||||
max_turns: 1,
|
|
||||||
allowed_tools: [
|
|
||||||
'Read', 'Write', 'Grep', 'Glob',
|
|
||||||
'mcp__scrum4me__update_idea_plan_reviewed',
|
|
||||||
'mcp__scrum4me__log_idea_decision',
|
|
||||||
'mcp__scrum4me__update_job_status',
|
|
||||||
'mcp__scrum4me__ask_user_question',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
PLAN_CHAT: {
|
|
||||||
model: 'claude-sonnet-4-6',
|
|
||||||
thinking_budget: 6000,
|
|
||||||
permission_mode: 'acceptEdits',
|
|
||||||
max_turns: 5,
|
|
||||||
allowed_tools: [
|
|
||||||
'Read', 'Grep', 'AskUserQuestion',
|
|
||||||
'mcp__scrum4me__update_job_status',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
TASK_IMPLEMENTATION: {
|
|
||||||
model: 'claude-sonnet-4-6',
|
|
||||||
thinking_budget: 6000,
|
|
||||||
permission_mode: 'bypassPermissions',
|
|
||||||
max_turns: 50,
|
|
||||||
allowed_tools: TASK_TOOLS,
|
|
||||||
},
|
|
||||||
SPRINT_IMPLEMENTATION: {
|
|
||||||
model: 'claude-sonnet-4-6',
|
|
||||||
thinking_budget: 6000,
|
|
||||||
permission_mode: 'bypassPermissions',
|
|
||||||
max_turns: null,
|
|
||||||
// Geen `mcp__scrum4me__job_heartbeat` — de runner verlengt de lease
|
|
||||||
// automatisch via setInterval (zie scrum4me-docker/bin/run-one-job.ts).
|
|
||||||
allowed_tools: [
|
|
||||||
...TASK_TOOLS,
|
|
||||||
'mcp__scrum4me__update_task_execution',
|
|
||||||
'mcp__scrum4me__verify_sprint_task',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const FALLBACK: JobConfig = {
|
|
||||||
model: 'claude-sonnet-4-6',
|
|
||||||
thinking_budget: 6000,
|
|
||||||
permission_mode: 'default',
|
|
||||||
max_turns: 50,
|
|
||||||
allowed_tools: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getKindDefault(kind: string): JobConfig {
|
|
||||||
return KIND_DEFAULTS[kind] ?? FALLBACK
|
|
||||||
}
|
|
||||||
|
|
||||||
// max_turns en allowed_tools blijven kind-default (geen product/task override
|
|
||||||
// in V1 — als de behoefte ontstaat, voeg analoge velden toe aan Product/Task).
|
|
||||||
export function resolveJobConfig(
|
|
||||||
job: JobInput,
|
|
||||||
product: ProductInput,
|
|
||||||
task?: TaskInput,
|
|
||||||
): JobConfig {
|
|
||||||
const base = getKindDefault(job.kind)
|
|
||||||
|
|
||||||
const model = (
|
|
||||||
task?.requires_opus
|
|
||||||
? 'claude-opus-4-7'
|
|
||||||
: job.requested_model ?? product.preferred_model ?? base.model
|
|
||||||
) as ClaudeModel
|
|
||||||
|
|
||||||
const thinking_budget =
|
|
||||||
job.requested_thinking_budget ?? product.thinking_budget_default ?? base.thinking_budget
|
|
||||||
|
|
||||||
const permission_mode = (job.requested_permission_mode ??
|
|
||||||
product.preferred_permission_mode ??
|
|
||||||
base.permission_mode) as PermissionMode
|
|
||||||
|
|
||||||
return {
|
|
||||||
model,
|
|
||||||
thinking_budget,
|
|
||||||
permission_mode,
|
|
||||||
max_turns: base.max_turns,
|
|
||||||
allowed_tools: base.allowed_tools,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map numeriek thinking_budget naar de Claude CLI 2.1.x --effort flag.
|
|
||||||
// Returns null als de flag niet meegegeven moet worden (budget = 0).
|
|
||||||
//
|
|
||||||
// Mapping (sync met Scrum4Me/lib/job-config.ts):
|
|
||||||
// 0 → null (geen --effort flag)
|
|
||||||
// 1..6000 → "medium"
|
|
||||||
// 6001..12000 → "high"
|
|
||||||
// 12001..24000→ "xhigh"
|
|
||||||
// >24000 → "max"
|
|
||||||
export function mapBudgetToEffort(budget: number): string | null {
|
|
||||||
if (budget <= 0) return null
|
|
||||||
if (budget <= 6000) return 'medium'
|
|
||||||
if (budget <= 12000) return 'high'
|
|
||||||
if (budget <= 24000) return 'xhigh'
|
|
||||||
return 'max'
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
// Loader voor embedded prompts per ClaudeJob-kind.
|
|
||||||
//
|
|
||||||
// De .md-bestanden in src/prompts/<kind>/ worden bewust meegebakken zodat
|
|
||||||
// elke runner ze kan inlezen zonder externe plugin-dependency. De runner
|
|
||||||
// (scrum4me-docker/bin/run-one-job.ts) leest de juiste prompt via
|
|
||||||
// getKindPromptText() en geeft die door als `claude -p`-prompt.
|
|
||||||
//
|
|
||||||
// Variabele-vervanging gebeurt door de runner zelf (bv. $PAYLOAD_PATH).
|
|
||||||
|
|
||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { dirname, join } from 'node:path'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
|
|
||||||
import type { ClaudeJobKind } from '@prisma/client'
|
|
||||||
|
|
||||||
const cache: Partial<Record<ClaudeJobKind, string>> = {}
|
|
||||||
|
|
||||||
function loadPrompt(rel: string): string {
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url))
|
|
||||||
// src/lib/kind-prompts.ts → src/lib → src → src/prompts/<rel>
|
|
||||||
const path = join(here, '..', 'prompts', rel)
|
|
||||||
return readFileSync(path, 'utf8')
|
|
||||||
}
|
|
||||||
|
|
||||||
const KIND_TO_PROMPT_PATH: Partial<Record<ClaudeJobKind, string>> = {
|
|
||||||
IDEA_GRILL: 'idea/grill.md',
|
|
||||||
IDEA_MAKE_PLAN: 'idea/make-plan.md',
|
|
||||||
IDEA_REVIEW_PLAN: 'idea/review-plan.md',
|
|
||||||
TASK_IMPLEMENTATION: 'task/implementation.md',
|
|
||||||
SPRINT_IMPLEMENTATION: 'sprint/implementation.md',
|
|
||||||
PLAN_CHAT: 'plan-chat/chat.md',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getKindPromptText(kind: ClaudeJobKind): string {
|
|
||||||
if (cache[kind]) return cache[kind]!
|
|
||||||
const rel = KIND_TO_PROMPT_PATH[kind]
|
|
||||||
if (!rel) return ''
|
|
||||||
const text = loadPrompt(rel)
|
|
||||||
cache[kind] = text
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
// Back-compat re-export. wait-for-job.ts roept getIdeaPromptText aan voor
|
|
||||||
// de drie idea-kinds; behouden zodat we de bestaande call-site niet hoeven
|
|
||||||
// te wijzigen tot een aparte cleanup-pass.
|
|
||||||
export function getIdeaPromptText(kind: ClaudeJobKind): string {
|
|
||||||
if (kind !== 'IDEA_GRILL' && kind !== 'IDEA_MAKE_PLAN' && kind !== 'IDEA_REVIEW_PLAN') return ''
|
|
||||||
return getKindPromptText(kind)
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
export type PushPayload = { title: string; body: string; url: string; tag?: string };
|
|
||||||
|
|
||||||
export async function triggerPush(userId: string, payload: PushPayload): Promise<void> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
// **HOUD SYNC** met Scrum4Me/lib/tasks-status-update.ts.
|
import type { Prisma, TaskStatus } from '@prisma/client'
|
||||||
// Beide repos delen dezelfde DB; deze helper moet bit-voor-bit gelijke
|
|
||||||
// statusovergangen produceren als de Scrum4Me-versie. Bij wijziging hier
|
|
||||||
// ook in de Scrum4Me-repo updaten en omgekeerd.
|
|
||||||
import type { Prisma, TaskStatus, StoryStatus, PbiStatus, SprintStatus } from '@prisma/client'
|
|
||||||
import { prisma } from '../prisma.js'
|
import { prisma } from '../prisma.js'
|
||||||
|
|
||||||
export interface PropagationResult {
|
export type StoryStatusChange = 'promoted' | 'demoted' | null
|
||||||
|
|
||||||
|
export interface UpdateTaskStatusResult {
|
||||||
task: {
|
task: {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
|
|
@ -13,38 +11,21 @@ export interface PropagationResult {
|
||||||
story_id: string
|
story_id: string
|
||||||
implementation_plan: string | null
|
implementation_plan: string | null
|
||||||
}
|
}
|
||||||
|
storyStatusChange: StoryStatusChange
|
||||||
storyId: string
|
storyId: string
|
||||||
storyChanged: boolean
|
|
||||||
pbiChanged: boolean
|
|
||||||
sprintChanged: boolean
|
|
||||||
sprintRunChanged: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Real-time status-propagatie: bij elke task-statuswijziging wordt de keten
|
// Update task.status atomically and auto-promote/demote the parent story:
|
||||||
// Task → Story → PBI → Sprint → SprintRun herevalueerd binnen één transactie.
|
// - All sibling tasks DONE → story.status = DONE
|
||||||
//
|
// - Story was DONE and a task moves out of DONE → story.status = IN_SPRINT
|
||||||
// Regels:
|
// Demote target is IN_SPRINT (not OPEN): OPEN means "back in product backlog",
|
||||||
// Story: ANY task FAILED → FAILED, ELSE ALL DONE → DONE,
|
// which is a sprint-management action, not a status side-effect.
|
||||||
// ELSE IN_SPRINT (mits story.sprint_id != null), anders OPEN
|
export async function updateTaskStatusWithStoryPromotion(
|
||||||
// PBI: ANY story FAILED → FAILED, ELSE ALL DONE → DONE, ELSE READY
|
|
||||||
// (BLOCKED is handmatig en wordt niet overschreven door deze helper)
|
|
||||||
// Sprint: ANY PBI van een story-in-sprint FAILED → FAILED,
|
|
||||||
// ELSE ALL PBIs van die stories DONE → COMPLETED,
|
|
||||||
// ELSE ACTIVE
|
|
||||||
// SprintRun: Sprint→FAILED → SprintRun=FAILED + cancel openstaand werk +
|
|
||||||
// zet failed_task_id; Sprint→COMPLETED → SprintRun=DONE; anders
|
|
||||||
// blijft SprintRun ongewijzigd.
|
|
||||||
export async function propagateStatusUpwards(
|
|
||||||
taskId: string,
|
taskId: string,
|
||||||
newStatus: TaskStatus,
|
newStatus: TaskStatus,
|
||||||
client?: Prisma.TransactionClient,
|
client?: Prisma.TransactionClient,
|
||||||
// PBI-50: optionele expliciete sprint_run_id voor SPRINT_IMPLEMENTATION
|
): Promise<UpdateTaskStatusResult> {
|
||||||
// (waar geen ClaudeJob.task_id-koppeling bestaat). Wanneer afwezig valt
|
const run = async (tx: Prisma.TransactionClient): Promise<UpdateTaskStatusResult> => {
|
||||||
// de helper terug op de lookup via ClaudeJob.task_id, met als laatste
|
|
||||||
// fallback Story → Sprint → SprintRun.findFirst({ status: active }).
|
|
||||||
sprintRunId?: string,
|
|
||||||
): Promise<PropagationResult> {
|
|
||||||
const run = async (tx: Prisma.TransactionClient): Promise<PropagationResult> => {
|
|
||||||
const task = await tx.task.update({
|
const task = await tx.task.update({
|
||||||
where: { id: taskId },
|
where: { id: taskId },
|
||||||
data: { status: newStatus },
|
data: { status: newStatus },
|
||||||
|
|
@ -57,232 +38,35 @@ export async function propagateStatusUpwards(
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Story herevalueren
|
|
||||||
const siblings = await tx.task.findMany({
|
const siblings = await tx.task.findMany({
|
||||||
where: { story_id: task.story_id },
|
where: { story_id: task.story_id },
|
||||||
select: { status: true },
|
select: { status: true },
|
||||||
})
|
})
|
||||||
const anyTaskFailed = siblings.some((s) => s.status === 'FAILED')
|
const allDone = siblings.every((s) => s.status === 'DONE')
|
||||||
const allTasksDone =
|
|
||||||
siblings.length > 0 && siblings.every((s) => s.status === 'DONE')
|
|
||||||
|
|
||||||
const story = await tx.story.findUniqueOrThrow({
|
const story = await tx.story.findUniqueOrThrow({
|
||||||
where: { id: task.story_id },
|
where: { id: task.story_id },
|
||||||
select: { id: true, status: true, pbi_id: true, sprint_id: true },
|
select: { status: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
const defaultActive: StoryStatus = story.sprint_id ? 'IN_SPRINT' : 'OPEN'
|
let storyStatusChange: StoryStatusChange = null
|
||||||
let nextStoryStatus: StoryStatus
|
if (newStatus === 'DONE' && allDone && story.status !== 'DONE') {
|
||||||
if (anyTaskFailed) nextStoryStatus = 'FAILED'
|
|
||||||
else if (allTasksDone) nextStoryStatus = 'DONE'
|
|
||||||
else nextStoryStatus = defaultActive
|
|
||||||
|
|
||||||
let storyChanged = false
|
|
||||||
if (nextStoryStatus !== story.status) {
|
|
||||||
await tx.story.update({
|
await tx.story.update({
|
||||||
where: { id: story.id },
|
where: { id: task.story_id },
|
||||||
data: { status: nextStoryStatus },
|
data: { status: 'DONE' },
|
||||||
})
|
})
|
||||||
storyChanged = true
|
storyStatusChange = 'promoted'
|
||||||
|
} else if (newStatus !== 'DONE' && story.status === 'DONE') {
|
||||||
|
await tx.story.update({
|
||||||
|
where: { id: task.story_id },
|
||||||
|
data: { status: 'IN_SPRINT' },
|
||||||
|
})
|
||||||
|
storyStatusChange = 'demoted'
|
||||||
}
|
}
|
||||||
|
|
||||||
// PBI herevalueren — BLOCKED met rust laten
|
return { task, storyStatusChange, storyId: task.story_id }
|
||||||
const pbi = await tx.pbi.findUniqueOrThrow({
|
|
||||||
where: { id: story.pbi_id },
|
|
||||||
select: { id: true, status: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
let pbiChanged = false
|
|
||||||
if (pbi.status !== 'BLOCKED') {
|
|
||||||
const pbiStories = await tx.story.findMany({
|
|
||||||
where: { pbi_id: pbi.id },
|
|
||||||
select: { status: true },
|
|
||||||
})
|
|
||||||
const anyStoryFailed = pbiStories.some((s) => s.status === 'FAILED')
|
|
||||||
const allStoriesDone =
|
|
||||||
pbiStories.length > 0 && pbiStories.every((s) => s.status === 'DONE')
|
|
||||||
|
|
||||||
let nextPbiStatus: PbiStatus
|
|
||||||
if (anyStoryFailed) nextPbiStatus = 'FAILED'
|
|
||||||
else if (allStoriesDone) nextPbiStatus = 'DONE'
|
|
||||||
else nextPbiStatus = 'READY'
|
|
||||||
|
|
||||||
if (nextPbiStatus !== pbi.status) {
|
|
||||||
await tx.pbi.update({
|
|
||||||
where: { id: pbi.id },
|
|
||||||
data: { status: nextPbiStatus },
|
|
||||||
})
|
|
||||||
pbiChanged = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprint herevalueren — alleen als deze story aan een sprint hangt
|
|
||||||
let sprintChanged = false
|
|
||||||
let nextSprintStatus: SprintStatus | null = null
|
|
||||||
if (story.sprint_id) {
|
|
||||||
const sprint = await tx.sprint.findUniqueOrThrow({
|
|
||||||
where: { id: story.sprint_id },
|
|
||||||
select: { id: true, status: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
const sprintPbiRows = await tx.story.findMany({
|
|
||||||
where: { sprint_id: sprint.id },
|
|
||||||
select: { pbi_id: true },
|
|
||||||
distinct: ['pbi_id'],
|
|
||||||
})
|
|
||||||
const sprintPbis = await tx.pbi.findMany({
|
|
||||||
where: { id: { in: sprintPbiRows.map((s) => s.pbi_id) } },
|
|
||||||
select: { status: true },
|
|
||||||
})
|
|
||||||
const anyPbiFailed = sprintPbis.some((p) => p.status === 'FAILED')
|
|
||||||
const allPbisDone =
|
|
||||||
sprintPbis.length > 0 && sprintPbis.every((p) => p.status === 'DONE')
|
|
||||||
|
|
||||||
let nextStatus: SprintStatus
|
|
||||||
if (anyPbiFailed) nextStatus = 'FAILED'
|
|
||||||
else if (allPbisDone) nextStatus = 'CLOSED'
|
|
||||||
else nextStatus = 'OPEN'
|
|
||||||
|
|
||||||
if (nextStatus !== sprint.status) {
|
|
||||||
await tx.sprint.update({
|
|
||||||
where: { id: sprint.id },
|
|
||||||
data: {
|
|
||||||
status: nextStatus,
|
|
||||||
...(nextStatus === 'CLOSED' ? { completed_at: new Date() } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
sprintChanged = true
|
|
||||||
nextSprintStatus = nextStatus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SprintRun herevalueren. Resolve sprint_run_id in volgorde:
|
|
||||||
// 1. Expliciete sprintRunId-arg (PBI-50: SPRINT_IMPLEMENTATION-pad).
|
|
||||||
// 2. ClaudeJob.task_id-lookup (PER_TASK-flow).
|
|
||||||
// 3. Story → Sprint → SprintRun.findFirst({ status: active }) (geen
|
|
||||||
// task-job, bv. handmatige task-statuswijziging via UI).
|
|
||||||
let sprintRunChanged = false
|
|
||||||
if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'CLOSED') {
|
|
||||||
let resolvedRunId: string | null = sprintRunId ?? null
|
|
||||||
let cancelExceptJobId: string | null = null
|
|
||||||
|
|
||||||
if (!resolvedRunId) {
|
|
||||||
const job = await tx.claudeJob.findFirst({
|
|
||||||
where: { task_id: taskId, sprint_run_id: { not: null } },
|
|
||||||
orderBy: { created_at: 'desc' },
|
|
||||||
select: { id: true, sprint_run_id: true },
|
|
||||||
})
|
|
||||||
if (job?.sprint_run_id) {
|
|
||||||
resolvedRunId = job.sprint_run_id
|
|
||||||
cancelExceptJobId = job.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resolvedRunId && story.sprint_id) {
|
|
||||||
const activeRun = await tx.sprintRun.findFirst({
|
|
||||||
where: {
|
|
||||||
sprint_id: story.sprint_id,
|
|
||||||
status: { in: ['QUEUED', 'RUNNING', 'PAUSED'] },
|
|
||||||
},
|
|
||||||
orderBy: { created_at: 'desc' },
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
if (activeRun) resolvedRunId = activeRun.id
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolvedRunId) {
|
|
||||||
const sprintRun = await tx.sprintRun.findUnique({
|
|
||||||
where: { id: resolvedRunId },
|
|
||||||
select: { id: true, status: true },
|
|
||||||
})
|
|
||||||
if (
|
|
||||||
sprintRun &&
|
|
||||||
(sprintRun.status === 'QUEUED' ||
|
|
||||||
sprintRun.status === 'RUNNING' ||
|
|
||||||
sprintRun.status === 'PAUSED')
|
|
||||||
) {
|
|
||||||
if (nextSprintStatus === 'FAILED') {
|
|
||||||
await tx.sprintRun.update({
|
|
||||||
where: { id: sprintRun.id },
|
|
||||||
data: {
|
|
||||||
status: 'FAILED',
|
|
||||||
finished_at: new Date(),
|
|
||||||
failed_task_id: taskId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
// Cancel sibling-jobs binnen dezelfde SprintRun behalve de
|
|
||||||
// huidige task-job (als die er is). Voor SPRINT_IMPLEMENTATION
|
|
||||||
// is cancelExceptJobId null en hebben we geen siblings om te
|
|
||||||
// cancellen — de SPRINT-job zelf blijft actief en de worker
|
|
||||||
// detecteert dit via job_heartbeat.
|
|
||||||
await tx.claudeJob.updateMany({
|
|
||||||
where: {
|
|
||||||
sprint_run_id: sprintRun.id,
|
|
||||||
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
|
|
||||||
...(cancelExceptJobId ? { id: { not: cancelExceptJobId } } : {}),
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
status: 'CANCELLED',
|
|
||||||
finished_at: new Date(),
|
|
||||||
error: `Cancelled: task ${taskId} failed in same sprint run`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
sprintRunChanged = true
|
|
||||||
} else {
|
|
||||||
// COMPLETED
|
|
||||||
await tx.sprintRun.update({
|
|
||||||
where: { id: sprintRun.id },
|
|
||||||
data: { status: 'DONE', finished_at: new Date() },
|
|
||||||
})
|
|
||||||
sprintRunChanged = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
task,
|
|
||||||
storyId: task.story_id,
|
|
||||||
storyChanged,
|
|
||||||
pbiChanged,
|
|
||||||
sprintChanged,
|
|
||||||
sprintRunChanged,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client) return run(client)
|
if (client) return run(client)
|
||||||
return prisma.$transaction(run)
|
return prisma.$transaction(run)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Backwards-compat wrapper ────────────────────────────────────────────────
|
|
||||||
// Bestaande tools (update-task-status, log-implementation, etc.) verwachten
|
|
||||||
// de oude { task, storyStatusChange, storyId } shape. We mappen storyChanged
|
|
||||||
// op promoted/demoted via een eenvoudige heuristiek op nieuwe TaskStatus.
|
|
||||||
|
|
||||||
export type StoryStatusChange = 'promoted' | 'demoted' | null
|
|
||||||
|
|
||||||
export interface UpdateTaskStatusResult {
|
|
||||||
task: PropagationResult['task']
|
|
||||||
storyStatusChange: StoryStatusChange
|
|
||||||
storyId: string
|
|
||||||
sprintRunChanged: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateTaskStatusWithStoryPromotion(
|
|
||||||
taskId: string,
|
|
||||||
newStatus: TaskStatus,
|
|
||||||
client?: Prisma.TransactionClient,
|
|
||||||
sprintRunId?: string,
|
|
||||||
): Promise<UpdateTaskStatusResult> {
|
|
||||||
const result = await propagateStatusUpwards(taskId, newStatus, client, sprintRunId)
|
|
||||||
let storyStatusChange: StoryStatusChange = null
|
|
||||||
if (result.storyChanged) {
|
|
||||||
storyStatusChange = newStatus === 'DONE' ? 'promoted' : 'demoted'
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
task: result.task,
|
|
||||||
storyStatusChange,
|
|
||||||
storyId: result.storyId,
|
|
||||||
sprintRunChanged: result.sprintRunChanged,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export function startHeartbeat(opts: {
|
||||||
} catch {
|
} catch {
|
||||||
// non-fatal — next tick retries
|
// non-fatal — next tick retries
|
||||||
}
|
}
|
||||||
}, opts.intervalMs ?? 10_000)
|
}, opts.intervalMs ?? 5_000)
|
||||||
|
|
||||||
return { stop: () => clearInterval(timer) }
|
return { stop: () => clearInterval(timer) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
# Grill-prompt voor IDEA_GRILL-jobs
|
|
||||||
|
|
||||||
> Deze prompt wordt door `scrum4me-docker/bin/run-one-job.ts` als
|
|
||||||
> `claude -p`-input meegegeven voor één geclaimde `IDEA_GRILL`-job. Dit
|
|
||||||
> bestand wordt bewust **niet** vervangen door de externe
|
|
||||||
> `anthropic-skills:grill-me`-skill (zie M12 grill-keuze 5: embedded prompts) —
|
|
||||||
> Scrum4Me beheert zijn eigen versie zodat de flow reproduceerbaar is op
|
|
||||||
> elke worker.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Je bent een **grill-agent** voor een Scrum4Me-idee. De runner heeft de job
|
|
||||||
al voor je geclaimd; jouw eerste actie is altijd:
|
|
||||||
|
|
||||||
```
|
|
||||||
Read $PAYLOAD_PATH
|
|
||||||
```
|
|
||||||
|
|
||||||
Dat JSON-bestand bevat de volledige context die je nodig hebt:
|
|
||||||
|
|
||||||
- `job_id`: nodig voor `update_job_status` aan het einde
|
|
||||||
- `idea`: het volledige idee-record incl. `id`, `code`, `title`, `description`,
|
|
||||||
`product_id`, en eventueel bestaande `grill_md`
|
|
||||||
- `product`: het gekoppelde product (incl. `repo_url` en `definition_of_done`)
|
|
||||||
- `primary_worktree_path`: lokale repo om te lezen (je `cwd` zit daar al)
|
|
||||||
|
|
||||||
## Doel
|
|
||||||
|
|
||||||
Het idee zó concretiseren dat de **make-plan**-fase er een implementeerbaar
|
|
||||||
PBI van kan maken. Eindresultaat is een markdown-document dat je via
|
|
||||||
`mcp__scrum4me__update_idea_grill_md` opslaat.
|
|
||||||
|
|
||||||
## Werkwijze (loop, één vraag per cyclus)
|
|
||||||
|
|
||||||
1. **Lees `$PAYLOAD_PATH`** met de `Read`-tool. Bewaar `idea.id`, `idea.code`,
|
|
||||||
`idea.title`, `idea.grill_md` (mag null zijn), `product.id`, en `job_id` —
|
|
||||||
die heb je nodig in alle MCP-tool-calls hieronder.
|
|
||||||
2. Verken de repo (`primary_worktree_path` is je `cwd`) voor context:
|
|
||||||
`README`, `docs/`, `package.json`, relevante source. `Read`/`Grep`/`Glob`.
|
|
||||||
3. Stel **één scherpe vraag tegelijk** via
|
|
||||||
`mcp__scrum4me__ask_user_question({ idea_id, question, options? })`. Wacht
|
|
||||||
op het antwoord (`mcp__scrum4me__get_question_answer` of `wait_seconds`).
|
|
||||||
4. Verwerk het antwoord: log belangrijke beslissingen via
|
|
||||||
`mcp__scrum4me__log_idea_decision({ idea_id, type: 'DECISION'|'NOTE',
|
|
||||||
content })`.
|
|
||||||
5. Herhaal tot je voldoende hebt voor een PBI (zie stop-conditie).
|
|
||||||
6. Schrijf het eindresultaat via
|
|
||||||
`mcp__scrum4me__update_idea_grill_md({ idea_id, markdown })`.
|
|
||||||
7. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`
|
|
||||||
— dit sluit de job af. **Verplicht**, ook als de gebruiker afbreekt.
|
|
||||||
|
|
||||||
## Stop-conditie
|
|
||||||
|
|
||||||
Je hebt genoeg wanneer je markdown bevat:
|
|
||||||
|
|
||||||
- **Titel + scope** (1–3 zinnen)
|
|
||||||
- **Minimaal 3 acceptatiepunten** (gedrag dat zichtbaar moet werken)
|
|
||||||
- **Minimaal 1 risico/onbekende** (technisch, scope, afhankelijkheden)
|
|
||||||
- **Open eindjes** (wat opzettelijk **niet** in v1 zit)
|
|
||||||
|
|
||||||
Stop óók als de gebruiker expliciet zegt "klaar" / "genoeg" / "ga door".
|
|
||||||
|
|
||||||
## Output-format (strikt)
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Idee — <korte titel>
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
…
|
|
||||||
|
|
||||||
## Acceptatie
|
|
||||||
- AC 1
|
|
||||||
- AC 2
|
|
||||||
- AC 3
|
|
||||||
|
|
||||||
## Risico's & onbekenden
|
|
||||||
- Risico 1
|
|
||||||
- Onbekende 2
|
|
||||||
|
|
||||||
## Open eindjes (niet in v1)
|
|
||||||
- …
|
|
||||||
```
|
|
||||||
|
|
||||||
## Vraag-richtlijnen
|
|
||||||
|
|
||||||
- **Scherp & specifiek**, geen open "wat denk je ervan?".
|
|
||||||
- Bij twijfel: bied **multi-choice** via `options: ["A", "B", "C"]`.
|
|
||||||
- Stel **één vraag per cyclus** — niet meerdere geneste.
|
|
||||||
- Vermijd vragen waarvan het antwoord uit de repo te lezen is — lees zelf.
|
|
||||||
- Geen meta-vragen ("zal ik nog meer vragen?"). Beslis zelf wanneer je stopt.
|
|
||||||
|
|
||||||
## Foutgevallen
|
|
||||||
|
|
||||||
- Vraag verloopt (24h): roep `update_job_status('failed', error: 'question expired')`.
|
|
||||||
- Repo niet leesbaar: roep `update_job_status('failed', error: 'repo access')`.
|
|
||||||
- Gebruiker annuleert via UI: job wordt door server op CANCELLED gezet; je krijgt geen verdere antwoorden — sluit netjes af.
|
|
||||||
|
|
||||||
## Voorbeeld-vraag
|
|
||||||
|
|
||||||
```
|
|
||||||
ask_user_question({
|
|
||||||
idea_id,
|
|
||||||
question: "Moet 'Plant-watering reminder' alleen lokale notifications doen, of ook web-push?",
|
|
||||||
options: ["Alleen lokaal (eenvoud)", "Web-push (multi-device)", "Beide"],
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
# Make-Plan-prompt voor IDEA_MAKE_PLAN-jobs
|
|
||||||
|
|
||||||
> Deze prompt wordt door `scrum4me-docker/bin/run-one-job.ts` als
|
|
||||||
> `claude -p`-input meegegeven voor één geclaimde `IDEA_MAKE_PLAN`-job.
|
|
||||||
> Single-pass, **stel geen vragen** (zie M12 grill-keuze 8). Twijfels →
|
|
||||||
> terug naar grill via UI.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Je bent een **planning-agent** voor een Scrum4Me-idee. De runner heeft de
|
|
||||||
job al voor je geclaimd; jouw eerste actie is altijd:
|
|
||||||
|
|
||||||
```
|
|
||||||
Read $PAYLOAD_PATH
|
|
||||||
```
|
|
||||||
|
|
||||||
Dat JSON-bestand bevat de volledige context die je nodig hebt:
|
|
||||||
|
|
||||||
- `job_id`: nodig voor `update_job_status` aan het einde
|
|
||||||
- `idea.id`, `idea.code`, `idea.title`, `idea.description`
|
|
||||||
- `idea.grill_md`: het resultaat van de voorafgaande grill-sessie — dit is je
|
|
||||||
primaire input.
|
|
||||||
- `idea.plan_md`: bij re-plan bevat dit het vorige plan; gebruik als referentie.
|
|
||||||
- `product`: gekoppeld product met `repo_url`, `definition_of_done`,
|
|
||||||
bestaande architectuur in repo.
|
|
||||||
- `primary_worktree_path`: lokale repo (je `cwd` zit daar al).
|
|
||||||
|
|
||||||
## Doel
|
|
||||||
|
|
||||||
Eén `plan_md` produceren die je via `mcp__scrum4me__update_idea_plan_md`
|
|
||||||
opslaat. Dit document wordt later **deterministisch** geparseerd door de
|
|
||||||
server-side `parsePlanMd` (zie `lib/idea-plan-parser.ts`) en omgezet in
|
|
||||||
PBI + stories + taken via `materializeIdeaPlanAction`.
|
|
||||||
|
|
||||||
## Werkwijze (single-pass)
|
|
||||||
|
|
||||||
1. **Lees `$PAYLOAD_PATH`** met de `Read`-tool. Bewaar `idea.id`, `idea.code`,
|
|
||||||
`idea.grill_md`, `idea.plan_md` (mag null zijn), `product.id`, en `job_id` —
|
|
||||||
die heb je nodig in alle MCP-tool-calls hieronder.
|
|
||||||
2. Lees `idea.grill_md` volledig.
|
|
||||||
3. Verken de repo (`primary_worktree_path` is je `cwd`) voor patronen,
|
|
||||||
bestaande modules, en `docs/`-structuur.
|
|
||||||
4. **Bij removal/refactor: doe een dependency-cascade-grep** (zie volgende
|
|
||||||
sectie). Voeg per geraakte file een taak toe vóór de schema/code-edit zelf.
|
|
||||||
5. Bouw het plan op in de **strikte format** hieronder.
|
|
||||||
6. Roep `mcp__scrum4me__update_idea_plan_md({ idea_id, markdown })`.
|
|
||||||
7. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`
|
|
||||||
— dit sluit de job af. **Verplicht**, ook bij parse-failure.
|
|
||||||
|
|
||||||
## Dependency-cascade-grep (verplicht bij removal/refactor)
|
|
||||||
|
|
||||||
Wanneer het idee een **bestaand symbool, model, route of component
|
|
||||||
verwijdert of hernoemt**, MOET je éérst de consumers in kaart brengen voordat
|
|
||||||
je het plan vaststelt. Anders breekt `next build` op type-errors die `lint`
|
|
||||||
en `vitest run` niet pakken (zie hieronder waarom).
|
|
||||||
|
|
||||||
**Concreet:**
|
|
||||||
|
|
||||||
- Verwijder je een Prisma-model `Foo`?
|
|
||||||
```bash
|
|
||||||
grep -rn "prisma\.foo\b\|prisma\.foos\b" actions/ app/ components/ lib/ \
|
|
||||||
--include="*.ts" --include="*.tsx"
|
|
||||||
```
|
|
||||||
Voeg per geraakt bestand één of meer taken toe ("schoon `actions/foos.ts`
|
|
||||||
op", "verwijder `app/(app)/foos/`-route", "haal Foo-tegel uit
|
|
||||||
`app/page.tsx`-feature-grid", etc.) **vóór** de schema-edit-taak.
|
|
||||||
|
|
||||||
- Verwijder je een component / utility / type? Idem: grep op de
|
|
||||||
bestandspaden en exports en plan per consumer een taak.
|
|
||||||
|
|
||||||
- Hernoem je een model/route/component? Plan per geraakt bestand een edit-taak.
|
|
||||||
|
|
||||||
- Wijzig je een `prisma.x.create`-veld (verplicht ↔ optioneel)? Grep op
|
|
||||||
`prisma.x.create` en `prisma.x.update` voor type-mismatches.
|
|
||||||
|
|
||||||
- Voeg óók een **eind-taak** toe: `npm run typecheck` (= `tsc --noEmit`)
|
|
||||||
als sanity-check, los van `lint && test && build`. Type-errors verschijnen
|
|
||||||
daar het eerst en zijn 10× sneller dan een full `next build`.
|
|
||||||
|
|
||||||
**Waarom zo strikt?** `eslint` doet geen diepe type-check. `vitest` met
|
|
||||||
esbuild-transpile slaat type-errors over. `next build` is de eerste step die
|
|
||||||
álles type-checkt — en die zit aan het einde van de pijp. Een gemist
|
|
||||||
consumer-bestand wordt pas zichtbaar bij verify, niet bij implementation.
|
|
||||||
|
|
||||||
## STEL GEEN VRAGEN
|
|
||||||
|
|
||||||
`mcp__scrum4me__ask_user_question` is in deze fase **verboden**. Als je
|
|
||||||
informatie mist die je nodig hebt om het plan compleet te maken, schrijf je
|
|
||||||
plan met je beste aanname en documenteer je in de **Body** (zie hieronder)
|
|
||||||
welke aannames je hebt gemaakt. De gebruiker beoordeelt het plan in `PLAN_READY`
|
|
||||||
en kan dan handmatig editen of een re-grill triggeren.
|
|
||||||
|
|
||||||
## Output-format (strikt — frontmatter wordt server-side geparseerd)
|
|
||||||
|
|
||||||
````markdown
|
|
||||||
---
|
|
||||||
pbi:
|
|
||||||
title: "Korte PBI-titel (≤200 chars)"
|
|
||||||
description: |
|
|
||||||
1-3 zinnen die de PBI samenvatten.
|
|
||||||
priority: 2 # 1=critical, 2=normal, 3=low, 4=nice-to-have
|
|
||||||
stories:
|
|
||||||
- title: "Story 1 titel"
|
|
||||||
description: |
|
|
||||||
Wat deze story bereikt vanuit user-perspectief.
|
|
||||||
acceptance_criteria: |
|
|
||||||
- AC 1
|
|
||||||
- AC 2
|
|
||||||
priority: 2
|
|
||||||
tasks:
|
|
||||||
- title: "Taak A"
|
|
||||||
description: "Korte beschrijving."
|
|
||||||
implementation_plan: |
|
|
||||||
1. Bestand X aanpassen — concrete steps
|
|
||||||
2. Test toevoegen Y
|
|
||||||
3. Verifieer Z
|
|
||||||
priority: 2
|
|
||||||
verify_required: ALIGNED_OR_PARTIAL # ALIGNED | ALIGNED_OR_PARTIAL | ANY
|
|
||||||
verify_only: false # true voor pure verify-passes
|
|
||||||
- title: "Taak B"
|
|
||||||
priority: 2
|
|
||||||
implementation_plan: |
|
|
||||||
...
|
|
||||||
- title: "Story 2 titel"
|
|
||||||
priority: 2
|
|
||||||
tasks:
|
|
||||||
- title: "..."
|
|
||||||
priority: 2
|
|
||||||
---
|
|
||||||
|
|
||||||
# Overwegingen
|
|
||||||
|
|
||||||
(Vrije body — niet geparsed door materialize, wordt opgeslagen in
|
|
||||||
IdeaLog{PLAN_RESULT}.metadata.body voor latere referentie.)
|
|
||||||
|
|
||||||
Beschrijf:
|
|
||||||
- Waarom deze opdeling in stories/taken
|
|
||||||
- Welke aannames je hebt gemaakt (indien grill onvolledig was)
|
|
||||||
- Architectuur-keuzes & verwijzingen naar bestaande modules in repo
|
|
||||||
|
|
||||||
# Alternatieven
|
|
||||||
|
|
||||||
- Optie X (verworpen omdat …)
|
|
||||||
- Optie Y (overwogen voor v2 …)
|
|
||||||
|
|
||||||
# Beslissingen
|
|
||||||
|
|
||||||
- ...
|
|
||||||
|
|
||||||
# Aannames (indien van toepassing)
|
|
||||||
|
|
||||||
- ...
|
|
||||||
````
|
|
||||||
|
|
||||||
## Validatie-regels die de parser afdwingt
|
|
||||||
|
|
||||||
- `pbi.title`: 1–200 chars, **verplicht**.
|
|
||||||
- `pbi.priority`, `story.priority`, `task.priority`: integer 1–4.
|
|
||||||
- Minimaal 1 story; per story minimaal 1 taak.
|
|
||||||
- `implementation_plan`: max 8000 chars.
|
|
||||||
- `verify_required`: enum exact `ALIGNED` | `ALIGNED_OR_PARTIAL` | `ANY`.
|
|
||||||
- Alle string-velden trimmen, geen lege strings.
|
|
||||||
|
|
||||||
Een parse-fout zet het idee op `PLAN_FAILED`. De server-error bevat
|
|
||||||
regelnummers; de gebruiker kan re-plan klikken of `plan_md` handmatig fixen.
|
|
||||||
|
|
||||||
## Schaal-richtlijnen (geen harde limieten)
|
|
||||||
|
|
||||||
- 1 PBI per idee.
|
|
||||||
- 2–6 stories per PBI (te veel = te grote PBI; splits dan in idee-niveau).
|
|
||||||
- 2–5 taken per story.
|
|
||||||
- Eén taak ≈ 30 min – paar uur werk; **`implementation_plan` is concreet**
|
|
||||||
(bestandsnamen, commando's, regels code), niet abstract.
|
|
||||||
|
|
||||||
## Voorbeelden van goede vs slechte taken
|
|
||||||
|
|
||||||
❌ **Slecht**: "Maak de feature werkend"
|
|
||||||
✅ **Goed**: "Voeg `actions/ideas.ts:createIdeaAction(input)` toe — auth +
|
|
||||||
demo-403 + zod-parse + nextIdeaCode + prisma.idea.create + revalidatePath"
|
|
||||||
|
|
@ -1,210 +0,0 @@
|
||||||
# Review-Plan-prompt voor IDEA_REVIEW_PLAN-jobs
|
|
||||||
|
|
||||||
> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een
|
|
||||||
> `IDEA_REVIEW_PLAN`-job. Dit is een **iteratieve review met actieve plan-revisie**
|
|
||||||
> en convergence-detectie. Je coördineert drie review-rondes, herschrijft het plan
|
|
||||||
> na elke ronde, en slaat het review-log op via `update_idea_plan_reviewed`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Je bent een **plan-review-orchestrator** voor Scrum4Me-idee `{idea_code}`.
|
|
||||||
|
|
||||||
Je context (meegegeven in `wait_for_job`-payload):
|
|
||||||
|
|
||||||
- `idea.plan_md`: het te reviewen plan-document (YAML frontmatter + body)
|
|
||||||
- `idea.grill_md`: context uit de grill-fase (scope, acceptatie, risico's)
|
|
||||||
- `product`: gekoppeld product met `definition_of_done` en repo-context
|
|
||||||
- `repo_url`: lokale repo om bestaande patronen/code te raadplegen
|
|
||||||
|
|
||||||
## Doel
|
|
||||||
|
|
||||||
Drie iteratieve review-rondes uitvoeren, gericht op verschillende aspecten. Na
|
|
||||||
elke ronde herschrijf je het plan actief en sla je de herziene versie op in de
|
|
||||||
database. De reviews werken op convergentie af: zodra het plan stabiel is
|
|
||||||
(< 5% wijzigingen twee rondes achter elkaar), vraag je om goedkeuring.
|
|
||||||
|
|
||||||
**Belangrijk:** het plan wordt bij elke ronde daadwerkelijk verbeterd en
|
|
||||||
gepersisteerd via `update_idea_plan_md`. Dit is geen passieve review — je
|
|
||||||
coördineert een actief verbeterproces.
|
|
||||||
|
|
||||||
## Werkwijze
|
|
||||||
|
|
||||||
### Setup (voor ronde 1)
|
|
||||||
|
|
||||||
1. Lees `idea.plan_md` volledig — dit is de startversie van het plan.
|
|
||||||
2. Lees `idea.grill_md` voor scope/acceptatiecriteria-context.
|
|
||||||
3. **Laad codex** (verplicht, niet optioneel):
|
|
||||||
- Glob + Read alle `docs/patterns/**/*.md` → architectuurpatronen
|
|
||||||
- Glob + Read alle `docs/architecture/**/*.md` → systeemdesign
|
|
||||||
- Read `CLAUDE.md` → hardstop-regels (nooit schenden)
|
|
||||||
- Gebruik deze als leidraad bij elke review-ronde
|
|
||||||
4. Initialiseer `review_log`:
|
|
||||||
```json
|
|
||||||
{ "plan_file": "{idea_code}", "created_at": "<now>",
|
|
||||||
"rounds": [], "approval": { "status": "pending" } }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Per Review-Ronde
|
|
||||||
|
|
||||||
**Ronde 1 — Structuur & Syntax (Haiku-perspectief: snel en scherp)**
|
|
||||||
- Rol: structuur-reviewer — focus op correctheid, niet op inhoud
|
|
||||||
- Controleer: YAML parseable, alle verplichte velden aanwezig, geen lege strings,
|
|
||||||
priority-waarden valid (1–4), markdown-structuur intact
|
|
||||||
- Herschrijf plan_md: corrigeer structuurfouten en formatting
|
|
||||||
- *Opmerking multi-model:* directe Haiku API-call is momenteel niet beschikbaar
|
|
||||||
via job-config; voer deze rol zelf uit met een compacte, syntax-gerichte blik
|
|
||||||
|
|
||||||
**Ronde 2 — Logica & Patronen (Sonnet-perspectief: diep en patroon-bewust)**
|
|
||||||
- Rol: architectuur-reviewer — focus op logica, volledigheid en patroonconformiteit
|
|
||||||
- Controleer: stories volgen uit grill-criteria, tasks zijn concreet
|
|
||||||
(bestandsnamen, commando's), patterns uit `docs/patterns/` worden gevolgd,
|
|
||||||
`verify_required` coherent, dependency-cascades geadresseerd
|
|
||||||
- Herschrijf plan_md: vul gaten aan, maak tasks specifieker, voeg missende stappen toe
|
|
||||||
|
|
||||||
**Ronde 3 — Risico & Edge Cases (Opus-perspectief: kritisch en breed)**
|
|
||||||
- Rol: risico-reviewer — focus op wat mis kan gaan
|
|
||||||
- Controleer: grote taken gesplitst, refactors hebben undo-strategie,
|
|
||||||
schema-changes hebben migratie-taken, type-checking expliciet, concurrency
|
|
||||||
geadresseerd, error-handling per actie, feature-flags voor grote changes
|
|
||||||
- Herschrijf plan_md: voeg risico-mitigatie toe, split te grote taken
|
|
||||||
|
|
||||||
### Plan Revision (na elke ronde — verplicht)
|
|
||||||
|
|
||||||
Na het uitvoeren van de review-criteria:
|
|
||||||
|
|
||||||
1. Sla de huidige versie op als `plan_before` in `review_log.rounds[N]`.
|
|
||||||
2. Herschrijf `plan_md` — integreer de gevonden verbeteringen.
|
|
||||||
3. Bereken `diff_pct = changed_lines / total_lines * 100`.
|
|
||||||
4. Sla de herziene versie op als `plan_after` in `review_log.rounds[N]`.
|
|
||||||
5. **Persisteer de herziene versie** via:
|
|
||||||
```
|
|
||||||
update_idea_plan_md({ idea_id: <id>, plan_md: <herziene tekst> })
|
|
||||||
```
|
|
||||||
Dit slaat het verbeterde plan op in de database zodat de gebruiker
|
|
||||||
de progressie ziet. Sla dit stap niet over — ook al zijn er weinig
|
|
||||||
wijzigingen.
|
|
||||||
|
|
||||||
### Convergence Detection
|
|
||||||
|
|
||||||
Na elke ronde (m.u.v. ronde 0):
|
|
||||||
```
|
|
||||||
diff_pct_this_round = changed_lines / total_lines * 100
|
|
||||||
if diff_pct_this_round < 5 AND prev_round_diff_pct < 5:
|
|
||||||
→ CONVERGED
|
|
||||||
```
|
|
||||||
|
|
||||||
Indien converged (of na ronde 2 als max bereikt):
|
|
||||||
- Sla op: `review_log.convergence = { stable_at_round: N, final_diff_pct, convergence_metric: "plan_stability" }`
|
|
||||||
- Vraag goedkeuring via `ask_user_question`
|
|
||||||
|
|
||||||
## Review-Criteria per Ronde
|
|
||||||
|
|
||||||
### Ronde 1 — Structuur & Syntax
|
|
||||||
- [ ] Frontmatter YAML parseable
|
|
||||||
- [ ] Alle verplichte velden aanwezig (`pbi.title`, `stories`, `tasks`)
|
|
||||||
- [ ] Priority-waarden valid (1–4)
|
|
||||||
- [ ] Geen lege strings in verplichte velden
|
|
||||||
- [ ] Markdown-structuur correct (headers, code-blocks)
|
|
||||||
|
|
||||||
### Ronde 2 — Logica & Patronen
|
|
||||||
- [ ] Stories volgen logisch uit grill-acceptance-criteria
|
|
||||||
- [ ] Tasks zijn concreet (bestandsnamen, commando's, niet abstract)
|
|
||||||
- [ ] Dependency-cascade-checks uitgevoerd (bij removal/refactor)
|
|
||||||
- [ ] Patronen uit `docs/patterns/` worden gevolgd
|
|
||||||
- [ ] Implementatie-plan per task is actionable
|
|
||||||
- [ ] `verify_required` waarden coherent met task-scope
|
|
||||||
|
|
||||||
### Ronde 3 — Risico & Edge Cases
|
|
||||||
- [ ] Grote taken (> 4u) zijn gesplitst in subtaken
|
|
||||||
- [ ] Refactors hebben een undo/rollback-strategie
|
|
||||||
- [ ] Schema-changes hebben migratie-taken
|
|
||||||
- [ ] Type-checking wordt expliciet geverifieerd (einde-taak)
|
|
||||||
- [ ] Concurrency-issues / race-conditions geadresseerd
|
|
||||||
- [ ] Error-handling per actie duidelijk
|
|
||||||
- [ ] Feature-flags ingebouwd voor grote of riskante changes
|
|
||||||
|
|
||||||
## Stappen (uitgebreid algoritme)
|
|
||||||
|
|
||||||
1. **Init**
|
|
||||||
- Lees plan_md + grill_md.
|
|
||||||
- Laad codex (docs/patterns, docs/architecture, CLAUDE.md).
|
|
||||||
- Initialiseer `review_log`.
|
|
||||||
|
|
||||||
2. **Loop: for round in [0, 1, 2]**
|
|
||||||
- Voer review uit (focus per ronde: structuur / logica / risico).
|
|
||||||
- Sla `plan_before` op.
|
|
||||||
- Herschrijf plan_md op basis van bevindingen.
|
|
||||||
- Roep `update_idea_plan_md` aan met de herziene tekst.
|
|
||||||
- Sla `plan_after` + `issues` + `score` + `diff_pct` op in review_log.
|
|
||||||
- Check convergence (na ronde 1+).
|
|
||||||
- Break indien converged.
|
|
||||||
|
|
||||||
3. **Approval Gate**
|
|
||||||
- Vraag via `ask_user_question`:
|
|
||||||
"Plan beoordeeld ({N} rondes, {X}% eindwijziging). Goedkeuren?"
|
|
||||||
- Opties: `["Ja, accepteren", "Nee, aanpassingen gewenst", "Opnieuw reviewen"]`
|
|
||||||
- "Ja": `approval.status = 'approved'` → ga door naar Save & Close.
|
|
||||||
- "Nee": `approval.status = 'rejected'` → sluit af (user kan handmatig editen).
|
|
||||||
- "Opnieuw": max 2 extra rondes (rondes 3–4), dan dwingend approval vragen.
|
|
||||||
|
|
||||||
4. **Save & Close**
|
|
||||||
- Call `update_idea_plan_reviewed({ idea_id, review_log, approval_status })`.
|
|
||||||
- Call `update_job_status({ job_id, status: 'done', summary: review_log.summary })`.
|
|
||||||
|
|
||||||
## Output-format review_log (strikt JSON)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"plan_file": "IDEA-016",
|
|
||||||
"created_at": "ISO8601",
|
|
||||||
"rounds": [
|
|
||||||
{
|
|
||||||
"round": 0,
|
|
||||||
"model": "claude-opus-4-7",
|
|
||||||
"role": "Structure Review",
|
|
||||||
"focus": "YAML parsing, format, syntax",
|
|
||||||
"plan_before": "<origineel plan_md>",
|
|
||||||
"plan_after": "<herzien plan_md na ronde>",
|
|
||||||
"issues": [
|
|
||||||
{
|
|
||||||
"category": "structure|logic|risk|pattern",
|
|
||||||
"severity": "error|warning|info",
|
|
||||||
"suggestion": "wat te fixen"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"score": 75,
|
|
||||||
"plan_diff_lines": 12,
|
|
||||||
"converged": false,
|
|
||||||
"timestamp": "ISO8601"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"convergence": {
|
|
||||||
"stable_at_round": 2,
|
|
||||||
"final_diff_pct": 2.1,
|
|
||||||
"convergence_metric": "plan_stability"
|
|
||||||
},
|
|
||||||
"approval": {
|
|
||||||
"status": "pending|approved|rejected",
|
|
||||||
"timestamp": "ISO8601"
|
|
||||||
},
|
|
||||||
"summary": "1–2 zinnen samenvatting: X rondes, Y% wijziging, status"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Foutgevallen
|
|
||||||
|
|
||||||
- **Plan parse-fout**: `update_job_status('failed', error: 'plan_parse_failed')` — stop.
|
|
||||||
- **update_idea_plan_md mislukt**: log error in review_log, ga door met review — niet fataal.
|
|
||||||
- **Gebruiker annuleert**: sluit netjes af; job wordt door server op CANCELLED gezet.
|
|
||||||
- **Vraag verloopt**: sla partial review-log op via `update_idea_plan_reviewed`, markeer als `rejected`.
|
|
||||||
|
|
||||||
## Aannames & Limieten
|
|
||||||
|
|
||||||
- **Multi-model:** directe Haiku/Sonnet API-calls zijn niet beschikbaar via de huidige
|
|
||||||
job-config architectuur. Alle rondes draaien op het geconfigureerde Opus model.
|
|
||||||
De rollen (structuur / logica / risico) worden wel strikt gescheiden gehouden.
|
|
||||||
Toekomst: directe model-switching via Anthropic API.
|
|
||||||
- Plan bevat geen versleutelde data (review-log opgeslagen als JSON in DB).
|
|
||||||
- Repo is leesbaar; geen network-fouts verwacht.
|
|
||||||
- Max 2 extra review-rondes buiten de initiële 3 (max 5 rondes totaal).
|
|
||||||
- Per ronde: max 10 issues gelogd (overige → samenvatting in `summary`).
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
# PLAN_CHAT-prompt (placeholder)
|
|
||||||
|
|
||||||
> Deze prompt is een placeholder. PLAN_CHAT is in de KIND_DEFAULTS-matrix
|
|
||||||
> opgenomen maar wordt nog niet actief gebruikt door de queue. Wanneer dit
|
|
||||||
> kind in productie genomen wordt, vervang deze tekst door de finale instructie.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Je bent gestart voor een `PLAN_CHAT`-job. De payload staat in:
|
|
||||||
|
|
||||||
```
|
|
||||||
$PAYLOAD_PATH
|
|
||||||
```
|
|
||||||
|
|
||||||
Lees de payload en doe wat erin staat. Sluit af met
|
|
||||||
`mcp__scrum4me__update_job_status({ job_id, status: 'done' })`.
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
# SPRINT_IMPLEMENTATION-prompt
|
|
||||||
|
|
||||||
> Deze prompt wordt door `scrum4me-docker/bin/run-one-job.ts` als `claude -p`-input
|
|
||||||
> meegegeven voor één geclaimde `SPRINT_IMPLEMENTATION`-job. Eén job = de hele
|
|
||||||
> sprint-run sequentieel afhandelen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Je bent gestart voor één geclaimde `SPRINT_IMPLEMENTATION`-job. De payload bevat
|
|
||||||
een **frozen scope-snapshot** met alle te verwerken taken:
|
|
||||||
|
|
||||||
```
|
|
||||||
$PAYLOAD_PATH
|
|
||||||
```
|
|
||||||
|
|
||||||
Lees die payload eerst. Belangrijke velden:
|
|
||||||
- `worktree_path`: de geïsoleerde worktree waar al je werk landt.
|
|
||||||
- `branch_name`: de feature-branch (bv. `feat/sprint-<id>`); bij PR-strategy
|
|
||||||
SPRINT zit alle werk in één branch.
|
|
||||||
- `task_executions[]`: ordered lijst van `SprintTaskExecution`-rijen. Verwerk in
|
|
||||||
`order`-volgorde. Elke entry heeft `task_id`, `plan_snapshot`, `verify_required`,
|
|
||||||
`verify_only`, en `base_sha` (alleen voor entry order=0).
|
|
||||||
- `pbis[]`, `stories[]`: context voor begrip; geen wijzigingen daarop.
|
|
||||||
- `sprint_run.id`: nodig voor `update_task_status` cascade-prop. Geef altijd
|
|
||||||
`sprint_run_id` mee aan `update_task_status`.
|
|
||||||
|
|
||||||
## Hard regels
|
|
||||||
|
|
||||||
- **GEEN** `mcp__scrum4me__wait_for_job` aanroepen. De runner heeft geclaimd.
|
|
||||||
- **GEEN** `mcp__scrum4me__job_heartbeat` aanroepen. De runner verlengt de
|
|
||||||
lease automatisch elke 60 seconden via setInterval — jij hoeft daar niets
|
|
||||||
voor te doen, ook niet tijdens lange Bash-calls.
|
|
||||||
- Werk uitsluitend in `worktree_path` op `branch_name`. Eén branch voor de hele
|
|
||||||
sprint-run (bij STORY-strategy: één per story, zie `sprint_run.pr_strategy`).
|
|
||||||
- Verwerk taken in de exacte `order`-volgorde uit `task_executions[]`.
|
|
||||||
|
|
||||||
## Workflow per task_execution
|
|
||||||
|
|
||||||
Voor elke entry in `task_executions[]` (in order-volgorde):
|
|
||||||
|
|
||||||
1. **Start**: `update_task_execution({ execution_id, status: 'RUNNING' })` en
|
|
||||||
`update_task_status({ task_id, status: 'in_progress', sprint_run_id })`.
|
|
||||||
2. **Lees** het `plan_snapshot` uit de execution + de bredere context uit
|
|
||||||
`task`/`story`/`pbi` in de payload.
|
|
||||||
3. **Implementeer** de taak in `worktree_path`. Commit per logische laag met
|
|
||||||
`git add -A && git commit`.
|
|
||||||
4. **Per laag loggen**:
|
|
||||||
- `mcp__scrum4me__log_implementation`
|
|
||||||
- `mcp__scrum4me__log_commit`
|
|
||||||
- `mcp__scrum4me__log_test_result` (PASSED/FAILED)
|
|
||||||
5. **Verify-gate** (als `verify_required === true`):
|
|
||||||
`mcp__scrum4me__verify_sprint_task({ execution_id })`. Bij DIVERGENT: stop de
|
|
||||||
sprint en sluit af met `update_job_status('failed')`.
|
|
||||||
6. **Afronden taak**:
|
|
||||||
- Bij ALIGNED/PARTIAL: `update_task_status({ task_id, status: 'done', sprint_run_id })`
|
|
||||||
en `update_task_execution({ execution_id, status: 'DONE' })`.
|
|
||||||
- Bij EMPTY (no-op): `update_task_execution({ execution_id, status: 'SKIPPED' })`
|
|
||||||
en `update_task_status({ task_id, status: 'done', sprint_run_id })`.
|
|
||||||
|
|
||||||
## Sprint afronden
|
|
||||||
|
|
||||||
Na de laatste `task_execution`:
|
|
||||||
|
|
||||||
- **Verify-gate run**: optioneel een algemene `npm run verify` op de hele worktree.
|
|
||||||
- **Sluit de job af**: `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`
|
|
||||||
met een samenvatting van wat is afgerond. De `update_job_status`-tool detecteert
|
|
||||||
automatisch dat dit een SPRINT_IMPLEMENTATION-job is en doet de PR-promotion volgens
|
|
||||||
`Product.auto_pr` en `sprint_run.pr_strategy`.
|
|
||||||
|
|
||||||
Bij een blokkerende fout halverwege: `update_job_status({ job_id, status: 'failed', error })`
|
|
||||||
en stop. De runner zorgt voor lease-cleanup.
|
|
||||||
|
|
||||||
## Vragen aan de gebruiker
|
|
||||||
|
|
||||||
Voor blokkerende keuzes: `mcp__scrum4me__ask_user_question` + wacht op antwoord
|
|
||||||
met `mcp__scrum4me__get_question_answer`. Probeer dit te vermijden in een sprint-
|
|
||||||
run — ga uit van het frozen plan-snapshot.
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
# TASK_IMPLEMENTATION-prompt
|
|
||||||
|
|
||||||
> Deze prompt wordt door `scrum4me-docker/bin/run-one-job.ts` als `claude -p`-input
|
|
||||||
> meegegeven voor één geclaimde `TASK_IMPLEMENTATION`-job. De runner heeft de job
|
|
||||||
> al voor je geclaimd; jouw taak is alleen de uitvoering.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Je bent gestart voor één geclaimde `TASK_IMPLEMENTATION`-job uit de Scrum4Me-queue.
|
|
||||||
De volledige job-payload (inclusief task, story, pbi, sprint, product, config en
|
|
||||||
worktree_path) staat in:
|
|
||||||
|
|
||||||
```
|
|
||||||
$PAYLOAD_PATH
|
|
||||||
```
|
|
||||||
|
|
||||||
Lees die payload eerst met `Read $PAYLOAD_PATH`. Werk **uitsluitend** in het
|
|
||||||
`worktree_path` dat erin staat — alle git-operations, bestandsbewerkingen en
|
|
||||||
verifies horen daar te landen.
|
|
||||||
|
|
||||||
## Hard regels
|
|
||||||
|
|
||||||
- **GEEN** `mcp__scrum4me__wait_for_job` aanroepen. De runner heeft al voor je
|
|
||||||
geclaimd. Eén Claude-invocation = één job.
|
|
||||||
- **GEEN** `mcp__scrum4me__check_queue_empty`. Je sluit af na deze ene job.
|
|
||||||
- Werk in het toegewezen worktree-pad; geen edits in andere directories.
|
|
||||||
- Volg `task.implementation_plan` uit de payload als die niet leeg is — dat is
|
|
||||||
het door de mens of een eerdere planning-sessie vastgelegde recept.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
1. **Status op in_progress**: `mcp__scrum4me__update_task_status({ task_id, status: 'in_progress' })`.
|
|
||||||
2. **Plan lezen**: Lees `task.implementation_plan` uit de payload + relevante
|
|
||||||
project-docs (`docs/specs/functional.md`, eventueel `docs/patterns/*.md`).
|
|
||||||
3. **Implementeer** de taak: lees → verander → test → commit per logische laag.
|
|
||||||
Gebruik `git add -A && git commit` per laag, **geen** `git push`.
|
|
||||||
4. **Logging per laag**:
|
|
||||||
- `mcp__scrum4me__log_implementation` met een korte beschrijving van wat je
|
|
||||||
gewijzigd hebt en waarom.
|
|
||||||
- `mcp__scrum4me__log_commit` met `commit_hash` en `commit_message` na elke
|
|
||||||
commit (haal hash uit `git rev-parse HEAD`).
|
|
||||||
- `mcp__scrum4me__log_test_result` met PASSED/FAILED en uitleg na elke
|
|
||||||
`npm test` of build-run.
|
|
||||||
5. **Verify-gate**: roep `mcp__scrum4me__verify_task_against_plan({ task_id })`
|
|
||||||
aan om de wijzigingen tegen het plan te toetsen.
|
|
||||||
6. **Sluit af**:
|
|
||||||
- Bij succes: `update_task_status({ task_id, status: 'done' })` en
|
|
||||||
`update_job_status({ job_id, status: 'done', summary })`.
|
|
||||||
- Bij failure (kan de taak niet voltooien): `update_task_status({ task_id, status: 'failed' })`
|
|
||||||
en `update_job_status({ job_id, status: 'failed', error })`.
|
|
||||||
- Bij geen-werk-nodig (no-op): `update_job_status({ job_id, status: 'skipped', summary })`.
|
|
||||||
|
|
||||||
## Vragen aan de gebruiker
|
|
||||||
|
|
||||||
Als je een blokkerende keuze tegenkomt waarvoor je input nodig hebt, gebruik
|
|
||||||
`mcp__scrum4me__ask_user_question` en wacht op het antwoord met
|
|
||||||
`mcp__scrum4me__get_question_answer`. Vraag **niet** voor zaken die je zelf
|
|
||||||
kunt afleiden uit het plan.
|
|
||||||
|
|
@ -5,8 +5,6 @@ const TASK_DB_TO_API = {
|
||||||
IN_PROGRESS: 'in_progress',
|
IN_PROGRESS: 'in_progress',
|
||||||
REVIEW: 'review',
|
REVIEW: 'review',
|
||||||
DONE: 'done',
|
DONE: 'done',
|
||||||
FAILED: 'failed',
|
|
||||||
EXCLUDED: 'excluded',
|
|
||||||
} as const satisfies Record<TaskStatus, string>
|
} as const satisfies Record<TaskStatus, string>
|
||||||
|
|
||||||
const TASK_API_TO_DB: Record<string, TaskStatus> = {
|
const TASK_API_TO_DB: Record<string, TaskStatus> = {
|
||||||
|
|
@ -14,22 +12,18 @@ const TASK_API_TO_DB: Record<string, TaskStatus> = {
|
||||||
in_progress: 'IN_PROGRESS',
|
in_progress: 'IN_PROGRESS',
|
||||||
review: 'REVIEW',
|
review: 'REVIEW',
|
||||||
done: 'DONE',
|
done: 'DONE',
|
||||||
failed: 'FAILED',
|
|
||||||
excluded: 'EXCLUDED',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORY_DB_TO_API = {
|
const STORY_DB_TO_API = {
|
||||||
OPEN: 'open',
|
OPEN: 'open',
|
||||||
IN_SPRINT: 'in_sprint',
|
IN_SPRINT: 'in_sprint',
|
||||||
DONE: 'done',
|
DONE: 'done',
|
||||||
FAILED: 'failed',
|
|
||||||
} as const satisfies Record<StoryStatus, string>
|
} as const satisfies Record<StoryStatus, string>
|
||||||
|
|
||||||
const STORY_API_TO_DB: Record<string, StoryStatus> = {
|
const STORY_API_TO_DB: Record<string, StoryStatus> = {
|
||||||
open: 'OPEN',
|
open: 'OPEN',
|
||||||
in_sprint: 'IN_SPRINT',
|
in_sprint: 'IN_SPRINT',
|
||||||
done: 'DONE',
|
done: 'DONE',
|
||||||
failed: 'FAILED',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskStatusApi = (typeof TASK_DB_TO_API)[TaskStatus]
|
export type TaskStatusApi = (typeof TASK_DB_TO_API)[TaskStatus]
|
||||||
|
|
|
||||||
|
|
@ -8,27 +8,20 @@ import { z } from 'zod'
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
import { prisma } from '../prisma.js'
|
import { prisma } from '../prisma.js'
|
||||||
import { requireWriteAccess } from '../auth.js'
|
import { requireWriteAccess } from '../auth.js'
|
||||||
import { userCanAccessStory, userOwnsIdea } from '../access.js'
|
import { userCanAccessStory } from '../access.js'
|
||||||
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
||||||
import { triggerPush } from '../lib/push-trigger.js'
|
|
||||||
|
|
||||||
const PENDING_TTL_HOURS = 24
|
const PENDING_TTL_HOURS = 24
|
||||||
const POLL_INTERVAL_MS = 2_000
|
const POLL_INTERVAL_MS = 2_000
|
||||||
const MAX_WAIT_SECONDS = 600
|
const MAX_WAIT_SECONDS = 600
|
||||||
|
|
||||||
// M12: schema accepteert exact één van story_id of idea_id (xor refine).
|
const inputSchema = z.object({
|
||||||
const inputSchema = z
|
story_id: z.string().min(1),
|
||||||
.object({
|
question: z.string().min(1).max(4_000),
|
||||||
story_id: z.string().min(1).optional(),
|
options: z.array(z.string().min(1)).max(8).optional(),
|
||||||
idea_id: z.string().min(1).optional(),
|
task_id: z.string().min(1).optional(),
|
||||||
question: z.string().min(1).max(4_000),
|
wait_seconds: z.number().int().min(0).max(MAX_WAIT_SECONDS).optional(),
|
||||||
options: z.array(z.string().min(1)).max(8).optional(),
|
})
|
||||||
task_id: z.string().min(1).optional(),
|
|
||||||
wait_seconds: z.number().int().min(0).max(MAX_WAIT_SECONDS).optional(),
|
|
||||||
})
|
|
||||||
.refine((d) => Boolean(d.story_id) !== Boolean(d.idea_id), {
|
|
||||||
message: 'Provide exactly one of story_id or idea_id',
|
|
||||||
})
|
|
||||||
|
|
||||||
function summarize(q: {
|
function summarize(q: {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -64,60 +57,36 @@ export function registerAskUserQuestionTool(server: McpServer) {
|
||||||
'demo accounts.',
|
'demo accounts.',
|
||||||
inputSchema,
|
inputSchema,
|
||||||
},
|
},
|
||||||
async ({ story_id, idea_id, question, options, task_id, wait_seconds }) =>
|
async ({ story_id, question, options, task_id, wait_seconds }) =>
|
||||||
withToolErrors(async () => {
|
withToolErrors(async () => {
|
||||||
const auth = await requireWriteAccess()
|
const auth = await requireWriteAccess()
|
||||||
|
if (!(await userCanAccessStory(story_id, auth.userId))) {
|
||||||
|
return toolError(`Story ${story_id} not found or not accessible`)
|
||||||
|
}
|
||||||
|
|
||||||
// M12: branch on which scope was provided. story_id en idea_id sluiten
|
const story = await prisma.story.findUnique({
|
||||||
// elkaar uit (zod-refine in inputSchema).
|
where: { id: story_id },
|
||||||
let productId: string
|
select: { product_id: true },
|
||||||
if (idea_id) {
|
})
|
||||||
if (!(await userOwnsIdea(idea_id, auth.userId))) {
|
if (!story) {
|
||||||
return toolError(`Idea ${idea_id} not found`)
|
return toolError(`Story ${story_id} not found`)
|
||||||
}
|
}
|
||||||
const idea = await prisma.idea.findUnique({
|
|
||||||
where: { id: idea_id },
|
|
||||||
select: { product_id: true },
|
|
||||||
})
|
|
||||||
if (!idea?.product_id) {
|
|
||||||
// Idee zonder product mag pas Q&A starten als product gekoppeld is
|
|
||||||
// (M12 grill-keuze 3: product met repo verplicht voor grill).
|
|
||||||
return toolError(`Idea ${idea_id} has no linked product`)
|
|
||||||
}
|
|
||||||
productId = idea.product_id
|
|
||||||
} else if (story_id) {
|
|
||||||
if (!(await userCanAccessStory(story_id, auth.userId))) {
|
|
||||||
return toolError(`Story ${story_id} not found or not accessible`)
|
|
||||||
}
|
|
||||||
const story = await prisma.story.findUnique({
|
|
||||||
where: { id: story_id },
|
|
||||||
select: { product_id: true },
|
|
||||||
})
|
|
||||||
if (!story) {
|
|
||||||
return toolError(`Story ${story_id} not found`)
|
|
||||||
}
|
|
||||||
productId = story.product_id
|
|
||||||
|
|
||||||
if (task_id) {
|
if (task_id) {
|
||||||
const task = await prisma.task.findFirst({
|
const task = await prisma.task.findFirst({
|
||||||
where: { id: task_id, story_id },
|
where: { id: task_id, story_id },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
if (!task) {
|
if (!task) {
|
||||||
return toolError(`Task ${task_id} does not belong to story ${story_id}`)
|
return toolError(`Task ${task_id} does not belong to story ${story_id}`)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Mag niet voorkomen door de zod-refine, maar TS-narrow.
|
|
||||||
return toolError('Provide exactly one of story_id or idea_id')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = await prisma.claudeQuestion.create({
|
const created = await prisma.claudeQuestion.create({
|
||||||
data: {
|
data: {
|
||||||
story_id: story_id ?? null,
|
story_id,
|
||||||
idea_id: idea_id ?? null,
|
|
||||||
task_id: task_id ?? null,
|
task_id: task_id ?? null,
|
||||||
product_id: productId,
|
product_id: story.product_id,
|
||||||
asked_by: auth.userId,
|
asked_by: auth.userId,
|
||||||
question,
|
question,
|
||||||
// Prisma's `Json?`-veld accepteert geen `null`-literal in `data`;
|
// Prisma's `Json?`-veld accepteert geen `null`-literal in `data`;
|
||||||
|
|
@ -128,13 +97,6 @@ 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.
|
// Async-mode (default): return direct.
|
||||||
if (!wait_seconds || wait_seconds === 0) {
|
if (!wait_seconds || wait_seconds === 0) {
|
||||||
return toolJson(summarize(created))
|
return toolJson(summarize(created))
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
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 })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import * as fs from 'node:fs/promises'
|
import * as fs from 'node:fs/promises'
|
||||||
|
import * as path from 'node:path'
|
||||||
|
import * as os from 'node:os'
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
import { prisma } from '../prisma.js'
|
import { prisma } from '../prisma.js'
|
||||||
import { requireWriteAccess } from '../auth.js'
|
import { requireWriteAccess } from '../auth.js'
|
||||||
import { toolJson, withToolErrors } from '../errors.js'
|
import { toolJson, withToolErrors } from '../errors.js'
|
||||||
import { removeWorktreeForJob } from '../git/worktree.js'
|
import { removeWorktreeForJob } from '../git/worktree.js'
|
||||||
import { getWorktreeRoot, SYSTEM_WORKTREE_DIRS } from '../git/worktree-paths.js'
|
|
||||||
import { resolveRepoRoot } from './wait-for-job.js'
|
import { resolveRepoRoot } from './wait-for-job.js'
|
||||||
|
|
||||||
const TERMINAL_STATUSES = new Set(['DONE', 'FAILED', 'CANCELLED'])
|
const TERMINAL_STATUSES = new Set(['DONE', 'FAILED', 'CANCELLED'])
|
||||||
|
|
@ -14,20 +15,16 @@ const ACTIVE_STATUSES = new Set(['QUEUED', 'CLAIMED', 'RUNNING'])
|
||||||
const inputSchema = z.object({})
|
const inputSchema = z.object({})
|
||||||
|
|
||||||
export async function getWorktreeParent(): Promise<string> {
|
export async function getWorktreeParent(): Promise<string> {
|
||||||
return getWorktreeRoot()
|
return (
|
||||||
|
process.env.SCRUM4ME_AGENT_WORKTREE_DIR ??
|
||||||
|
path.join(os.homedir(), '.scrum4me-agent-worktrees')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listWorktreeJobIds(worktreeParent: string): Promise<string[]> {
|
export async function listWorktreeJobIds(worktreeParent: string): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const entries = await fs.readdir(worktreeParent, { withFileTypes: true })
|
const entries = await fs.readdir(worktreeParent, { withFileTypes: true })
|
||||||
return entries
|
return entries.filter((e) => e.isDirectory()).map((e) => e.name)
|
||||||
.filter(
|
|
||||||
(e) =>
|
|
||||||
e.isDirectory()
|
|
||||||
&& !SYSTEM_WORKTREE_DIRS.has(e.name)
|
|
||||||
&& !e.name.endsWith('.lock'),
|
|
||||||
)
|
|
||||||
.map((e) => e.name)
|
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,16 @@
|
||||||
// MCP authoring tool: create een Product Backlog Item.
|
// MCP authoring tool: create een Product Backlog Item.
|
||||||
//
|
//
|
||||||
// Sort_order wordt automatisch op last+1 binnen de prioriteits-groep gezet als
|
// Sort_order wordt automatisch op last+1 binnen de prioriteits-groep gezet als
|
||||||
// niet meegegeven. Code wordt auto-gegenereerd als PBI-N (zelfde logica als de
|
// niet meegegeven. Code-veld blijft null — auto-codes (PBI-1, PBI-2, …) worden
|
||||||
// Scrum4Me-app), met retry bij een race-condition op de unique constraint.
|
// door de Scrum4Me-app gegenereerd, kan optioneel later via UI worden gezet.
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
import { Prisma } from '@prisma/client'
|
|
||||||
import { prisma } from '../prisma.js'
|
import { prisma } from '../prisma.js'
|
||||||
import { requireWriteAccess } from '../auth.js'
|
import { requireWriteAccess } from '../auth.js'
|
||||||
import { userCanAccessProduct } from '../access.js'
|
import { userCanAccessProduct } from '../access.js'
|
||||||
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
||||||
|
|
||||||
const PBI_AUTO_RE = /^PBI-(\d+)$/
|
|
||||||
const MAX_CODE_ATTEMPTS = 3
|
|
||||||
|
|
||||||
async function generateNextPbiCode(productId: string): Promise<string> {
|
|
||||||
const pbis = await prisma.pbi.findMany({
|
|
||||||
where: { product_id: productId },
|
|
||||||
select: { code: true },
|
|
||||||
})
|
|
||||||
let max = 0
|
|
||||||
for (const p of pbis) {
|
|
||||||
const m = p.code?.match(PBI_AUTO_RE)
|
|
||||||
if (m) {
|
|
||||||
const n = Number.parseInt(m[1], 10)
|
|
||||||
if (!Number.isNaN(n) && n > max) max = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `PBI-${max + 1}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCodeUniqueConflict(error: unknown): boolean {
|
|
||||||
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false
|
|
||||||
if (error.code !== 'P2002') return false
|
|
||||||
const target = (error.meta as { target?: string[] | string } | undefined)?.target
|
|
||||||
if (!target) return false
|
|
||||||
return Array.isArray(target) ? target.includes('code') : target.includes('code')
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputSchema = z.object({
|
const inputSchema = z.object({
|
||||||
product_id: z.string().min(1),
|
product_id: z.string().min(1),
|
||||||
title: z.string().min(1).max(200),
|
title: z.string().min(1).max(200),
|
||||||
|
|
@ -73,36 +45,24 @@ export function registerCreatePbiTool(server: McpServer) {
|
||||||
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
|
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastError: unknown
|
const pbi = await prisma.pbi.create({
|
||||||
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
|
data: {
|
||||||
const code = await generateNextPbiCode(product_id)
|
product_id,
|
||||||
try {
|
title,
|
||||||
const pbi = await prisma.pbi.create({
|
description: description ?? null,
|
||||||
data: {
|
priority,
|
||||||
product_id,
|
sort_order: resolvedSortOrder,
|
||||||
code,
|
},
|
||||||
title,
|
select: {
|
||||||
description: description ?? null,
|
id: true,
|
||||||
priority,
|
title: true,
|
||||||
sort_order: resolvedSortOrder,
|
description: true,
|
||||||
},
|
priority: true,
|
||||||
select: {
|
sort_order: true,
|
||||||
id: true,
|
created_at: true,
|
||||||
code: true,
|
},
|
||||||
title: true,
|
})
|
||||||
description: true,
|
return toolJson(pbi)
|
||||||
priority: true,
|
|
||||||
sort_order: true,
|
|
||||||
created_at: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return toolJson(pbi)
|
|
||||||
} catch (e) {
|
|
||||||
if (isCodeUniqueConflict(e)) { lastError = e; continue }
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw lastError ?? new Error('Kon geen unieke PBI-code genereren')
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
// MCP authoring tool: create een Sprint binnen een product.
|
|
||||||
//
|
|
||||||
// Status start altijd op OPEN; geen reuse-check op bestaande OPEN-sprints
|
|
||||||
// (per plan-to-pbi-flow.md "altijd nieuwe sprint"). Code wordt auto-gegenereerd
|
|
||||||
// als S-{YYYY-MM-DD}-{N} per product per datum, met retry bij race-condition
|
|
||||||
// op de unique constraint (@@unique([product_id, code])).
|
|
||||||
|
|
||||||
import { z } from 'zod'
|
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
||||||
import { Prisma } from '@prisma/client'
|
|
||||||
import { prisma } from '../prisma.js'
|
|
||||||
import { requireWriteAccess } from '../auth.js'
|
|
||||||
import { userCanAccessProduct } from '../access.js'
|
|
||||||
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
|
||||||
|
|
||||||
const SPRINT_AUTO_RE = /^S-(\d{4}-\d{2}-\d{2})-(\d+)$/
|
|
||||||
const MAX_CODE_ATTEMPTS = 3
|
|
||||||
|
|
||||||
function todayIsoDate(): string {
|
|
||||||
return new Date().toISOString().slice(0, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateNextSprintCode(productId: string): Promise<string> {
|
|
||||||
const today = todayIsoDate()
|
|
||||||
const sprints = await prisma.sprint.findMany({
|
|
||||||
where: { product_id: productId, code: { startsWith: `S-${today}-` } },
|
|
||||||
select: { code: true },
|
|
||||||
})
|
|
||||||
let max = 0
|
|
||||||
for (const s of sprints) {
|
|
||||||
const m = s.code?.match(SPRINT_AUTO_RE)
|
|
||||||
// Dubbele check op de datum — defensive tegen filterveranderingen
|
|
||||||
// of mock-data die niet door de DB-where heen ging.
|
|
||||||
if (m && m[1] === today) {
|
|
||||||
const n = Number.parseInt(m[2], 10)
|
|
||||||
if (!Number.isNaN(n) && n > max) max = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `S-${today}-${max + 1}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCodeUniqueConflict(error: unknown): boolean {
|
|
||||||
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false
|
|
||||||
if (error.code !== 'P2002') return false
|
|
||||||
const target = (error.meta as { target?: string[] | string } | undefined)?.target
|
|
||||||
if (!target) return false
|
|
||||||
return Array.isArray(target) ? target.includes('code') : target.includes('code')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const inputSchema = z.object({
|
|
||||||
product_id: z.string().min(1),
|
|
||||||
code: z.string().min(1).max(30).optional(),
|
|
||||||
sprint_goal: z.string().min(1).max(500),
|
|
||||||
start_date: z.string().date().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function handleCreateSprint(
|
|
||||||
{ product_id, code, sprint_goal, start_date }: z.infer<typeof inputSchema>,
|
|
||||||
) {
|
|
||||||
return withToolErrors(async () => {
|
|
||||||
const auth = await requireWriteAccess()
|
|
||||||
if (!(await userCanAccessProduct(product_id, auth.userId))) {
|
|
||||||
return toolError(`Product ${product_id} not found or not accessible`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedStartDate = start_date ? new Date(start_date) : new Date()
|
|
||||||
const baseSelect = {
|
|
||||||
id: true,
|
|
||||||
code: true,
|
|
||||||
sprint_goal: true,
|
|
||||||
status: true,
|
|
||||||
start_date: true,
|
|
||||||
created_at: true,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
if (code) {
|
|
||||||
const sprint = await prisma.sprint.create({
|
|
||||||
data: { product_id, code, sprint_goal, status: 'OPEN', start_date: resolvedStartDate },
|
|
||||||
select: baseSelect,
|
|
||||||
})
|
|
||||||
return toolJson(sprint)
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastError: unknown
|
|
||||||
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
|
|
||||||
const generated = await generateNextSprintCode(product_id)
|
|
||||||
try {
|
|
||||||
const sprint = await prisma.sprint.create({
|
|
||||||
data: { product_id, code: generated, sprint_goal, status: 'OPEN', start_date: resolvedStartDate },
|
|
||||||
select: baseSelect,
|
|
||||||
})
|
|
||||||
return toolJson(sprint)
|
|
||||||
} catch (e) {
|
|
||||||
if (isCodeUniqueConflict(e)) { lastError = e; continue }
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw lastError ?? new Error('Kon geen unieke sprint-code genereren')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerCreateSprintTool(server: McpServer) {
|
|
||||||
server.registerTool(
|
|
||||||
'create_sprint',
|
|
||||||
{
|
|
||||||
title: 'Create Sprint',
|
|
||||||
description:
|
|
||||||
'Create a new sprint for a product with status=OPEN. Code auto-generated as S-{YYYY-MM-DD}-{N} per product per date if not provided. Forbidden for demo accounts.',
|
|
||||||
inputSchema,
|
|
||||||
},
|
|
||||||
handleCreateSprint,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +1,16 @@
|
||||||
// MCP authoring tool: create een Story onder een bestaande PBI.
|
// MCP authoring tool: create een Story onder een bestaande PBI.
|
||||||
//
|
//
|
||||||
// product_id wordt afgeleid uit de PBI (denormalized FK conform CLAUDE.md
|
// product_id wordt afgeleid uit de PBI (denormalized FK conform CLAUDE.md
|
||||||
// convention — nooit vertrouwen op client-input). Zonder sprint_id is
|
// convention — nooit vertrouwen op client-input). status='OPEN' default;
|
||||||
// status='OPEN' en landt de story in de Product Backlog; mét sprint_id
|
// landt in de Product Backlog, niet auto in een sprint.
|
||||||
// wordt de story direct aan die sprint gekoppeld (status='IN_SPRINT').
|
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
import { Prisma } from '@prisma/client'
|
|
||||||
import { prisma } from '../prisma.js'
|
import { prisma } from '../prisma.js'
|
||||||
import { requireWriteAccess } from '../auth.js'
|
import { requireWriteAccess } from '../auth.js'
|
||||||
import { userCanAccessProduct } from '../access.js'
|
import { userCanAccessProduct } from '../access.js'
|
||||||
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
||||||
|
|
||||||
const STORY_AUTO_RE = /^ST-(\d+)$/
|
|
||||||
const MAX_CODE_ATTEMPTS = 3
|
|
||||||
|
|
||||||
async function generateNextStoryCode(productId: string): Promise<string> {
|
|
||||||
const stories = await prisma.story.findMany({
|
|
||||||
where: { product_id: productId },
|
|
||||||
select: { code: true },
|
|
||||||
})
|
|
||||||
let max = 0
|
|
||||||
for (const s of stories) {
|
|
||||||
const m = s.code?.match(STORY_AUTO_RE)
|
|
||||||
if (m) {
|
|
||||||
const n = Number.parseInt(m[1], 10)
|
|
||||||
if (!Number.isNaN(n) && n > max) max = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `ST-${String(max + 1).padStart(3, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCodeUniqueConflict(error: unknown): boolean {
|
|
||||||
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false
|
|
||||||
if (error.code !== 'P2002') return false
|
|
||||||
const target = (error.meta as { target?: string[] | string } | undefined)?.target
|
|
||||||
if (!target) return false
|
|
||||||
return Array.isArray(target) ? target.includes('code') : target.includes('code')
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputSchema = z.object({
|
const inputSchema = z.object({
|
||||||
pbi_id: z.string().min(1),
|
pbi_id: z.string().min(1),
|
||||||
title: z.string().min(1).max(200),
|
title: z.string().min(1).max(200),
|
||||||
|
|
@ -47,108 +18,63 @@ const inputSchema = z.object({
|
||||||
acceptance_criteria: z.string().max(4000).optional(),
|
acceptance_criteria: z.string().max(4000).optional(),
|
||||||
priority: z.number().int().min(1).max(4),
|
priority: z.number().int().min(1).max(4),
|
||||||
sort_order: z.number().optional(),
|
sort_order: z.number().optional(),
|
||||||
// Optionele sprint-koppeling: bij creatie de story direct aan een sprint
|
|
||||||
// hangen (status=IN_SPRINT). De sprint moet bij hetzelfde product horen.
|
|
||||||
sprint_id: z.string().min(1).optional(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function handleCreateStory(
|
|
||||||
{
|
|
||||||
pbi_id,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
acceptance_criteria,
|
|
||||||
priority,
|
|
||||||
sort_order,
|
|
||||||
sprint_id,
|
|
||||||
}: z.infer<typeof inputSchema>,
|
|
||||||
) {
|
|
||||||
return withToolErrors(async () => {
|
|
||||||
const auth = await requireWriteAccess()
|
|
||||||
|
|
||||||
const pbi = await prisma.pbi.findUnique({
|
|
||||||
where: { id: pbi_id },
|
|
||||||
select: { product_id: true },
|
|
||||||
})
|
|
||||||
if (!pbi) return toolError(`PBI ${pbi_id} not found`)
|
|
||||||
if (!(await userCanAccessProduct(pbi.product_id, auth.userId))) {
|
|
||||||
return toolError(`PBI ${pbi_id} not accessible`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optionele sprint-koppeling: valideer dat de sprint bestaat én bij
|
|
||||||
// hetzelfde product hoort — voorkomt een cross-product koppeling.
|
|
||||||
if (sprint_id !== undefined) {
|
|
||||||
const sprint = await prisma.sprint.findUnique({
|
|
||||||
where: { id: sprint_id },
|
|
||||||
select: { product_id: true },
|
|
||||||
})
|
|
||||||
if (!sprint) return toolError(`Sprint ${sprint_id} not found`)
|
|
||||||
if (sprint.product_id !== pbi.product_id) {
|
|
||||||
return toolError(
|
|
||||||
`Sprint ${sprint_id} belongs to a different product than PBI ${pbi_id}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolvedSortOrder = sort_order
|
|
||||||
if (resolvedSortOrder === undefined) {
|
|
||||||
const last = await prisma.story.findFirst({
|
|
||||||
where: { pbi_id, priority },
|
|
||||||
orderBy: { sort_order: 'desc' },
|
|
||||||
select: { sort_order: true },
|
|
||||||
})
|
|
||||||
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
|
|
||||||
}
|
|
||||||
|
|
||||||
let lastError: unknown
|
|
||||||
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
|
|
||||||
const code = await generateNextStoryCode(pbi.product_id)
|
|
||||||
try {
|
|
||||||
const story = await prisma.story.create({
|
|
||||||
data: {
|
|
||||||
pbi_id,
|
|
||||||
product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input
|
|
||||||
sprint_id: sprint_id ?? null,
|
|
||||||
code,
|
|
||||||
title,
|
|
||||||
description: description ?? null,
|
|
||||||
acceptance_criteria: acceptance_criteria ?? null,
|
|
||||||
priority,
|
|
||||||
sort_order: resolvedSortOrder,
|
|
||||||
status: sprint_id ? 'IN_SPRINT' : 'OPEN',
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
code: true,
|
|
||||||
title: true,
|
|
||||||
description: true,
|
|
||||||
acceptance_criteria: true,
|
|
||||||
priority: true,
|
|
||||||
sort_order: true,
|
|
||||||
status: true,
|
|
||||||
sprint_id: true,
|
|
||||||
created_at: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return toolJson(story)
|
|
||||||
} catch (e) {
|
|
||||||
if (isCodeUniqueConflict(e)) { lastError = e; continue }
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw lastError ?? new Error('Kon geen unieke Story-code genereren')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerCreateStoryTool(server: McpServer) {
|
export function registerCreateStoryTool(server: McpServer) {
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
'create_story',
|
'create_story',
|
||||||
{
|
{
|
||||||
title: 'Create story',
|
title: 'Create story',
|
||||||
description:
|
description:
|
||||||
'Add a story under an existing PBI. Optionally link it to a sprint via sprint_id — when given, the story is created with status=IN_SPRINT and the sprint must belong to the same product as the PBI; otherwise status=OPEN and the story lands in the product backlog. Sort_order auto-set to last+1 within the PBI/priority group if not provided. Forbidden for demo accounts.',
|
'Add a story under an existing PBI. Status defaults to OPEN (lands in product backlog, not in a sprint). Sort_order auto-set to last+1 within the PBI/priority group if not provided. Forbidden for demo accounts.',
|
||||||
inputSchema,
|
inputSchema,
|
||||||
},
|
},
|
||||||
handleCreateStory,
|
async ({ pbi_id, title, description, acceptance_criteria, priority, sort_order }) =>
|
||||||
|
withToolErrors(async () => {
|
||||||
|
const auth = await requireWriteAccess()
|
||||||
|
|
||||||
|
const pbi = await prisma.pbi.findUnique({
|
||||||
|
where: { id: pbi_id },
|
||||||
|
select: { product_id: true },
|
||||||
|
})
|
||||||
|
if (!pbi) return toolError(`PBI ${pbi_id} not found`)
|
||||||
|
if (!(await userCanAccessProduct(pbi.product_id, auth.userId))) {
|
||||||
|
return toolError(`PBI ${pbi_id} not accessible`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedSortOrder = sort_order
|
||||||
|
if (resolvedSortOrder === undefined) {
|
||||||
|
const last = await prisma.story.findFirst({
|
||||||
|
where: { pbi_id, priority },
|
||||||
|
orderBy: { sort_order: 'desc' },
|
||||||
|
select: { sort_order: true },
|
||||||
|
})
|
||||||
|
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
const story = await prisma.story.create({
|
||||||
|
data: {
|
||||||
|
pbi_id,
|
||||||
|
product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input
|
||||||
|
title,
|
||||||
|
description: description ?? null,
|
||||||
|
acceptance_criteria: acceptance_criteria ?? null,
|
||||||
|
priority,
|
||||||
|
sort_order: resolvedSortOrder,
|
||||||
|
status: 'OPEN',
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
acceptance_criteria: true,
|
||||||
|
priority: true,
|
||||||
|
sort_order: true,
|
||||||
|
status: true,
|
||||||
|
created_at: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return toolJson(story)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,39 +5,11 @@
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
import { Prisma } from '@prisma/client'
|
|
||||||
import { prisma } from '../prisma.js'
|
import { prisma } from '../prisma.js'
|
||||||
import { requireWriteAccess } from '../auth.js'
|
import { requireWriteAccess } from '../auth.js'
|
||||||
import { userCanAccessProduct } from '../access.js'
|
import { userCanAccessProduct } from '../access.js'
|
||||||
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
||||||
|
|
||||||
const TASK_AUTO_RE = /^T-(\d+)$/
|
|
||||||
const MAX_CODE_ATTEMPTS = 3
|
|
||||||
|
|
||||||
async function generateNextTaskCode(productId: string): Promise<string> {
|
|
||||||
const tasks = await prisma.task.findMany({
|
|
||||||
where: { product_id: productId },
|
|
||||||
select: { code: true },
|
|
||||||
})
|
|
||||||
let max = 0
|
|
||||||
for (const t of tasks) {
|
|
||||||
const m = t.code?.match(TASK_AUTO_RE)
|
|
||||||
if (m) {
|
|
||||||
const n = Number.parseInt(m[1], 10)
|
|
||||||
if (!Number.isNaN(n) && n > max) max = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `T-${max + 1}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCodeUniqueConflict(error: unknown): boolean {
|
|
||||||
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false
|
|
||||||
if (error.code !== 'P2002') return false
|
|
||||||
const target = (error.meta as { target?: string[] | string } | undefined)?.target
|
|
||||||
if (!target) return false
|
|
||||||
return Array.isArray(target) ? target.includes('code') : target.includes('code')
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputSchema = z.object({
|
const inputSchema = z.object({
|
||||||
story_id: z.string().min(1),
|
story_id: z.string().min(1),
|
||||||
title: z.string().min(1).max(200),
|
title: z.string().min(1).max(200),
|
||||||
|
|
@ -45,13 +17,6 @@ const inputSchema = z.object({
|
||||||
implementation_plan: z.string().max(8000).optional(),
|
implementation_plan: z.string().max(8000).optional(),
|
||||||
priority: z.number().int().min(1).max(4),
|
priority: z.number().int().min(1).max(4),
|
||||||
sort_order: z.number().optional(),
|
sort_order: z.number().optional(),
|
||||||
// Cross-repo override: zet expliciet de repo waarop de worker deze task
|
|
||||||
// moet uitvoeren (overrides product.repo_url). Gebruik dit voor PBI's die
|
|
||||||
// werk in meerdere repos coördineren — bv. PBI op Scrum4Me-product met
|
|
||||||
// tasks die in scrum4me-mcp of scrum4me-docker landen.
|
|
||||||
// Format: full git URL (https://github.com/owner/repo). Null/omit = erf
|
|
||||||
// van product.repo_url.
|
|
||||||
repo_url: z.string().url().optional(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export function registerCreateTaskTool(server: McpServer) {
|
export function registerCreateTaskTool(server: McpServer) {
|
||||||
|
|
@ -60,10 +25,10 @@ export function registerCreateTaskTool(server: McpServer) {
|
||||||
{
|
{
|
||||||
title: 'Create task',
|
title: 'Create task',
|
||||||
description:
|
description:
|
||||||
'Add a task under an existing story. Inherits sprint_id from the story (denormalized). Status defaults to TO_DO. Sort_order auto-set to last+1 within the story/priority group if not provided. Optional repo_url overrides the product.repo_url for cross-repo work (e.g. tasks targeting scrum4me-mcp under a Scrum4Me PBI). Forbidden for demo accounts.',
|
'Add a task under an existing story. Inherits sprint_id from the story (denormalized). Status defaults to TO_DO. Sort_order auto-set to last+1 within the story/priority group if not provided. Forbidden for demo accounts.',
|
||||||
inputSchema,
|
inputSchema,
|
||||||
},
|
},
|
||||||
async ({ story_id, title, description, implementation_plan, priority, sort_order, repo_url }) =>
|
async ({ story_id, title, description, implementation_plan, priority, sort_order }) =>
|
||||||
withToolErrors(async () => {
|
withToolErrors(async () => {
|
||||||
const auth = await requireWriteAccess()
|
const auth = await requireWriteAccess()
|
||||||
|
|
||||||
|
|
@ -86,44 +51,29 @@ export function registerCreateTaskTool(server: McpServer) {
|
||||||
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
|
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastError: unknown
|
const task = await prisma.task.create({
|
||||||
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
|
data: {
|
||||||
const code = await generateNextTaskCode(story.product_id)
|
story_id,
|
||||||
try {
|
sprint_id: story.sprint_id, // denormalized — erf van story
|
||||||
const task = await prisma.task.create({
|
title,
|
||||||
data: {
|
description: description ?? null,
|
||||||
story_id,
|
implementation_plan: implementation_plan ?? null,
|
||||||
product_id: story.product_id, // denormalized — erf van story
|
priority,
|
||||||
sprint_id: story.sprint_id, // denormalized — erf van story
|
sort_order: resolvedSortOrder,
|
||||||
code,
|
status: 'TO_DO',
|
||||||
title,
|
},
|
||||||
description: description ?? null,
|
select: {
|
||||||
implementation_plan: implementation_plan ?? null,
|
id: true,
|
||||||
priority,
|
title: true,
|
||||||
sort_order: resolvedSortOrder,
|
description: true,
|
||||||
status: 'TO_DO',
|
implementation_plan: true,
|
||||||
repo_url: repo_url ?? null,
|
priority: true,
|
||||||
},
|
sort_order: true,
|
||||||
select: {
|
status: true,
|
||||||
id: true,
|
created_at: true,
|
||||||
code: true,
|
},
|
||||||
title: true,
|
})
|
||||||
description: true,
|
return toolJson(task)
|
||||||
implementation_plan: true,
|
|
||||||
priority: true,
|
|
||||||
sort_order: true,
|
|
||||||
status: true,
|
|
||||||
repo_url: true,
|
|
||||||
created_at: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return toolJson(task)
|
|
||||||
} catch (e) {
|
|
||||||
if (isCodeUniqueConflict(e)) { lastError = e; continue }
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw lastError ?? new Error('Kon geen unieke Task-code genereren')
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
42
src/tools/create-todo.ts
Normal file
42
src/tools/create-todo.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
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 inputSchema = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
description: z.string().max(2000).optional(),
|
||||||
|
product_id: z.string().min(1).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function registerCreateTodoTool(server: McpServer) {
|
||||||
|
server.registerTool(
|
||||||
|
'create_todo',
|
||||||
|
{
|
||||||
|
title: 'Create todo',
|
||||||
|
description:
|
||||||
|
'Add a todo for the authenticated user, optionally scoped to a product. ' +
|
||||||
|
'Forbidden for demo accounts.',
|
||||||
|
inputSchema,
|
||||||
|
},
|
||||||
|
async ({ title, description, product_id }) =>
|
||||||
|
withToolErrors(async () => {
|
||||||
|
const auth = await requireWriteAccess()
|
||||||
|
if (product_id && !(await userCanAccessProduct(product_id, auth.userId))) {
|
||||||
|
return toolError(`Product ${product_id} not found or not accessible`)
|
||||||
|
}
|
||||||
|
const todo = await prisma.todo.create({
|
||||||
|
data: {
|
||||||
|
user_id: auth.userId,
|
||||||
|
product_id: product_id ?? null,
|
||||||
|
title,
|
||||||
|
description: description ?? null,
|
||||||
|
},
|
||||||
|
select: { id: true, title: true, description: true, created_at: true },
|
||||||
|
})
|
||||||
|
return toolJson(todo)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -47,7 +47,7 @@ export function registerGetClaudeContextTool(server: McpServer) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeSprint = await prisma.sprint.findFirst({
|
const activeSprint = await prisma.sprint.findFirst({
|
||||||
where: { product_id, status: 'OPEN' },
|
where: { product_id, status: 'ACTIVE' },
|
||||||
orderBy: { created_at: 'desc' },
|
orderBy: { created_at: 'desc' },
|
||||||
select: { id: true, sprint_goal: true, status: true },
|
select: { id: true, sprint_goal: true, status: true },
|
||||||
})
|
})
|
||||||
|
|
@ -99,21 +99,19 @@ export function registerGetClaudeContextTool(server: McpServer) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openIdeas = await prisma.idea.findMany({
|
const openTodos = await prisma.todo.findMany({
|
||||||
where: {
|
where: {
|
||||||
user_id: auth.userId,
|
user_id: auth.userId,
|
||||||
|
done: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
status: { not: 'PLANNED' },
|
|
||||||
OR: [{ product_id: product_id }, { product_id: null }],
|
OR: [{ product_id: product_id }, { product_id: null }],
|
||||||
},
|
},
|
||||||
orderBy: { created_at: 'asc' },
|
orderBy: { created_at: 'asc' },
|
||||||
take: 50,
|
take: 50,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
code: true,
|
|
||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
status: true,
|
|
||||||
created_at: true,
|
created_at: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -122,7 +120,7 @@ export function registerGetClaudeContextTool(server: McpServer) {
|
||||||
product,
|
product,
|
||||||
active_sprint: activeSprint,
|
active_sprint: activeSprint,
|
||||||
next_story: nextStory,
|
next_story: nextStory,
|
||||||
open_ideas: openIdeas,
|
open_todos: openTodos,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
// MCP-tool: laadt volledige context voor een idee — voor agents die
|
|
||||||
// idee-jobs uitvoeren of via UI-acties idee-info nodig hebben.
|
|
||||||
//
|
|
||||||
// Strikt user_id-only (M12 grill-keuze 8). Demo MAY read.
|
|
||||||
|
|
||||||
import { z } from 'zod'
|
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
||||||
|
|
||||||
import { prisma } from '../prisma.js'
|
|
||||||
import { getAuth } from '../auth.js'
|
|
||||||
import { userOwnsIdea } from '../access.js'
|
|
||||||
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
|
||||||
|
|
||||||
const inputSchema = z.object({
|
|
||||||
idea_id: z.string().min(1),
|
|
||||||
})
|
|
||||||
|
|
||||||
export function registerGetIdeaContextTool(server: McpServer) {
|
|
||||||
server.registerTool(
|
|
||||||
'get_idea_context',
|
|
||||||
{
|
|
||||||
title: 'Get idea context',
|
|
||||||
description:
|
|
||||||
'Fetch full idea context (idea + product + repo_url + open questions + recent logs). Strict user_id-only scope. Read-only.',
|
|
||||||
inputSchema,
|
|
||||||
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
||||||
},
|
|
||||||
async ({ idea_id }) =>
|
|
||||||
withToolErrors(async () => {
|
|
||||||
const auth = await getAuth()
|
|
||||||
|
|
||||||
const idea = await prisma.idea.findFirst({
|
|
||||||
where: { id: idea_id, user_id: auth.userId },
|
|
||||||
include: {
|
|
||||||
product: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
code: true,
|
|
||||||
repo_url: true,
|
|
||||||
definition_of_done: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pbi: { select: { id: true, code: true, title: true } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (!idea) {
|
|
||||||
// 404, niet 403 — vermijdt enumeratie van andermans idea-ids.
|
|
||||||
return toolError('Idea not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open vragen + recente logs voor agent-context.
|
|
||||||
const [openQuestions, recentLogs] = await Promise.all([
|
|
||||||
prisma.claudeQuestion.findMany({
|
|
||||||
where: { idea_id: idea.id, status: 'open' },
|
|
||||||
orderBy: { created_at: 'desc' },
|
|
||||||
take: 10,
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
question: true,
|
|
||||||
options: true,
|
|
||||||
created_at: true,
|
|
||||||
expires_at: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.ideaLog.findMany({
|
|
||||||
where: { idea_id: idea.id },
|
|
||||||
orderBy: { created_at: 'desc' },
|
|
||||||
take: 20,
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
type: true,
|
|
||||||
content: true,
|
|
||||||
metadata: true,
|
|
||||||
created_at: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
return toolJson({
|
|
||||||
idea: {
|
|
||||||
id: idea.id,
|
|
||||||
code: idea.code,
|
|
||||||
title: idea.title,
|
|
||||||
description: idea.description,
|
|
||||||
grill_md: idea.grill_md,
|
|
||||||
plan_md: idea.plan_md,
|
|
||||||
status: idea.status,
|
|
||||||
product_id: idea.product_id,
|
|
||||||
pbi_id: idea.pbi_id,
|
|
||||||
archived: idea.archived,
|
|
||||||
created_at: idea.created_at.toISOString(),
|
|
||||||
updated_at: idea.updated_at.toISOString(),
|
|
||||||
},
|
|
||||||
product: idea.product,
|
|
||||||
pbi: idea.pbi,
|
|
||||||
repo_url: idea.product?.repo_url ?? null,
|
|
||||||
grill_md_so_far: idea.grill_md,
|
|
||||||
open_questions: openQuestions.map((q) => ({
|
|
||||||
id: q.id,
|
|
||||||
question: q.question,
|
|
||||||
options: Array.isArray(q.options) ? (q.options as string[]) : null,
|
|
||||||
created_at: q.created_at.toISOString(),
|
|
||||||
expires_at: q.expires_at.toISOString(),
|
|
||||||
})),
|
|
||||||
recent_logs: recentLogs.map((l) => ({
|
|
||||||
id: l.id,
|
|
||||||
type: l.type,
|
|
||||||
content: l.content,
|
|
||||||
metadata: l.metadata,
|
|
||||||
created_at: l.created_at.toISOString(),
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Note: prompt_text wordt door wait_for_job in de job-payload
|
|
||||||
// meegestuurd (single source). get_idea_context is voor adhoc lookups
|
|
||||||
// — geen prompt-text nodig.
|
|
||||||
void userOwnsIdea
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// 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 })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +1,9 @@
|
||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { dirname, join } from 'node:path'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
import { prisma } from '../prisma.js'
|
import { prisma } from '../prisma.js'
|
||||||
import { toolJson, withToolErrors } from '../errors.js'
|
import { toolJson, withToolErrors } from '../errors.js'
|
||||||
|
|
||||||
// Read once at module-load. Health is hot-path enough that we don't want
|
const VERSION = '0.1.0'
|
||||||
// disk-IO per call, and the version string is fixed for the running process.
|
|
||||||
function readPkgVersion(): string {
|
|
||||||
try {
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url))
|
|
||||||
// src/tools/health.ts → src/tools → src → repo-root
|
|
||||||
const pkgPath = join(here, '..', '..', 'package.json')
|
|
||||||
const raw = readFileSync(pkgPath, 'utf8')
|
|
||||||
return (JSON.parse(raw) as { version?: string }).version ?? '0.0.0'
|
|
||||||
} catch {
|
|
||||||
return '0.0.0'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const VERSION = readPkgVersion()
|
|
||||||
|
|
||||||
export function registerHealthTool(server: McpServer) {
|
export function registerHealthTool(server: McpServer) {
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
// PBI-50 F3-T3: job_heartbeat
|
|
||||||
//
|
|
||||||
// Verlengt ClaudeJob.lease_until met 5 min zodat resetStaleClaimedJobs een
|
|
||||||
// long-running job (bv. SPRINT_IMPLEMENTATION over 30+ min) niet ten onrechte
|
|
||||||
// als stale markt. Worker draait een achtergrond-loop elke 60s.
|
|
||||||
//
|
|
||||||
// Voor SPRINT-jobs: respons bevat sprint_run_status zodat de worker zijn
|
|
||||||
// loop kan breken bij ≠ RUNNING (bv. UI-side cancel of MERGE_CONFLICT-pause).
|
|
||||||
|
|
||||||
import { z } from 'zod'
|
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
||||||
import { prisma } from '../prisma.js'
|
|
||||||
import { requireWriteAccess } from '../auth.js'
|
|
||||||
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
|
||||||
|
|
||||||
const inputSchema = z.object({
|
|
||||||
job_id: z.string().min(1),
|
|
||||||
})
|
|
||||||
|
|
||||||
export function registerJobHeartbeatTool(server: McpServer) {
|
|
||||||
server.registerTool(
|
|
||||||
'job_heartbeat',
|
|
||||||
{
|
|
||||||
title: 'Job heartbeat',
|
|
||||||
description:
|
|
||||||
'Extend the lease on a CLAIMED/RUNNING job by 5 minutes. Token must own the job. ' +
|
|
||||||
'For SPRINT_IMPLEMENTATION jobs: response includes sprint_run_status so the worker ' +
|
|
||||||
'can break its task-loop on UI-side cancel/pause without an extra query. ' +
|
|
||||||
'Worker should call this every ~60s during long-running batches. ' +
|
|
||||||
'Forbidden for demo accounts.',
|
|
||||||
inputSchema,
|
|
||||||
},
|
|
||||||
async ({ job_id }) =>
|
|
||||||
withToolErrors(async () => {
|
|
||||||
const auth = await requireWriteAccess()
|
|
||||||
|
|
||||||
// Atomic conditional UPDATE so a non-owner / non-active job is rejected
|
|
||||||
// without a separate read.
|
|
||||||
const updated = await prisma.$queryRaw<
|
|
||||||
Array<{ id: string; lease_until: Date; kind: string; sprint_run_id: string | null }>
|
|
||||||
>`
|
|
||||||
UPDATE claude_jobs
|
|
||||||
SET lease_until = NOW() + INTERVAL '5 minutes'
|
|
||||||
WHERE id = ${job_id}
|
|
||||||
AND claimed_by_token_id = ${auth.tokenId}
|
|
||||||
AND status IN ('CLAIMED', 'RUNNING')
|
|
||||||
RETURNING id, lease_until, kind::text AS kind, sprint_run_id
|
|
||||||
`
|
|
||||||
if (updated.length === 0) {
|
|
||||||
return toolError(
|
|
||||||
`Job ${job_id} not found, not claimed by your token, or in terminal state`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const row = updated[0]
|
|
||||||
|
|
||||||
let sprint_run_status: string | null = null
|
|
||||||
let sprint_run_pause_reason: string | null = null
|
|
||||||
if (row.kind === 'SPRINT_IMPLEMENTATION' && row.sprint_run_id) {
|
|
||||||
const sprintRun = await prisma.sprintRun.findUnique({
|
|
||||||
where: { id: row.sprint_run_id },
|
|
||||||
select: { status: true, pause_context: true },
|
|
||||||
})
|
|
||||||
sprint_run_status = sprintRun?.status ?? null
|
|
||||||
// Extract pause_reason from pause_context Json (best-effort)
|
|
||||||
const ctx = sprintRun?.pause_context as
|
|
||||||
| { pause_reason?: string }
|
|
||||||
| null
|
|
||||||
| undefined
|
|
||||||
sprint_run_pause_reason = ctx?.pause_reason ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
return toolJson({
|
|
||||||
ok: true,
|
|
||||||
job_id: row.id,
|
|
||||||
lease_until: row.lease_until.toISOString(),
|
|
||||||
sprint_run_status,
|
|
||||||
sprint_run_pause_reason,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
// MCP-tool: agents loggen een tussentijdse beslissing of notitie tijdens
|
|
||||||
// een grill- of make-plan-sessie. Verschijnt in de Timeline-tab van de
|
|
||||||
// idea-detailpagina.
|
|
||||||
|
|
||||||
import { z } from 'zod'
|
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
||||||
import type { Prisma } from '@prisma/client'
|
|
||||||
|
|
||||||
import { prisma } from '../prisma.js'
|
|
||||||
import { requireWriteAccess } from '../auth.js'
|
|
||||||
import { userOwnsIdea } from '../access.js'
|
|
||||||
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
|
||||||
|
|
||||||
const inputSchema = z.object({
|
|
||||||
idea_id: z.string().min(1),
|
|
||||||
type: z.enum(['DECISION', 'NOTE']),
|
|
||||||
content: z.string().min(1).max(4_000),
|
|
||||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export function registerLogIdeaDecisionTool(server: McpServer) {
|
|
||||||
server.registerTool(
|
|
||||||
'log_idea_decision',
|
|
||||||
{
|
|
||||||
title: 'Log idea decision/note',
|
|
||||||
description:
|
|
||||||
"Append a DECISION or NOTE entry to an idea's timeline. Use to capture deliberations during grill or make-plan sessions. Forbidden for demo accounts.",
|
|
||||||
inputSchema,
|
|
||||||
},
|
|
||||||
async ({ idea_id, type, content, metadata }) =>
|
|
||||||
withToolErrors(async () => {
|
|
||||||
const auth = await requireWriteAccess()
|
|
||||||
if (!(await userOwnsIdea(idea_id, auth.userId))) {
|
|
||||||
return toolError('Idea not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
const log = await prisma.ideaLog.create({
|
|
||||||
data: {
|
|
||||||
idea_id,
|
|
||||||
type,
|
|
||||||
content,
|
|
||||||
metadata: (metadata as Prisma.InputJsonValue | undefined) ?? undefined,
|
|
||||||
},
|
|
||||||
select: { id: true, type: true, created_at: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
return toolJson({
|
|
||||||
ok: true,
|
|
||||||
log: {
|
|
||||||
id: log.id,
|
|
||||||
type: log.type,
|
|
||||||
created_at: log.created_at.toISOString(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
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 inputSchema = z.object({
|
|
||||||
pbi_id: z.string().min(1),
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function handleMarkPbiPrMerged({ pbi_id }: z.infer<typeof inputSchema>) {
|
|
||||||
return withToolErrors(async () => {
|
|
||||||
const auth = await requireWriteAccess()
|
|
||||||
|
|
||||||
const pbi = await prisma.pbi.findUnique({
|
|
||||||
where: { id: pbi_id },
|
|
||||||
select: { product_id: true, pr_url: true },
|
|
||||||
})
|
|
||||||
if (!pbi || !(await userCanAccessProduct(pbi.product_id, auth.userId))) {
|
|
||||||
return toolError(`PBI ${pbi_id} not found or not accessible`)
|
|
||||||
}
|
|
||||||
if (!pbi.pr_url) {
|
|
||||||
return toolError(`PBI ${pbi_id} heeft geen gekoppelde PR`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await prisma.pbi.update({
|
|
||||||
where: { id: pbi_id },
|
|
||||||
data: { pr_merged_at: new Date() },
|
|
||||||
select: { id: true, pr_url: true, pr_merged_at: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
return toolJson({ ok: true, pbi_id, pr_url: updated.pr_url, pr_merged_at: updated.pr_merged_at })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerMarkPbiPrMergedTool(server: McpServer) {
|
|
||||||
server.registerTool(
|
|
||||||
'mark_pbi_pr_merged',
|
|
||||||
{
|
|
||||||
title: 'Mark PBI PR Merged',
|
|
||||||
description:
|
|
||||||
'Set pr_merged_at = now() on a PBI, signalling the PR has been merged. Requires pr_url to already be set. Idempotent: re-calling overwrites the timestamp. Forbidden for demo accounts.',
|
|
||||||
inputSchema,
|
|
||||||
},
|
|
||||||
handleMarkPbiPrMerged,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
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'
|
|
||||||
|
|
||||||
export const inputSchema = z.object({
|
|
||||||
pbi_id: z.string().min(1),
|
|
||||||
pr_url: z.string().regex(/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+$/),
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function handleSetPbiPr({ pbi_id, pr_url }: z.infer<typeof inputSchema>) {
|
|
||||||
return withToolErrors(async () => {
|
|
||||||
const auth = await requireWriteAccess()
|
|
||||||
|
|
||||||
const pbi = await prisma.pbi.findUnique({
|
|
||||||
where: { id: pbi_id },
|
|
||||||
select: { product_id: true },
|
|
||||||
})
|
|
||||||
if (!pbi || !(await userCanAccessProduct(pbi.product_id, auth.userId))) {
|
|
||||||
return toolError(`PBI ${pbi_id} not found or not accessible`)
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.pbi.update({
|
|
||||||
where: { id: pbi_id },
|
|
||||||
data: { pr_url, pr_merged_at: null },
|
|
||||||
})
|
|
||||||
|
|
||||||
return toolJson({ ok: true, pbi_id, pr_url })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerSetPbiPrTool(server: McpServer) {
|
|
||||||
server.registerTool(
|
|
||||||
'set_pbi_pr',
|
|
||||||
{
|
|
||||||
title: 'Set PBI PR URL',
|
|
||||||
description:
|
|
||||||
'Write pr_url on a PBI and clear pr_merged_at. Idempotent: re-calling overwrites pr_url and resets pr_merged_at to null. Forbidden for demo accounts.',
|
|
||||||
inputSchema,
|
|
||||||
},
|
|
||||||
handleSetPbiPr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
// MCP-tool: schrijft het grill_md-resultaat na een IDEA_GRILL-job en zet
|
|
||||||
// de idea-status op GRILLED. Logt een IdeaLog{GRILL_RESULT}-entry.
|
|
||||||
//
|
|
||||||
// Wordt aangeroepen door de worker als laatste stap van een grill-sessie.
|
|
||||||
|
|
||||||
import { z } from 'zod'
|
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
||||||
|
|
||||||
import { prisma } from '../prisma.js'
|
|
||||||
import { requireWriteAccess } from '../auth.js'
|
|
||||||
import { userOwnsIdea } from '../access.js'
|
|
||||||
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
|
||||||
|
|
||||||
const inputSchema = z.object({
|
|
||||||
idea_id: z.string().min(1),
|
|
||||||
markdown: z.string().min(1).max(64_000),
|
|
||||||
})
|
|
||||||
|
|
||||||
export function registerUpdateIdeaGrillMdTool(server: McpServer) {
|
|
||||||
server.registerTool(
|
|
||||||
'update_idea_grill_md',
|
|
||||||
{
|
|
||||||
title: 'Update idea grill_md',
|
|
||||||
description:
|
|
||||||
'Save the grill-result markdown for an idea and transition status to GRILLED. Forbidden for demo accounts.',
|
|
||||||
inputSchema,
|
|
||||||
},
|
|
||||||
async ({ idea_id, markdown }) =>
|
|
||||||
withToolErrors(async () => {
|
|
||||||
const auth = await requireWriteAccess()
|
|
||||||
if (!(await userOwnsIdea(idea_id, auth.userId))) {
|
|
||||||
return toolError('Idea not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await prisma.$transaction([
|
|
||||||
prisma.idea.update({
|
|
||||||
where: { id: idea_id },
|
|
||||||
data: { grill_md: markdown, status: 'GRILLED' },
|
|
||||||
select: { id: true, status: true, code: true },
|
|
||||||
}),
|
|
||||||
prisma.ideaLog.create({
|
|
||||||
data: {
|
|
||||||
idea_id,
|
|
||||||
type: 'GRILL_RESULT',
|
|
||||||
content: `Grill result (${markdown.length} chars)`,
|
|
||||||
metadata: { length: markdown.length },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
return toolJson({
|
|
||||||
ok: true,
|
|
||||||
idea: result[0],
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
// MCP-tool: schrijft het plan_md-resultaat na een IDEA_MAKE_PLAN-job en
|
|
||||||
// transitioneert de idea-status naar PLAN_READY (bij geldige yaml-frontmatter)
|
|
||||||
// of PLAN_FAILED (bij parse-fout).
|
|
||||||
//
|
|
||||||
// Wordt aangeroepen door de worker als laatste stap van een make-plan-sessie.
|
|
||||||
|
|
||||||
import { z } from 'zod'
|
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
||||||
|
|
||||||
import { prisma } from '../prisma.js'
|
|
||||||
import { requireWriteAccess } from '../auth.js'
|
|
||||||
import { userOwnsIdea } from '../access.js'
|
|
||||||
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
|
||||||
import { parsePlanMd } from '../lib/idea-plan-parser.js'
|
|
||||||
|
|
||||||
const inputSchema = z.object({
|
|
||||||
idea_id: z.string().min(1),
|
|
||||||
markdown: z.string().min(1).max(64_000),
|
|
||||||
})
|
|
||||||
|
|
||||||
export function registerUpdateIdeaPlanMdTool(server: McpServer) {
|
|
||||||
server.registerTool(
|
|
||||||
'update_idea_plan_md',
|
|
||||||
{
|
|
||||||
title: 'Update idea plan_md',
|
|
||||||
description:
|
|
||||||
'Save the make-plan-result markdown for an idea. Server validates yaml-frontmatter; on success status → PLAN_READY, on parse-fail → PLAN_FAILED. Forbidden for demo accounts.',
|
|
||||||
inputSchema,
|
|
||||||
},
|
|
||||||
async ({ idea_id, markdown }) =>
|
|
||||||
withToolErrors(async () => {
|
|
||||||
const auth = await requireWriteAccess()
|
|
||||||
if (!(await userOwnsIdea(idea_id, auth.userId))) {
|
|
||||||
return toolError('Idea not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parsePlanMd(markdown)
|
|
||||||
|
|
||||||
if (!parsed.ok) {
|
|
||||||
// Persist md + flip to PLAN_FAILED + log de errors zodat de UI ze
|
|
||||||
// aan de user kan tonen.
|
|
||||||
const result = await prisma.$transaction([
|
|
||||||
prisma.idea.update({
|
|
||||||
where: { id: idea_id },
|
|
||||||
data: { plan_md: markdown, status: 'PLAN_FAILED' },
|
|
||||||
select: { id: true, status: true, code: true },
|
|
||||||
}),
|
|
||||||
prisma.ideaLog.create({
|
|
||||||
data: {
|
|
||||||
idea_id,
|
|
||||||
type: 'JOB_EVENT',
|
|
||||||
content: 'plan_md parse failed',
|
|
||||||
metadata: { errors: parsed.errors },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
return toolJson({
|
|
||||||
ok: false,
|
|
||||||
idea: result[0],
|
|
||||||
errors: parsed.errors,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await prisma.$transaction([
|
|
||||||
prisma.idea.update({
|
|
||||||
where: { id: idea_id },
|
|
||||||
data: { plan_md: markdown, status: 'PLAN_READY' },
|
|
||||||
select: { id: true, status: true, code: true },
|
|
||||||
}),
|
|
||||||
prisma.ideaLog.create({
|
|
||||||
data: {
|
|
||||||
idea_id,
|
|
||||||
type: 'PLAN_RESULT',
|
|
||||||
content: `Plan ready: ${parsed.plan.stories.length} stories, ${parsed.plan.stories.reduce((n, s) => n + s.tasks.length, 0)} tasks`,
|
|
||||||
metadata: {
|
|
||||||
pbi_title: parsed.plan.pbi.title,
|
|
||||||
story_count: parsed.plan.stories.length,
|
|
||||||
task_count: parsed.plan.stories.reduce((n, s) => n + s.tasks.length, 0),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
return toolJson({
|
|
||||||
ok: true,
|
|
||||||
idea: result[0],
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
// MCP-tool: writes the review-log result after an IDEA_REVIEW_PLAN job and
|
|
||||||
// transitions idea.status. Only an explicit approval_status='approved' moves
|
|
||||||
// the idea to PLAN_REVIEWED; anything else (rejected, pending, or omitted)
|
|
||||||
// goes to PLAN_REVIEW_FAILED — a human must then decide. The tool never
|
|
||||||
// silently approves.
|
|
||||||
//
|
|
||||||
// Called by the worker as the final step of a review-plan session.
|
|
||||||
|
|
||||||
import { z } from 'zod'
|
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
||||||
|
|
||||||
import { prisma } from '../prisma.js'
|
|
||||||
import { requireWriteAccess } from '../auth.js'
|
|
||||||
import { userOwnsIdea } from '../access.js'
|
|
||||||
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
|
||||||
|
|
||||||
export const inputSchema = z.object({
|
|
||||||
idea_id: z.string().min(1),
|
|
||||||
review_log: z.object({}).passthrough(), // Full ReviewLog from orchestrator (JSON object)
|
|
||||||
approval_status: z
|
|
||||||
.enum(['pending', 'approved', 'rejected'] as const)
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function handleUpdateIdeaPlanReviewed(
|
|
||||||
{ idea_id, review_log, approval_status }: z.infer<typeof inputSchema>,
|
|
||||||
) {
|
|
||||||
return withToolErrors(async () => {
|
|
||||||
const auth = await requireWriteAccess()
|
|
||||||
if (!(await userOwnsIdea(idea_id, auth.userId))) {
|
|
||||||
return toolError('Idea not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alleen een expliciete 'approved' brengt het idee naar PLAN_REVIEWED.
|
|
||||||
// 'rejected', 'pending' én een weggelaten approval_status betekenen
|
|
||||||
// allemaal "niet auto-goedgekeurd — mens moet beslissen" en gaan naar
|
|
||||||
// PLAN_REVIEW_FAILED. Nooit stilzwijgend goedkeuren (de vorige
|
|
||||||
// `: 'PLAN_REVIEWED'`-default deed dat wel bij pending/undefined).
|
|
||||||
const nextStatus =
|
|
||||||
approval_status === 'approved' ? 'PLAN_REVIEWED' : 'PLAN_REVIEW_FAILED'
|
|
||||||
|
|
||||||
// Log summary metrics from review_log
|
|
||||||
const logSummary = buildReviewLogSummary(review_log)
|
|
||||||
|
|
||||||
const result = await prisma.$transaction([
|
|
||||||
prisma.idea.update({
|
|
||||||
where: { id: idea_id },
|
|
||||||
data: {
|
|
||||||
plan_review_log: review_log as any,
|
|
||||||
reviewed_at: new Date(),
|
|
||||||
status: nextStatus,
|
|
||||||
},
|
|
||||||
select: { id: true, status: true, code: true },
|
|
||||||
}),
|
|
||||||
prisma.ideaLog.create({
|
|
||||||
data: {
|
|
||||||
idea_id,
|
|
||||||
type: 'PLAN_REVIEW_RESULT',
|
|
||||||
content: logSummary.summary,
|
|
||||||
metadata: {
|
|
||||||
approval_status,
|
|
||||||
convergence_status: logSummary.convergence_status,
|
|
||||||
final_score: logSummary.final_score,
|
|
||||||
rounds_completed: logSummary.rounds_completed,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
return toolJson({
|
|
||||||
ok: true,
|
|
||||||
idea: result[0],
|
|
||||||
review_log_summary: logSummary,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerUpdateIdeaPlanReviewedTool(server: McpServer) {
|
|
||||||
server.registerTool(
|
|
||||||
'update_idea_plan_reviewed',
|
|
||||||
{
|
|
||||||
title: 'Mark plan as reviewed',
|
|
||||||
description:
|
|
||||||
'Save review-log after a plan review cycle and transition idea.status. ' +
|
|
||||||
'Only approval_status="approved" → PLAN_REVIEWED; "rejected", "pending", ' +
|
|
||||||
'or an omitted approval_status → PLAN_REVIEW_FAILED (needs manual ' +
|
|
||||||
'approval — never silently approved). Forbidden for demo accounts.',
|
|
||||||
inputSchema,
|
|
||||||
},
|
|
||||||
handleUpdateIdeaPlanReviewed,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildReviewLogSummary(
|
|
||||||
reviewLog: Record<string, any>,
|
|
||||||
): {
|
|
||||||
summary: string
|
|
||||||
convergence_status: string
|
|
||||||
final_score: number
|
|
||||||
rounds_completed: number
|
|
||||||
} {
|
|
||||||
const rounds = Array.isArray(reviewLog.rounds) ? reviewLog.rounds : []
|
|
||||||
const convergence = reviewLog.convergence || {}
|
|
||||||
const finalScore =
|
|
||||||
rounds.length > 0 ? rounds[rounds.length - 1].score ?? 0 : 0
|
|
||||||
|
|
||||||
const convergenceStatus =
|
|
||||||
convergence.stable_at_round !== undefined
|
|
||||||
? `stable at round ${convergence.stable_at_round}`
|
|
||||||
: convergence.final_diff_pct !== undefined
|
|
||||||
? `${convergence.final_diff_pct}% diff`
|
|
||||||
: 'pending'
|
|
||||||
|
|
||||||
const summary =
|
|
||||||
`Plan reviewed in ${rounds.length} rounds. ` +
|
|
||||||
`Convergence: ${convergenceStatus}. ` +
|
|
||||||
`Final score: ${finalScore}/100. ` +
|
|
||||||
`Status: ${reviewLog.approval?.status || 'pending'}.`
|
|
||||||
|
|
||||||
return {
|
|
||||||
summary,
|
|
||||||
convergence_status: convergenceStatus,
|
|
||||||
final_score: finalScore,
|
|
||||||
rounds_completed: rounds.length,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,102 +0,0 @@
|
||||||
// MCP tool: update een Sprint.
|
|
||||||
//
|
|
||||||
// Generieke update — wijzigt elke combinatie van status, sprint_goal,
|
|
||||||
// start_date en end_date. Géén state-machine validatie (zie
|
|
||||||
// docs/plans/sprint-mcp-tools.md): last-write-wins, het resubmit/heropen-pad
|
|
||||||
// zit elders. Bij status → CLOSED/FAILED/ARCHIVED zonder expliciete end_date
|
|
||||||
// wordt end_date automatisch op vandaag gezet. Bij status → CLOSED wordt
|
|
||||||
// daarnaast `completed_at` op now() gezet (parity met
|
|
||||||
// src/lib/tasks-status-update.ts dat hetzelfde doet bij auto-close via
|
|
||||||
// task-status-cascade; zo houden reporting en UI één bron van waarheid voor
|
|
||||||
// completion-tijd).
|
|
||||||
|
|
||||||
import { z } from 'zod'
|
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
||||||
import type { SprintStatus } from '@prisma/client'
|
|
||||||
import { prisma } from '../prisma.js'
|
|
||||||
import { requireWriteAccess } from '../auth.js'
|
|
||||||
import { userCanAccessProduct } from '../access.js'
|
|
||||||
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
|
||||||
|
|
||||||
const TERMINAL_STATUSES = new Set<SprintStatus>(['CLOSED', 'FAILED', 'ARCHIVED'])
|
|
||||||
|
|
||||||
export const inputSchema = z.object({
|
|
||||||
sprint_id: z.string().min(1),
|
|
||||||
status: z.enum(['OPEN', 'CLOSED', 'ARCHIVED', 'FAILED']).optional(),
|
|
||||||
sprint_goal: z.string().min(1).max(500).optional(),
|
|
||||||
end_date: z.string().date().optional(),
|
|
||||||
start_date: z.string().date().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function handleUpdateSprint(
|
|
||||||
{ sprint_id, status, sprint_goal, end_date, start_date }: z.infer<typeof inputSchema>,
|
|
||||||
) {
|
|
||||||
return withToolErrors(async () => {
|
|
||||||
if (
|
|
||||||
status === undefined &&
|
|
||||||
sprint_goal === undefined &&
|
|
||||||
end_date === undefined &&
|
|
||||||
start_date === undefined
|
|
||||||
) {
|
|
||||||
return toolError('Minstens één veld vereist om te wijzigen')
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = await requireWriteAccess()
|
|
||||||
|
|
||||||
const sprint = await prisma.sprint.findUnique({
|
|
||||||
where: { id: sprint_id },
|
|
||||||
select: { id: true, product_id: true },
|
|
||||||
})
|
|
||||||
if (!sprint) {
|
|
||||||
return toolError(`Sprint ${sprint_id} not found`)
|
|
||||||
}
|
|
||||||
if (!(await userCanAccessProduct(sprint.product_id, auth.userId))) {
|
|
||||||
return toolError(`Sprint ${sprint_id} not accessible`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: {
|
|
||||||
status?: SprintStatus
|
|
||||||
sprint_goal?: string
|
|
||||||
start_date?: Date
|
|
||||||
end_date?: Date
|
|
||||||
completed_at?: Date
|
|
||||||
} = {}
|
|
||||||
if (status !== undefined) data.status = status
|
|
||||||
if (sprint_goal !== undefined) data.sprint_goal = sprint_goal
|
|
||||||
if (start_date !== undefined) data.start_date = new Date(start_date)
|
|
||||||
if (end_date !== undefined) {
|
|
||||||
data.end_date = new Date(end_date)
|
|
||||||
} else if (status !== undefined && TERMINAL_STATUSES.has(status)) {
|
|
||||||
data.end_date = new Date()
|
|
||||||
}
|
|
||||||
if (status === 'CLOSED') data.completed_at = new Date()
|
|
||||||
|
|
||||||
const updated = await prisma.sprint.update({
|
|
||||||
where: { id: sprint_id },
|
|
||||||
data,
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
code: true,
|
|
||||||
sprint_goal: true,
|
|
||||||
status: true,
|
|
||||||
start_date: true,
|
|
||||||
end_date: true,
|
|
||||||
completed_at: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return toolJson(updated)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerUpdateSprintTool(server: McpServer) {
|
|
||||||
server.registerTool(
|
|
||||||
'update_sprint',
|
|
||||||
{
|
|
||||||
title: 'Update Sprint',
|
|
||||||
description:
|
|
||||||
'Update a sprint: status, sprint_goal, start_date and/or end_date. At least one field required. No state-machine validation — last-write-wins. When status goes to CLOSED/FAILED/ARCHIVED and end_date is not provided, end_date is set to today. When status goes to CLOSED, completed_at is set to now (parity with auto-close via task-cascade). Forbidden for demo accounts.',
|
|
||||||
inputSchema,
|
|
||||||
},
|
|
||||||
handleUpdateSprint,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
// PBI-50 F3-T2: update_task_execution
|
|
||||||
//
|
|
||||||
// SPRINT_IMPLEMENTATION-flow lifecycle-tool. Worker roept dit aan voor elke
|
|
||||||
// task in de batch om de SprintTaskExecution-row te muteren:
|
|
||||||
// PENDING → RUNNING → DONE/FAILED/SKIPPED
|
|
||||||
// Idempotent: dezelfde call kan veilig herhaald worden.
|
|
||||||
|
|
||||||
import { z } from 'zod'
|
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
||||||
import { prisma } from '../prisma.js'
|
|
||||||
import { requireWriteAccess } from '../auth.js'
|
|
||||||
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
|
||||||
|
|
||||||
const inputSchema = z.object({
|
|
||||||
execution_id: z.string().min(1),
|
|
||||||
status: z.enum(['PENDING', 'RUNNING', 'DONE', 'FAILED', 'SKIPPED']),
|
|
||||||
base_sha: z.string().optional(),
|
|
||||||
head_sha: z.string().optional(),
|
|
||||||
skip_reason: z.string().max(2000).optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export function registerUpdateTaskExecutionTool(server: McpServer) {
|
|
||||||
server.registerTool(
|
|
||||||
'update_task_execution',
|
|
||||||
{
|
|
||||||
title: 'Update SprintTaskExecution status',
|
|
||||||
description:
|
|
||||||
'Mutate a SprintTaskExecution row in a SPRINT_IMPLEMENTATION batch. ' +
|
|
||||||
'Status: PENDING|RUNNING|DONE|FAILED|SKIPPED. Worker calls this for each ' +
|
|
||||||
'task transition. Token must own the parent SPRINT_IMPLEMENTATION ClaudeJob. ' +
|
|
||||||
'Idempotent — safe to retry. Schrijft started_at (RUNNING) en finished_at ' +
|
|
||||||
'(DONE/FAILED/SKIPPED). Forbidden for demo accounts.',
|
|
||||||
inputSchema,
|
|
||||||
},
|
|
||||||
async ({ execution_id, status, base_sha, head_sha, skip_reason }) =>
|
|
||||||
withToolErrors(async () => {
|
|
||||||
const auth = await requireWriteAccess()
|
|
||||||
|
|
||||||
const execution = await prisma.sprintTaskExecution.findUnique({
|
|
||||||
where: { id: execution_id },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
sprint_job_id: true,
|
|
||||||
sprint_job: {
|
|
||||||
select: { claimed_by_token_id: true, status: true, kind: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (!execution) {
|
|
||||||
return toolError(`SprintTaskExecution ${execution_id} not found`)
|
|
||||||
}
|
|
||||||
if (execution.sprint_job.kind !== 'SPRINT_IMPLEMENTATION') {
|
|
||||||
return toolError(
|
|
||||||
`Execution ${execution_id} hangs at job kind ${execution.sprint_job.kind}, expected SPRINT_IMPLEMENTATION`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (execution.sprint_job.claimed_by_token_id !== auth.tokenId) {
|
|
||||||
return toolError(
|
|
||||||
`Forbidden: token does not own SPRINT_IMPLEMENTATION job for execution ${execution_id}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
execution.sprint_job.status !== 'CLAIMED' &&
|
|
||||||
execution.sprint_job.status !== 'RUNNING'
|
|
||||||
) {
|
|
||||||
return toolError(
|
|
||||||
`Sprint job is in terminal state ${execution.sprint_job.status}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
const updated = await prisma.sprintTaskExecution.update({
|
|
||||||
where: { id: execution_id },
|
|
||||||
data: {
|
|
||||||
status,
|
|
||||||
...(base_sha !== undefined ? { base_sha } : {}),
|
|
||||||
...(head_sha !== undefined ? { head_sha } : {}),
|
|
||||||
...(skip_reason !== undefined ? { skip_reason } : {}),
|
|
||||||
...(status === 'RUNNING' ? { started_at: now } : {}),
|
|
||||||
...(status === 'DONE' || status === 'FAILED' || status === 'SKIPPED'
|
|
||||||
? { finished_at: now }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
status: true,
|
|
||||||
base_sha: true,
|
|
||||||
head_sha: true,
|
|
||||||
verify_result: true,
|
|
||||||
verify_summary: true,
|
|
||||||
skip_reason: true,
|
|
||||||
started_at: true,
|
|
||||||
finished_at: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return toolJson({
|
|
||||||
execution_id: updated.id,
|
|
||||||
status: updated.status,
|
|
||||||
base_sha: updated.base_sha,
|
|
||||||
head_sha: updated.head_sha,
|
|
||||||
verify_result: updated.verify_result,
|
|
||||||
verify_summary: updated.verify_summary,
|
|
||||||
skip_reason: updated.skip_reason,
|
|
||||||
started_at: updated.started_at?.toISOString() ?? null,
|
|
||||||
finished_at: updated.finished_at?.toISOString() ?? null,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
import { prisma } from '../prisma.js'
|
|
||||||
import { requireWriteAccess } from '../auth.js'
|
import { requireWriteAccess } from '../auth.js'
|
||||||
import { userCanAccessTask } from '../access.js'
|
import { userCanAccessTask } from '../access.js'
|
||||||
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
||||||
|
|
@ -10,10 +9,6 @@ import { updateTaskStatusWithStoryPromotion } from '../lib/tasks-status-update.j
|
||||||
const inputSchema = z.object({
|
const inputSchema = z.object({
|
||||||
task_id: z.string().min(1),
|
task_id: z.string().min(1),
|
||||||
status: z.enum(TASK_STATUS_API_VALUES as [string, ...string[]]),
|
status: z.enum(TASK_STATUS_API_VALUES as [string, ...string[]]),
|
||||||
// PBI-50: optionele sprint_run_id voor SPRINT_IMPLEMENTATION-flow.
|
|
||||||
// Wanneer aanwezig: server valideert dat task in deze sprint zit, run
|
|
||||||
// actief is, en de huidige token een ClaudeJob in deze run heeft geclaimt.
|
|
||||||
sprint_run_id: z.string().min(1).optional(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export function registerUpdateTaskStatusTool(server: McpServer) {
|
export function registerUpdateTaskStatusTool(server: McpServer) {
|
||||||
|
|
@ -22,14 +17,11 @@ export function registerUpdateTaskStatusTool(server: McpServer) {
|
||||||
{
|
{
|
||||||
title: 'Update task status',
|
title: 'Update task status',
|
||||||
description:
|
description:
|
||||||
'Set the status of a task. Allowed values: todo, in_progress, review, done, failed. ' +
|
'Set the status of a task. Allowed values: todo, in_progress, review, done. ' +
|
||||||
'Optional sprint_run_id binds the update to a SPRINT_IMPLEMENTATION run for ' +
|
|
||||||
'cascade-propagation; the server validates that the task belongs to the sprint ' +
|
|
||||||
'and that the calling token has claimed a job in that run. ' +
|
|
||||||
'Forbidden for demo accounts.',
|
'Forbidden for demo accounts.',
|
||||||
inputSchema,
|
inputSchema,
|
||||||
},
|
},
|
||||||
async ({ task_id, status, sprint_run_id }) =>
|
async ({ task_id, status }) =>
|
||||||
withToolErrors(async () => {
|
withToolErrors(async () => {
|
||||||
const auth = await requireWriteAccess()
|
const auth = await requireWriteAccess()
|
||||||
const dbStatus = taskStatusFromApi(status)
|
const dbStatus = taskStatusFromApi(status)
|
||||||
|
|
@ -39,74 +31,15 @@ export function registerUpdateTaskStatusTool(server: McpServer) {
|
||||||
if (!(await userCanAccessTask(task_id, auth.userId))) {
|
if (!(await userCanAccessTask(task_id, auth.userId))) {
|
||||||
return toolError(`Task ${task_id} not found or not accessible`)
|
return toolError(`Task ${task_id} not found or not accessible`)
|
||||||
}
|
}
|
||||||
|
const { task, storyStatusChange } = await updateTaskStatusWithStoryPromotion(
|
||||||
// PBI-50: validate explicit sprint_run_id binding.
|
task_id,
|
||||||
if (sprint_run_id) {
|
dbStatus,
|
||||||
const sprintRun = await prisma.sprintRun.findUnique({
|
)
|
||||||
where: { id: sprint_run_id },
|
|
||||||
select: { id: true, status: true, sprint_id: true },
|
|
||||||
})
|
|
||||||
if (!sprintRun) {
|
|
||||||
return toolError(`SprintRun ${sprint_run_id} not found`)
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
sprintRun.status !== 'QUEUED' &&
|
|
||||||
sprintRun.status !== 'RUNNING' &&
|
|
||||||
sprintRun.status !== 'PAUSED'
|
|
||||||
) {
|
|
||||||
return toolError(
|
|
||||||
`SprintRun ${sprint_run_id} is in terminal state ${sprintRun.status}; cannot update task status against it`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Task moet in deze sprint zitten
|
|
||||||
const task = await prisma.task.findUnique({
|
|
||||||
where: { id: task_id },
|
|
||||||
select: { story: { select: { sprint_id: true } } },
|
|
||||||
})
|
|
||||||
if (!task || task.story.sprint_id !== sprintRun.sprint_id) {
|
|
||||||
return toolError(
|
|
||||||
`Task ${task_id} is not in sprint ${sprintRun.sprint_id} (sprint_run ${sprint_run_id})`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Token-coupling: huidige token moet een actieve ClaudeJob in deze
|
|
||||||
// SprintRun hebben geclaimt (typisch de SPRINT_IMPLEMENTATION-job).
|
|
||||||
const tokenJob = await prisma.claudeJob.findFirst({
|
|
||||||
where: {
|
|
||||||
sprint_run_id,
|
|
||||||
claimed_by_token_id: auth.tokenId,
|
|
||||||
status: { in: ['CLAIMED', 'RUNNING'] },
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
if (!tokenJob) {
|
|
||||||
return toolError(
|
|
||||||
`Forbidden: current token has no active claim in sprint_run ${sprint_run_id}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { task, storyStatusChange, sprintRunChanged } =
|
|
||||||
await updateTaskStatusWithStoryPromotion(task_id, dbStatus, undefined, sprint_run_id)
|
|
||||||
|
|
||||||
// Voor SPRINT-flow: stuur expliciete sprint_run_status mee zodat
|
|
||||||
// worker zijn loop kan breken bij FAILED/PAUSED zonder extra query.
|
|
||||||
let sprintRunStatusChange: string | null = null
|
|
||||||
if (sprintRunChanged && sprint_run_id) {
|
|
||||||
const updated = await prisma.sprintRun.findUnique({
|
|
||||||
where: { id: sprint_run_id },
|
|
||||||
select: { status: true },
|
|
||||||
})
|
|
||||||
sprintRunStatusChange = updated?.status ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
return toolJson({
|
return toolJson({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
status: taskStatusToApi(task.status),
|
status: taskStatusToApi(task.status),
|
||||||
implementation_plan: task.implementation_plan,
|
implementation_plan: task.implementation_plan,
|
||||||
story_status_change: storyStatusChange,
|
story_status_change: storyStatusChange,
|
||||||
sprint_run_status_change: sprintRunStatusChange,
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
// PBI-50 F3-T1: verify_sprint_task
|
|
||||||
//
|
|
||||||
// Execution-aware verify-tool voor SPRINT_IMPLEMENTATION-flow.
|
|
||||||
// Verschilt van verify_task_against_plan in:
|
|
||||||
// - input via execution_id (niet task_id)
|
|
||||||
// - base_sha komt uit SprintTaskExecution.base_sha; voor task[1..N] zonder
|
|
||||||
// base_sha vult de tool dynamisch met head_sha van vorige DONE-execution
|
|
||||||
// - plan_snapshot komt uit execution.plan_snapshot (frozen op claim-tijd)
|
|
||||||
// - resultaat opgeslagen op execution-row, niet op ClaudeJob.verify_result
|
|
||||||
// - response geeft allowed_for_done direct mee
|
|
||||||
|
|
||||||
import { execFile } from 'node:child_process'
|
|
||||||
import { promisify } from 'node:util'
|
|
||||||
import { z } from 'zod'
|
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
||||||
import { prisma } from '../prisma.js'
|
|
||||||
import { requireWriteAccess } from '../auth.js'
|
|
||||||
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
|
||||||
import { classifyDiffAgainstPlan } from '../verify/classify.js'
|
|
||||||
import { checkVerifyGate } from './update-job-status.js'
|
|
||||||
|
|
||||||
const exec = promisify(execFile)
|
|
||||||
|
|
||||||
const inputSchema = z.object({
|
|
||||||
execution_id: z.string().min(1),
|
|
||||||
worktree_path: z.string().min(1),
|
|
||||||
summary: z.string().max(2000).optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export function registerVerifySprintTaskTool(server: McpServer) {
|
|
||||||
server.registerTool(
|
|
||||||
'verify_sprint_task',
|
|
||||||
{
|
|
||||||
title: 'Verify SprintTaskExecution against frozen plan',
|
|
||||||
description:
|
|
||||||
'Run `git diff <base_sha>...HEAD` in the worktree and classify against the ' +
|
|
||||||
'frozen plan_snapshot of this SprintTaskExecution. Returns ALIGNED|PARTIAL|EMPTY|' +
|
|
||||||
'DIVERGENT plus reasoning + allowed_for_done (computed via the standard verify-gate ' +
|
|
||||||
'with the execution\'s frozen verify_required/verify_only). ' +
|
|
||||||
'For task[1..N] zonder base_sha vult de tool die in op basis van de head_sha van de ' +
|
|
||||||
'vorige DONE-execution. Optional summary is opgeslagen voor PARTIAL/DIVERGENT-rationale ' +
|
|
||||||
'en gebruikt door de gate. ' +
|
|
||||||
'Call this BEFORE update_task_execution(DONE) for each task in the sprint batch. ' +
|
|
||||||
'Forbidden for demo accounts.',
|
|
||||||
inputSchema,
|
|
||||||
annotations: { readOnlyHint: false },
|
|
||||||
},
|
|
||||||
async ({ execution_id, worktree_path, summary }) =>
|
|
||||||
withToolErrors(async () => {
|
|
||||||
const auth = await requireWriteAccess()
|
|
||||||
|
|
||||||
const execution = await prisma.sprintTaskExecution.findUnique({
|
|
||||||
where: { id: execution_id },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
sprint_job_id: true,
|
|
||||||
order: true,
|
|
||||||
base_sha: true,
|
|
||||||
plan_snapshot: true,
|
|
||||||
verify_required_snapshot: true,
|
|
||||||
verify_only_snapshot: true,
|
|
||||||
sprint_job: {
|
|
||||||
select: { claimed_by_token_id: true, status: true, kind: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (!execution) {
|
|
||||||
return toolError(`SprintTaskExecution ${execution_id} not found`)
|
|
||||||
}
|
|
||||||
if (execution.sprint_job.kind !== 'SPRINT_IMPLEMENTATION') {
|
|
||||||
return toolError(
|
|
||||||
`Execution ${execution_id} hangs at job kind ${execution.sprint_job.kind}, expected SPRINT_IMPLEMENTATION`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (execution.sprint_job.claimed_by_token_id !== auth.tokenId) {
|
|
||||||
return toolError(
|
|
||||||
`Forbidden: token does not own SPRINT_IMPLEMENTATION job for execution ${execution_id}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve base_sha. Voor task[0] is dit gevuld bij claim. Voor
|
|
||||||
// task[1..N] wordt dit dynamisch gevuld op basis van de vorige
|
|
||||||
// DONE-execution's head_sha. Persist na fill zodat herhaalde calls
|
|
||||||
// dezelfde base gebruiken.
|
|
||||||
let baseSha = execution.base_sha
|
|
||||||
if (!baseSha) {
|
|
||||||
const previousDone = await prisma.sprintTaskExecution.findFirst({
|
|
||||||
where: {
|
|
||||||
sprint_job_id: execution.sprint_job_id,
|
|
||||||
order: { lt: execution.order },
|
|
||||||
status: 'DONE',
|
|
||||||
head_sha: { not: null },
|
|
||||||
},
|
|
||||||
orderBy: { order: 'desc' },
|
|
||||||
select: { head_sha: true },
|
|
||||||
})
|
|
||||||
if (!previousDone?.head_sha) {
|
|
||||||
return toolError(
|
|
||||||
`MISSING_BASE_SHA: execution ${execution_id} has no base_sha and no previous DONE-execution with head_sha. Did you skip update_task_execution(DONE) on a prior task?`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
baseSha = previousDone.head_sha
|
|
||||||
await prisma.sprintTaskExecution.update({
|
|
||||||
where: { id: execution_id },
|
|
||||||
data: { base_sha: baseSha },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
let diff: string
|
|
||||||
try {
|
|
||||||
const { stdout } = await exec('git', ['diff', `${baseSha}...HEAD`], {
|
|
||||||
cwd: worktree_path,
|
|
||||||
})
|
|
||||||
diff = stdout
|
|
||||||
} catch (err) {
|
|
||||||
return toolError(
|
|
||||||
`git diff failed in worktree (${worktree_path}): ${(err as Error).message ?? 'unknown error'}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { result, reasoning } = classifyDiffAgainstPlan({
|
|
||||||
diff,
|
|
||||||
plan: execution.plan_snapshot,
|
|
||||||
})
|
|
||||||
|
|
||||||
await prisma.sprintTaskExecution.update({
|
|
||||||
where: { id: execution_id },
|
|
||||||
data: {
|
|
||||||
verify_result: result,
|
|
||||||
...(summary !== undefined ? { verify_summary: summary } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const gate = checkVerifyGate(
|
|
||||||
result,
|
|
||||||
execution.verify_only_snapshot,
|
|
||||||
execution.verify_required_snapshot,
|
|
||||||
summary,
|
|
||||||
)
|
|
||||||
|
|
||||||
return toolJson({
|
|
||||||
execution_id: execution.id,
|
|
||||||
result: result.toLowerCase() as 'aligned' | 'partial' | 'empty' | 'divergent',
|
|
||||||
reasoning,
|
|
||||||
base_sha: baseSha,
|
|
||||||
allowed_for_done: gate.allowed,
|
|
||||||
reason: gate.allowed ? null : gate.error,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -15,15 +15,8 @@ const inputSchema = z.object({
|
||||||
worktree_path: z.string().min(1),
|
worktree_path: z.string().min(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function getDiffInWorktree(
|
export async function getDiffInWorktree(worktreePath: string): Promise<string> {
|
||||||
worktreePath: string,
|
const { stdout } = await exec('git', ['diff', 'origin/main...HEAD'], { cwd: worktreePath })
|
||||||
baseSha?: string,
|
|
||||||
): Promise<string> {
|
|
||||||
// PBI-47 (P0): when base_sha is provided, diff against the per-job base
|
|
||||||
// captured at claim-time so verify only sees the current task's changes.
|
|
||||||
// Falls back to origin/main only for legacy callers without base_sha.
|
|
||||||
const range = baseSha ? `${baseSha}...HEAD` : 'origin/main...HEAD'
|
|
||||||
const { stdout } = await exec('git', ['diff', range], { cwd: worktreePath })
|
|
||||||
return stdout
|
return stdout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,7 +58,7 @@ export function registerVerifyTaskAgainstPlanTool(server: McpServer) {
|
||||||
where: { status: { in: ['CLAIMED', 'RUNNING'] } },
|
where: { status: { in: ['CLAIMED', 'RUNNING'] } },
|
||||||
orderBy: { created_at: 'desc' },
|
orderBy: { created_at: 'desc' },
|
||||||
take: 1,
|
take: 1,
|
||||||
select: { id: true, plan_snapshot: true, base_sha: true },
|
select: { id: true, plan_snapshot: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -74,19 +67,9 @@ export function registerVerifyTaskAgainstPlanTool(server: McpServer) {
|
||||||
|
|
||||||
const activeJob = task.claude_jobs[0] ?? null
|
const activeJob = task.claude_jobs[0] ?? null
|
||||||
|
|
||||||
// PBI-47 (P0): require base_sha so diff is scoped to this job's work,
|
|
||||||
// not the full origin/main...HEAD which would include sibling commits
|
|
||||||
// on a reused story/sprint branch.
|
|
||||||
if (activeJob && !activeJob.base_sha) {
|
|
||||||
return toolError(
|
|
||||||
'MISSING_BASE_SHA: This claim has no base_sha. '
|
|
||||||
+ 'Re-claim the task (cancel + wait_for_job) so a fresh base_sha is captured.',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let diff: string
|
let diff: string
|
||||||
try {
|
try {
|
||||||
diff = await getDiffInWorktree(worktree_path, activeJob?.base_sha ?? undefined)
|
diff = await getDiffInWorktree(worktree_path)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return toolError(
|
return toolError(
|
||||||
`git diff failed in worktree (${worktree_path}): ${(err as Error).message ?? 'unknown error'}`,
|
`git diff failed in worktree (${worktree_path}): ${(err as Error).message ?? 'unknown error'}`,
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,10 @@ import { Client } from 'pg'
|
||||||
import * as fs from 'node:fs/promises'
|
import * as fs from 'node:fs/promises'
|
||||||
import * as os from 'node:os'
|
import * as os from 'node:os'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import { execFile } from 'node:child_process'
|
|
||||||
import { promisify } from 'node:util'
|
|
||||||
import { prisma } from '../prisma.js'
|
import { prisma } from '../prisma.js'
|
||||||
|
|
||||||
const execFileP = promisify(execFile)
|
|
||||||
import { requireWriteAccess } from '../auth.js'
|
import { requireWriteAccess } from '../auth.js'
|
||||||
import { toolJson, toolError, withToolErrors } from '../errors.js'
|
import { toolJson, toolError, withToolErrors } from '../errors.js'
|
||||||
import { createWorktreeForJob } from '../git/worktree.js'
|
import { createWorktreeForJob } from '../git/worktree.js'
|
||||||
import { getWorktreeRoot } from '../git/worktree-paths.js'
|
|
||||||
import { setupProductWorktrees, releaseLocksOnTerminal } from '../git/job-locks.js'
|
|
||||||
import { pushBranchForJob } from '../git/push.js'
|
|
||||||
import { resolveJobConfig } from '../lib/job-config.js'
|
|
||||||
|
|
||||||
/** Parse `https://github.com/<owner>/<name>(.git)?` → `<name>`. */
|
/** Parse `https://github.com/<owner>/<name>(.git)?` → `<name>`. */
|
||||||
export function repoNameFromUrl(repoUrl: string | null | undefined): string | null {
|
export function repoNameFromUrl(repoUrl: string | null | undefined): string | null {
|
||||||
|
|
@ -27,60 +19,22 @@ export function repoNameFromUrl(repoUrl: string | null | undefined): string | nu
|
||||||
return m ? m[1] : null
|
return m ? m[1] : null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function resolveRepoRoot(productId: string): Promise<string | null> {
|
||||||
* Resolve the repo-root path on disk for a job's worktree.
|
|
||||||
*
|
|
||||||
* Lookup order (first hit wins):
|
|
||||||
* 1. `task.repo_url`-override → match against config / convention via repo-name
|
|
||||||
* 2. env var `SCRUM4ME_REPO_ROOT_<productId>`
|
|
||||||
* 3. `~/.scrum4me-agent-config.json` `repoRoots[productId]`
|
|
||||||
* 4. Convention `~/Projects/<repo-name-from-product.repo_url>/.git`
|
|
||||||
*
|
|
||||||
* The task-level override exists for cross-repo tasks (e.g. an MCP-server
|
|
||||||
* task tracked under the main product's PBI). Falls back to product-level
|
|
||||||
* resolution when null. Documented in CLAUDE.md.
|
|
||||||
*/
|
|
||||||
export async function resolveRepoRoot(
|
|
||||||
productId: string,
|
|
||||||
taskRepoUrl?: string | null,
|
|
||||||
): Promise<string | null> {
|
|
||||||
// 1. Task-level override: match by repo-name through config/convention
|
|
||||||
if (taskRepoUrl) {
|
|
||||||
const taskRepoName = repoNameFromUrl(taskRepoUrl)
|
|
||||||
if (taskRepoName) {
|
|
||||||
const overrideEnv = `SCRUM4ME_REPO_ROOT_REPO_${taskRepoName}`
|
|
||||||
if (process.env[overrideEnv]) return process.env[overrideEnv]!
|
|
||||||
|
|
||||||
const configPath = path.join(os.homedir(), '.scrum4me-agent-config.json')
|
|
||||||
try {
|
|
||||||
const raw = await fs.readFile(configPath, 'utf-8')
|
|
||||||
const config = JSON.parse(raw) as { repoRoots?: Record<string, string> }
|
|
||||||
if (config.repoRoots?.[taskRepoName]) return config.repoRoots[taskRepoName]
|
|
||||||
} catch { /* fall through */ }
|
|
||||||
|
|
||||||
const candidate = path.join(os.homedir(), 'Projects', taskRepoName)
|
|
||||||
try {
|
|
||||||
await fs.access(path.join(candidate, '.git'))
|
|
||||||
return candidate
|
|
||||||
} catch { /* fall through to product-level */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Env var per-product
|
|
||||||
const envKey = `SCRUM4ME_REPO_ROOT_${productId}`
|
const envKey = `SCRUM4ME_REPO_ROOT_${productId}`
|
||||||
if (process.env[envKey]) return process.env[envKey]!
|
if (process.env[envKey]) return process.env[envKey]!
|
||||||
|
|
||||||
// 3. Config file per-product
|
|
||||||
const configPath = path.join(os.homedir(), '.scrum4me-agent-config.json')
|
const configPath = path.join(os.homedir(), '.scrum4me-agent-config.json')
|
||||||
try {
|
try {
|
||||||
const raw = await fs.readFile(configPath, 'utf-8')
|
const raw = await fs.readFile(configPath, 'utf-8')
|
||||||
const config = JSON.parse(raw) as { repoRoots?: Record<string, string> }
|
const config = JSON.parse(raw) as { repoRoots?: Record<string, string> }
|
||||||
if (config.repoRoots?.[productId]) return config.repoRoots[productId]
|
if (config.repoRoots?.[productId]) return config.repoRoots[productId]
|
||||||
} catch {
|
} catch {
|
||||||
// ignore — fall through
|
// ignore — fall through to convention-based fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Convention via product.repo_url
|
// Convention-based fallback: ~/Projects/<repo-name> with .git/ inside.
|
||||||
|
// Lets the agent work without explicit env-config when checkouts follow
|
||||||
|
// the standard ~/Projects/<name> layout.
|
||||||
try {
|
try {
|
||||||
const product = await prisma.product.findUnique({
|
const product = await prisma.product.findUnique({
|
||||||
where: { id: productId },
|
where: { id: productId },
|
||||||
|
|
@ -119,35 +73,6 @@ export async function resolveBranchForJob(
|
||||||
jobId: string,
|
jobId: string,
|
||||||
storyId: string,
|
storyId: string,
|
||||||
): Promise<{ branchName: string; reused: boolean }> {
|
): Promise<{ branchName: string; reused: boolean }> {
|
||||||
// Sprint-flow (PBI-46): als deze job aan een SprintRun hangt, kies de branch
|
|
||||||
// op basis van Product.pr_strategy:
|
|
||||||
// SPRINT → feat/sprint-<sprint_run_id-suffix> (één branch voor hele run)
|
|
||||||
// STORY → feat/story-<story_id-suffix> (één branch per story; sibling-tasks delen 'm)
|
|
||||||
// Voor legacy task-jobs zonder sprint_run_id valt de logica terug op het
|
|
||||||
// bestaande feat/story-<storyId>-pad.
|
|
||||||
const job = await prisma.claudeJob.findUnique({
|
|
||||||
where: { id: jobId },
|
|
||||||
select: {
|
|
||||||
sprint_run_id: true,
|
|
||||||
sprint_run: { select: { id: true, pr_strategy: true } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (job?.sprint_run && job.sprint_run.pr_strategy === 'SPRINT') {
|
|
||||||
const branchName = `feat/sprint-${job.sprint_run.id.slice(-8)}`
|
|
||||||
const sibling = await prisma.claudeJob.findFirst({
|
|
||||||
where: {
|
|
||||||
sprint_run_id: job.sprint_run_id,
|
|
||||||
branch: branchName,
|
|
||||||
id: { not: jobId },
|
|
||||||
},
|
|
||||||
orderBy: { created_at: 'asc' },
|
|
||||||
select: { branch: true },
|
|
||||||
})
|
|
||||||
return { branchName, reused: sibling !== null }
|
|
||||||
}
|
|
||||||
|
|
||||||
// STORY-mode (default) of legacy: branch per story
|
|
||||||
const sibling = await prisma.claudeJob.findFirst({
|
const sibling = await prisma.claudeJob.findFirst({
|
||||||
where: {
|
where: {
|
||||||
task: { story_id: storyId },
|
task: { story_id: storyId },
|
||||||
|
|
@ -165,19 +90,14 @@ export async function attachWorktreeToJob(
|
||||||
productId: string,
|
productId: string,
|
||||||
jobId: string,
|
jobId: string,
|
||||||
storyId: string,
|
storyId: string,
|
||||||
taskRepoUrl?: string | null,
|
|
||||||
): Promise<{ worktree_path: string; branch_name: string; reused_branch: boolean } | { error: string }> {
|
): Promise<{ worktree_path: string; branch_name: string; reused_branch: boolean } | { error: string }> {
|
||||||
const repoRoot = await resolveRepoRoot(productId, taskRepoUrl)
|
const repoRoot = await resolveRepoRoot(productId)
|
||||||
if (!repoRoot) {
|
if (!repoRoot) {
|
||||||
await rollbackClaim(jobId)
|
await rollbackClaim(jobId)
|
||||||
const repoHint = taskRepoUrl
|
|
||||||
? `task.repo_url=${taskRepoUrl}`
|
|
||||||
: `product ${productId}`
|
|
||||||
return {
|
return {
|
||||||
error:
|
error:
|
||||||
`No repo root configured for ${repoHint}. ` +
|
`No repo root configured for product ${productId}. ` +
|
||||||
`Set env var SCRUM4ME_REPO_ROOT_${productId}, add a repoRoots entry to ~/.scrum4me-agent-config.json, ` +
|
`Set env var SCRUM4ME_REPO_ROOT_${productId} or add to ~/.scrum4me-agent-config.json.`,
|
||||||
`or place a clone at ~/Projects/<repo-name>.`,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,32 +109,6 @@ export async function attachWorktreeToJob(
|
||||||
branchName,
|
branchName,
|
||||||
reuseBranch: reused,
|
reuseBranch: reused,
|
||||||
})
|
})
|
||||||
|
|
||||||
// PBI-47 (P0): capture base_sha so verify_task_against_plan can diff
|
|
||||||
// against the claim-time HEAD instead of origin/main. For reused branches
|
|
||||||
// (siblings already pushed), base_sha = SHA of the worktree HEAD now.
|
|
||||||
// For fresh branches, base_sha = origin/main HEAD which createWorktreeForJob
|
|
||||||
// just checked out.
|
|
||||||
let baseSha: string | null = null
|
|
||||||
try {
|
|
||||||
const { stdout } = await execFileP('git', ['rev-parse', 'HEAD'], { cwd: worktreePath })
|
|
||||||
baseSha = stdout.trim()
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[attachWorktreeToJob] failed to resolve base_sha for ${jobId}:`, err)
|
|
||||||
}
|
|
||||||
// Persist branch + base_sha. update_job_status (prepareDoneUpdate)
|
|
||||||
// leest claudeJob.branch om naar de juiste ref te pushen — zonder deze
|
|
||||||
// update valt 'ie terug op het legacy `feat/job-<8>` patroon en faalt
|
|
||||||
// de push met "src refspec ... does not match any" voor sprint/story
|
|
||||||
// strategy branches.
|
|
||||||
await prisma.claudeJob.update({
|
|
||||||
where: { id: jobId },
|
|
||||||
data: {
|
|
||||||
branch: actualBranch,
|
|
||||||
...(baseSha ? { base_sha: baseSha } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return { worktree_path: worktreePath, branch_name: actualBranch, reused_branch: reused }
|
return { worktree_path: worktreePath, branch_name: actualBranch, reused_branch: reused }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await rollbackClaim(jobId)
|
await rollbackClaim(jobId)
|
||||||
|
|
@ -234,96 +128,40 @@ const inputSchema = z.object({
|
||||||
const STALE_ERROR_MSG = 'agent did not complete job within 2 attempts'
|
const STALE_ERROR_MSG = 'agent did not complete job within 2 attempts'
|
||||||
|
|
||||||
export async function resetStaleClaimedJobs(userId: string): Promise<void> {
|
export async function resetStaleClaimedJobs(userId: string): Promise<void> {
|
||||||
// PBI-50: lease-driven stale-detection. Jobs in CLAIMED of RUNNING met
|
// Jobs that exceeded the retry limit → FAILED
|
||||||
// verlopen lease_until (default 5 min, verlengd door job_heartbeat) worden
|
const failedRows = await prisma.$queryRaw<
|
||||||
// gereset. Legacy jobs zonder lease_until vallen terug op de oude
|
Array<{ id: string; task_id: string; product_id: string }>
|
||||||
// claimed_at + 30-min-regel.
|
>`
|
||||||
type StaleRow = {
|
|
||||||
id: string
|
|
||||||
task_id: string | null
|
|
||||||
product_id: string
|
|
||||||
kind: string
|
|
||||||
sprint_run_id: string | null
|
|
||||||
branch: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const failedRows = await prisma.$queryRaw<StaleRow[]>`
|
|
||||||
UPDATE claude_jobs
|
UPDATE claude_jobs
|
||||||
SET status = 'FAILED',
|
SET status = 'FAILED',
|
||||||
finished_at = NOW(),
|
finished_at = NOW(),
|
||||||
error = ${STALE_ERROR_MSG}
|
error = ${STALE_ERROR_MSG}
|
||||||
WHERE user_id = ${userId}
|
WHERE user_id = ${userId}
|
||||||
AND status IN ('CLAIMED', 'RUNNING')
|
AND status = 'CLAIMED'
|
||||||
|
AND claimed_at < NOW() - INTERVAL '30 minutes'
|
||||||
AND retry_count >= 2
|
AND retry_count >= 2
|
||||||
AND (
|
RETURNING id, task_id, product_id
|
||||||
lease_until < NOW()
|
|
||||||
OR (lease_until IS NULL AND claimed_at < NOW() - INTERVAL '30 minutes')
|
|
||||||
)
|
|
||||||
RETURNING id, task_id, product_id, kind::text AS kind, sprint_run_id, branch
|
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// Jobs under the retry limit → back to QUEUED, increment retry_count
|
||||||
const requeuedRows = await prisma.$queryRaw<
|
const requeuedRows = await prisma.$queryRaw<
|
||||||
(StaleRow & { retry_count: number })[]
|
Array<{ id: string; task_id: string; product_id: string; retry_count: number }>
|
||||||
>`
|
>`
|
||||||
UPDATE claude_jobs
|
UPDATE claude_jobs
|
||||||
SET status = 'QUEUED',
|
SET status = 'QUEUED',
|
||||||
claimed_by_token_id = NULL,
|
claimed_by_token_id = NULL,
|
||||||
claimed_at = NULL,
|
claimed_at = NULL,
|
||||||
plan_snapshot = NULL,
|
plan_snapshot = NULL,
|
||||||
lease_until = NULL,
|
|
||||||
retry_count = retry_count + 1
|
retry_count = retry_count + 1
|
||||||
WHERE user_id = ${userId}
|
WHERE user_id = ${userId}
|
||||||
AND status IN ('CLAIMED', 'RUNNING')
|
AND status = 'CLAIMED'
|
||||||
|
AND claimed_at < NOW() - INTERVAL '30 minutes'
|
||||||
AND retry_count < 2
|
AND retry_count < 2
|
||||||
AND (
|
RETURNING id, task_id, product_id, retry_count
|
||||||
lease_until < NOW()
|
|
||||||
OR (lease_until IS NULL AND claimed_at < NOW() - INTERVAL '30 minutes')
|
|
||||||
)
|
|
||||||
RETURNING id, task_id, product_id, kind::text AS kind, sprint_run_id, branch, retry_count
|
|
||||||
`
|
`
|
||||||
|
|
||||||
if (failedRows.length === 0 && requeuedRows.length === 0) return
|
if (failedRows.length === 0 && requeuedRows.length === 0) return
|
||||||
|
|
||||||
// PBI-9: release any product-worktree locks held by these stale jobs.
|
|
||||||
for (const j of failedRows) await releaseLocksOnTerminal(j.id)
|
|
||||||
for (const j of requeuedRows) await releaseLocksOnTerminal(j.id)
|
|
||||||
|
|
||||||
// PBI-50: voor stale FAILED SPRINT_IMPLEMENTATION jobs — push de branch
|
|
||||||
// zodat het werk niet verloren gaat (geen mark-ready / PR-promotie),
|
|
||||||
// en zet SprintRun.failure_reason met een verwijzing naar de laatst
|
|
||||||
// RUNNING execution voor diagnose.
|
|
||||||
for (const j of failedRows.filter((r) => r.kind === 'SPRINT_IMPLEMENTATION')) {
|
|
||||||
if (j.branch && j.product_id) {
|
|
||||||
const repoRoot = await resolveRepoRoot(j.product_id).catch(() => null)
|
|
||||||
if (repoRoot) {
|
|
||||||
const worktreeDir = getWorktreeRoot()
|
|
||||||
const worktreePath = path.join(worktreeDir, j.id)
|
|
||||||
try {
|
|
||||||
await pushBranchForJob({ worktreePath, branchName: j.branch })
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[stale-reset] push failed for stale sprint-job ${j.id}:`, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (j.sprint_run_id) {
|
|
||||||
const lastRunning = await prisma.sprintTaskExecution.findFirst({
|
|
||||||
where: { sprint_job_id: j.id, status: 'RUNNING' },
|
|
||||||
orderBy: { order: 'desc' },
|
|
||||||
select: { order: true, task_id: true },
|
|
||||||
})
|
|
||||||
const reasonSuffix = lastRunning
|
|
||||||
? `, last execution: order ${lastRunning.order} task ${lastRunning.task_id}`
|
|
||||||
: ''
|
|
||||||
await prisma.sprintRun.update({
|
|
||||||
where: { id: j.sprint_run_id },
|
|
||||||
data: {
|
|
||||||
status: 'FAILED',
|
|
||||||
failure_reason: `stale: lease verlopen${reasonSuffix}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify UI via SSE for each transition (best-effort)
|
// Notify UI via SSE for each transition (best-effort)
|
||||||
try {
|
try {
|
||||||
const pg = new Client({ connectionString: process.env.DATABASE_URL })
|
const pg = new Client({ connectionString: process.env.DATABASE_URL })
|
||||||
|
|
@ -366,54 +204,27 @@ export async function tryClaimJob(
|
||||||
tokenId: string,
|
tokenId: string,
|
||||||
productId?: string,
|
productId?: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
// Atomic claim in a single transaction — also captures plan_snapshot from task.
|
// Atomic claim in a single transaction — also captures plan_snapshot from task
|
||||||
//
|
|
||||||
// PBI-50: claim-filter discrimineert via cj.kind:
|
|
||||||
// - IDEA_GRILL/IDEA_MAKE_PLAN/PLAN_CHAT: standalone idea-jobs.
|
|
||||||
// - TASK_IMPLEMENTATION/SPRINT_IMPLEMENTATION: alleen via actieve SprintRun
|
|
||||||
// (status QUEUED of RUNNING). Legacy task-jobs zonder sprint_run_id en
|
|
||||||
// jobs in PAUSED/FAILED/CANCELLED/DONE SprintRuns worden overgeslagen.
|
|
||||||
// Bij eerste claim van een nog QUEUED SprintRun → status RUNNING.
|
|
||||||
//
|
|
||||||
// PBI-50 lease: lease_until = NOW() + 5min op claim. resetStaleClaimedJobs
|
|
||||||
// reset bij verlopen lease.
|
|
||||||
const rows = await prisma.$transaction(async (tx) => {
|
const rows = await prisma.$transaction(async (tx) => {
|
||||||
|
// SELECT FOR UPDATE OF claude_jobs SKIP LOCKED — join tasks to read implementation_plan
|
||||||
const found = productId
|
const found = productId
|
||||||
? await tx.$queryRaw<
|
? await tx.$queryRaw<Array<{ id: string; implementation_plan: string | null }>>`
|
||||||
Array<{ id: string; implementation_plan: string | null; sprint_run_id: string | null }>
|
SELECT cj.id, t.implementation_plan
|
||||||
>`
|
|
||||||
SELECT cj.id, t.implementation_plan, cj.sprint_run_id
|
|
||||||
FROM claude_jobs cj
|
FROM claude_jobs cj
|
||||||
LEFT JOIN tasks t ON t.id = cj.task_id
|
JOIN tasks t ON t.id = cj.task_id
|
||||||
LEFT JOIN sprint_runs sr ON sr.id = cj.sprint_run_id
|
|
||||||
WHERE cj.user_id = ${userId}
|
WHERE cj.user_id = ${userId}
|
||||||
AND cj.product_id = ${productId}
|
AND cj.product_id = ${productId}
|
||||||
AND cj.status = 'QUEUED'
|
AND cj.status = 'QUEUED'
|
||||||
AND (
|
|
||||||
cj.kind IN ('IDEA_GRILL', 'IDEA_MAKE_PLAN', 'PLAN_CHAT')
|
|
||||||
OR (cj.kind IN ('TASK_IMPLEMENTATION', 'SPRINT_IMPLEMENTATION')
|
|
||||||
AND cj.sprint_run_id IS NOT NULL
|
|
||||||
AND sr.status IN ('QUEUED', 'RUNNING'))
|
|
||||||
)
|
|
||||||
ORDER BY cj.created_at ASC
|
ORDER BY cj.created_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
FOR UPDATE OF cj SKIP LOCKED
|
FOR UPDATE OF cj SKIP LOCKED
|
||||||
`
|
`
|
||||||
: await tx.$queryRaw<
|
: await tx.$queryRaw<Array<{ id: string; implementation_plan: string | null }>>`
|
||||||
Array<{ id: string; implementation_plan: string | null; sprint_run_id: string | null }>
|
SELECT cj.id, t.implementation_plan
|
||||||
>`
|
|
||||||
SELECT cj.id, t.implementation_plan, cj.sprint_run_id
|
|
||||||
FROM claude_jobs cj
|
FROM claude_jobs cj
|
||||||
LEFT JOIN tasks t ON t.id = cj.task_id
|
JOIN tasks t ON t.id = cj.task_id
|
||||||
LEFT JOIN sprint_runs sr ON sr.id = cj.sprint_run_id
|
|
||||||
WHERE cj.user_id = ${userId}
|
WHERE cj.user_id = ${userId}
|
||||||
AND cj.status = 'QUEUED'
|
AND cj.status = 'QUEUED'
|
||||||
AND (
|
|
||||||
cj.kind IN ('IDEA_GRILL', 'IDEA_MAKE_PLAN', 'PLAN_CHAT')
|
|
||||||
OR (cj.kind IN ('TASK_IMPLEMENTATION', 'SPRINT_IMPLEMENTATION')
|
|
||||||
AND cj.sprint_run_id IS NOT NULL
|
|
||||||
AND sr.status IN ('QUEUED', 'RUNNING'))
|
|
||||||
)
|
|
||||||
ORDER BY cj.created_at ASC
|
ORDER BY cj.created_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
FOR UPDATE OF cj SKIP LOCKED
|
FOR UPDATE OF cj SKIP LOCKED
|
||||||
|
|
@ -423,36 +234,21 @@ export async function tryClaimJob(
|
||||||
|
|
||||||
const jobId = found[0].id
|
const jobId = found[0].id
|
||||||
const snapshot = found[0].implementation_plan ?? ''
|
const snapshot = found[0].implementation_plan ?? ''
|
||||||
const sprintRunId = found[0].sprint_run_id
|
|
||||||
await tx.$executeRaw`
|
await tx.$executeRaw`
|
||||||
UPDATE claude_jobs
|
UPDATE claude_jobs
|
||||||
SET status = 'CLAIMED',
|
SET status = 'CLAIMED',
|
||||||
claimed_by_token_id = ${tokenId},
|
claimed_by_token_id = ${tokenId},
|
||||||
claimed_at = NOW(),
|
claimed_at = NOW(),
|
||||||
plan_snapshot = ${snapshot},
|
plan_snapshot = ${snapshot}
|
||||||
lease_until = NOW() + INTERVAL '5 minutes'
|
|
||||||
WHERE id = ${jobId}
|
WHERE id = ${jobId}
|
||||||
`
|
`
|
||||||
|
|
||||||
// SprintRun QUEUED → RUNNING bij eerste claim, in dezelfde tx zodat
|
|
||||||
// concurrent claims dezelfde overgang niet dubbel doen (UPDATE skipt
|
|
||||||
// rows die al RUNNING zijn).
|
|
||||||
if (sprintRunId) {
|
|
||||||
await tx.$executeRaw`
|
|
||||||
UPDATE sprint_runs
|
|
||||||
SET status = 'RUNNING',
|
|
||||||
started_at = COALESCE(started_at, NOW()),
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = ${sprintRunId} AND status = 'QUEUED'
|
|
||||||
`
|
|
||||||
}
|
|
||||||
return [{ id: jobId }]
|
return [{ id: jobId }]
|
||||||
})
|
})
|
||||||
|
|
||||||
return rows.length > 0 ? rows[0].id : null
|
return rows.length > 0 ? rows[0].id : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFullJobContext(jobId: string) {
|
async function getFullJobContext(jobId: string) {
|
||||||
const job = await prisma.claudeJob.findUnique({
|
const job = await prisma.claudeJob.findUnique({
|
||||||
where: { id: jobId },
|
where: { id: jobId },
|
||||||
include: {
|
include: {
|
||||||
|
|
@ -466,312 +262,24 @@ export async function getFullJobContext(jobId: string) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
idea: {
|
product: { select: { id: true, name: true, repo_url: true } },
|
||||||
include: {
|
|
||||||
pbi: { select: { id: true, code: true, title: true } },
|
|
||||||
secondary_products: {
|
|
||||||
include: { product: { select: { id: true, repo_url: true } } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
product: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
repo_url: true,
|
|
||||||
definition_of_done: true,
|
|
||||||
preferred_model: true,
|
|
||||||
thinking_budget_default: true,
|
|
||||||
preferred_permission_mode: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (!job) return null
|
if (!job) return null
|
||||||
|
|
||||||
// PBI-67: model + mode-selectie. Resolved op claim-moment; override-cascade
|
|
||||||
// task.requires_opus → job.requested_* → product.preferred_* → kind-default.
|
|
||||||
const config = resolveJobConfig(
|
|
||||||
{
|
|
||||||
kind: job.kind,
|
|
||||||
requested_model: job.requested_model,
|
|
||||||
requested_thinking_budget: job.requested_thinking_budget,
|
|
||||||
requested_permission_mode: job.requested_permission_mode,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
preferred_model: job.product.preferred_model,
|
|
||||||
thinking_budget_default: job.product.thinking_budget_default,
|
|
||||||
preferred_permission_mode: job.product.preferred_permission_mode,
|
|
||||||
},
|
|
||||||
job.task ? { requires_opus: job.task.requires_opus } : undefined,
|
|
||||||
)
|
|
||||||
|
|
||||||
// M12: branch on kind. Idea-jobs hebben geen task/story/pbi/sprint; ze
|
|
||||||
// hebben in plaats daarvan idea + embedded prompt_text.
|
|
||||||
if (job.kind === 'IDEA_GRILL' || job.kind === 'IDEA_MAKE_PLAN' || job.kind === 'IDEA_REVIEW_PLAN') {
|
|
||||||
if (!job.idea) return null
|
|
||||||
const { idea } = job
|
|
||||||
const { getIdeaPromptText } = await import('../lib/kind-prompts.js')
|
|
||||||
|
|
||||||
// Setup persistent product-worktrees for this idea-job (PBI-9).
|
|
||||||
// Primary product is gated by repo_url via resolveRepoRoot returning null.
|
|
||||||
// Secondary products from IdeaProduct[] need explicit repo_url filter.
|
|
||||||
const involvedProductIds: string[] = []
|
|
||||||
if (idea.product_id) involvedProductIds.push(idea.product_id)
|
|
||||||
for (const ip of idea.secondary_products ?? []) {
|
|
||||||
if (ip.product?.repo_url && !involvedProductIds.includes(ip.product_id)) {
|
|
||||||
involvedProductIds.push(ip.product_id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// PBI-49 P1: rollback the claim if worktree setup fails so the job
|
|
||||||
// doesn't hang in CLAIMED until the 30-min stale-reset, and any partial
|
|
||||||
// locks are released. Mirrors attachWorktreeToJob's task-pad behaviour.
|
|
||||||
let worktrees: Array<{ productId: string; worktreePath: string }> = []
|
|
||||||
if (involvedProductIds.length > 0) {
|
|
||||||
try {
|
|
||||||
worktrees = await setupProductWorktrees(
|
|
||||||
job.id,
|
|
||||||
involvedProductIds,
|
|
||||||
(pid) => resolveRepoRoot(pid),
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(
|
|
||||||
`[wait-for-job] product-worktree setup failed for idea-job ${job.id}; rolling back claim:`,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
await releaseLocksOnTerminal(job.id)
|
|
||||||
await rollbackClaim(job.id)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
job_id: job.id,
|
|
||||||
kind: job.kind,
|
|
||||||
status: 'claimed',
|
|
||||||
config,
|
|
||||||
idea: {
|
|
||||||
id: idea.id,
|
|
||||||
code: idea.code,
|
|
||||||
title: idea.title,
|
|
||||||
description: idea.description,
|
|
||||||
grill_md: idea.grill_md,
|
|
||||||
plan_md: idea.plan_md,
|
|
||||||
status: idea.status,
|
|
||||||
product_id: idea.product_id,
|
|
||||||
},
|
|
||||||
product: {
|
|
||||||
id: job.product.id,
|
|
||||||
name: job.product.name,
|
|
||||||
repo_url: job.product.repo_url,
|
|
||||||
definition_of_done: job.product.definition_of_done,
|
|
||||||
},
|
|
||||||
pbi: idea.pbi,
|
|
||||||
repo_url: job.product.repo_url,
|
|
||||||
prompt_text: getIdeaPromptText(job.kind),
|
|
||||||
branch_suggestion: `feat/idea-${idea.code.toLowerCase()}-${(() => {
|
|
||||||
if (job.kind === 'IDEA_GRILL') return 'grill'
|
|
||||||
if (job.kind === 'IDEA_REVIEW_PLAN') return 'review'
|
|
||||||
return 'plan'
|
|
||||||
})()}`,
|
|
||||||
product_worktrees: worktrees.map((w) => ({
|
|
||||||
product_id: w.productId,
|
|
||||||
worktree_path: w.worktreePath,
|
|
||||||
})),
|
|
||||||
primary_worktree_path: worktrees[0]?.worktreePath ?? null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PBI-50: SPRINT_IMPLEMENTATION — single-session sprint runner.
|
|
||||||
// Eén ClaudeJob per SprintRun handelt sequentieel alle TO_DO-tasks af.
|
|
||||||
// Bij claim: maak frozen scope-snapshot via SprintTaskExecution-rows,
|
|
||||||
// resolve worktree (verse branch of hergebruikt via previous_run_id),
|
|
||||||
// capture base_sha. Worker werkt uitsluitend op deze frozen snapshot.
|
|
||||||
if (job.kind === 'SPRINT_IMPLEMENTATION') {
|
|
||||||
if (!job.sprint_run_id) {
|
|
||||||
await rollbackClaim(job.id)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const sprintRun = await prisma.sprintRun.findUnique({
|
|
||||||
where: { id: job.sprint_run_id },
|
|
||||||
include: {
|
|
||||||
sprint: {
|
|
||||||
include: {
|
|
||||||
product: true,
|
|
||||||
stories: {
|
|
||||||
where: { status: { not: 'DONE' } },
|
|
||||||
include: {
|
|
||||||
pbi: {
|
|
||||||
select: { id: true, code: true, title: true, priority: true, sort_order: true, status: true },
|
|
||||||
},
|
|
||||||
tasks: {
|
|
||||||
where: { status: 'TO_DO' },
|
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (!sprintRun) {
|
|
||||||
await rollbackClaim(job.id)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const repoRoot = await resolveRepoRoot(sprintRun.sprint.product_id)
|
|
||||||
if (!repoRoot) {
|
|
||||||
await rollbackClaim(job.id)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Branch resolution: previous_run_id + branch → reuse; anders verse.
|
|
||||||
const isResume = !!(sprintRun.previous_run_id && sprintRun.branch)
|
|
||||||
const branchName = isResume
|
|
||||||
? sprintRun.branch!
|
|
||||||
: `feat/sprint-${job.sprint_run_id.slice(-8)}`
|
|
||||||
|
|
||||||
let worktreePath: string
|
|
||||||
let baseSha: string
|
|
||||||
try {
|
|
||||||
const wt = await createWorktreeForJob({
|
|
||||||
repoRoot,
|
|
||||||
jobId: job.id,
|
|
||||||
branchName,
|
|
||||||
reuseBranch: isResume,
|
|
||||||
})
|
|
||||||
worktreePath = wt.worktreePath
|
|
||||||
|
|
||||||
const { stdout: headSha } = await execFileP('git', ['rev-parse', 'HEAD'], {
|
|
||||||
cwd: worktreePath,
|
|
||||||
})
|
|
||||||
baseSha = headSha.trim()
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[wait-for-job] sprint-worktree setup failed for ${job.id}:`, err)
|
|
||||||
await rollbackClaim(job.id)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verzamel ordered tasks in flat list, behoud volgorde
|
|
||||||
const orderedTasks = sprintRun.sprint.stories.flatMap((s) =>
|
|
||||||
s.tasks.map((t) => ({ ...t, story_pbi_id: s.pbi.id })),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Persist branch + base_sha + scope-snapshot in één transactie
|
|
||||||
await prisma.$transaction([
|
|
||||||
prisma.claudeJob.update({
|
|
||||||
where: { id: job.id },
|
|
||||||
data: { branch: branchName, base_sha: baseSha },
|
|
||||||
}),
|
|
||||||
prisma.sprintTaskExecution.createMany({
|
|
||||||
data: orderedTasks.map((t, idx) => ({
|
|
||||||
sprint_job_id: job.id,
|
|
||||||
task_id: t.id,
|
|
||||||
order: idx,
|
|
||||||
plan_snapshot: t.implementation_plan ?? '',
|
|
||||||
verify_required_snapshot: t.verify_required,
|
|
||||||
verify_only_snapshot: t.verify_only,
|
|
||||||
base_sha: idx === 0 ? baseSha : null,
|
|
||||||
status: 'PENDING' as const,
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
prisma.sprintRun.update({
|
|
||||||
where: { id: job.sprint_run_id },
|
|
||||||
data: { branch: branchName },
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
// Lookup execution_ids in volgorde voor de response
|
|
||||||
const executions = await prisma.sprintTaskExecution.findMany({
|
|
||||||
where: { sprint_job_id: job.id },
|
|
||||||
orderBy: { order: 'asc' },
|
|
||||||
select: { id: true, task_id: true, order: true, base_sha: true },
|
|
||||||
})
|
|
||||||
const execIdByTaskId = new Map(executions.map((e) => [e.task_id, e.id]))
|
|
||||||
|
|
||||||
// Dedupe PBIs uit de stories (één PBI kan meerdere stories hebben)
|
|
||||||
const pbiMap = new Map<string, typeof sprintRun.sprint.stories[number]['pbi']>()
|
|
||||||
for (const s of sprintRun.sprint.stories) pbiMap.set(s.pbi.id, s.pbi)
|
|
||||||
|
|
||||||
return {
|
|
||||||
job_id: job.id,
|
|
||||||
kind: job.kind,
|
|
||||||
status: 'claimed',
|
|
||||||
config,
|
|
||||||
sprint: {
|
|
||||||
id: sprintRun.sprint.id,
|
|
||||||
sprint_goal: sprintRun.sprint.sprint_goal,
|
|
||||||
status: sprintRun.sprint.status,
|
|
||||||
},
|
|
||||||
sprint_run: {
|
|
||||||
id: sprintRun.id,
|
|
||||||
pr_strategy: sprintRun.pr_strategy,
|
|
||||||
branch: branchName,
|
|
||||||
previous_run_id: sprintRun.previous_run_id,
|
|
||||||
},
|
|
||||||
product: {
|
|
||||||
id: sprintRun.sprint.product.id,
|
|
||||||
name: sprintRun.sprint.product.name,
|
|
||||||
repo_url: sprintRun.sprint.product.repo_url,
|
|
||||||
definition_of_done: sprintRun.sprint.product.definition_of_done,
|
|
||||||
auto_pr: sprintRun.sprint.product.auto_pr,
|
|
||||||
},
|
|
||||||
pbis: Array.from(pbiMap.values()).map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
code: p.code,
|
|
||||||
title: p.title,
|
|
||||||
priority: p.priority,
|
|
||||||
sort_order: p.sort_order,
|
|
||||||
status: p.status,
|
|
||||||
})),
|
|
||||||
stories: sprintRun.sprint.stories.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
code: s.code,
|
|
||||||
title: s.title,
|
|
||||||
pbi_id: s.pbi_id,
|
|
||||||
priority: s.priority,
|
|
||||||
sort_order: s.sort_order,
|
|
||||||
status: s.status,
|
|
||||||
})),
|
|
||||||
task_executions: orderedTasks.map((t, idx) => ({
|
|
||||||
execution_id: execIdByTaskId.get(t.id)!,
|
|
||||||
task_id: t.id,
|
|
||||||
code: t.code,
|
|
||||||
title: t.title,
|
|
||||||
story_id: t.story_id,
|
|
||||||
order: idx,
|
|
||||||
plan_snapshot: t.implementation_plan ?? '',
|
|
||||||
verify_required: t.verify_required,
|
|
||||||
verify_only: t.verify_only,
|
|
||||||
base_sha: idx === 0 ? baseSha : null,
|
|
||||||
})),
|
|
||||||
worktree_path: worktreePath,
|
|
||||||
branch_name: branchName,
|
|
||||||
repo_url: sprintRun.sprint.product.repo_url,
|
|
||||||
base_sha: baseSha,
|
|
||||||
heartbeat_interval_seconds: 60,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TASK_IMPLEMENTATION (default) — bestaande gedrag onaangetast.
|
|
||||||
const { task } = job
|
const { task } = job
|
||||||
if (!task) return null
|
|
||||||
const { story } = task
|
const { story } = task
|
||||||
const { pbi, sprint } = story
|
const { pbi, sprint } = story
|
||||||
|
|
||||||
return {
|
return {
|
||||||
job_id: job.id,
|
job_id: job.id,
|
||||||
kind: job.kind,
|
|
||||||
status: 'claimed',
|
status: 'claimed',
|
||||||
config,
|
|
||||||
task: {
|
task: {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
title: task.title,
|
title: task.title,
|
||||||
description: task.description,
|
description: task.description,
|
||||||
implementation_plan: task.implementation_plan,
|
implementation_plan: task.implementation_plan,
|
||||||
priority: task.priority,
|
priority: task.priority,
|
||||||
repo_url: task.repo_url,
|
|
||||||
},
|
},
|
||||||
story: {
|
story: {
|
||||||
id: story.id,
|
id: story.id,
|
||||||
|
|
@ -826,23 +334,9 @@ export function registerWaitForJobTool(server: McpServer) {
|
||||||
if (jobId) {
|
if (jobId) {
|
||||||
const ctx = await getFullJobContext(jobId)
|
const ctx = await getFullJobContext(jobId)
|
||||||
if (!ctx) return toolError('Job claimed but context fetch failed')
|
if (!ctx) return toolError('Job claimed but context fetch failed')
|
||||||
// M12: idee-jobs hebben geen worktree nodig — de agent werkt in de
|
const wt = await attachWorktreeToJob(ctx.product.id, jobId, ctx.story.id)
|
||||||
// bestaande user-repo (geen branch/commit-flow). Alleen task-jobs
|
if ('error' in wt) return toolError(wt.error)
|
||||||
// krijgen een worktree.
|
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
|
||||||
if (ctx.kind === 'TASK_IMPLEMENTATION') {
|
|
||||||
if (!ctx.story || !ctx.task) {
|
|
||||||
return toolError('Task-job claimed but story/task context is incomplete')
|
|
||||||
}
|
|
||||||
const wt = await attachWorktreeToJob(
|
|
||||||
ctx.product.id,
|
|
||||||
jobId,
|
|
||||||
ctx.story.id,
|
|
||||||
ctx.task.repo_url,
|
|
||||||
)
|
|
||||||
if ('error' in wt) return toolError(wt.error)
|
|
||||||
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
|
|
||||||
}
|
|
||||||
return toolJson(ctx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. No job available — LISTEN and poll until timeout
|
// 3. No job available — LISTEN and poll until timeout
|
||||||
|
|
@ -878,20 +372,9 @@ export function registerWaitForJobTool(server: McpServer) {
|
||||||
if (jobId) {
|
if (jobId) {
|
||||||
const ctx = await getFullJobContext(jobId)
|
const ctx = await getFullJobContext(jobId)
|
||||||
if (!ctx) return toolError('Job claimed but context fetch failed')
|
if (!ctx) return toolError('Job claimed but context fetch failed')
|
||||||
if (ctx.kind === 'TASK_IMPLEMENTATION') {
|
const wt = await attachWorktreeToJob(ctx.product.id, jobId, ctx.story.id)
|
||||||
if (!ctx.story || !ctx.task) {
|
if ('error' in wt) return toolError(wt.error)
|
||||||
return toolError('Task-job claimed but story/task context is incomplete')
|
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
|
||||||
}
|
|
||||||
const wt = await attachWorktreeToJob(
|
|
||||||
ctx.product.id,
|
|
||||||
jobId,
|
|
||||||
ctx.story.id,
|
|
||||||
ctx.task.repo_url,
|
|
||||||
)
|
|
||||||
if ('error' in wt) return toolError(wt.error)
|
|
||||||
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
|
|
||||||
}
|
|
||||||
return toolJson(ctx)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
// 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(),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue