Merge pull request #5 from madhura68/feat/job-slv6i9xm
M13: Veilige Claude-agent-workflow (MCP-server-side)
This commit is contained in:
commit
994f28f103
23 changed files with 2108 additions and 74 deletions
61
CLAUDE.md
Normal file
61
CLAUDE.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# CLAUDE.md — scrum4me-mcp
|
||||
|
||||
MCP server that exposes the Scrum4Me dev-flow as native tools for Claude Code.
|
||||
|
||||
## Agent worktree-flow
|
||||
|
||||
`wait_for_job` creates an isolated git worktree per job so agent changes never touch the user's main checkout.
|
||||
|
||||
### How it works
|
||||
|
||||
1. On successful claim, `wait_for_job` calls `createWorktreeForJob`:
|
||||
- Worktree directory: `SCRUM4ME_AGENT_WORKTREE_DIR/<job-id>` (default: `~/.scrum4me-agent-worktrees/<job-id>`)
|
||||
- Branch: `feat/job-<last-8-chars-of-job-id>` (timestamp-suffixed if branch already exists)
|
||||
- Base: `origin/main`
|
||||
2. Tool response includes `worktree_path` and `branch_name`.
|
||||
3. **Work exclusively in `worktree_path`** — all file edits and commits go there.
|
||||
4. On `update_job_status(done|failed)`, `removeWorktreeForJob` runs automatically:
|
||||
- `keepBranch=true` if `done` and a `branch` was reported (agent pushed)
|
||||
- `keepBranch=false` otherwise (branch deleted with worktree)
|
||||
|
||||
### Required configuration
|
||||
|
||||
Set env var per product:
|
||||
|
||||
```
|
||||
SCRUM4ME_REPO_ROOT_<productId>=/absolute/path/to/local/clone
|
||||
```
|
||||
|
||||
Or add to `~/.scrum4me-agent-config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"repoRoots": {
|
||||
"<productId>": "/absolute/path/to/local/clone"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If no repo root is found, `wait_for_job` rolls the claim back to QUEUED and returns an error.
|
||||
|
||||
## 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 }`.
|
||||
|
||||
## Key source files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `src/git/worktree.ts` | `createWorktreeForJob` + `removeWorktreeForJob` |
|
||||
| `src/tools/wait-for-job.ts` | `resolveRepoRoot`, `rollbackClaim`, `attachWorktreeToJob` |
|
||||
| `src/tools/update-job-status.ts` | `cleanupWorktreeForTerminalStatus` |
|
||||
| `src/tools/cleanup-my-worktrees.ts` | `cleanup_my_worktrees` tool — scans + removes stale worktrees |
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
npm test # vitest run
|
||||
npm run typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
All worktree helpers have unit tests under `__tests__/git/worktree.test.ts`, `__tests__/wait-for-job-worktree.test.ts`, and `__tests__/update-job-status-worktree.test.ts`.
|
||||
44
README.md
44
README.md
|
|
@ -25,8 +25,8 @@ activity and create todos via native tool calls instead of curl.
|
|||
| `get_question_answer` | Fetch the current status + answer of a previously-asked question | n/a |
|
||||
| `list_open_questions` | List own open/answered questions, most recent first (max 50) | n/a |
|
||||
| `cancel_question` | Cancel an own open question (asker-only) | no |
|
||||
| `wait_for_job` | Block until a QUEUED ClaudeJob is available, claim it atomically, return full task context with frozen `plan_snapshot` | no |
|
||||
| `update_job_status` | Report job transition to `running`, `done`, or `failed`; triggers SSE event to UI | 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 |
|
||||
| `verify_task_against_plan` | Compare frozen `plan_snapshot` against current plan + story logs + commits; returns per-AC ✓/✗/? heuristic and drift-score | yes (read-only) |
|
||||
|
||||
Demo accounts may read but writes return `PERMISSION_DENIED`.
|
||||
|
|
@ -116,6 +116,46 @@ Add to `~/.claude/mcp_servers.json`:
|
|||
Restart Claude Code. The 9 tools and 1 prompt show up under the
|
||||
`scrum4me` namespace.
|
||||
|
||||
## Agent worktree-flow
|
||||
|
||||
When a job is claimed via `wait_for_job`, the MCP server automatically creates an isolated git worktree for the job under `~/.scrum4me-agent-worktrees/<job-id>/` with a dedicated branch `feat/job-<suffix>`. The tool response includes:
|
||||
|
||||
- `worktree_path` — absolute path to the worktree directory
|
||||
- `branch_name` — the branch checked out in that worktree
|
||||
|
||||
**The agent must work exclusively inside `worktree_path`**. All file edits and commits belong there; the user's main checkout stays clean.
|
||||
|
||||
When `update_job_status` is called with `done` or `failed`, the worktree is automatically removed. If the agent reported a `branch` (indicating a push), the local branch is preserved on `done`; otherwise it is deleted together with the worktree directory.
|
||||
|
||||
### Required env vars
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `SCRUM4ME_AGENT_WORKTREE_DIR` | Override the default worktree parent directory (default: `~/.scrum4me-agent-worktrees`) |
|
||||
| `SCRUM4ME_REPO_ROOT_<productId>` | Absolute path to the local git clone for that product, e.g. `SCRUM4ME_REPO_ROOT_cmohrysyj0000rd17clnjy4tc=/home/user/projects/scrum4me` |
|
||||
|
||||
Alternatively, configure repo roots in `~/.scrum4me-agent-config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"repoRoots": {
|
||||
"<productId>": "/home/user/projects/scrum4me"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If no repo root is configured for the product, `wait_for_job` rolls back the claim to `QUEUED` and returns an error.
|
||||
|
||||
### Smoke-test checklist
|
||||
|
||||
After starting the server on the feature branch:
|
||||
|
||||
1. Enqueue a job in Scrum4Me (Solo Paneel → Start agent).
|
||||
2. Call `wait_for_job` — response must contain `worktree_path` and `branch_name`.
|
||||
3. In the **main checkout**: `git worktree list` → the agent worktree appears.
|
||||
4. In the **main checkout**: `git status` → clean (no agent changes).
|
||||
5. Call `update_job_status(done)` → worktree directory disappears.
|
||||
|
||||
## Schema sync
|
||||
|
||||
The Prisma schema is the source of truth in the upstream Scrum4Me
|
||||
|
|
|
|||
171
__tests__/cleanup-my-worktrees.test.ts
Normal file
171
__tests__/cleanup-my-worktrees.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
|
||||
vi.mock('../src/prisma.js', () => ({
|
||||
prisma: {
|
||||
claudeJob: { findMany: 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() }
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readdir: 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 * as fsPromises from 'node:fs/promises'
|
||||
import { cleanupWorktrees, listWorktreeJobIds, getWorktreeParent } from '../src/tools/cleanup-my-worktrees.js'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
claudeJob: { findMany: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
const mockRemove = removeWorktreeForJob as ReturnType<typeof vi.fn>
|
||||
const mockResolve = resolveRepoRoot as ReturnType<typeof vi.fn>
|
||||
const mockReaddir = fsPromises.readdir as ReturnType<typeof vi.fn>
|
||||
|
||||
const REPO_ROOT = '/repos/my-project'
|
||||
const USER_ID = 'user-1'
|
||||
const PRODUCT_ID = 'product-1'
|
||||
const WORKTREE_PARENT = '/home/user/.scrum4me-agent-worktrees'
|
||||
|
||||
function makeDirent(name: string, isDir = true) {
|
||||
return { name, isDirectory: () => isDir } as unknown as import('node:fs').Dirent
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockResolve.mockResolvedValue(REPO_ROOT)
|
||||
mockRemove.mockResolvedValue({ removed: true })
|
||||
})
|
||||
|
||||
describe('getWorktreeParent', () => {
|
||||
it('uses SCRUM4ME_AGENT_WORKTREE_DIR env var when set', async () => {
|
||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/custom/dir'
|
||||
expect(await getWorktreeParent()).toBe('/custom/dir')
|
||||
delete process.env.SCRUM4ME_AGENT_WORKTREE_DIR
|
||||
})
|
||||
|
||||
it('defaults to ~/.scrum4me-agent-worktrees', async () => {
|
||||
delete process.env.SCRUM4ME_AGENT_WORKTREE_DIR
|
||||
expect(await getWorktreeParent()).toBe(path.join(os.homedir(), '.scrum4me-agent-worktrees'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('listWorktreeJobIds', () => {
|
||||
it('returns directory names from the worktree parent', async () => {
|
||||
mockReaddir.mockResolvedValue([makeDirent('job-aaa'), makeDirent('job-bbb'), makeDirent('file.txt', false)])
|
||||
const ids = await listWorktreeJobIds(WORKTREE_PARENT)
|
||||
expect(ids).toEqual(['job-aaa', 'job-bbb'])
|
||||
})
|
||||
|
||||
it('returns empty array when parent does not exist', async () => {
|
||||
mockReaddir.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
|
||||
expect(await listWorktreeJobIds(WORKTREE_PARENT)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanupWorktrees', () => {
|
||||
it('removes worktrees for DONE/FAILED/CANCELLED jobs', async () => {
|
||||
mockReaddir.mockResolvedValue([
|
||||
makeDirent('job-done'),
|
||||
makeDirent('job-failed'),
|
||||
makeDirent('job-cancelled'),
|
||||
])
|
||||
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
||||
{ id: 'job-done', status: 'DONE', product_id: PRODUCT_ID, branch: 'feat/job-done' },
|
||||
{ id: 'job-failed', status: 'FAILED', product_id: PRODUCT_ID, branch: null },
|
||||
{ id: 'job-cancelled', status: 'CANCELLED', product_id: PRODUCT_ID, branch: null },
|
||||
])
|
||||
|
||||
const result = await cleanupWorktrees(WORKTREE_PARENT, USER_ID)
|
||||
|
||||
expect(result.removed).toEqual(expect.arrayContaining(['job-done', 'job-failed', 'job-cancelled']))
|
||||
expect(result.kept).toEqual([])
|
||||
expect(result.skipped).toEqual([])
|
||||
expect(mockRemove).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('keeps worktrees for QUEUED/CLAIMED/RUNNING jobs', async () => {
|
||||
mockReaddir.mockResolvedValue([
|
||||
makeDirent('job-queued'),
|
||||
makeDirent('job-claimed'),
|
||||
makeDirent('job-running'),
|
||||
])
|
||||
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
||||
{ id: 'job-queued', status: 'QUEUED', product_id: PRODUCT_ID, branch: null },
|
||||
{ id: 'job-claimed', status: 'CLAIMED', product_id: PRODUCT_ID, branch: null },
|
||||
{ id: 'job-running', status: 'RUNNING', product_id: PRODUCT_ID, branch: null },
|
||||
])
|
||||
|
||||
const result = await cleanupWorktrees(WORKTREE_PARENT, USER_ID)
|
||||
|
||||
expect(result.kept).toEqual(expect.arrayContaining(['job-queued', 'job-claimed', 'job-running']))
|
||||
expect(result.removed).toEqual([])
|
||||
expect(mockRemove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls removeWorktreeForJob with keepBranch=true for DONE with branch', async () => {
|
||||
mockReaddir.mockResolvedValue([makeDirent('job-done')])
|
||||
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
||||
{ id: 'job-done', status: 'DONE', product_id: PRODUCT_ID, branch: 'feat/job-done' },
|
||||
])
|
||||
|
||||
await cleanupWorktrees(WORKTREE_PARENT, USER_ID)
|
||||
|
||||
expect(mockRemove).toHaveBeenCalledWith({ repoRoot: REPO_ROOT, jobId: 'job-done', keepBranch: true })
|
||||
})
|
||||
|
||||
it('calls removeWorktreeForJob with keepBranch=false for FAILED jobs', async () => {
|
||||
mockReaddir.mockResolvedValue([makeDirent('job-failed')])
|
||||
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
||||
{ id: 'job-failed', status: 'FAILED', product_id: PRODUCT_ID, branch: null },
|
||||
])
|
||||
|
||||
await cleanupWorktrees(WORKTREE_PARENT, USER_ID)
|
||||
|
||||
expect(mockRemove).toHaveBeenCalledWith({ repoRoot: REPO_ROOT, jobId: 'job-failed', keepBranch: false })
|
||||
})
|
||||
|
||||
it('skips orphan worktrees (no DB record)', async () => {
|
||||
mockReaddir.mockResolvedValue([makeDirent('job-orphan')])
|
||||
mockPrisma.claudeJob.findMany.mockResolvedValue([])
|
||||
|
||||
const result = await cleanupWorktrees(WORKTREE_PARENT, USER_ID)
|
||||
|
||||
expect(result.skipped).toContain('job-orphan')
|
||||
expect(mockRemove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips worktrees when no repoRoot is configured', async () => {
|
||||
mockReaddir.mockResolvedValue([makeDirent('job-norepo')])
|
||||
mockPrisma.claudeJob.findMany.mockResolvedValue([
|
||||
{ id: 'job-norepo', status: 'FAILED', product_id: 'unknown-product', branch: null },
|
||||
])
|
||||
mockResolve.mockResolvedValue(null)
|
||||
|
||||
const result = await cleanupWorktrees(WORKTREE_PARENT, USER_ID)
|
||||
|
||||
expect(result.skipped).toContain('job-norepo')
|
||||
expect(mockRemove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns empty result when worktree parent is empty', async () => {
|
||||
mockReaddir.mockResolvedValue([])
|
||||
|
||||
const result = await cleanupWorktrees(WORKTREE_PARENT, USER_ID)
|
||||
|
||||
expect(result).toEqual({ removed: [], kept: [], skipped: [] })
|
||||
expect(mockPrisma.claudeJob.findMany).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
69
__tests__/git/pr.test.ts
Normal file
69
__tests__/git/pr.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { mockExecFile } = vi.hoisted(() => ({ mockExecFile: vi.fn() }))
|
||||
|
||||
vi.mock('node:child_process', () => ({ execFile: mockExecFile }))
|
||||
vi.mock('node:util', () => ({
|
||||
promisify:
|
||||
(fn: (...args: unknown[]) => void) =>
|
||||
(...args: unknown[]) =>
|
||||
new Promise((resolve, reject) =>
|
||||
fn(...args, (err: Error | null, result: unknown) => (err ? reject(err) : resolve(result))),
|
||||
),
|
||||
}))
|
||||
|
||||
import { createPullRequest } from '../../src/git/pr.js'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('createPullRequest', () => {
|
||||
it('returns PR URL when gh succeeds', async () => {
|
||||
mockExecFile.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: (err: null, res: { stdout: string; stderr: string }) => void) =>
|
||||
cb(null, { stdout: 'Creating pull request...\nhttps://github.com/org/repo/pull/42\n', stderr: '' }),
|
||||
)
|
||||
|
||||
const result = await createPullRequest({
|
||||
worktreePath: '/worktrees/job-abc',
|
||||
branchName: 'feat/job-abc',
|
||||
title: 'SCRUM-1: Add feature',
|
||||
body: 'Summary\n\n---\n\n*Auto-generated*',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ url: 'https://github.com/org/repo/pull/42' })
|
||||
})
|
||||
|
||||
it('returns error when gh is not installed (ENOENT)', async () => {
|
||||
mockExecFile.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) =>
|
||||
cb(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' })),
|
||||
)
|
||||
|
||||
const result = await createPullRequest({
|
||||
worktreePath: '/worktrees/job-abc',
|
||||
branchName: 'feat/job-abc',
|
||||
title: 'My PR',
|
||||
body: 'Body',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('gh CLI not found') })
|
||||
})
|
||||
|
||||
it('returns error on generic gh failure', async () => {
|
||||
mockExecFile.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) =>
|
||||
cb(new Error('authentication required')),
|
||||
)
|
||||
|
||||
const result = await createPullRequest({
|
||||
worktreePath: '/worktrees/job-abc',
|
||||
branchName: 'feat/job-abc',
|
||||
title: 'My PR',
|
||||
body: 'Body',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('gh pr create failed') })
|
||||
})
|
||||
})
|
||||
93
__tests__/git/push.test.ts
Normal file
93
__tests__/git/push.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn(),
|
||||
}))
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import { pushBranchForJob } from '../../src/git/push.js'
|
||||
|
||||
// promisify(execFile) will call execFile(cmd, args, opts, cb) internally
|
||||
type ExecCallback = (err: Error | null, result?: { stdout: string; stderr: string }) => void
|
||||
const mockExec = execFile as unknown as ReturnType<typeof vi.fn>
|
||||
|
||||
const SHA_HEAD = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||
const SHA_BASE = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('pushBranchForJob', () => {
|
||||
it('returns pushed=true with remoteRef on successful push', async () => {
|
||||
mockExec.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: ExecCallback) => {
|
||||
if (args.includes('HEAD')) return cb(null, { stdout: `${SHA_HEAD}\n`, stderr: '' })
|
||||
if (args.includes('origin/main')) return cb(null, { stdout: `${SHA_BASE}\n`, stderr: '' })
|
||||
// git push -u origin <branch>
|
||||
return cb(null, { stdout: '', stderr: '' })
|
||||
})
|
||||
|
||||
const result = await pushBranchForJob({ worktreePath: '/wt/job-abc', branchName: 'feat/job-abc' })
|
||||
|
||||
expect(result).toEqual({ pushed: true, remoteRef: 'refs/heads/feat/job-abc' })
|
||||
})
|
||||
|
||||
it('returns no-changes when HEAD equals origin/main', async () => {
|
||||
mockExec.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: ExecCallback) => {
|
||||
if (args.includes('HEAD') || args.includes('origin/main')) {
|
||||
return cb(null, { stdout: `${SHA_BASE}\n`, stderr: '' })
|
||||
}
|
||||
return cb(null, { stdout: '', stderr: '' })
|
||||
})
|
||||
|
||||
const result = await pushBranchForJob({ worktreePath: '/wt/job-abc', branchName: 'feat/job-abc' })
|
||||
|
||||
expect(result).toEqual({ pushed: false, reason: 'no-changes', stderr: '' })
|
||||
})
|
||||
|
||||
it('returns no-credentials when push fails with Authentication failed', async () => {
|
||||
const authError = Object.assign(new Error('git push failed'), {
|
||||
stderr: 'fatal: Authentication failed for https://github.com/...',
|
||||
})
|
||||
mockExec.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: ExecCallback) => {
|
||||
if (args.includes('HEAD')) return cb(null, { stdout: `${SHA_HEAD}\n`, stderr: '' })
|
||||
if (args.includes('origin/main')) return cb(null, { stdout: `${SHA_BASE}\n`, stderr: '' })
|
||||
return cb(authError)
|
||||
})
|
||||
|
||||
const result = await pushBranchForJob({ worktreePath: '/wt/job-abc', branchName: 'feat/job-abc' })
|
||||
|
||||
expect(result).toMatchObject({ pushed: false, reason: 'no-credentials' })
|
||||
expect((result as { stderr: string }).stderr).toContain('Authentication failed')
|
||||
})
|
||||
|
||||
it('returns conflict when push is rejected (non-fast-forward)', async () => {
|
||||
const conflictError = Object.assign(new Error('git push failed'), {
|
||||
stderr: '! [rejected] feat/job-abc -> feat/job-abc (non-fast-forward)',
|
||||
})
|
||||
mockExec.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: ExecCallback) => {
|
||||
if (args.includes('HEAD')) return cb(null, { stdout: `${SHA_HEAD}\n`, stderr: '' })
|
||||
if (args.includes('origin/main')) return cb(null, { stdout: `${SHA_BASE}\n`, stderr: '' })
|
||||
return cb(conflictError)
|
||||
})
|
||||
|
||||
const result = await pushBranchForJob({ worktreePath: '/wt/job-abc', branchName: 'feat/job-abc' })
|
||||
|
||||
expect(result).toMatchObject({ pushed: false, reason: 'conflict' })
|
||||
})
|
||||
|
||||
it('returns unknown for unrecognised push errors', async () => {
|
||||
const unknownError = Object.assign(new Error('git push failed'), {
|
||||
stderr: 'error: some unexpected thing happened',
|
||||
})
|
||||
mockExec.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: ExecCallback) => {
|
||||
if (args.includes('HEAD')) return cb(null, { stdout: `${SHA_HEAD}\n`, stderr: '' })
|
||||
if (args.includes('origin/main')) return cb(null, { stdout: `${SHA_BASE}\n`, stderr: '' })
|
||||
return cb(unknownError)
|
||||
})
|
||||
|
||||
const result = await pushBranchForJob({ worktreePath: '/wt/job-abc', branchName: 'feat/job-abc' })
|
||||
|
||||
expect(result).toMatchObject({ pushed: false, reason: 'unknown' })
|
||||
})
|
||||
})
|
||||
204
__tests__/git/worktree.test.ts
Normal file
204
__tests__/git/worktree.test.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import * as os from 'node:os'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import { createWorktreeForJob, removeWorktreeForJob } from '../../src/git/worktree.js'
|
||||
|
||||
const exec = promisify(execFile)
|
||||
|
||||
async function git(args: string[], cwd: string) {
|
||||
return exec('git', args, { cwd })
|
||||
}
|
||||
|
||||
async function setupRepo(): Promise<{ repoDir: string; originDir: string }> {
|
||||
const base = os.tmpdir()
|
||||
const originDir = await fs.mkdtemp(path.join(base, 'scrum4me-origin-'))
|
||||
const repoDir = await fs.mkdtemp(path.join(base, 'scrum4me-repo-'))
|
||||
|
||||
await git(['init', '--bare'], originDir)
|
||||
await git(['init'], repoDir)
|
||||
await git(['config', 'user.email', 'test@test.com'], repoDir)
|
||||
await git(['config', 'user.name', 'Test'], repoDir)
|
||||
await git(['remote', 'add', 'origin', originDir], repoDir)
|
||||
await fs.writeFile(path.join(repoDir, 'README.md'), '# test')
|
||||
await git(['add', '.'], repoDir)
|
||||
await git(['commit', '-m', 'init'], repoDir)
|
||||
await git(['push', 'origin', 'HEAD:main'], repoDir)
|
||||
|
||||
return { repoDir, originDir }
|
||||
}
|
||||
|
||||
describe('createWorktreeForJob', () => {
|
||||
const tmpDirs: string[] = []
|
||||
const originalWorktreeDir = process.env.SCRUM4ME_AGENT_WORKTREE_DIR
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalWorktreeDir === undefined) {
|
||||
delete process.env.SCRUM4ME_AGENT_WORKTREE_DIR
|
||||
} else {
|
||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = originalWorktreeDir
|
||||
}
|
||||
for (const dir of tmpDirs.splice(0)) {
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function makeWorktreeParent(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'scrum4me-worktrees-'))
|
||||
tmpDirs.push(dir)
|
||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = dir
|
||||
return dir
|
||||
}
|
||||
|
||||
it('creates a worktree directory with the correct branch as HEAD', async () => {
|
||||
const { repoDir, originDir } = await setupRepo()
|
||||
tmpDirs.push(repoDir, originDir)
|
||||
const wtParent = await makeWorktreeParent()
|
||||
|
||||
const result = await createWorktreeForJob({
|
||||
repoRoot: repoDir,
|
||||
jobId: 'job-001',
|
||||
branchName: 'feat/job-001',
|
||||
baseRef: 'origin/main',
|
||||
})
|
||||
|
||||
const stat = await fs.stat(result.worktreePath)
|
||||
expect(stat.isDirectory()).toBe(true)
|
||||
|
||||
const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath)
|
||||
expect(stdout.trim()).toBe('feat/job-001')
|
||||
|
||||
expect(result.branchName).toBe('feat/job-001')
|
||||
expect(result.worktreePath).toBe(path.join(wtParent, 'job-001'))
|
||||
})
|
||||
|
||||
it('suffixes branch name with timestamp when branch already exists', async () => {
|
||||
const { repoDir, originDir } = await setupRepo()
|
||||
tmpDirs.push(repoDir, originDir)
|
||||
await makeWorktreeParent()
|
||||
|
||||
await git(['branch', 'feat/job-002'], repoDir)
|
||||
|
||||
const result = await createWorktreeForJob({
|
||||
repoRoot: repoDir,
|
||||
jobId: 'job-002',
|
||||
branchName: 'feat/job-002',
|
||||
baseRef: 'origin/main',
|
||||
})
|
||||
|
||||
expect(result.branchName).toMatch(/^feat\/job-002-\d+$/)
|
||||
|
||||
const { stdout } = await git(['rev-parse', '--abbrev-ref', 'HEAD'], result.worktreePath)
|
||||
expect(stdout.trim()).toBe(result.branchName)
|
||||
})
|
||||
|
||||
it('rejects when worktree path already exists', async () => {
|
||||
const { repoDir, originDir } = await setupRepo()
|
||||
tmpDirs.push(repoDir, originDir)
|
||||
const wtParent = await makeWorktreeParent()
|
||||
|
||||
const existingPath = path.join(wtParent, 'job-003')
|
||||
await fs.mkdir(existingPath)
|
||||
|
||||
await expect(
|
||||
createWorktreeForJob({
|
||||
repoRoot: repoDir,
|
||||
jobId: 'job-003',
|
||||
branchName: 'feat/job-003',
|
||||
baseRef: 'origin/main',
|
||||
}),
|
||||
).rejects.toThrow('Worktree path already exists')
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeWorktreeForJob', () => {
|
||||
const tmpDirs: string[] = []
|
||||
const originalWorktreeDir = process.env.SCRUM4ME_AGENT_WORKTREE_DIR
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalWorktreeDir === undefined) {
|
||||
delete process.env.SCRUM4ME_AGENT_WORKTREE_DIR
|
||||
} else {
|
||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = originalWorktreeDir
|
||||
}
|
||||
for (const dir of tmpDirs.splice(0)) {
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function makeWorktreeParent(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'scrum4me-worktrees-'))
|
||||
tmpDirs.push(dir)
|
||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = dir
|
||||
return dir
|
||||
}
|
||||
|
||||
it('removes worktree directory and deletes branch by default', async () => {
|
||||
const { repoDir, originDir } = await setupRepo()
|
||||
tmpDirs.push(repoDir, originDir)
|
||||
const wtParent = await makeWorktreeParent()
|
||||
|
||||
const { worktreePath, branchName } = await createWorktreeForJob({
|
||||
repoRoot: repoDir,
|
||||
jobId: 'job-rm-01',
|
||||
branchName: 'feat/job-rm-01',
|
||||
baseRef: 'origin/main',
|
||||
})
|
||||
|
||||
const result = await removeWorktreeForJob({ repoRoot: repoDir, jobId: 'job-rm-01' })
|
||||
|
||||
expect(result.removed).toBe(true)
|
||||
await expect(fs.access(worktreePath)).rejects.toThrow()
|
||||
await expect(fs.access(path.join(wtParent, 'job-rm-01'))).rejects.toThrow()
|
||||
|
||||
// Branch should be deleted
|
||||
await expect(
|
||||
exec('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`], {
|
||||
cwd: repoDir,
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('removes worktree directory but keeps branch when keepBranch=true', async () => {
|
||||
const { repoDir, originDir } = await setupRepo()
|
||||
tmpDirs.push(repoDir, originDir)
|
||||
const wtParent = await makeWorktreeParent()
|
||||
|
||||
const { worktreePath, branchName } = await createWorktreeForJob({
|
||||
repoRoot: repoDir,
|
||||
jobId: 'job-rm-02',
|
||||
branchName: 'feat/job-rm-02',
|
||||
baseRef: 'origin/main',
|
||||
})
|
||||
|
||||
const result = await removeWorktreeForJob({
|
||||
repoRoot: repoDir,
|
||||
jobId: 'job-rm-02',
|
||||
keepBranch: true,
|
||||
})
|
||||
|
||||
expect(result.removed).toBe(true)
|
||||
await expect(fs.access(worktreePath)).rejects.toThrow()
|
||||
await expect(fs.access(path.join(wtParent, 'job-rm-02'))).rejects.toThrow()
|
||||
|
||||
// Branch should still exist
|
||||
const { stdout } = await exec(
|
||||
'git',
|
||||
['show-ref', '--verify', `refs/heads/${branchName}`],
|
||||
{ cwd: repoDir },
|
||||
)
|
||||
expect(stdout).toContain(branchName)
|
||||
})
|
||||
|
||||
it('returns { removed: false } when worktree does not exist', async () => {
|
||||
const { repoDir, originDir } = await setupRepo()
|
||||
tmpDirs.push(repoDir, originDir)
|
||||
await makeWorktreeParent()
|
||||
|
||||
const result = await removeWorktreeForJob({ repoRoot: repoDir, jobId: 'job-rm-nonexistent' })
|
||||
|
||||
expect(result.removed).toBe(false)
|
||||
})
|
||||
})
|
||||
85
__tests__/update-job-status-auto-pr.test.ts
Normal file
85
__tests__/update-job-status-auto-pr.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('../src/prisma.js', () => ({
|
||||
prisma: {
|
||||
product: { findUnique: vi.fn() },
|
||||
task: { findUnique: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../src/git/pr.js', () => ({
|
||||
createPullRequest: vi.fn(),
|
||||
}))
|
||||
|
||||
import { prisma } from '../src/prisma.js'
|
||||
import { createPullRequest } from '../src/git/pr.js'
|
||||
import { maybeCreateAutoPr } from '../src/tools/update-job-status.js'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
product: { findUnique: ReturnType<typeof vi.fn> }
|
||||
task: { findUnique: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
const mockCreatePr = createPullRequest as ReturnType<typeof vi.fn>
|
||||
|
||||
const BASE_OPTS = {
|
||||
jobId: 'job-abc',
|
||||
productId: 'prod-1',
|
||||
taskId: 'task-1',
|
||||
worktreePath: '/wt/job-abc',
|
||||
branchName: 'feat/job-abc',
|
||||
summary: 'Implemented the feature',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: true })
|
||||
mockPrisma.task.findUnique.mockResolvedValue({
|
||||
title: 'Add feature',
|
||||
story: { code: 'SCRUM-42' },
|
||||
})
|
||||
mockCreatePr.mockResolvedValue({ url: 'https://github.com/org/repo/pull/99' })
|
||||
})
|
||||
|
||||
describe('maybeCreateAutoPr', () => {
|
||||
it('returns PR URL when auto_pr=true and gh succeeds', async () => {
|
||||
const url = await maybeCreateAutoPr(BASE_OPTS)
|
||||
expect(url).toBe('https://github.com/org/repo/pull/99')
|
||||
expect(mockCreatePr).toHaveBeenCalledWith({
|
||||
worktreePath: BASE_OPTS.worktreePath,
|
||||
branchName: BASE_OPTS.branchName,
|
||||
title: 'SCRUM-42: Add feature',
|
||||
body: expect.stringContaining(BASE_OPTS.summary),
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when auto_pr=false', async () => {
|
||||
mockPrisma.product.findUnique.mockResolvedValue({ auto_pr: false })
|
||||
const url = await maybeCreateAutoPr(BASE_OPTS)
|
||||
expect(url).toBeNull()
|
||||
expect(mockCreatePr).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses task title without code prefix when story has no code', async () => {
|
||||
mockPrisma.task.findUnique.mockResolvedValue({
|
||||
title: 'Add feature',
|
||||
story: { code: null },
|
||||
})
|
||||
await maybeCreateAutoPr(BASE_OPTS)
|
||||
expect(mockCreatePr).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Add feature' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null and does not throw when gh fails', async () => {
|
||||
mockCreatePr.mockResolvedValue({ error: 'gh CLI not found' })
|
||||
const url = await maybeCreateAutoPr(BASE_OPTS)
|
||||
expect(url).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when product not found', async () => {
|
||||
mockPrisma.product.findUnique.mockResolvedValue(null)
|
||||
const url = await maybeCreateAutoPr(BASE_OPTS)
|
||||
expect(url).toBeNull()
|
||||
expect(mockCreatePr).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
110
__tests__/update-job-status-push.test.ts
Normal file
110
__tests__/update-job-status-push.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import * as path from 'node:path'
|
||||
|
||||
vi.mock('../src/git/push.js', () => ({
|
||||
pushBranchForJob: vi.fn(),
|
||||
}))
|
||||
|
||||
import { pushBranchForJob } from '../src/git/push.js'
|
||||
import { prepareDoneUpdate } from '../src/tools/update-job-status.js'
|
||||
|
||||
const mockPush = pushBranchForJob as ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('prepareDoneUpdate', () => {
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
afterEach(() => {
|
||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = originalEnv.SCRUM4ME_AGENT_WORKTREE_DIR
|
||||
})
|
||||
|
||||
it('returns DONE with pushedAt and branchOverride when push succeeds', async () => {
|
||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt'
|
||||
mockPush.mockResolvedValue({ pushed: true, remoteRef: 'refs/heads/feat/job-abc' })
|
||||
|
||||
const plan = await prepareDoneUpdate('job-abc', 'feat/job-abc')
|
||||
|
||||
expect(plan.dbStatus).toBe('DONE')
|
||||
expect(plan.pushedAt).toBeInstanceOf(Date)
|
||||
expect(plan.branchOverride).toBe('feat/job-abc')
|
||||
expect(plan.errorOverride).toBeUndefined()
|
||||
expect(plan.skipWorktreeCleanup).toBe(false)
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith({
|
||||
worktreePath: path.join('/wt', 'job-abc'),
|
||||
branchName: 'feat/job-abc',
|
||||
})
|
||||
})
|
||||
|
||||
it('derives branchName from jobId when branch is undefined', async () => {
|
||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt'
|
||||
mockPush.mockResolvedValue({ pushed: true, remoteRef: 'refs/heads/feat/job-abc12345' })
|
||||
|
||||
await prepareDoneUpdate('job-abc12345', undefined)
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ branchName: 'feat/job-abc12345' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns DONE without pushedAt when no-changes', async () => {
|
||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt'
|
||||
mockPush.mockResolvedValue({ pushed: false, reason: 'no-changes', stderr: '' })
|
||||
|
||||
const plan = await prepareDoneUpdate('job-abc', 'feat/job-abc')
|
||||
|
||||
expect(plan.dbStatus).toBe('DONE')
|
||||
expect(plan.pushedAt).toBeUndefined()
|
||||
expect(plan.branchOverride).toBeUndefined()
|
||||
expect(plan.errorOverride).toBeUndefined()
|
||||
expect(plan.skipWorktreeCleanup).toBe(false)
|
||||
})
|
||||
|
||||
it('returns FAILED with error and skipWorktreeCleanup when no-credentials', async () => {
|
||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt'
|
||||
mockPush.mockResolvedValue({
|
||||
pushed: false,
|
||||
reason: 'no-credentials',
|
||||
stderr: 'fatal: Authentication failed',
|
||||
})
|
||||
|
||||
const plan = await prepareDoneUpdate('job-abc', 'feat/job-abc')
|
||||
|
||||
expect(plan.dbStatus).toBe('FAILED')
|
||||
expect(plan.errorOverride).toContain('push failed (no-credentials)')
|
||||
expect(plan.errorOverride).toContain('Authentication failed')
|
||||
expect(plan.skipWorktreeCleanup).toBe(true)
|
||||
})
|
||||
|
||||
it('returns FAILED with error and skipWorktreeCleanup when conflict', async () => {
|
||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt'
|
||||
mockPush.mockResolvedValue({
|
||||
pushed: false,
|
||||
reason: 'conflict',
|
||||
stderr: '! [rejected] non-fast-forward',
|
||||
})
|
||||
|
||||
const plan = await prepareDoneUpdate('job-abc', 'feat/job-abc')
|
||||
|
||||
expect(plan.dbStatus).toBe('FAILED')
|
||||
expect(plan.errorOverride).toContain('push failed (conflict)')
|
||||
expect(plan.skipWorktreeCleanup).toBe(true)
|
||||
})
|
||||
|
||||
it('returns FAILED with error and skipWorktreeCleanup when unknown push error', async () => {
|
||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR = '/wt'
|
||||
mockPush.mockResolvedValue({
|
||||
pushed: false,
|
||||
reason: 'unknown',
|
||||
stderr: 'something went wrong',
|
||||
})
|
||||
|
||||
const plan = await prepareDoneUpdate('job-abc', 'feat/job-abc')
|
||||
|
||||
expect(plan.dbStatus).toBe('FAILED')
|
||||
expect(plan.skipWorktreeCleanup).toBe(true)
|
||||
})
|
||||
})
|
||||
84
__tests__/update-job-status-worktree.test.ts
Normal file
84
__tests__/update-job-status-worktree.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
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 { removeWorktreeForJob } from '../src/git/worktree.js'
|
||||
import { resolveRepoRoot } from '../src/tools/wait-for-job.js'
|
||||
import { cleanupWorktreeForTerminalStatus } from '../src/tools/update-job-status.js'
|
||||
|
||||
const mockRemove = removeWorktreeForJob as ReturnType<typeof vi.fn>
|
||||
const mockResolve = resolveRepoRoot as ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('cleanupWorktreeForTerminalStatus', () => {
|
||||
it('calls removeWorktreeForJob with keepBranch=true when done and branch set', async () => {
|
||||
mockResolve.mockResolvedValue('/repos/my-project')
|
||||
mockRemove.mockResolvedValue({ removed: true })
|
||||
|
||||
await cleanupWorktreeForTerminalStatus('prod-001', 'job-abc', 'done', 'feat/job-abc')
|
||||
|
||||
expect(mockRemove).toHaveBeenCalledWith({
|
||||
repoRoot: '/repos/my-project',
|
||||
jobId: 'job-abc',
|
||||
keepBranch: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('calls removeWorktreeForJob with keepBranch=false when done but no branch', async () => {
|
||||
mockResolve.mockResolvedValue('/repos/my-project')
|
||||
mockRemove.mockResolvedValue({ removed: true })
|
||||
|
||||
await cleanupWorktreeForTerminalStatus('prod-001', 'job-abc', 'done', undefined)
|
||||
|
||||
expect(mockRemove).toHaveBeenCalledWith({
|
||||
repoRoot: '/repos/my-project',
|
||||
jobId: 'job-abc',
|
||||
keepBranch: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('calls removeWorktreeForJob with keepBranch=false when failed', async () => {
|
||||
mockResolve.mockResolvedValue('/repos/my-project')
|
||||
mockRemove.mockResolvedValue({ removed: true })
|
||||
|
||||
await cleanupWorktreeForTerminalStatus('prod-001', 'job-abc', 'failed', 'feat/job-abc')
|
||||
|
||||
expect(mockRemove).toHaveBeenCalledWith({
|
||||
repoRoot: '/repos/my-project',
|
||||
jobId: 'job-abc',
|
||||
keepBranch: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('skips cleanup and does not throw when no repoRoot configured', async () => {
|
||||
mockResolve.mockResolvedValue(null)
|
||||
|
||||
await expect(
|
||||
cleanupWorktreeForTerminalStatus('prod-no-root', 'job-abc', 'done', undefined),
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(mockRemove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not throw when removeWorktreeForJob fails (best-effort)', async () => {
|
||||
mockResolve.mockResolvedValue('/repos/my-project')
|
||||
mockRemove.mockRejectedValue(new Error('git error'))
|
||||
|
||||
await expect(
|
||||
cleanupWorktreeForTerminalStatus('prod-001', 'job-abc', 'done', 'feat/job-abc'),
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
123
__tests__/verify-task-against-plan.test.ts
Normal file
123
__tests__/verify-task-against-plan.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('../src/prisma.js', () => ({
|
||||
prisma: {
|
||||
task: { findUnique: vi.fn() },
|
||||
claudeJob: { update: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../src/verify/classify.js', () => ({
|
||||
classifyDiffAgainstPlan: vi.fn(),
|
||||
}))
|
||||
|
||||
import { prisma } from '../src/prisma.js'
|
||||
import { classifyDiffAgainstPlan } from '../src/verify/classify.js'
|
||||
import { getDiffInWorktree, saveVerifyResult } from '../src/tools/verify-task-against-plan.js'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
task: { findUnique: ReturnType<typeof vi.fn> }
|
||||
claudeJob: { update: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
const mockClassify = classifyDiffAgainstPlan as ReturnType<typeof vi.fn>
|
||||
|
||||
// Mock node:child_process so getDiffInWorktree doesn't need a real git repo
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn(),
|
||||
}))
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
const mockExecFile = execFile as unknown as ReturnType<typeof vi.fn>
|
||||
|
||||
// Promisify internally calls execFile in callback form: (cmd, args, opts, cb)
|
||||
function stubExecFile(stdout: string) {
|
||||
mockExecFile.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: (err: null, result: { stdout: string; stderr: string }) => void) => {
|
||||
cb(null, { stdout, stderr: '' })
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function stubExecFileError(message: string) {
|
||||
mockExecFile.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: (err: Error) => void) => {
|
||||
cb(new Error(message))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const ALIGNED_DIFF = `diff --git a/src/verify/classify.ts b/src/verify/classify.ts
|
||||
--- a/src/verify/classify.ts
|
||||
+++ b/src/verify/classify.ts
|
||||
+export function classifyDiffAgainstPlan(opts) {}`
|
||||
|
||||
describe('getDiffInWorktree', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('returns stdout from git diff', async () => {
|
||||
stubExecFile(ALIGNED_DIFF)
|
||||
const result = await getDiffInWorktree('/worktrees/job-abc')
|
||||
expect(result).toBe(ALIGNED_DIFF)
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'git',
|
||||
['diff', 'origin/main...HEAD'],
|
||||
expect.objectContaining({ cwd: '/worktrees/job-abc' }),
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when git diff fails', async () => {
|
||||
stubExecFileError('not a git repo')
|
||||
await expect(getDiffInWorktree('/bad/path')).rejects.toThrow('not a git repo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveVerifyResult', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('updates claudeJob with the given verify_result', async () => {
|
||||
mockPrisma.claudeJob.update.mockResolvedValue({})
|
||||
await saveVerifyResult('job-123', 'ALIGNED')
|
||||
expect(mockPrisma.claudeJob.update).toHaveBeenCalledWith({
|
||||
where: { id: 'job-123' },
|
||||
data: { verify_result: 'ALIGNED' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify_task_against_plan — integration of helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
stubExecFile(ALIGNED_DIFF)
|
||||
mockClassify.mockReturnValue({ result: 'ALIGNED', reasoning: 'All paths covered.' })
|
||||
mockPrisma.claudeJob.update.mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('happy path: runs diff, classifies, saves verify_result', async () => {
|
||||
// Simulate: getDiffInWorktree + classifyDiffAgainstPlan + saveVerifyResult
|
||||
const diff = await getDiffInWorktree('/wt/job-abc')
|
||||
mockClassify({ diff, plan: 'Modify `src/verify/classify.ts`.' })
|
||||
expect(mockClassify).toHaveBeenCalledWith(expect.objectContaining({ diff }))
|
||||
|
||||
await saveVerifyResult('job-123', 'ALIGNED')
|
||||
expect(mockPrisma.claudeJob.update).toHaveBeenCalledWith({
|
||||
where: { id: 'job-123' },
|
||||
data: { verify_result: 'ALIGNED' },
|
||||
})
|
||||
})
|
||||
|
||||
it('EMPTY path: saves EMPTY to DB', async () => {
|
||||
stubExecFile('') // no diff output
|
||||
mockClassify.mockReturnValue({ result: 'EMPTY', reasoning: 'Geen bestandswijzigingen in de diff.' })
|
||||
|
||||
const diff = await getDiffInWorktree('/wt/job-xyz')
|
||||
const { result } = mockClassify({ diff, plan: null })
|
||||
expect(result).toBe('EMPTY')
|
||||
|
||||
await saveVerifyResult('job-xyz', 'EMPTY')
|
||||
expect(mockPrisma.claudeJob.update).toHaveBeenCalledWith({
|
||||
where: { id: 'job-xyz' },
|
||||
data: { verify_result: 'EMPTY' },
|
||||
})
|
||||
})
|
||||
})
|
||||
126
__tests__/verify/classify.test.ts
Normal file
126
__tests__/verify/classify.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { classifyDiffAgainstPlan } from '../../src/verify/classify.js'
|
||||
|
||||
// Helpers to build minimal unified diff snippets
|
||||
function makeDiff(files: string[], linesPerFile = 5): string {
|
||||
return files
|
||||
.map(
|
||||
(f) =>
|
||||
`diff --git a/${f} b/${f}\n--- a/${f}\n+++ b/${f}\n` +
|
||||
Array.from({ length: linesPerFile }, (_, i) => `+added line ${i}`).join('\n'),
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function largeFileDiff(file: string, addedLines = 60): string {
|
||||
return (
|
||||
`diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n` +
|
||||
Array.from({ length: addedLines }, (_, i) => `+line ${i}`).join('\n')
|
||||
)
|
||||
}
|
||||
|
||||
describe('classifyDiffAgainstPlan — empty diff', () => {
|
||||
it('returns EMPTY for blank diff string', () => {
|
||||
const r = classifyDiffAgainstPlan({ diff: '', plan: 'some plan' })
|
||||
expect(r.result).toBe('EMPTY')
|
||||
expect(r.reasoning).toMatch(/geen bestandswijzigingen/i)
|
||||
})
|
||||
|
||||
it('returns EMPTY for diff with no +++ b/ lines', () => {
|
||||
const r = classifyDiffAgainstPlan({ diff: 'Binary files differ\n', plan: 'plan' })
|
||||
expect(r.result).toBe('EMPTY')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyDiffAgainstPlan — no plan baseline', () => {
|
||||
it('returns ALIGNED for null plan with small diff', () => {
|
||||
const r = classifyDiffAgainstPlan({ diff: makeDiff(['src/foo.ts']), plan: null })
|
||||
expect(r.result).toBe('ALIGNED')
|
||||
})
|
||||
|
||||
it('returns ALIGNED for empty string plan with small diff', () => {
|
||||
const r = classifyDiffAgainstPlan({ diff: makeDiff(['src/foo.ts']), plan: ' ' })
|
||||
expect(r.result).toBe('ALIGNED')
|
||||
})
|
||||
|
||||
it('returns DIVERGENT for null plan with large diff (>50 changed lines)', () => {
|
||||
const r = classifyDiffAgainstPlan({ diff: largeFileDiff('src/big.ts', 60), plan: null })
|
||||
expect(r.result).toBe('DIVERGENT')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyDiffAgainstPlan — plan has no extractable paths', () => {
|
||||
const plan = 'Add a new feature. Update the logic. Write tests.'
|
||||
|
||||
it('returns ALIGNED for small diff when plan has no paths', () => {
|
||||
const r = classifyDiffAgainstPlan({ diff: makeDiff(['src/feature.ts']), plan })
|
||||
expect(r.result).toBe('ALIGNED')
|
||||
})
|
||||
|
||||
it('returns DIVERGENT for large diff when plan has no paths', () => {
|
||||
const r = classifyDiffAgainstPlan({ diff: largeFileDiff('src/feature.ts', 60), plan })
|
||||
expect(r.result).toBe('DIVERGENT')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyDiffAgainstPlan — PARTIAL coverage', () => {
|
||||
const plan = 'Edit `src/api.ts` and `src/ui.ts`.'
|
||||
|
||||
it('returns PARTIAL when only one of two plan paths is in diff', () => {
|
||||
const diff = makeDiff(['src/api.ts'])
|
||||
const r = classifyDiffAgainstPlan({ diff, plan })
|
||||
expect(r.result).toBe('PARTIAL')
|
||||
expect(r.reasoning).toMatch(/1\/2/)
|
||||
expect(r.reasoning).toMatch(/ontbrekend/i)
|
||||
})
|
||||
|
||||
it('returns PARTIAL when none of the plan paths are in diff', () => {
|
||||
const diff = makeDiff(['src/unrelated.ts'])
|
||||
const r = classifyDiffAgainstPlan({ diff, plan })
|
||||
expect(r.result).toBe('PARTIAL')
|
||||
expect(r.reasoning).toMatch(/0\/2/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyDiffAgainstPlan — ALIGNED', () => {
|
||||
it('returns ALIGNED when all plan paths are in diff with ratio < 3', () => {
|
||||
const plan = 'Modify `src/a.ts` and `src/b.ts`.'
|
||||
const diff = makeDiff(['src/a.ts', 'src/b.ts', 'src/c.ts']) // ratio 3/2 = 1.5 < 3
|
||||
const r = classifyDiffAgainstPlan({ diff, plan })
|
||||
expect(r.result).toBe('ALIGNED')
|
||||
expect(r.reasoning).toMatch(/alle 2/i)
|
||||
})
|
||||
|
||||
it('returns ALIGNED for exact 1-to-1 match', () => {
|
||||
const plan = 'Update `src/verify/classify.ts`.'
|
||||
const diff = makeDiff(['src/verify/classify.ts'])
|
||||
const r = classifyDiffAgainstPlan({ diff, plan })
|
||||
expect(r.result).toBe('ALIGNED')
|
||||
})
|
||||
|
||||
it('suffix-matches short plan path against full diff path', () => {
|
||||
const plan = 'Edit `classify.ts` in the verify module.'
|
||||
const diff = makeDiff(['src/verify/classify.ts'])
|
||||
const r = classifyDiffAgainstPlan({ diff, plan })
|
||||
expect(r.result).toBe('ALIGNED')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyDiffAgainstPlan — DIVERGENT (scope creep)', () => {
|
||||
it('returns DIVERGENT when diff has 3x+ more paths than plan', () => {
|
||||
const plan = 'Update `src/a.ts`.'
|
||||
// 1 plan path, 4 diff paths → ratio 4.0 >= 3
|
||||
const diff = makeDiff(['src/a.ts', 'src/b.ts', 'src/c.ts', 'src/d.ts'])
|
||||
const r = classifyDiffAgainstPlan({ diff, plan })
|
||||
expect(r.result).toBe('DIVERGENT')
|
||||
expect(r.reasoning).toMatch(/4\.0x/)
|
||||
})
|
||||
|
||||
it('shows extra file paths in reasoning', () => {
|
||||
const plan = 'Modify `src/core.ts`.'
|
||||
const diff = makeDiff(['src/core.ts', 'src/extra1.ts', 'src/extra2.ts', 'src/extra3.ts', 'src/extra4.ts'])
|
||||
const r = classifyDiffAgainstPlan({ diff, plan })
|
||||
expect(r.result).toBe('DIVERGENT')
|
||||
expect(r.reasoning).toMatch(/extra/i)
|
||||
})
|
||||
})
|
||||
|
|
@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|||
|
||||
vi.mock('../src/prisma.js', () => ({
|
||||
prisma: {
|
||||
$executeRaw: vi.fn(),
|
||||
$queryRaw: vi.fn(),
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
|
@ -11,27 +11,41 @@ import { prisma } from '../src/prisma.js'
|
|||
import { resetStaleClaimedJobs, tryClaimJob } from '../src/tools/wait-for-job.js'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
$executeRaw: ReturnType<typeof vi.fn>
|
||||
$queryRaw: ReturnType<typeof vi.fn>
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Default: no stale jobs returned from either query
|
||||
mockPrisma.$queryRaw.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('resetStaleClaimedJobs', () => {
|
||||
it('resets plan_snapshot to NULL when resetting stale claimed jobs', async () => {
|
||||
mockPrisma.$executeRaw.mockResolvedValue(0)
|
||||
it('runs two $queryRaw calls: one for FAILED, one for QUEUED re-enqueue', async () => {
|
||||
await resetStaleClaimedJobs('user-1')
|
||||
// Two queries: failed jobs + requeued jobs
|
||||
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
expect(mockPrisma.$executeRaw).toHaveBeenCalledOnce()
|
||||
// Verify the template literal includes plan_snapshot = NULL
|
||||
const call = mockPrisma.$executeRaw.mock.calls[0]
|
||||
const sqlParts: string[] = call[0]
|
||||
const fullSql = sqlParts.join('')
|
||||
expect(fullSql).toContain('plan_snapshot = NULL')
|
||||
expect(fullSql).toContain("status = 'QUEUED'")
|
||||
expect(fullSql).toContain('claimed_at < NOW()')
|
||||
it('FAILED query includes plan_snapshot = NULL reset and retry_count >= 2', async () => {
|
||||
await resetStaleClaimedJobs('user-1')
|
||||
const calls = mockPrisma.$queryRaw.mock.calls
|
||||
// First call: FAILED transition
|
||||
const failedSql = (calls[0][0] as string[]).join('')
|
||||
expect(failedSql).toContain("status = 'FAILED'")
|
||||
expect(failedSql).toContain('retry_count >= 2')
|
||||
})
|
||||
|
||||
it('QUEUED re-enqueue query includes plan_snapshot = NULL and retry_count increment', async () => {
|
||||
await resetStaleClaimedJobs('user-1')
|
||||
const calls = mockPrisma.$queryRaw.mock.calls
|
||||
// Second call: re-enqueue transition
|
||||
const requeueSql = (calls[1][0] as string[]).join('')
|
||||
expect(requeueSql).toContain("status = 'QUEUED'")
|
||||
expect(requeueSql).toContain('plan_snapshot = NULL')
|
||||
expect(requeueSql).toContain('retry_count = retry_count + 1')
|
||||
expect(requeueSql).toContain('retry_count < 2')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
138
__tests__/wait-for-job-worktree.test.ts
Normal file
138
__tests__/wait-for-job-worktree.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import * as fs from 'node:fs/promises'
|
||||
|
||||
vi.mock('../src/prisma.js', () => ({
|
||||
prisma: {
|
||||
$executeRaw: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../src/git/worktree.js', () => ({
|
||||
createWorktreeForJob: vi.fn(),
|
||||
}))
|
||||
|
||||
import { prisma } from '../src/prisma.js'
|
||||
import { createWorktreeForJob } from '../src/git/worktree.js'
|
||||
import { resolveRepoRoot, rollbackClaim, attachWorktreeToJob } from '../src/tools/wait-for-job.js'
|
||||
|
||||
const mockPrisma = prisma as unknown as { $executeRaw: ReturnType<typeof vi.fn> }
|
||||
const mockCreateWorktree = createWorktreeForJob as ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('resolveRepoRoot', () => {
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
afterEach(() => {
|
||||
// Restore env
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (key.startsWith('SCRUM4ME_REPO_ROOT_')) delete process.env[key]
|
||||
}
|
||||
Object.assign(process.env, originalEnv)
|
||||
})
|
||||
|
||||
it('returns value from env var when set', async () => {
|
||||
process.env['SCRUM4ME_REPO_ROOT_prod-001'] = '/repos/my-project'
|
||||
const result = await resolveRepoRoot('prod-001')
|
||||
expect(result).toBe('/repos/my-project')
|
||||
})
|
||||
|
||||
it('returns null when no env var and no config file', async () => {
|
||||
delete process.env['SCRUM4ME_REPO_ROOT_prod-999']
|
||||
// Config file at home won't have this productId in CI
|
||||
const result = await resolveRepoRoot('prod-999-nonexistent')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('reads from config file when env var is absent', async () => {
|
||||
const configPath = path.join(os.homedir(), '.scrum4me-agent-config.json')
|
||||
const config = { repoRoots: { 'prod-config': '/repos/from-config' } }
|
||||
let wroteConfig = false
|
||||
try {
|
||||
await fs.writeFile(configPath, JSON.stringify(config), 'utf-8')
|
||||
wroteConfig = true
|
||||
delete process.env['SCRUM4ME_REPO_ROOT_prod-config']
|
||||
|
||||
const result = await resolveRepoRoot('prod-config')
|
||||
expect(result).toBe('/repos/from-config')
|
||||
} finally {
|
||||
// Clean up only what we wrote — don't delete if it pre-existed
|
||||
if (wroteConfig) {
|
||||
try {
|
||||
const existing = JSON.parse(await fs.readFile(configPath, 'utf-8'))
|
||||
delete existing.repoRoots?.['prod-config']
|
||||
if (Object.keys(existing.repoRoots ?? {}).length === 0 && Object.keys(existing).length === 1) {
|
||||
await fs.rm(configPath)
|
||||
} else {
|
||||
await fs.writeFile(configPath, JSON.stringify(existing), 'utf-8')
|
||||
}
|
||||
} catch {
|
||||
await fs.rm(configPath).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('attachWorktreeToJob', () => {
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (key.startsWith('SCRUM4ME_REPO_ROOT_')) delete process.env[key]
|
||||
}
|
||||
Object.assign(process.env, originalEnv)
|
||||
})
|
||||
|
||||
it('returns worktree_path and branch_name on success', async () => {
|
||||
process.env['SCRUM4ME_REPO_ROOT_prod-001'] = '/repos/my-project'
|
||||
mockCreateWorktree.mockResolvedValue({
|
||||
worktreePath: '/home/user/.scrum4me-agent-worktrees/job-abc12345',
|
||||
branchName: 'feat/job-abc12345',
|
||||
})
|
||||
mockPrisma.$executeRaw.mockResolvedValue(0)
|
||||
|
||||
const result = await attachWorktreeToJob('prod-001', 'job-abc12345')
|
||||
|
||||
expect(result).toEqual({
|
||||
worktree_path: '/home/user/.scrum4me-agent-worktrees/job-abc12345',
|
||||
branch_name: 'feat/job-abc12345',
|
||||
})
|
||||
expect(mockCreateWorktree).toHaveBeenCalledWith({
|
||||
repoRoot: '/repos/my-project',
|
||||
jobId: 'job-abc12345',
|
||||
branchName: 'feat/job-abc12345',
|
||||
})
|
||||
})
|
||||
|
||||
it('rolls back claim and returns error when no repoRoot configured', async () => {
|
||||
delete process.env['SCRUM4ME_REPO_ROOT_prod-no-root']
|
||||
mockPrisma.$executeRaw.mockResolvedValue(0)
|
||||
|
||||
const result = await attachWorktreeToJob('prod-no-root', 'job-xyz')
|
||||
|
||||
expect('error' in result).toBe(true)
|
||||
expect((result as { error: string }).error).toContain('No repo root configured')
|
||||
expect(mockPrisma.$executeRaw).toHaveBeenCalledOnce()
|
||||
const sqlParts: string[] = mockPrisma.$executeRaw.mock.calls[0][0]
|
||||
expect(sqlParts.join('')).toContain("status = 'QUEUED'")
|
||||
})
|
||||
|
||||
it('rolls back claim and returns error when createWorktreeForJob throws', async () => {
|
||||
process.env['SCRUM4ME_REPO_ROOT_prod-001'] = '/repos/my-project'
|
||||
mockCreateWorktree.mockRejectedValue(new Error('git fetch failed'))
|
||||
mockPrisma.$executeRaw.mockResolvedValue(0)
|
||||
|
||||
const result = await attachWorktreeToJob('prod-001', 'job-fail')
|
||||
|
||||
expect('error' in result).toBe(true)
|
||||
expect((result as { error: string }).error).toContain('git fetch failed')
|
||||
expect(mockPrisma.$executeRaw).toHaveBeenCalledOnce()
|
||||
const sqlParts: string[] = mockPrisma.$executeRaw.mock.calls[0][0]
|
||||
expect(sqlParts.join('')).toContain("status = 'QUEUED'")
|
||||
})
|
||||
})
|
||||
|
|
@ -57,6 +57,13 @@ enum SprintStatus {
|
|||
COMPLETED
|
||||
}
|
||||
|
||||
enum VerifyResult {
|
||||
ALIGNED
|
||||
PARTIAL
|
||||
EMPTY
|
||||
DIVERGENT
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
username String @unique
|
||||
|
|
@ -120,6 +127,7 @@ model Product {
|
|||
description String?
|
||||
repo_url String?
|
||||
definition_of_done String
|
||||
auto_pr Boolean @default(false)
|
||||
archived Boolean @default(false)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
|
@ -232,6 +240,7 @@ model Task {
|
|||
priority Int
|
||||
sort_order Float
|
||||
status TaskStatus @default(TO_DO)
|
||||
verify_only Boolean @default(false)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
claude_questions ClaudeQuestion[]
|
||||
|
|
@ -256,10 +265,14 @@ model ClaudeJob {
|
|||
claimed_at DateTime?
|
||||
started_at DateTime?
|
||||
finished_at DateTime?
|
||||
pushed_at DateTime?
|
||||
plan_snapshot String?
|
||||
branch String?
|
||||
pr_url String?
|
||||
summary String?
|
||||
error String?
|
||||
verify_result VerifyResult?
|
||||
retry_count Int @default(0)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
|
|
|
|||
38
src/git/pr.ts
Normal file
38
src/git/pr.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const exec = promisify(execFile)
|
||||
|
||||
export async function createPullRequest(opts: {
|
||||
worktreePath: string
|
||||
branchName: string
|
||||
title: string
|
||||
body: string
|
||||
}): Promise<{ url: string } | { error: string }> {
|
||||
const { worktreePath, branchName, title, body } = opts
|
||||
|
||||
try {
|
||||
const { stdout } = await exec(
|
||||
'gh',
|
||||
['pr', 'create', '--title', title, '--body', body, '--head', branchName],
|
||||
{ cwd: worktreePath },
|
||||
)
|
||||
// gh prints the PR URL as the last non-empty line
|
||||
const lines = stdout.trim().split('\n').filter(Boolean)
|
||||
const url = lines[lines.length - 1]?.trim() ?? ''
|
||||
if (!url.startsWith('http')) {
|
||||
return { error: `gh pr create produced unexpected output: ${stdout.slice(0, 200)}` }
|
||||
}
|
||||
return { url }
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { message?: string }).message ?? String(err)
|
||||
const isNotFound =
|
||||
msg.includes('command not found') ||
|
||||
msg.includes('is not recognized') ||
|
||||
msg.includes('ENOENT')
|
||||
if (isNotFound) {
|
||||
return { error: 'gh CLI not found — install GitHub CLI to enable auto-PR' }
|
||||
}
|
||||
return { error: `gh pr create failed: ${msg.slice(0, 300)}` }
|
||||
}
|
||||
}
|
||||
53
src/git/push.ts
Normal file
53
src/git/push.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const exec = promisify(execFile)
|
||||
|
||||
type PushSuccess = { pushed: true; remoteRef: string }
|
||||
type PushFailure = {
|
||||
pushed: false
|
||||
reason: 'no-credentials' | 'conflict' | 'no-changes' | 'unknown'
|
||||
stderr: string
|
||||
}
|
||||
|
||||
export type PushResult = PushSuccess | PushFailure
|
||||
|
||||
export async function pushBranchForJob(opts: {
|
||||
worktreePath: string
|
||||
branchName: string
|
||||
}): Promise<PushResult> {
|
||||
const { worktreePath, branchName } = opts
|
||||
|
||||
// Detect no new commits vs origin/main
|
||||
let headSha: string
|
||||
let baseSha: string
|
||||
try {
|
||||
const [headResult, baseResult] = await Promise.all([
|
||||
exec('git', ['rev-parse', 'HEAD'], { cwd: worktreePath }),
|
||||
exec('git', ['rev-parse', 'origin/main'], { cwd: worktreePath }),
|
||||
])
|
||||
headSha = headResult.stdout.trim()
|
||||
baseSha = baseResult.stdout.trim()
|
||||
} catch (err) {
|
||||
return { pushed: false, reason: 'unknown', stderr: (err as Error).message }
|
||||
}
|
||||
|
||||
if (headSha === baseSha) {
|
||||
return { pushed: false, reason: 'no-changes', stderr: '' }
|
||||
}
|
||||
|
||||
// Push
|
||||
try {
|
||||
await exec('git', ['push', '-u', 'origin', branchName], { cwd: worktreePath })
|
||||
return { pushed: true, remoteRef: `refs/heads/${branchName}` }
|
||||
} catch (err) {
|
||||
const stderr = (err as { stderr?: string }).stderr ?? (err as Error).message ?? ''
|
||||
if (/Authentication failed|could not read Username/i.test(stderr)) {
|
||||
return { pushed: false, reason: 'no-credentials', stderr }
|
||||
}
|
||||
if (/non-fast-forward|already exists on the remote|rejected/i.test(stderr)) {
|
||||
return { pushed: false, reason: 'conflict', stderr }
|
||||
}
|
||||
return { pushed: false, reason: 'unknown', stderr }
|
||||
}
|
||||
}
|
||||
97
src/git/worktree.ts
Normal file
97
src/git/worktree.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
import * as fs from 'node:fs/promises'
|
||||
|
||||
const exec = promisify(execFile)
|
||||
|
||||
async function branchExists(repoRoot: string, name: string): Promise<boolean> {
|
||||
try {
|
||||
await exec('git', ['show-ref', '--verify', '--quiet', `refs/heads/${name}`], { cwd: repoRoot })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWorktreeForJob(opts: {
|
||||
repoRoot: string
|
||||
jobId: string
|
||||
branchName: string
|
||||
baseRef?: string
|
||||
}): Promise<{ worktreePath: string; branchName: string }> {
|
||||
const { repoRoot, jobId, baseRef = 'origin/main' } = opts
|
||||
let { branchName } = opts
|
||||
|
||||
const parent =
|
||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR ??
|
||||
path.join(os.homedir(), '.scrum4me-agent-worktrees')
|
||||
|
||||
await fs.mkdir(parent, { recursive: true })
|
||||
|
||||
const worktreePath = path.join(parent, jobId)
|
||||
|
||||
// Reject if worktree path already exists — caller must remove it first
|
||||
try {
|
||||
await fs.access(worktreePath)
|
||||
throw new Error(
|
||||
`Worktree path already exists: ${worktreePath}. Call removeWorktreeForJob first.`,
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
||||
}
|
||||
|
||||
await exec('git', ['fetch', 'origin', '--prune'], { cwd: repoRoot })
|
||||
|
||||
// Suffix with timestamp when branch already exists
|
||||
if (await branchExists(repoRoot, branchName)) {
|
||||
branchName = `${branchName}-${Date.now()}`
|
||||
}
|
||||
|
||||
await exec('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], {
|
||||
cwd: repoRoot,
|
||||
})
|
||||
|
||||
return { worktreePath, branchName }
|
||||
}
|
||||
|
||||
export async function removeWorktreeForJob(opts: {
|
||||
repoRoot: string
|
||||
jobId: string
|
||||
keepBranch?: boolean
|
||||
}): Promise<{ removed: boolean }> {
|
||||
const { repoRoot, jobId, keepBranch = false } = opts
|
||||
|
||||
const parent =
|
||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR ??
|
||||
path.join(os.homedir(), '.scrum4me-agent-worktrees')
|
||||
|
||||
const worktreePath = path.join(parent, jobId)
|
||||
|
||||
try {
|
||||
await fs.access(worktreePath)
|
||||
} catch {
|
||||
return { removed: false }
|
||||
}
|
||||
|
||||
let branchName: string | undefined
|
||||
if (!keepBranch) {
|
||||
try {
|
||||
const { stdout } = await exec('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
cwd: worktreePath,
|
||||
})
|
||||
branchName = stdout.trim()
|
||||
} catch {
|
||||
// worktree HEAD unreadable — skip branch deletion
|
||||
}
|
||||
}
|
||||
|
||||
await exec('git', ['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot })
|
||||
|
||||
if (!keepBranch && branchName && (await branchExists(repoRoot, branchName))) {
|
||||
await exec('git', ['branch', '-D', branchName], { cwd: repoRoot })
|
||||
}
|
||||
|
||||
return { removed: true }
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import { registerCancelQuestionTool } from './tools/cancel-question.js'
|
|||
import { registerWaitForJobTool } from './tools/wait-for-job.js'
|
||||
import { registerUpdateJobStatusTool } from './tools/update-job-status.js'
|
||||
import { registerVerifyTaskAgainstPlanTool } from './tools/verify-task-against-plan.js'
|
||||
import { registerCleanupMyWorktreesTool } from './tools/cleanup-my-worktrees.js'
|
||||
import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js'
|
||||
|
||||
const VERSION = '0.1.0'
|
||||
|
|
@ -53,6 +54,7 @@ async function main() {
|
|||
registerWaitForJobTool(server)
|
||||
registerUpdateJobStatusTool(server)
|
||||
registerVerifyTaskAgainstPlanTool(server)
|
||||
registerCleanupMyWorktreesTool(server)
|
||||
registerImplementNextStoryPrompt(server)
|
||||
|
||||
const transport = new StdioServerTransport()
|
||||
|
|
|
|||
107
src/tools/cleanup-my-worktrees.ts
Normal file
107
src/tools/cleanup-my-worktrees.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { z } from 'zod'
|
||||
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 { prisma } from '../prisma.js'
|
||||
import { requireWriteAccess } from '../auth.js'
|
||||
import { toolJson, withToolErrors } from '../errors.js'
|
||||
import { removeWorktreeForJob } from '../git/worktree.js'
|
||||
import { resolveRepoRoot } from './wait-for-job.js'
|
||||
|
||||
const TERMINAL_STATUSES = new Set(['DONE', 'FAILED', 'CANCELLED'])
|
||||
const ACTIVE_STATUSES = new Set(['QUEUED', 'CLAIMED', 'RUNNING'])
|
||||
|
||||
const inputSchema = z.object({})
|
||||
|
||||
export async function getWorktreeParent(): Promise<string> {
|
||||
return (
|
||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR ??
|
||||
path.join(os.homedir(), '.scrum4me-agent-worktrees')
|
||||
)
|
||||
}
|
||||
|
||||
export async function listWorktreeJobIds(worktreeParent: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await fs.readdir(worktreeParent, { withFileTypes: true })
|
||||
return entries.filter((e) => e.isDirectory()).map((e) => e.name)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupWorktrees(
|
||||
worktreeParent: string,
|
||||
userId: string,
|
||||
): Promise<{ removed: string[]; kept: string[]; skipped: string[] }> {
|
||||
const jobIds = await listWorktreeJobIds(worktreeParent)
|
||||
const removed: string[] = []
|
||||
const kept: string[] = []
|
||||
const skipped: string[] = []
|
||||
|
||||
if (jobIds.length === 0) return { removed, kept, skipped }
|
||||
|
||||
const jobs = await prisma.claudeJob.findMany({
|
||||
where: { id: { in: jobIds }, user_id: userId },
|
||||
select: { id: true, status: true, product_id: true, branch: true },
|
||||
})
|
||||
const jobMap = new Map(jobs.map((j) => [j.id, j]))
|
||||
|
||||
for (const jobId of jobIds) {
|
||||
const job = jobMap.get(jobId)
|
||||
|
||||
// No DB record for this jobId — orphan worktree, skip safely
|
||||
if (!job) {
|
||||
skipped.push(jobId)
|
||||
continue
|
||||
}
|
||||
|
||||
if (ACTIVE_STATUSES.has(job.status)) {
|
||||
kept.push(jobId)
|
||||
continue
|
||||
}
|
||||
|
||||
if (TERMINAL_STATUSES.has(job.status)) {
|
||||
const repoRoot = await resolveRepoRoot(job.product_id)
|
||||
if (!repoRoot) {
|
||||
skipped.push(jobId)
|
||||
continue
|
||||
}
|
||||
|
||||
// Keep branch for DONE jobs that already pushed (job.branch is set)
|
||||
const keepBranch = job.status === 'DONE' && job.branch !== null
|
||||
try {
|
||||
await removeWorktreeForJob({ repoRoot, jobId, keepBranch })
|
||||
removed.push(jobId)
|
||||
} catch {
|
||||
skipped.push(jobId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { removed, kept, skipped }
|
||||
}
|
||||
|
||||
export function registerCleanupMyWorktreesTool(server: McpServer) {
|
||||
server.registerTool(
|
||||
'cleanup_my_worktrees',
|
||||
{
|
||||
title: 'Cleanup my worktrees',
|
||||
description:
|
||||
'Remove stale git worktrees left by crashed or cancelled agent runs. ' +
|
||||
'Scans ~/.scrum4me-agent-worktrees/ (or SCRUM4ME_AGENT_WORKTREE_DIR) for job directories, ' +
|
||||
'looks up each job\'s status, and removes worktrees whose jobs are in a terminal state ' +
|
||||
'(DONE, FAILED, CANCELLED). Worktrees for active jobs (QUEUED, CLAIMED, RUNNING) are kept. ' +
|
||||
'Returns { removed, kept, skipped } for inspection.',
|
||||
inputSchema,
|
||||
annotations: { readOnlyHint: false },
|
||||
},
|
||||
async () =>
|
||||
withToolErrors(async () => {
|
||||
const auth = await requireWriteAccess()
|
||||
const worktreeParent = await getWorktreeParent()
|
||||
const result = await cleanupWorktrees(worktreeParent, auth.userId)
|
||||
return toolJson(result)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -5,9 +5,15 @@
|
|||
import { z } from 'zod'
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { Client } from 'pg'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { prisma } from '../prisma.js'
|
||||
import { requireWriteAccess } from '../auth.js'
|
||||
import { toolJson, toolError, withToolErrors } from '../errors.js'
|
||||
import { removeWorktreeForJob } from '../git/worktree.js'
|
||||
import { resolveRepoRoot } from './wait-for-job.js'
|
||||
import { pushBranchForJob } from '../git/push.js'
|
||||
import { createPullRequest } from '../git/pr.js'
|
||||
|
||||
const inputSchema = z.object({
|
||||
job_id: z.string().min(1),
|
||||
|
|
@ -17,12 +23,114 @@ const inputSchema = z.object({
|
|||
error: z.string().max(2_000).optional(),
|
||||
})
|
||||
|
||||
export async function cleanupWorktreeForTerminalStatus(
|
||||
productId: string,
|
||||
jobId: string,
|
||||
status: 'done' | 'failed',
|
||||
branch: string | undefined,
|
||||
): Promise<void> {
|
||||
const repoRoot = await resolveRepoRoot(productId)
|
||||
if (!repoRoot) return
|
||||
|
||||
// Keep branch when job is done and a branch was reported (agent pushed)
|
||||
const keepBranch = status === 'done' && branch !== undefined
|
||||
try {
|
||||
await removeWorktreeForJob({ repoRoot, jobId, keepBranch })
|
||||
} catch (err) {
|
||||
console.warn(`[update_job_status] Worktree cleanup failed for job ${jobId}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
export type DoneUpdatePlan = {
|
||||
dbStatus: 'DONE' | 'FAILED'
|
||||
pushedAt: Date | undefined
|
||||
branchOverride: string | undefined
|
||||
errorOverride: string | undefined
|
||||
skipWorktreeCleanup: boolean
|
||||
}
|
||||
|
||||
export async function prepareDoneUpdate(
|
||||
jobId: string,
|
||||
branch: string | undefined,
|
||||
): Promise<DoneUpdatePlan> {
|
||||
const worktreeDir =
|
||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR ?? path.join(os.homedir(), '.scrum4me-agent-worktrees')
|
||||
const worktreePath = path.join(worktreeDir, jobId)
|
||||
const branchName = branch ?? `feat/job-${jobId.slice(-8)}`
|
||||
|
||||
const pushResult = await pushBranchForJob({ worktreePath, branchName })
|
||||
|
||||
if (pushResult.pushed) {
|
||||
return {
|
||||
dbStatus: 'DONE',
|
||||
pushedAt: new Date(),
|
||||
branchOverride: branchName,
|
||||
errorOverride: undefined,
|
||||
skipWorktreeCleanup: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (pushResult.reason === 'no-changes') {
|
||||
return {
|
||||
dbStatus: 'DONE',
|
||||
pushedAt: undefined,
|
||||
branchOverride: undefined,
|
||||
errorOverride: undefined,
|
||||
skipWorktreeCleanup: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Push failed — job becomes FAILED, worktree stays for manual inspection
|
||||
const snippet = pushResult.stderr.slice(0, 200)
|
||||
return {
|
||||
dbStatus: 'FAILED',
|
||||
pushedAt: undefined,
|
||||
branchOverride: undefined,
|
||||
errorOverride: `push failed (${pushResult.reason}): ${snippet}`,
|
||||
skipWorktreeCleanup: true,
|
||||
}
|
||||
}
|
||||
|
||||
const DB_STATUS_MAP = {
|
||||
running: 'RUNNING',
|
||||
done: 'DONE',
|
||||
failed: 'FAILED',
|
||||
} as const
|
||||
|
||||
export async function maybeCreateAutoPr(opts: {
|
||||
jobId: string
|
||||
productId: string
|
||||
taskId: string
|
||||
worktreePath: string
|
||||
branchName: string
|
||||
summary: string | undefined
|
||||
}): Promise<string | null> {
|
||||
const { jobId, productId, taskId, worktreePath, branchName, summary } = opts
|
||||
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { id: productId },
|
||||
select: { auto_pr: true },
|
||||
})
|
||||
if (!product?.auto_pr) return null
|
||||
|
||||
const task = await prisma.task.findUnique({
|
||||
where: { id: taskId },
|
||||
select: { title: true, story: { select: { code: true } } },
|
||||
})
|
||||
if (!task) return null
|
||||
|
||||
const title = task.story.code ? `${task.story.code}: ${task.title}` : task.title
|
||||
const body = summary
|
||||
? `${summary}\n\n---\n\n*Auto-generated by Scrum4Me agent*`
|
||||
: '*Auto-generated by Scrum4Me agent*'
|
||||
|
||||
const result = await createPullRequest({ worktreePath, branchName, title, body })
|
||||
if ('url' in result) return result.url
|
||||
|
||||
console.warn(`[update_job_status] auto-PR skipped for job ${jobId}:`, result.error)
|
||||
return null
|
||||
}
|
||||
|
||||
export function registerUpdateJobStatusTool(server: McpServer) {
|
||||
server.registerTool(
|
||||
'update_job_status',
|
||||
|
|
@ -60,22 +168,61 @@ export function registerUpdateJobStatusTool(server: McpServer) {
|
|||
return toolError(`Job is already in terminal state: ${job.status.toLowerCase()}`)
|
||||
}
|
||||
|
||||
const dbStatus = DB_STATUS_MAP[status]
|
||||
// For DONE: push first, adjust DB status based on result
|
||||
let actualStatus = status
|
||||
let pushedAt: Date | undefined
|
||||
let branchToWrite = branch
|
||||
let errorToWrite = error
|
||||
let skipWorktreeCleanup = false
|
||||
|
||||
if (status === 'done') {
|
||||
const plan = await prepareDoneUpdate(job_id, branch)
|
||||
actualStatus = plan.dbStatus === 'DONE' ? 'done' : 'failed'
|
||||
pushedAt = plan.pushedAt
|
||||
if (plan.branchOverride !== undefined) branchToWrite = plan.branchOverride
|
||||
if (plan.errorOverride !== undefined) errorToWrite = plan.errorOverride
|
||||
skipWorktreeCleanup = plan.skipWorktreeCleanup
|
||||
}
|
||||
|
||||
// Auto-PR: best-effort, only when push actually happened
|
||||
let prUrl: string | null = null
|
||||
if (actualStatus === 'done' && pushedAt && branchToWrite) {
|
||||
const worktreeDir =
|
||||
process.env.SCRUM4ME_AGENT_WORKTREE_DIR ??
|
||||
path.join(os.homedir(), '.scrum4me-agent-worktrees')
|
||||
prUrl = await maybeCreateAutoPr({
|
||||
jobId: job_id,
|
||||
productId: job.product_id,
|
||||
taskId: job.task_id,
|
||||
worktreePath: path.join(worktreeDir, job_id),
|
||||
branchName: branchToWrite,
|
||||
summary,
|
||||
}).catch((err) => {
|
||||
console.warn(`[update_job_status] auto-PR error for job ${job_id}:`, err)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
const dbStatus = DB_STATUS_MAP[actualStatus as keyof typeof DB_STATUS_MAP]
|
||||
const now = new Date()
|
||||
const updated = await prisma.claudeJob.update({
|
||||
where: { id: job_id },
|
||||
data: {
|
||||
status: dbStatus,
|
||||
...(status === 'running' ? { started_at: now } : {}),
|
||||
...(status === 'done' || status === 'failed' ? { finished_at: now } : {}),
|
||||
...(branch !== undefined ? { branch } : {}),
|
||||
...(actualStatus === 'running' ? { started_at: now } : {}),
|
||||
...(actualStatus === 'done' || actualStatus === 'failed' ? { finished_at: now } : {}),
|
||||
...(branchToWrite !== undefined ? { branch: branchToWrite } : {}),
|
||||
...(pushedAt !== undefined ? { pushed_at: pushedAt } : {}),
|
||||
...(summary !== undefined ? { summary } : {}),
|
||||
...(error !== undefined ? { error } : {}),
|
||||
...(errorToWrite !== undefined ? { error: errorToWrite } : {}),
|
||||
...(prUrl !== null ? { pr_url: prUrl } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
branch: true,
|
||||
pushed_at: true,
|
||||
pr_url: true,
|
||||
summary: true,
|
||||
error: true,
|
||||
started_at: true,
|
||||
|
|
@ -96,8 +243,10 @@ export function registerUpdateJobStatusTool(server: McpServer) {
|
|||
task_id: job.task_id,
|
||||
user_id: job.user_id,
|
||||
product_id: job.product_id,
|
||||
status,
|
||||
status: actualStatus,
|
||||
branch: updated.branch ?? undefined,
|
||||
pushed_at: updated.pushed_at?.toISOString() ?? undefined,
|
||||
pr_url: updated.pr_url ?? undefined,
|
||||
summary: updated.summary ?? undefined,
|
||||
error: updated.error ?? undefined,
|
||||
}),
|
||||
|
|
@ -108,10 +257,17 @@ export function registerUpdateJobStatusTool(server: McpServer) {
|
|||
// non-fatal — status is already persisted
|
||||
}
|
||||
|
||||
// Best-effort worktree cleanup on terminal transitions (skip if push failed — worktree preserved)
|
||||
if ((actualStatus === 'done' || actualStatus === 'failed') && !skipWorktreeCleanup) {
|
||||
await cleanupWorktreeForTerminalStatus(job.product_id, job_id, actualStatus, branchToWrite)
|
||||
}
|
||||
|
||||
return toolJson({
|
||||
job_id: updated.id,
|
||||
status,
|
||||
status: actualStatus,
|
||||
branch: updated.branch,
|
||||
pushed_at: updated.pushed_at?.toISOString() ?? null,
|
||||
pr_url: updated.pr_url ?? null,
|
||||
summary: updated.summary,
|
||||
error: updated.error,
|
||||
started_at: updated.started_at?.toISOString() ?? null,
|
||||
|
|
|
|||
|
|
@ -1,28 +1,47 @@
|
|||
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 { getAuth } from '../auth.js'
|
||||
import { userCanAccessTask } from '../access.js'
|
||||
import { toolError, toolJson, withToolErrors } from '../errors.js'
|
||||
import { buildVerifyResult, renderMarkdownReport } from '../lib/verify-plan.js'
|
||||
import { classifyDiffAgainstPlan, type VerifyResultValue } from '../verify/classify.js'
|
||||
|
||||
const exec = promisify(execFile)
|
||||
|
||||
const inputSchema = z.object({
|
||||
task_id: z.string().min(1),
|
||||
worktree_path: z.string().min(1),
|
||||
})
|
||||
|
||||
export async function getDiffInWorktree(worktreePath: string): Promise<string> {
|
||||
const { stdout } = await exec('git', ['diff', 'origin/main...HEAD'], { cwd: worktreePath })
|
||||
return stdout
|
||||
}
|
||||
|
||||
export async function saveVerifyResult(jobId: string, result: VerifyResultValue): Promise<void> {
|
||||
await prisma.claudeJob.update({
|
||||
where: { id: jobId },
|
||||
data: { verify_result: result },
|
||||
})
|
||||
}
|
||||
|
||||
export function registerVerifyTaskAgainstPlanTool(server: McpServer) {
|
||||
server.registerTool(
|
||||
'verify_task_against_plan',
|
||||
{
|
||||
title: 'Verify task against plan',
|
||||
description:
|
||||
'Compare the frozen plan_snapshot (captured at claim time) against current ' +
|
||||
'task.implementation_plan, story logs, and commits. Returns a markdown report ' +
|
||||
'with per-AC ✓/✗/? heuristic checks and a drift-score. Read-only — demo users allowed.',
|
||||
'Run `git diff origin/main...HEAD` in the worktree and compare it against the ' +
|
||||
'frozen plan_snapshot captured at claim time. Returns ALIGNED|PARTIAL|EMPTY|DIVERGENT ' +
|
||||
'and saves verify_result on the active job. ' +
|
||||
'Call this BEFORE update_job_status("done"). ' +
|
||||
'If the result is EMPTY and task.verify_only is false, update_job_status("done") will be rejected.',
|
||||
inputSchema,
|
||||
annotations: { readOnlyHint: true },
|
||||
annotations: { readOnlyHint: false },
|
||||
},
|
||||
async ({ task_id }) =>
|
||||
async ({ task_id, worktree_path }) =>
|
||||
withToolErrors(async () => {
|
||||
const auth = await getAuth()
|
||||
if (!auth) return toolError('Unauthorized')
|
||||
|
|
@ -34,62 +53,44 @@ export function registerVerifyTaskAgainstPlanTool(server: McpServer) {
|
|||
where: { id: task_id },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
implementation_plan: true,
|
||||
story: {
|
||||
select: {
|
||||
id: true,
|
||||
acceptance_criteria: true,
|
||||
logs: {
|
||||
orderBy: { created_at: 'asc' },
|
||||
select: {
|
||||
type: true,
|
||||
content: true,
|
||||
commit_hash: true,
|
||||
commit_message: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
verify_only: true,
|
||||
claude_jobs: {
|
||||
where: { status: { in: ['CLAIMED', 'RUNNING', 'DONE', 'FAILED'] } },
|
||||
where: { status: { in: ['CLAIMED', 'RUNNING'] } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: 1,
|
||||
select: { plan_snapshot: true },
|
||||
select: { id: true, plan_snapshot: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!task) return toolError(`Task ${task_id} not found`)
|
||||
|
||||
const latestJob = task.claude_jobs[0] ?? null
|
||||
const planSnapshot = latestJob ? latestJob.plan_snapshot : null
|
||||
const activeJob = task.claude_jobs[0] ?? null
|
||||
|
||||
const implementationLogs = task.story.logs
|
||||
.filter((l) => l.type === 'IMPLEMENTATION_PLAN')
|
||||
.map((l) => l.content)
|
||||
let diff: string
|
||||
try {
|
||||
diff = await getDiffInWorktree(worktree_path)
|
||||
} catch (err) {
|
||||
return toolError(
|
||||
`git diff failed in worktree (${worktree_path}): ${(err as Error).message ?? 'unknown error'}`,
|
||||
)
|
||||
}
|
||||
|
||||
const commits = task.story.logs
|
||||
.filter((l) => l.type === 'COMMIT')
|
||||
.map((l) => ({ hash: l.commit_hash, message: l.commit_message }))
|
||||
|
||||
const result = buildVerifyResult({
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
planSnapshot,
|
||||
currentPlan: task.implementation_plan,
|
||||
acceptanceCriteriaText: task.story.acceptance_criteria,
|
||||
implementationLogs,
|
||||
commits,
|
||||
const { result, reasoning } = classifyDiffAgainstPlan({
|
||||
diff,
|
||||
plan: activeJob?.plan_snapshot ?? null,
|
||||
})
|
||||
|
||||
if (activeJob) {
|
||||
await saveVerifyResult(activeJob.id, result)
|
||||
}
|
||||
|
||||
return toolJson({
|
||||
report: renderMarkdownReport(result),
|
||||
task_id: result.taskId,
|
||||
drift_score: result.driftScore,
|
||||
ac_results: result.acceptanceCriteria,
|
||||
plan_edited: result.planEdited,
|
||||
has_baseline: result.hasBaseline,
|
||||
result: result.toLowerCase() as 'aligned' | 'partial' | 'empty' | 'divergent',
|
||||
reasoning,
|
||||
verify_only: task.verify_only,
|
||||
task_id,
|
||||
job_id: activeJob?.id ?? null,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,9 +5,63 @@
|
|||
import { z } from 'zod'
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { Client } from 'pg'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { prisma } from '../prisma.js'
|
||||
import { requireWriteAccess } from '../auth.js'
|
||||
import { toolJson, toolError, withToolErrors } from '../errors.js'
|
||||
import { createWorktreeForJob } from '../git/worktree.js'
|
||||
|
||||
export async function resolveRepoRoot(productId: string): Promise<string | null> {
|
||||
const envKey = `SCRUM4ME_REPO_ROOT_${productId}`
|
||||
if (process.env[envKey]) return process.env[envKey]!
|
||||
|
||||
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> }
|
||||
return config.repoRoots?.[productId] ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function rollbackClaim(jobId: string): Promise<void> {
|
||||
await prisma.$executeRaw`
|
||||
UPDATE claude_jobs
|
||||
SET status = 'QUEUED', claimed_by_token_id = NULL, claimed_at = NULL, plan_snapshot = NULL
|
||||
WHERE id = ${jobId}
|
||||
`
|
||||
}
|
||||
|
||||
export async function attachWorktreeToJob(
|
||||
productId: string,
|
||||
jobId: string,
|
||||
): Promise<{ worktree_path: string; branch_name: string } | { error: string }> {
|
||||
const repoRoot = await resolveRepoRoot(productId)
|
||||
if (!repoRoot) {
|
||||
await rollbackClaim(jobId)
|
||||
return {
|
||||
error:
|
||||
`No repo root configured for product ${productId}. ` +
|
||||
`Set env var SCRUM4ME_REPO_ROOT_${productId} or add to ~/.scrum4me-agent-config.json.`,
|
||||
}
|
||||
}
|
||||
|
||||
const branchName = `feat/job-${jobId.slice(-8)}`
|
||||
try {
|
||||
const { worktreePath, branchName: actualBranch } = await createWorktreeForJob({
|
||||
repoRoot,
|
||||
jobId,
|
||||
branchName,
|
||||
})
|
||||
return { worktree_path: worktreePath, branch_name: actualBranch }
|
||||
} catch (err) {
|
||||
await rollbackClaim(jobId)
|
||||
return { error: `Worktree creation failed: ${(err as Error).message}` }
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_WAIT_SECONDS = 600
|
||||
const POLL_INTERVAL_MS = 5_000
|
||||
|
|
@ -19,14 +73,78 @@ const inputSchema = z.object({
|
|||
wait_seconds: z.number().int().min(1).max(MAX_WAIT_SECONDS).default(300),
|
||||
})
|
||||
|
||||
export async function resetStaleClaimedJobs(userId: string) {
|
||||
await prisma.$executeRaw`
|
||||
const STALE_ERROR_MSG = 'agent did not complete job within 2 attempts'
|
||||
|
||||
export async function resetStaleClaimedJobs(userId: string): Promise<void> {
|
||||
// Jobs that exceeded the retry limit → FAILED
|
||||
const failedRows = await prisma.$queryRaw<
|
||||
Array<{ id: string; task_id: string; product_id: string }>
|
||||
>`
|
||||
UPDATE claude_jobs
|
||||
SET status = 'QUEUED', claimed_by_token_id = NULL, claimed_at = NULL, plan_snapshot = NULL
|
||||
SET status = 'FAILED',
|
||||
finished_at = NOW(),
|
||||
error = ${STALE_ERROR_MSG}
|
||||
WHERE user_id = ${userId}
|
||||
AND status = 'CLAIMED'
|
||||
AND claimed_at < NOW() - INTERVAL '30 minutes'
|
||||
AND retry_count >= 2
|
||||
RETURNING id, task_id, product_id
|
||||
`
|
||||
|
||||
// Jobs under the retry limit → back to QUEUED, increment retry_count
|
||||
const requeuedRows = await prisma.$queryRaw<
|
||||
Array<{ id: string; task_id: string; product_id: string; retry_count: number }>
|
||||
>`
|
||||
UPDATE claude_jobs
|
||||
SET status = 'QUEUED',
|
||||
claimed_by_token_id = NULL,
|
||||
claimed_at = NULL,
|
||||
plan_snapshot = NULL,
|
||||
retry_count = retry_count + 1
|
||||
WHERE user_id = ${userId}
|
||||
AND status = 'CLAIMED'
|
||||
AND claimed_at < NOW() - INTERVAL '30 minutes'
|
||||
AND retry_count < 2
|
||||
RETURNING id, task_id, product_id, retry_count
|
||||
`
|
||||
|
||||
if (failedRows.length === 0 && requeuedRows.length === 0) return
|
||||
|
||||
// Notify UI via SSE for each transition (best-effort)
|
||||
try {
|
||||
const pg = new Client({ connectionString: process.env.DATABASE_URL })
|
||||
await pg.connect()
|
||||
for (const j of failedRows) {
|
||||
await pg.query('SELECT pg_notify($1, $2)', [
|
||||
'scrum4me_changes',
|
||||
JSON.stringify({
|
||||
type: 'claude_job_status',
|
||||
job_id: j.id,
|
||||
task_id: j.task_id,
|
||||
user_id: userId,
|
||||
product_id: j.product_id,
|
||||
status: 'failed',
|
||||
error: STALE_ERROR_MSG,
|
||||
}),
|
||||
])
|
||||
}
|
||||
for (const j of requeuedRows) {
|
||||
await pg.query('SELECT pg_notify($1, $2)', [
|
||||
'scrum4me_changes',
|
||||
JSON.stringify({
|
||||
type: 'claude_job_status',
|
||||
job_id: j.id,
|
||||
task_id: j.task_id,
|
||||
user_id: userId,
|
||||
product_id: j.product_id,
|
||||
status: 'queued',
|
||||
}),
|
||||
])
|
||||
}
|
||||
await pg.end()
|
||||
} catch {
|
||||
// non-fatal — status transitions are already persisted
|
||||
}
|
||||
}
|
||||
|
||||
export async function tryClaimJob(
|
||||
|
|
@ -162,6 +280,8 @@ export function registerWaitForJobTool(server: McpServer) {
|
|||
description:
|
||||
'Block until a QUEUED ClaudeJob is available for this user, then claim it atomically ' +
|
||||
'and return full task context (implementation_plan, story, pbi, sprint, repo_url). ' +
|
||||
'Also creates a git worktree for the job and returns worktree_path and branch_name. ' +
|
||||
'Work exclusively in worktree_path — do all file edits and commits there. ' +
|
||||
'Registers worker presence so the Scrum4Me UI can show "Agent verbonden". ' +
|
||||
'Resets stale CLAIMED jobs (>30min) back to QUEUED before scanning. ' +
|
||||
'Pass optional product_id to scope to a specific product. ' +
|
||||
|
|
@ -199,7 +319,9 @@ export function registerWaitForJobTool(server: McpServer) {
|
|||
if (jobId) {
|
||||
const ctx = await getFullJobContext(jobId)
|
||||
if (!ctx) return toolError('Job claimed but context fetch failed')
|
||||
return toolJson(ctx)
|
||||
const wt = await attachWorktreeToJob(ctx.product.id, jobId)
|
||||
if ('error' in wt) return toolError(wt.error)
|
||||
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
|
||||
}
|
||||
|
||||
// 3. No job available — LISTEN and poll until timeout
|
||||
|
|
@ -243,7 +365,9 @@ export function registerWaitForJobTool(server: McpServer) {
|
|||
if (jobId) {
|
||||
const ctx = await getFullJobContext(jobId)
|
||||
if (!ctx) return toolError('Job claimed but context fetch failed')
|
||||
return toolJson(ctx)
|
||||
const wt = await attachWorktreeToJob(ctx.product.id, jobId)
|
||||
if ('error' in wt) return toolError(wt.error)
|
||||
return toolJson({ ...ctx, worktree_path: wt.worktree_path, branch_name: wt.branch_name })
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
|
|
|||
125
src/verify/classify.ts
Normal file
125
src/verify/classify.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
export type VerifyResultValue = 'ALIGNED' | 'PARTIAL' | 'EMPTY' | 'DIVERGENT'
|
||||
|
||||
export interface ClassifyResult {
|
||||
result: VerifyResultValue
|
||||
reasoning: string
|
||||
}
|
||||
|
||||
// Extract changed file paths from a unified diff ("+++ b/<path>" lines).
|
||||
function extractDiffPaths(diff: string): string[] {
|
||||
const paths = new Set<string>()
|
||||
for (const line of diff.split('\n')) {
|
||||
const m = line.match(/^\+\+\+ b\/(.+)$/)
|
||||
if (m && m[1].trim() !== '/dev/null') paths.add(m[1].trim())
|
||||
}
|
||||
return [...paths]
|
||||
}
|
||||
|
||||
// Extract file paths mentioned in a plan (backtick-quoted, parenthesised, or bullet-list headings).
|
||||
function extractPlanPaths(plan: string): string[] {
|
||||
const paths = new Set<string>()
|
||||
|
||||
const backtickRe = /`([^`\s][^`]*[^`\s]|[^`\s])`/g
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = backtickRe.exec(plan)) !== null) {
|
||||
const p = m[1].trim()
|
||||
if ((p.includes('/') || p.includes('.')) && !p.includes(' ') && p.length > 3) paths.add(p)
|
||||
}
|
||||
|
||||
const bulletRe = /^[-*]\s+\*{0,2}([^\s*][^\s]*)\.([a-zA-Z]{1,6})\*{0,2}\s*[:\n]/gm
|
||||
while ((m = bulletRe.exec(plan)) !== null) {
|
||||
paths.add(`${m[1]}.${m[2]}`)
|
||||
}
|
||||
|
||||
return [...paths]
|
||||
}
|
||||
|
||||
// Path match: exact or suffix match so "classify.ts" matches "src/verify/classify.ts".
|
||||
function pathMatches(planPath: string, diffPaths: string[]): boolean {
|
||||
const norm = planPath.replace(/\\/g, '/')
|
||||
return diffPaths.some((dp) => {
|
||||
const ndp = dp.replace(/\\/g, '/')
|
||||
return ndp === norm || ndp.endsWith(`/${norm}`) || norm.endsWith(`/${ndp}`)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a unified git diff against an implementation plan.
|
||||
* Returns a VerifyResult (ALIGNED|PARTIAL|EMPTY|DIVERGENT) plus human-readable reasoning.
|
||||
*
|
||||
* v1 heuristic — no LLM required:
|
||||
* - No file changes in diff → EMPTY
|
||||
* - Plan empty, diff ≤50 changed lines → ALIGNED (targeted fix)
|
||||
* - Plan empty, diff >50 changed lines → DIVERGENT (too large to trust)
|
||||
* - Plan paths < 100% covered in diff → PARTIAL
|
||||
* - Plan paths 100% covered, diff ≥3× more paths → DIVERGENT (scope creep)
|
||||
* - Plan paths 100% covered, diff <3× more paths → ALIGNED
|
||||
*/
|
||||
export function classifyDiffAgainstPlan(opts: {
|
||||
diff: string
|
||||
plan: string | null
|
||||
}): ClassifyResult {
|
||||
const { diff, plan } = opts
|
||||
|
||||
const diffPaths = extractDiffPaths(diff)
|
||||
if (diffPaths.length === 0) {
|
||||
return { result: 'EMPTY', reasoning: 'Geen bestandswijzigingen in de diff.' }
|
||||
}
|
||||
|
||||
const changedLines = diff.split('\n').filter((l) => l.startsWith('+') || l.startsWith('-')).length
|
||||
|
||||
if (!plan || plan.trim().length === 0) {
|
||||
if (changedLines > 50) {
|
||||
return {
|
||||
result: 'DIVERGENT',
|
||||
reasoning: `Geen plan-baseline aanwezig; ${changedLines} gewijzigde regels — te groot om als aligned te bestempelen.`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
result: 'ALIGNED',
|
||||
reasoning: `Geen plan-baseline; ${diffPaths.length} bestand(en) gewijzigd — kleine gerichte wijziging.`,
|
||||
}
|
||||
}
|
||||
|
||||
const planPaths = extractPlanPaths(plan)
|
||||
|
||||
if (planPaths.length === 0) {
|
||||
if (changedLines > 50) {
|
||||
return {
|
||||
result: 'DIVERGENT',
|
||||
reasoning: `Plan vermeldt geen specifieke paden; ${changedLines} gewijzigde regels — te groot om als aligned te bestempelen.`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
result: 'ALIGNED',
|
||||
reasoning: `Plan vermeldt geen specifieke paden; ${diffPaths.length} bestand(en) gewijzigd.`,
|
||||
}
|
||||
}
|
||||
|
||||
const covered = planPaths.filter((pp) => pathMatches(pp, diffPaths))
|
||||
const coverage = covered.length / planPaths.length
|
||||
const ratio = diffPaths.length / planPaths.length
|
||||
|
||||
if (coverage < 1) {
|
||||
const missing = planPaths.filter((pp) => !pathMatches(pp, diffPaths))
|
||||
const missingStr = missing.slice(0, 3).join(', ') + (missing.length > 3 ? ` + ${missing.length - 3} meer` : '')
|
||||
return {
|
||||
result: 'PARTIAL',
|
||||
reasoning: `${covered.length}/${planPaths.length} plan-paden aanwezig in diff. Ontbrekend: ${missingStr}.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (ratio >= 3) {
|
||||
const extra = diffPaths.filter((dp) => !planPaths.some((pp) => pathMatches(pp, [dp])))
|
||||
const extraStr = extra.slice(0, 3).join(', ') + (extra.length > 3 ? ` + ${extra.length - 3} meer` : '')
|
||||
return {
|
||||
result: 'DIVERGENT',
|
||||
reasoning: `Alle ${planPaths.length} plan-paden aanwezig, maar diff bevat ${diffPaths.length} paden (${ratio.toFixed(1)}x). Extra: ${extraStr}.`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: 'ALIGNED',
|
||||
reasoning: `Alle ${planPaths.length} plan-paden aanwezig in diff (${diffPaths.length} totaal; ${ratio.toFixed(1)}x).`,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue