From 813979388b92f366a01771e3d1ffcf6b931b19b3 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 16:28:56 +0100 Subject: [PATCH] feat: wire conflictFiles through errand.get and add repository tests - `errand.get` now returns `conflictFiles: string[]` (always an array, never null) with defensive JSON.parse error handling - `errand.get` returns `projectPath: string | null` computed from workspaceRoot + getProjectCloneDir so cw errand resolve can locate the worktree without a second tRPC call - Add `apps/server/db/repositories/drizzle/errand.test.ts` covering conflictFiles store/retrieve, null for non-conflict errands, and findAll including conflictFiles - Update `errand.test.ts` mock to include getProjectCloneDir and fix conflictFiles expectation from null to [] Co-Authored-By: Claude Sonnet 4.6 --- .../db/repositories/drizzle/errand.test.ts | 161 ++++++++++++++++++ apps/server/trpc/routers/errand.test.ts | 3 +- apps/server/trpc/routers/errand.ts | 28 ++- docs/server-api.md | 2 +- 4 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 apps/server/db/repositories/drizzle/errand.test.ts diff --git a/apps/server/db/repositories/drizzle/errand.test.ts b/apps/server/db/repositories/drizzle/errand.test.ts new file mode 100644 index 0000000..021a925 --- /dev/null +++ b/apps/server/db/repositories/drizzle/errand.test.ts @@ -0,0 +1,161 @@ +/** + * DrizzleErrandRepository Tests + * + * Tests for the Errand repository adapter. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { DrizzleErrandRepository } from './errand.js'; +import { DrizzleProjectRepository } from './project.js'; +import { createTestDatabase } from './test-helpers.js'; +import type { DrizzleDatabase } from '../../index.js'; +import type { Project } from '../../schema.js'; + +describe('DrizzleErrandRepository', () => { + let db: DrizzleDatabase; + let repo: DrizzleErrandRepository; + let projectRepo: DrizzleProjectRepository; + + const createProject = async (): Promise => { + const suffix = Math.random().toString(36).slice(2, 8); + return projectRepo.create({ + name: `test-project-${suffix}`, + url: `https://github.com/test/repo-${suffix}`, + }); + }; + + beforeEach(() => { + db = createTestDatabase(); + repo = new DrizzleErrandRepository(db); + projectRepo = new DrizzleProjectRepository(db); + }); + + describe('create', () => { + it('creates an errand with generated id and timestamps', async () => { + const project = await createProject(); + const errand = await repo.create({ + description: 'fix typo', + branch: 'cw/errand/fix-typo-abc12345', + baseBranch: 'main', + agentId: null, + projectId: project.id, + status: 'active', + }); + + expect(errand.id).toBeDefined(); + expect(errand.id.length).toBeGreaterThan(0); + expect(errand.description).toBe('fix typo'); + expect(errand.branch).toBe('cw/errand/fix-typo-abc12345'); + expect(errand.baseBranch).toBe('main'); + expect(errand.agentId).toBeNull(); + expect(errand.projectId).toBe(project.id); + expect(errand.status).toBe('active'); + expect(errand.conflictFiles).toBeNull(); + expect(errand.createdAt).toBeInstanceOf(Date); + expect(errand.updatedAt).toBeInstanceOf(Date); + }); + }); + + describe('findById', () => { + it('returns null for non-existent errand', async () => { + const result = await repo.findById('does-not-exist'); + expect(result).toBeNull(); + }); + + it('returns errand with agentAlias null when no agent', async () => { + const project = await createProject(); + const created = await repo.create({ + description: 'test', + branch: 'cw/errand/test-xyz', + baseBranch: 'main', + agentId: null, + projectId: project.id, + status: 'active', + }); + const found = await repo.findById(created.id); + expect(found).not.toBeNull(); + expect(found!.agentAlias).toBeNull(); + }); + }); + + describe('findAll', () => { + it('returns empty array when no errands', async () => { + const results = await repo.findAll(); + expect(results).toEqual([]); + }); + + it('filters by projectId', async () => { + const projectA = await createProject(); + const projectB = await createProject(); + await repo.create({ description: 'a', branch: 'cw/errand/a', baseBranch: 'main', agentId: null, projectId: projectA.id, status: 'active' }); + await repo.create({ description: 'b', branch: 'cw/errand/b', baseBranch: 'main', agentId: null, projectId: projectB.id, status: 'active' }); + + const results = await repo.findAll({ projectId: projectA.id }); + expect(results).toHaveLength(1); + expect(results[0].description).toBe('a'); + }); + }); + + describe('update', () => { + it('updates errand status', async () => { + const project = await createProject(); + const created = await repo.create({ + description: 'upd test', + branch: 'cw/errand/upd', + baseBranch: 'main', + agentId: null, + projectId: project.id, + status: 'active', + }); + const updated = await repo.update(created.id, { status: 'pending_review' }); + expect(updated!.status).toBe('pending_review'); + }); + }); + + describe('conflictFiles column', () => { + it('stores and retrieves conflictFiles via update + findById', async () => { + const project = await createProject(); + const created = await repo.create({ + description: 'x', + branch: 'cw/errand/x', + baseBranch: 'main', + agentId: null, + projectId: project.id, + status: 'active', + }); + await repo.update(created.id, { status: 'conflict', conflictFiles: '["src/a.ts","src/b.ts"]' }); + const found = await repo.findById(created.id); + expect(found!.conflictFiles).toBe('["src/a.ts","src/b.ts"]'); + expect(found!.status).toBe('conflict'); + }); + + it('returns null conflictFiles for non-conflict errands', async () => { + const project = await createProject(); + const created = await repo.create({ + description: 'y', + branch: 'cw/errand/y', + baseBranch: 'main', + agentId: null, + projectId: project.id, + status: 'active', + }); + const found = await repo.findById(created.id); + expect(found!.conflictFiles).toBeNull(); + }); + + it('findAll includes conflictFiles in results', async () => { + const project = await createProject(); + const created = await repo.create({ + description: 'z', + branch: 'cw/errand/z', + baseBranch: 'main', + agentId: null, + projectId: project.id, + status: 'active', + }); + await repo.update(created.id, { conflictFiles: '["x.ts"]' }); + const all = await repo.findAll({ projectId: project.id }); + expect(all[0].conflictFiles).toBe('["x.ts"]'); + }); + }); +}); diff --git a/apps/server/trpc/routers/errand.test.ts b/apps/server/trpc/routers/errand.test.ts index c21e0b8..a389e92 100644 --- a/apps/server/trpc/routers/errand.test.ts +++ b/apps/server/trpc/routers/errand.test.ts @@ -35,6 +35,7 @@ vi.mock('../../git/manager.js', () => ({ vi.mock('../../git/project-clones.js', () => ({ ensureProjectClone: mockEnsureProjectClone, + getProjectCloneDir: vi.fn().mockReturnValue('repos/test-project-abc123'), })); vi.mock('../../agent/file-io.js', async (importOriginal) => { @@ -393,7 +394,7 @@ describe('errand procedures', () => { expect(result.id).toBe(errand.id); expect(result).toHaveProperty('agentAlias'); - expect(result.conflictFiles).toBeNull(); + expect(result.conflictFiles).toEqual([]); }); it('parses conflictFiles JSON when present', async () => { diff --git a/apps/server/trpc/routers/errand.ts b/apps/server/trpc/routers/errand.ts index 4ef6a32..a2f5502 100644 --- a/apps/server/trpc/routers/errand.ts +++ b/apps/server/trpc/routers/errand.ts @@ -18,8 +18,9 @@ import { } from './_helpers.js'; import { writeErrandManifest } from '../../agent/file-io.js'; import { buildErrandPrompt } from '../../agent/prompts/index.js'; +import { join } from 'node:path'; import { SimpleGitWorktreeManager } from '../../git/manager.js'; -import { ensureProjectClone } from '../../git/project-clones.js'; +import { ensureProjectClone, getProjectCloneDir } from '../../git/project-clones.js'; import type { TRPCContext } from '../context.js'; // ErrandStatus values for input validation @@ -200,10 +201,27 @@ export function errandProcedures(publicProcedure: ProcedureBuilder) { if (!errand) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Errand not found' }); } - return { - ...errand, - conflictFiles: errand.conflictFiles ? (JSON.parse(errand.conflictFiles) as string[]) : null, - }; + + // Parse conflictFiles; return [] on null or malformed JSON + let conflictFiles: string[] = []; + if (errand.conflictFiles) { + try { + conflictFiles = JSON.parse(errand.conflictFiles) as string[]; + } catch { + conflictFiles = []; + } + } + + // Compute project clone path for cw errand resolve + let projectPath: string | null = null; + if (errand.projectId && ctx.workspaceRoot) { + const project = await requireProjectRepository(ctx).findById(errand.projectId); + if (project) { + projectPath = join(ctx.workspaceRoot, getProjectCloneDir(project.name, project.id)); + } + } + + return { ...errand, conflictFiles, projectPath }; }), // ----------------------------------------------------------------------- diff --git a/docs/server-api.md b/docs/server-api.md index dc7def9..eee2e4d 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -278,7 +278,7 @@ Small isolated changes that spawn a dedicated agent in a git worktree. Errands a |-----------|------|-------------| | `errand.create` | mutation | Create errand: `{description, projectId, baseBranch?}` → `{id, branch, agentId}`. Creates branch, worktree, DB record, spawns agent. | | `errand.list` | query | List errands: `{projectId?, status?}` → ErrandWithAlias[] (ordered newest-first) | -| `errand.get` | query | Get errand by ID: `{id}` → ErrandWithAlias with parsed `conflictFiles` | +| `errand.get` | query | Get errand by ID: `{id}` → ErrandWithAlias with parsed `conflictFiles: string[]` (never null) and `projectPath: string \| null` (computed from workspaceRoot) | | `errand.diff` | query | Get branch diff: `{id}` → `{diff: string}` | | `errand.complete` | mutation | Mark active errand ready for review (stops agent): `{id}` → Errand | | `errand.merge` | mutation | Merge errand branch: `{id, target?}` → `{status: 'merged'}` or throws conflict |