feat: Add errands schema, repository, and wire into tRPC context/container

Creates the errands table (with conflictFiles column), errand-repository
port interface, DrizzleErrandRepository adapter, and wires the repository
into TRPCContext, the DI container, _helpers.ts requireErrandRepository guard,
and the test harness. Also fixes pre-existing TS error in controller.test.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas May
2026-03-06 15:49:26 +01:00
parent 67658fb717
commit 3a328d2b1c
13 changed files with 2226 additions and 2 deletions

View File

@@ -50,7 +50,6 @@ function makeController(overrides: {
cleanupStrategy,
overrides.accountRepository as AccountRepository | undefined,
false,
overrides.eventBus,
);
}

View File

@@ -22,6 +22,7 @@ import {
DrizzleConversationRepository,
DrizzleChatSessionRepository,
DrizzleReviewCommentRepository,
DrizzleErrandRepository,
} from './db/index.js';
import type { InitiativeRepository } from './db/repositories/initiative-repository.js';
import type { PhaseRepository } from './db/repositories/phase-repository.js';
@@ -36,6 +37,7 @@ import type { LogChunkRepository } from './db/repositories/log-chunk-repository.
import type { ConversationRepository } from './db/repositories/conversation-repository.js';
import type { ChatSessionRepository } from './db/repositories/chat-session-repository.js';
import type { ReviewCommentRepository } from './db/repositories/review-comment-repository.js';
import type { ErrandRepository } from './db/repositories/errand-repository.js';
import type { EventBus } from './events/index.js';
import { createEventBus } from './events/index.js';
import { ProcessManager, ProcessRegistry } from './process/index.js';
@@ -77,6 +79,7 @@ export interface Repositories {
conversationRepository: ConversationRepository;
chatSessionRepository: ChatSessionRepository;
reviewCommentRepository: ReviewCommentRepository;
errandRepository: ErrandRepository;
}
/**
@@ -98,6 +101,7 @@ export function createRepositories(db: DrizzleDatabase): Repositories {
conversationRepository: new DrizzleConversationRepository(db),
chatSessionRepository: new DrizzleChatSessionRepository(db),
reviewCommentRepository: new DrizzleReviewCommentRepository(db),
errandRepository: new DrizzleErrandRepository(db),
};
}

View File

@@ -0,0 +1,104 @@
/**
* Drizzle Errand Repository Adapter
*
* Implements ErrandRepository interface using Drizzle ORM.
*/
import { eq, and } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { errands, agents, type Errand } from '../../schema.js';
import type {
ErrandRepository,
CreateErrandData,
UpdateErrandData,
ErrandWithAlias,
FindAllErrandOptions,
} from '../errand-repository.js';
export class DrizzleErrandRepository implements ErrandRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreateErrandData): Promise<Errand> {
const now = new Date();
const id = nanoid();
const [created] = await this.db.insert(errands).values({
id,
description: data.description,
branch: data.branch,
baseBranch: data.baseBranch ?? 'main',
agentId: data.agentId ?? null,
projectId: data.projectId,
status: data.status ?? 'active',
conflictFiles: data.conflictFiles ?? null,
createdAt: now,
updatedAt: now,
}).returning();
return created;
}
async findById(id: string): Promise<ErrandWithAlias | null> {
const rows = await this.db
.select({
id: errands.id,
description: errands.description,
branch: errands.branch,
baseBranch: errands.baseBranch,
agentId: errands.agentId,
projectId: errands.projectId,
status: errands.status,
conflictFiles: errands.conflictFiles,
createdAt: errands.createdAt,
updatedAt: errands.updatedAt,
agentAlias: agents.name,
})
.from(errands)
.leftJoin(agents, eq(errands.agentId, agents.id))
.where(eq(errands.id, id))
.limit(1);
if (!rows[0]) return null;
return rows[0] as ErrandWithAlias;
}
async findAll(options?: FindAllErrandOptions): Promise<ErrandWithAlias[]> {
const conditions = [];
if (options?.projectId) conditions.push(eq(errands.projectId, options.projectId));
if (options?.status) conditions.push(eq(errands.status, options.status));
const rows = await this.db
.select({
id: errands.id,
description: errands.description,
branch: errands.branch,
baseBranch: errands.baseBranch,
agentId: errands.agentId,
projectId: errands.projectId,
status: errands.status,
conflictFiles: errands.conflictFiles,
createdAt: errands.createdAt,
updatedAt: errands.updatedAt,
agentAlias: agents.name,
})
.from(errands)
.leftJoin(agents, eq(errands.agentId, agents.id))
.where(conditions.length > 0 ? and(...conditions) : undefined);
return rows as ErrandWithAlias[];
}
async update(id: string, data: UpdateErrandData): Promise<Errand | null> {
await this.db
.update(errands)
.set({ ...data, updatedAt: new Date() })
.where(eq(errands.id, id));
const rows = await this.db
.select()
.from(errands)
.where(eq(errands.id, id))
.limit(1);
return rows[0] ?? null;
}
async delete(id: string): Promise<void> {
await this.db.delete(errands).where(eq(errands.id, id));
}
}

View File

@@ -18,3 +18,4 @@ export { DrizzleLogChunkRepository } from './log-chunk.js';
export { DrizzleConversationRepository } from './conversation.js';
export { DrizzleChatSessionRepository } from './chat-session.js';
export { DrizzleReviewCommentRepository } from './review-comment.js';
export { DrizzleErrandRepository } from './errand.js';

View File

@@ -0,0 +1,45 @@
/**
* Errand Repository Port Interface
*
* Port for Errand aggregate operations.
* Implementations (Drizzle, etc.) are adapters.
*/
import type { Errand, NewErrand, ErrandStatus } from '../schema.js';
/**
* Data for creating a new errand.
* Omits system-managed fields (id, createdAt, updatedAt).
*/
export type CreateErrandData = Omit<NewErrand, 'id' | 'createdAt' | 'updatedAt'>;
/**
* Data for updating an errand.
*/
export type UpdateErrandData = Partial<Omit<NewErrand, 'id' | 'createdAt'>>;
/**
* Errand with the agent alias joined in.
*/
export interface ErrandWithAlias extends Errand {
agentAlias: string | null;
}
/**
* Filter options for listing errands.
*/
export interface FindAllErrandOptions {
projectId?: string;
status?: ErrandStatus;
}
/**
* Errand Repository Port
*/
export interface ErrandRepository {
create(data: CreateErrandData): Promise<Errand>;
findById(id: string): Promise<ErrandWithAlias | null>;
findAll(options?: FindAllErrandOptions): Promise<ErrandWithAlias[]>;
update(id: string, data: UpdateErrandData): Promise<Errand | null>;
delete(id: string): Promise<void>;
}

View File

@@ -82,3 +82,11 @@ export type {
ReviewCommentRepository,
CreateReviewCommentData,
} from './review-comment-repository.js';
export type {
ErrandRepository,
CreateErrandData,
UpdateErrandData,
ErrandWithAlias,
FindAllErrandOptions,
} from './errand-repository.js';

View File

@@ -628,3 +628,33 @@ export const reviewComments = sqliteTable('review_comments', {
export type ReviewComment = InferSelectModel<typeof reviewComments>;
export type NewReviewComment = InferInsertModel<typeof reviewComments>;
// ============================================================================
// ERRANDS
// ============================================================================
export const ERRAND_STATUS_VALUES = ['active', 'pending_review', 'conflict', 'merged', 'abandoned'] as const;
export type ErrandStatus = (typeof ERRAND_STATUS_VALUES)[number];
export const errands = sqliteTable('errands', {
id: text('id').primaryKey(),
description: text('description').notNull(),
branch: text('branch').notNull(),
baseBranch: text('base_branch').notNull().default('main'),
agentId: text('agent_id').references(() => agents.id, { onDelete: 'set null' }),
projectId: text('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
status: text('status', { enum: ERRAND_STATUS_VALUES })
.notNull()
.default('active'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
conflictFiles: text('conflict_files'), // JSON-encoded string[] | null; set on merge conflict
}, (table) => [
index('errands_project_id_idx').on(table.projectId),
index('errands_status_idx').on(table.status),
]);
export type Errand = InferSelectModel<typeof errands>;
export type NewErrand = InferInsertModel<typeof errands>;

View File

@@ -0,0 +1,17 @@
CREATE TABLE `errands` (
`id` text PRIMARY KEY NOT NULL,
`description` text NOT NULL,
`branch` text NOT NULL,
`base_branch` text DEFAULT 'main' NOT NULL,
`agent_id` text,
`project_id` text NOT NULL,
`status` text DEFAULT 'active' NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`conflict_files` text,
FOREIGN KEY (`agent_id`) REFERENCES `agents`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `errands_project_id_idx` ON `errands` (`project_id`);--> statement-breakpoint
CREATE INDEX `errands_status_idx` ON `errands` (`status`);

File diff suppressed because it is too large Load Diff

View File

@@ -239,6 +239,13 @@
"when": 1772409600000,
"tag": "0033_drop_approval_columns",
"breakpoints": true
},
{
"idx": 34,
"version": "6",
"when": 1772808163349,
"tag": "0034_salty_next_avengers",
"breakpoints": true
}
]
}

View File

@@ -25,6 +25,7 @@ import type { MessageRepository } from '../db/repositories/message-repository.js
import type { AgentRepository } from '../db/repositories/agent-repository.js';
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { ErrandRepository } from '../db/repositories/errand-repository.js';
import type { Initiative, Phase, Task } from '../db/schema.js';
import { createTestDatabase } from '../db/repositories/drizzle/test-helpers.js';
import { createRepositories } from '../container.js';
@@ -204,6 +205,8 @@ export interface TestHarness {
initiativeRepository: InitiativeRepository;
/** Phase repository */
phaseRepository: PhaseRepository;
/** Errand repository */
errandRepository: ErrandRepository;
// tRPC Caller
/** tRPC caller for direct procedure calls */
@@ -409,7 +412,7 @@ export function createTestHarness(): TestHarness {
// Create repositories
const repos = createRepositories(db);
const { taskRepository, messageRepository, agentRepository, initiativeRepository, phaseRepository } = repos;
const { taskRepository, messageRepository, agentRepository, initiativeRepository, phaseRepository, errandRepository } = repos;
// Create real managers wired to mocks
const dispatchManager = new DefaultDispatchManager(
@@ -447,6 +450,7 @@ export function createTestHarness(): TestHarness {
coordinationManager,
initiativeRepository,
phaseRepository,
errandRepository,
});
// Create tRPC caller
@@ -470,6 +474,7 @@ export function createTestHarness(): TestHarness {
agentRepository,
initiativeRepository,
phaseRepository,
errandRepository,
// tRPC Caller
caller,

View File

@@ -19,6 +19,7 @@ import type { LogChunkRepository } from '../db/repositories/log-chunk-repository
import type { ConversationRepository } from '../db/repositories/conversation-repository.js';
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js';
import type { ErrandRepository } from '../db/repositories/errand-repository.js';
import type { AccountCredentialManager } from '../agent/credentials/types.js';
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
import type { CoordinationManager } from '../coordination/types.js';
@@ -80,6 +81,8 @@ export interface TRPCContext {
chatSessionRepository?: ChatSessionRepository;
/** Review comment repository for inline review comments on phase diffs */
reviewCommentRepository?: ReviewCommentRepository;
/** Errand repository for errand CRUD operations */
errandRepository?: ErrandRepository;
/** Project sync manager for remote fetch/sync operations */
projectSyncManager?: ProjectSyncManager;
/** Absolute path to the workspace root (.cwrc directory) */
@@ -113,6 +116,7 @@ export interface CreateContextOptions {
conversationRepository?: ConversationRepository;
chatSessionRepository?: ChatSessionRepository;
reviewCommentRepository?: ReviewCommentRepository;
errandRepository?: ErrandRepository;
projectSyncManager?: ProjectSyncManager;
workspaceRoot?: string;
}
@@ -148,6 +152,7 @@ export function createContext(options: CreateContextOptions): TRPCContext {
conversationRepository: options.conversationRepository,
chatSessionRepository: options.chatSessionRepository,
reviewCommentRepository: options.reviewCommentRepository,
errandRepository: options.errandRepository,
projectSyncManager: options.projectSyncManager,
workspaceRoot: options.workspaceRoot,
};

View File

@@ -19,6 +19,7 @@ import type { LogChunkRepository } from '../../db/repositories/log-chunk-reposit
import type { ConversationRepository } from '../../db/repositories/conversation-repository.js';
import type { ChatSessionRepository } from '../../db/repositories/chat-session-repository.js';
import type { ReviewCommentRepository } from '../../db/repositories/review-comment-repository.js';
import type { ErrandRepository } from '../../db/repositories/errand-repository.js';
import type { DispatchManager, PhaseDispatchManager } from '../../dispatch/types.js';
import type { CoordinationManager } from '../../coordination/types.js';
import type { BranchManager } from '../../git/branch-manager.js';
@@ -225,3 +226,13 @@ export function requireProjectSyncManager(ctx: TRPCContext): ProjectSyncManager
}
return ctx.projectSyncManager;
}
export function requireErrandRepository(ctx: TRPCContext): ErrandRepository {
if (!ctx.errandRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Errand repository not available',
});
}
return ctx.errandRepository;
}