refactor: Restructure monorepo to apps/server/ and apps/web/ layout

Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt
standard monorepo conventions (apps/ for runnable apps, packages/
for reusable libraries). Update all config files, shared package
imports, test fixtures, and documentation to reflect new paths.

Key fixes:
- Update workspace config to ["apps/*", "packages/*"]
- Update tsconfig.json rootDir/include for apps/server/
- Add apps/web/** to vitest exclude list
- Update drizzle.config.ts schema path
- Fix ensure-schema.ts migration path detection (3 levels up in dev,
  2 levels up in dist)
- Fix tests/integration/cli-server.test.ts import paths
- Update packages/shared imports to apps/server/ paths
- Update all docs/ files with new paths
This commit is contained in:
Lukas May
2026-03-03 11:22:53 +01:00
parent 8c38d958ce
commit 34578d39c6
535 changed files with 75452 additions and 687 deletions

40
apps/server/db/config.ts Normal file
View File

@@ -0,0 +1,40 @@
import { mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { findWorkspaceRoot } from '../config/cwrc.js';
/**
* Get the database path.
*
* - Default: <workspace-root>/.cw/cw.db
* - Throws if no .cwrc workspace is found
* - Override via CW_DB_PATH environment variable
* - For testing, pass ':memory:' as CW_DB_PATH
*/
export function getDbPath(): string {
const envPath = process.env.CW_DB_PATH;
if (envPath) {
return envPath;
}
const root = findWorkspaceRoot();
if (!root) {
throw new Error(
'No .cwrc workspace found. Run `cw init` to initialize a workspace.',
);
}
return join(root, '.cw', 'cw.db');
}
/**
* Ensure the parent directory for the database file exists.
* No-op for in-memory databases.
*/
export function ensureDbDirectory(dbPath: string): void {
// Skip for in-memory database
if (dbPath === ':memory:') {
return;
}
const dir = dirname(dbPath);
mkdirSync(dir, { recursive: true });
}

View File

@@ -0,0 +1,42 @@
/**
* Database Migration
*
* Runs drizzle-kit migrations from the drizzle/ directory.
* Safe to call on every startup - only applies pending migrations.
*/
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync } from 'node:fs';
import type { DrizzleDatabase } from './index.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('db');
/**
* Resolve the migrations directory relative to the package root.
* Works both in development (src/) and after build (dist/).
*/
function getMigrationsPath(): string {
const currentDir = dirname(fileURLToPath(import.meta.url));
// In dev (tsx): apps/server/db/ — need 3 levels up to workspace root
// In dist (tsc): dist/db/ — need 2 levels up to workspace root
const upThree = join(currentDir, '..', '..', '..', 'drizzle');
if (existsSync(upThree)) return upThree;
return join(currentDir, '..', '..', 'drizzle');
}
/**
* Run all pending database migrations.
*
* Uses drizzle-kit's migration system which tracks applied migrations
* in a __drizzle_migrations table. Safe to call on every startup.
*
* @param db - Drizzle database instance
*/
export function ensureSchema(db: DrizzleDatabase): void {
log.info('applying database migrations');
migrate(db, { migrationsFolder: getMigrationsPath() });
log.info('database migrations complete');
}

52
apps/server/db/index.ts Normal file
View File

@@ -0,0 +1,52 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import { getDbPath, ensureDbDirectory } from './config.js';
import * as schema from './schema.js';
export type DrizzleDatabase = BetterSQLite3Database<typeof schema>;
/**
* Create a new database connection.
*
* This is a factory function (not a singleton) to allow multiple instances
* for testing with isolated databases.
*
* @param path - Optional path override. Defaults to getDbPath().
* Use ':memory:' for in-memory testing database.
* @returns Drizzle database instance with schema
*/
export function createDatabase(path?: string): DrizzleDatabase {
const dbPath = path ?? getDbPath();
// Ensure directory exists for file-based databases
ensureDbDirectory(dbPath);
// Create SQLite connection
const sqlite = new Database(dbPath);
// Enable WAL mode for better concurrent read performance
sqlite.pragma('journal_mode = WAL');
// Enable foreign keys (SQLite has them disabled by default)
sqlite.pragma('foreign_keys = ON');
// Create Drizzle instance with schema
return drizzle(sqlite, { schema });
}
// Re-export config utilities
export { getDbPath, ensureDbDirectory } from './config.js';
// Re-export schema initialization
export { ensureSchema } from './ensure-schema.js';
// Re-export schema and types
export * from './schema.js';
// Re-export repository interfaces (ports)
export * from './repositories/index.js';
// Re-export Drizzle adapters
export * from './repositories/drizzle/index.js';

View File

@@ -0,0 +1,61 @@
/**
* Account Repository Port Interface
*
* Port for Account aggregate operations.
* Accounts represent authenticated provider logins (e.g. Claude OAuth accounts)
* used for round-robin agent spawning and usage-limit failover.
*/
import type { Account } from '../schema.js';
export interface CreateAccountData {
email: string;
provider?: string; // defaults to 'claude'
configJson?: string; // .claude.json content
credentials?: string; // .credentials.json content
}
export interface AccountRepository {
/** Create a new account. Generates id and sets timestamps. */
create(data: CreateAccountData): Promise<Account>;
/** Find an account by its ID. */
findById(id: string): Promise<Account | null>;
/** Find an account by email. */
findByEmail(email: string): Promise<Account | null>;
/** Find all accounts for a given provider. */
findByProvider(provider: string): Promise<Account[]>;
/**
* Find the next available (non-exhausted) account for a provider.
* Uses round-robin via lastUsedAt ordering (least-recently-used first).
* Automatically clears expired exhaustion before querying.
*/
findNextAvailable(provider: string): Promise<Account | null>;
/** Mark an account as exhausted until a given time. */
markExhausted(id: string, until: Date): Promise<Account>;
/** Mark an account as available (clear exhaustion). */
markAvailable(id: string): Promise<Account>;
/** Update the lastUsedAt timestamp for an account. */
updateLastUsed(id: string): Promise<Account>;
/** Clear exhaustion for all accounts whose exhaustedUntil has passed. Returns count cleared. */
clearExpiredExhaustion(): Promise<number>;
/** Find all accounts. */
findAll(): Promise<Account[]>;
/** Update stored credentials for an account. */
updateCredentials(id: string, credentials: string): Promise<Account>;
/** Update both configJson and credentials for an account (used by account add upsert). */
updateAccountAuth(id: string, configJson: string, credentials: string): Promise<Account>;
/** Delete an account. Throws if not found. */
delete(id: string): Promise<void>;
}

View File

@@ -0,0 +1,119 @@
/**
* Agent Repository Port Interface
*
* Port for Agent aggregate operations.
* Implementations (Drizzle, etc.) are adapters.
*/
import type { Agent } from '../schema.js';
import type { AgentMode } from '../../agent/types.js';
/**
* Agent status values.
*/
export type AgentStatus = 'idle' | 'running' | 'waiting_for_input' | 'stopped' | 'crashed';
/**
* Data for creating a new agent.
* Omits system-managed fields and makes optional fields explicit.
*/
export interface CreateAgentData {
name: string;
worktreeId: string;
taskId?: string | null;
initiativeId?: string | null;
sessionId?: string | null;
status?: AgentStatus;
mode?: AgentMode; // Defaults to 'execute' if not provided
provider?: string; // Defaults to 'claude' if not provided
accountId?: string | null;
}
/**
* Data for updating an existing agent.
* All fields optional. System-managed fields (id, createdAt, updatedAt) are excluded.
*/
export interface UpdateAgentData {
name?: string;
worktreeId?: string;
taskId?: string | null;
initiativeId?: string | null;
sessionId?: string | null;
status?: AgentStatus;
mode?: AgentMode;
provider?: string;
accountId?: string | null;
pid?: number | null;
exitCode?: number | null;
outputFilePath?: string | null;
result?: string | null;
pendingQuestions?: string | null;
userDismissedAt?: Date | null;
updatedAt?: Date;
}
/**
* Agent Repository Port
*
* Defines operations for the Agent aggregate.
* Enables agent state persistence for session resumption and listing.
*/
export interface AgentRepository {
/**
* Create a new agent.
* Generates id and sets timestamps automatically.
* Name must be unique.
*/
create(agent: CreateAgentData): Promise<Agent>;
/**
* Find an agent by its ID.
* Returns null if not found.
*/
findById(id: string): Promise<Agent | null>;
/**
* Find an agent by its human-readable name.
* Returns null if not found.
*/
findByName(name: string): Promise<Agent | null>;
/**
* Find an agent by its associated task.
* Returns null if no agent is assigned to that task.
*/
findByTaskId(taskId: string): Promise<Agent | null>;
/**
* Find an agent by its Claude CLI session ID.
* Used for session resumption.
* Returns null if not found.
*/
findBySessionId(sessionId: string): Promise<Agent | null>;
/**
* Find all agents.
* Returns empty array if none exist.
*/
findAll(): Promise<Agent[]>;
/**
* Find agents by status.
* Returns empty array if no agents have that status.
*/
findByStatus(status: AgentStatus): Promise<Agent[]>;
/**
* Update an agent with partial data.
* Only provided fields are updated, others remain unchanged.
* Throws if agent not found.
* Updates updatedAt timestamp automatically.
*/
update(id: string, data: UpdateAgentData): Promise<Agent>;
/**
* Delete an agent.
* Throws if agent not found.
*/
delete(id: string): Promise<void>;
}

View File

@@ -0,0 +1,36 @@
/**
* Change Set Repository Port Interface
*
* Port for ChangeSet aggregate operations.
* Implementations (Drizzle, etc.) are adapters.
*/
import type { ChangeSet, ChangeSetEntry } from '../schema.js';
export type CreateChangeSetData = {
agentId: string | null;
agentName: string;
initiativeId: string;
mode: 'plan' | 'detail' | 'refine';
summary?: string | null;
};
export type CreateChangeSetEntryData = {
entityType: 'page' | 'phase' | 'task' | 'phase_dependency';
entityId: string;
action: 'create' | 'update' | 'delete';
previousState?: string | null;
newState?: string | null;
sortOrder?: number;
};
export type ChangeSetWithEntries = ChangeSet & { entries: ChangeSetEntry[] };
export interface ChangeSetRepository {
createWithEntries(data: CreateChangeSetData, entries: CreateChangeSetEntryData[]): Promise<ChangeSet>;
findById(id: string): Promise<ChangeSet | null>;
findByIdWithEntries(id: string): Promise<ChangeSetWithEntries | null>;
findByInitiativeId(initiativeId: string): Promise<ChangeSet[]>;
findByAgentId(agentId: string): Promise<ChangeSet[]>;
markReverted(id: string): Promise<ChangeSet>;
}

View File

@@ -0,0 +1,23 @@
/**
* Conversation Repository Port Interface
*
* Port for inter-agent conversation persistence operations.
*/
import type { Conversation } from '../schema.js';
export interface CreateConversationData {
fromAgentId: string;
toAgentId: string;
initiativeId?: string | null;
phaseId?: string | null;
taskId?: string | null;
question: string;
}
export interface ConversationRepository {
create(data: CreateConversationData): Promise<Conversation>;
findById(id: string): Promise<Conversation | null>;
findPendingForAgent(toAgentId: string): Promise<Conversation[]>;
answer(id: string, answer: string): Promise<Conversation | null>;
}

View File

@@ -0,0 +1,203 @@
/**
* Drizzle Account Repository Adapter
*
* Implements AccountRepository interface using Drizzle ORM.
* Handles round-robin selection via lastUsedAt ordering
* and automatic exhaustion expiry.
*/
import { eq, and, asc, lte } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { accounts, agents, type Account } from '../../schema.js';
import type { AccountRepository, CreateAccountData } from '../account-repository.js';
export class DrizzleAccountRepository implements AccountRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreateAccountData): Promise<Account> {
const id = nanoid();
const now = new Date();
const [created] = await this.db.insert(accounts).values({
id,
email: data.email,
provider: data.provider ?? 'claude',
configJson: data.configJson ?? null,
credentials: data.credentials ?? null,
isExhausted: false,
exhaustedUntil: null,
lastUsedAt: null,
sortOrder: 0,
createdAt: now,
updatedAt: now,
}).returning();
return created;
}
async findById(id: string): Promise<Account | null> {
const result = await this.db
.select()
.from(accounts)
.where(eq(accounts.id, id))
.limit(1);
return result[0] ?? null;
}
async findByEmail(email: string): Promise<Account | null> {
const result = await this.db
.select()
.from(accounts)
.where(eq(accounts.email, email))
.limit(1);
return result[0] ?? null;
}
async findByProvider(provider: string): Promise<Account[]> {
return this.db
.select()
.from(accounts)
.where(eq(accounts.provider, provider));
}
async findNextAvailable(provider: string): Promise<Account | null> {
await this.clearExpiredExhaustion();
const result = await this.db
.select()
.from(accounts)
.where(
and(
eq(accounts.provider, provider),
eq(accounts.isExhausted, false),
),
)
.orderBy(asc(accounts.lastUsedAt))
.limit(1);
return result[0] ?? null;
}
async markExhausted(id: string, until: Date): Promise<Account> {
const now = new Date();
const [updated] = await this.db
.update(accounts)
.set({
isExhausted: true,
exhaustedUntil: until,
updatedAt: now,
})
.where(eq(accounts.id, id))
.returning();
if (!updated) {
throw new Error(`Account not found: ${id}`);
}
return updated;
}
async markAvailable(id: string): Promise<Account> {
const now = new Date();
const [updated] = await this.db
.update(accounts)
.set({
isExhausted: false,
exhaustedUntil: null,
updatedAt: now,
})
.where(eq(accounts.id, id))
.returning();
if (!updated) {
throw new Error(`Account not found: ${id}`);
}
return updated;
}
async updateLastUsed(id: string): Promise<Account> {
const now = new Date();
const [updated] = await this.db
.update(accounts)
.set({ lastUsedAt: now, updatedAt: now })
.where(eq(accounts.id, id))
.returning();
if (!updated) {
throw new Error(`Account not found: ${id}`);
}
return updated;
}
async clearExpiredExhaustion(): Promise<number> {
const now = new Date();
const cleared = await this.db
.update(accounts)
.set({
isExhausted: false,
exhaustedUntil: null,
updatedAt: now,
})
.where(
and(
eq(accounts.isExhausted, true),
lte(accounts.exhaustedUntil, now),
),
)
.returning({ id: accounts.id });
return cleared.length;
}
async findAll(): Promise<Account[]> {
return this.db.select().from(accounts);
}
async updateCredentials(id: string, credentials: string): Promise<Account> {
const now = new Date();
const [updated] = await this.db
.update(accounts)
.set({ credentials, updatedAt: now })
.where(eq(accounts.id, id))
.returning();
if (!updated) {
throw new Error(`Account not found: ${id}`);
}
return updated;
}
async updateAccountAuth(id: string, configJson: string, credentials: string): Promise<Account> {
const now = new Date();
const [updated] = await this.db
.update(accounts)
.set({ configJson, credentials, updatedAt: now })
.where(eq(accounts.id, id))
.returning();
if (!updated) {
throw new Error(`Account not found: ${id}`);
}
return updated;
}
async delete(id: string): Promise<void> {
// Manually nullify agent FK — the migration lacks ON DELETE SET NULL
await this.db
.update(agents)
.set({ accountId: null })
.where(eq(agents.accountId, id));
const [deleted] = await this.db.delete(accounts).where(eq(accounts.id, id)).returning();
if (!deleted) {
throw new Error(`Account not found: ${id}`);
}
}
}

View File

@@ -0,0 +1,279 @@
/**
* DrizzleAgentRepository Tests
*
* Tests for the Agent repository adapter.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { DrizzleAgentRepository } from './agent.js';
import { DrizzleTaskRepository } from './task.js';
import { DrizzlePhaseRepository } from './phase.js';
import { DrizzleInitiativeRepository } from './initiative.js';
import { createTestDatabase } from './test-helpers.js';
import type { DrizzleDatabase } from '../../index.js';
describe('DrizzleAgentRepository', () => {
let db: DrizzleDatabase;
let agentRepo: DrizzleAgentRepository;
let taskRepo: DrizzleTaskRepository;
let phaseRepo: DrizzlePhaseRepository;
let initiativeRepo: DrizzleInitiativeRepository;
let testTaskId: string;
beforeEach(async () => {
db = createTestDatabase();
agentRepo = new DrizzleAgentRepository(db);
taskRepo = new DrizzleTaskRepository(db);
phaseRepo = new DrizzlePhaseRepository(db);
initiativeRepo = new DrizzleInitiativeRepository(db);
// Create full hierarchy for FK constraint
const initiative = await initiativeRepo.create({
name: 'Test Initiative',
});
const phase = await phaseRepo.create({
initiativeId: initiative.id,
name: 'Test Phase',
});
const task = await taskRepo.create({
phaseId: phase.id,
name: 'Test Task',
order: 1,
});
testTaskId = task.id;
});
describe('create', () => {
it('should create an agent with generated id and timestamps', async () => {
const agent = await agentRepo.create({
name: 'gastown',
worktreeId: 'worktree-123',
taskId: testTaskId,
});
expect(agent.id).toBeDefined();
expect(agent.id.length).toBeGreaterThan(0);
expect(agent.name).toBe('gastown');
expect(agent.worktreeId).toBe('worktree-123');
expect(agent.taskId).toBe(testTaskId);
expect(agent.sessionId).toBeNull();
expect(agent.status).toBe('idle');
expect(agent.createdAt).toBeInstanceOf(Date);
expect(agent.updatedAt).toBeInstanceOf(Date);
});
it('should create agent without taskId', async () => {
const agent = await agentRepo.create({
name: 'standalone',
worktreeId: 'worktree-456',
});
expect(agent.taskId).toBeNull();
});
it('should reject duplicate names', async () => {
await agentRepo.create({
name: 'unique-name',
worktreeId: 'worktree-1',
});
await expect(
agentRepo.create({
name: 'unique-name',
worktreeId: 'worktree-2',
})
).rejects.toThrow();
});
});
describe('findById', () => {
it('should return null for non-existent agent', async () => {
const result = await agentRepo.findById('non-existent-id');
expect(result).toBeNull();
});
it('should find an existing agent', async () => {
const created = await agentRepo.create({
name: 'findme',
worktreeId: 'worktree-123',
});
const found = await agentRepo.findById(created.id);
expect(found).not.toBeNull();
expect(found!.id).toBe(created.id);
expect(found!.name).toBe('findme');
});
});
describe('findByName', () => {
it('should return null for non-existent name', async () => {
const result = await agentRepo.findByName('nonexistent');
expect(result).toBeNull();
});
it('should find agent by human-readable name', async () => {
await agentRepo.create({
name: 'chinatown',
worktreeId: 'worktree-123',
});
const found = await agentRepo.findByName('chinatown');
expect(found).not.toBeNull();
expect(found!.name).toBe('chinatown');
});
});
describe('findByTaskId', () => {
it('should return null when no agent assigned to task', async () => {
const result = await agentRepo.findByTaskId(testTaskId);
expect(result).toBeNull();
});
it('should find agent by task', async () => {
await agentRepo.create({
name: 'task-agent',
worktreeId: 'worktree-123',
taskId: testTaskId,
});
const found = await agentRepo.findByTaskId(testTaskId);
expect(found).not.toBeNull();
expect(found!.taskId).toBe(testTaskId);
});
});
describe('findBySessionId', () => {
it('should return null when no agent has session', async () => {
const result = await agentRepo.findBySessionId('session-123');
expect(result).toBeNull();
});
it('should find agent by session ID', async () => {
const agent = await agentRepo.create({
name: 'session-agent',
worktreeId: 'worktree-123',
});
await agentRepo.update(agent.id, { sessionId: 'session-abc' });
const found = await agentRepo.findBySessionId('session-abc');
expect(found).not.toBeNull();
expect(found!.sessionId).toBe('session-abc');
});
});
describe('findAll', () => {
it('should return empty array when no agents', async () => {
const agents = await agentRepo.findAll();
expect(agents).toEqual([]);
});
it('should return all agents', async () => {
await agentRepo.create({ name: 'agent-1', worktreeId: 'wt-1' });
await agentRepo.create({ name: 'agent-2', worktreeId: 'wt-2' });
await agentRepo.create({ name: 'agent-3', worktreeId: 'wt-3' });
const agents = await agentRepo.findAll();
expect(agents.length).toBe(3);
});
});
describe('findByStatus', () => {
it('should return empty array when no agents have status', async () => {
const agents = await agentRepo.findByStatus('running');
expect(agents).toEqual([]);
});
it('should filter by status correctly', async () => {
const agent1 = await agentRepo.create({
name: 'idle-agent',
worktreeId: 'wt-1',
});
const agent2 = await agentRepo.create({
name: 'running-agent',
worktreeId: 'wt-2',
});
await agentRepo.update(agent2.id, { status: 'running' });
const idleAgents = await agentRepo.findByStatus('idle');
const runningAgents = await agentRepo.findByStatus('running');
expect(idleAgents.length).toBe(1);
expect(idleAgents[0].name).toBe('idle-agent');
expect(runningAgents.length).toBe(1);
expect(runningAgents[0].name).toBe('running-agent');
});
it('should filter by waiting_for_input status', async () => {
const agent = await agentRepo.create({
name: 'waiting-agent',
worktreeId: 'wt-1',
});
await agentRepo.update(agent.id, { status: 'waiting_for_input' });
const waitingAgents = await agentRepo.findByStatus('waiting_for_input');
expect(waitingAgents.length).toBe(1);
expect(waitingAgents[0].status).toBe('waiting_for_input');
});
});
describe('update', () => {
it('should change status and updatedAt', async () => {
const created = await agentRepo.create({
name: 'status-test',
worktreeId: 'wt-1',
});
await new Promise((resolve) => setTimeout(resolve, 10));
const updated = await agentRepo.update(created.id, { status: 'running' });
expect(updated.status).toBe('running');
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(
created.updatedAt.getTime()
);
});
it('should change sessionId and updatedAt', async () => {
const created = await agentRepo.create({
name: 'session-test',
worktreeId: 'wt-1',
});
expect(created.sessionId).toBeNull();
await new Promise((resolve) => setTimeout(resolve, 10));
const updated = await agentRepo.update(created.id, { sessionId: 'new-session-id' });
expect(updated.sessionId).toBe('new-session-id');
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(
created.updatedAt.getTime()
);
});
it('should throw for non-existent agent', async () => {
await expect(
agentRepo.update('non-existent-id', { status: 'running' })
).rejects.toThrow('Agent not found');
});
});
describe('delete', () => {
it('should delete an existing agent', async () => {
const created = await agentRepo.create({
name: 'to-delete',
worktreeId: 'wt-1',
});
await agentRepo.delete(created.id);
const found = await agentRepo.findById(created.id);
expect(found).toBeNull();
});
it('should throw for non-existent agent', async () => {
await expect(agentRepo.delete('non-existent-id')).rejects.toThrow(
'Agent not found'
);
});
});
});

View File

@@ -0,0 +1,119 @@
/**
* Drizzle Agent Repository Adapter
*
* Implements AgentRepository interface using Drizzle ORM.
*/
import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { agents, type Agent } from '../../schema.js';
import type {
AgentRepository,
AgentStatus,
CreateAgentData,
UpdateAgentData,
} from '../agent-repository.js';
/**
* Drizzle adapter for AgentRepository.
*
* Uses dependency injection for database instance,
* enabling isolated test databases.
*/
export class DrizzleAgentRepository implements AgentRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreateAgentData): Promise<Agent> {
const id = nanoid();
const now = new Date();
const [created] = await this.db.insert(agents).values({
id,
name: data.name,
taskId: data.taskId ?? null,
initiativeId: data.initiativeId ?? null,
sessionId: data.sessionId ?? null,
worktreeId: data.worktreeId,
provider: data.provider ?? 'claude',
accountId: data.accountId ?? null,
status: data.status ?? 'idle',
mode: data.mode ?? 'execute',
createdAt: now,
updatedAt: now,
}).returning();
return created;
}
async findById(id: string): Promise<Agent | null> {
const result = await this.db
.select()
.from(agents)
.where(eq(agents.id, id))
.limit(1);
return result[0] ?? null;
}
async findByName(name: string): Promise<Agent | null> {
const result = await this.db
.select()
.from(agents)
.where(eq(agents.name, name))
.limit(1);
return result[0] ?? null;
}
async findByTaskId(taskId: string): Promise<Agent | null> {
const result = await this.db
.select()
.from(agents)
.where(eq(agents.taskId, taskId))
.limit(1);
return result[0] ?? null;
}
async findBySessionId(sessionId: string): Promise<Agent | null> {
const result = await this.db
.select()
.from(agents)
.where(eq(agents.sessionId, sessionId))
.limit(1);
return result[0] ?? null;
}
async findAll(): Promise<Agent[]> {
return this.db.select().from(agents);
}
async findByStatus(status: AgentStatus): Promise<Agent[]> {
return this.db.select().from(agents).where(eq(agents.status, status));
}
async update(id: string, data: UpdateAgentData): Promise<Agent> {
const now = new Date();
const [updated] = await this.db
.update(agents)
.set({ ...data, updatedAt: now })
.where(eq(agents.id, id))
.returning();
if (!updated) {
throw new Error(`Agent not found: ${id}`);
}
return updated;
}
async delete(id: string): Promise<void> {
const [deleted] = await this.db.delete(agents).where(eq(agents.id, id)).returning();
if (!deleted) {
throw new Error(`Agent not found: ${id}`);
}
}
}

View File

@@ -0,0 +1,301 @@
/**
* Cascade Delete Tests
*
* Tests that cascade deletes work correctly through the repository layer.
* Verifies the SQLite foreign key cascade behavior configured in schema.ts.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { nanoid } from 'nanoid';
import { DrizzleInitiativeRepository } from './initiative.js';
import { DrizzlePhaseRepository } from './phase.js';
import { DrizzleTaskRepository } from './task.js';
import { DrizzlePageRepository } from './page.js';
import { DrizzleProjectRepository } from './project.js';
import { DrizzleChangeSetRepository } from './change-set.js';
import { DrizzleAgentRepository } from './agent.js';
import { DrizzleConversationRepository } from './conversation.js';
import { createTestDatabase } from './test-helpers.js';
import { changeSets } from '../../schema.js';
import type { DrizzleDatabase } from '../../index.js';
describe('Cascade Deletes', () => {
let db: DrizzleDatabase;
let initiativeRepo: DrizzleInitiativeRepository;
let phaseRepo: DrizzlePhaseRepository;
let taskRepo: DrizzleTaskRepository;
let pageRepo: DrizzlePageRepository;
let projectRepo: DrizzleProjectRepository;
let changeSetRepo: DrizzleChangeSetRepository;
let agentRepo: DrizzleAgentRepository;
let conversationRepo: DrizzleConversationRepository;
beforeEach(() => {
db = createTestDatabase();
initiativeRepo = new DrizzleInitiativeRepository(db);
phaseRepo = new DrizzlePhaseRepository(db);
taskRepo = new DrizzleTaskRepository(db);
pageRepo = new DrizzlePageRepository(db);
projectRepo = new DrizzleProjectRepository(db);
changeSetRepo = new DrizzleChangeSetRepository(db);
agentRepo = new DrizzleAgentRepository(db);
conversationRepo = new DrizzleConversationRepository(db);
});
/**
* Helper to create a full hierarchy for testing.
* Uses parent tasks (detail category) to group child tasks.
*/
async function createFullHierarchy() {
const initiative = await initiativeRepo.create({
name: 'Test Initiative',
});
const phase1 = await phaseRepo.create({
initiativeId: initiative.id,
name: 'Phase 1',
});
const phase2 = await phaseRepo.create({
initiativeId: initiative.id,
name: 'Phase 2',
});
// Create parent (detail) tasks that group child tasks
const parentTask1 = await taskRepo.create({
phaseId: phase1.id,
initiativeId: initiative.id,
name: 'Parent Task 1-1',
category: 'detail',
order: 1,
});
const parentTask2 = await taskRepo.create({
phaseId: phase1.id,
initiativeId: initiative.id,
name: 'Parent Task 1-2',
category: 'detail',
order: 2,
});
const parentTask3 = await taskRepo.create({
phaseId: phase2.id,
initiativeId: initiative.id,
name: 'Parent Task 2-1',
category: 'detail',
order: 1,
});
// Create child tasks under parent tasks
const task1 = await taskRepo.create({
parentTaskId: parentTask1.id,
phaseId: phase1.id,
initiativeId: initiative.id,
name: 'Task 1-1-1',
order: 1,
});
const task2 = await taskRepo.create({
parentTaskId: parentTask1.id,
phaseId: phase1.id,
initiativeId: initiative.id,
name: 'Task 1-1-2',
order: 2,
});
const task3 = await taskRepo.create({
parentTaskId: parentTask2.id,
phaseId: phase1.id,
initiativeId: initiative.id,
name: 'Task 1-2-1',
order: 1,
});
const task4 = await taskRepo.create({
parentTaskId: parentTask3.id,
phaseId: phase2.id,
initiativeId: initiative.id,
name: 'Task 2-1-1',
order: 1,
});
// Create a page for the initiative
const page = await pageRepo.create({
initiativeId: initiative.id,
parentPageId: null,
title: 'Root Page',
content: null,
sortOrder: 0,
});
// Create a project and link it via junction table
const project = await projectRepo.create({
name: 'test-project',
url: 'https://github.com/test/test-project.git',
});
await projectRepo.setInitiativeProjects(initiative.id, [project.id]);
// Create two agents (need two for conversations, and one for changeSet FK)
const agent1 = await agentRepo.create({
name: 'agent-1',
worktreeId: 'wt-1',
initiativeId: initiative.id,
});
const agent2 = await agentRepo.create({
name: 'agent-2',
worktreeId: 'wt-2',
initiativeId: initiative.id,
});
// Insert change set directly (createWithEntries uses async tx, incompatible with better-sqlite3 sync driver)
const changeSetId = nanoid();
await db.insert(changeSets).values({
id: changeSetId,
agentId: agent1.id,
agentName: agent1.name,
initiativeId: initiative.id,
mode: 'plan',
status: 'applied',
createdAt: new Date(),
});
const changeSet = (await changeSetRepo.findById(changeSetId))!;
// Create a conversation between agents with initiative context
const conversation = await conversationRepo.create({
fromAgentId: agent1.id,
toAgentId: agent2.id,
initiativeId: initiative.id,
question: 'Test question',
});
return {
initiative,
phases: { phase1, phase2 },
parentTasks: { parentTask1, parentTask2, parentTask3 },
tasks: { task1, task2, task3, task4 },
page,
project,
changeSet,
agents: { agent1, agent2 },
conversation,
};
}
describe('delete initiative', () => {
it('should cascade delete all phases, tasks, pages, junction rows, and change sets', async () => {
const { initiative, phases, parentTasks, tasks, page, project, changeSet } =
await createFullHierarchy();
// Verify everything exists
expect(await initiativeRepo.findById(initiative.id)).not.toBeNull();
expect(await phaseRepo.findById(phases.phase1.id)).not.toBeNull();
expect(await phaseRepo.findById(phases.phase2.id)).not.toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask1.id)).not.toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask2.id)).not.toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask3.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task1.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task2.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task3.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task4.id)).not.toBeNull();
expect(await pageRepo.findById(page.id)).not.toBeNull();
expect(await changeSetRepo.findById(changeSet.id)).not.toBeNull();
const linkedProjects = await projectRepo.findProjectsByInitiativeId(initiative.id);
expect(linkedProjects).toHaveLength(1);
// Delete initiative
await initiativeRepo.delete(initiative.id);
// Verify cascade deletes — all gone
expect(await initiativeRepo.findById(initiative.id)).toBeNull();
expect(await phaseRepo.findById(phases.phase1.id)).toBeNull();
expect(await phaseRepo.findById(phases.phase2.id)).toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask1.id)).toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask2.id)).toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask3.id)).toBeNull();
expect(await taskRepo.findById(tasks.task1.id)).toBeNull();
expect(await taskRepo.findById(tasks.task2.id)).toBeNull();
expect(await taskRepo.findById(tasks.task3.id)).toBeNull();
expect(await taskRepo.findById(tasks.task4.id)).toBeNull();
expect(await pageRepo.findById(page.id)).toBeNull();
expect(await changeSetRepo.findById(changeSet.id)).toBeNull();
// Junction row gone but project itself survives
const remainingLinks = await projectRepo.findProjectsByInitiativeId(initiative.id);
expect(remainingLinks).toHaveLength(0);
expect(await projectRepo.findById(project.id)).not.toBeNull();
});
it('should set null on agents and conversations (not cascade)', async () => {
const { initiative, agents, conversation } = await createFullHierarchy();
// Verify agents are linked
const a1Before = await agentRepo.findById(agents.agent1.id);
expect(a1Before!.initiativeId).toBe(initiative.id);
// Delete initiative
await initiativeRepo.delete(initiative.id);
// Agents survive with initiativeId set to null
const a1After = await agentRepo.findById(agents.agent1.id);
expect(a1After).not.toBeNull();
expect(a1After!.initiativeId).toBeNull();
const a2After = await agentRepo.findById(agents.agent2.id);
expect(a2After).not.toBeNull();
expect(a2After!.initiativeId).toBeNull();
// Conversation survives with initiativeId set to null
const convAfter = await conversationRepo.findById(conversation.id);
expect(convAfter).not.toBeNull();
expect(convAfter!.initiativeId).toBeNull();
});
});
describe('delete phase', () => {
it('should cascade delete tasks under that phase only', async () => {
const { initiative, phases, parentTasks, tasks } = await createFullHierarchy();
// Delete phase 1
await phaseRepo.delete(phases.phase1.id);
// Initiative still exists
expect(await initiativeRepo.findById(initiative.id)).not.toBeNull();
// Phase 1 and its tasks are gone
expect(await phaseRepo.findById(phases.phase1.id)).toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask1.id)).toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask2.id)).toBeNull();
expect(await taskRepo.findById(tasks.task1.id)).toBeNull();
expect(await taskRepo.findById(tasks.task2.id)).toBeNull();
expect(await taskRepo.findById(tasks.task3.id)).toBeNull();
// Phase 2 and its tasks still exist
expect(await phaseRepo.findById(phases.phase2.id)).not.toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask3.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task4.id)).not.toBeNull();
});
});
describe('delete parent task', () => {
it('should cascade delete child tasks under that parent only', async () => {
const { phases, parentTasks, tasks } = await createFullHierarchy();
// Delete parent task 1
await taskRepo.delete(parentTasks.parentTask1.id);
// Phase still exists
expect(await phaseRepo.findById(phases.phase1.id)).not.toBeNull();
// Parent task 1 and its children are gone
expect(await taskRepo.findById(parentTasks.parentTask1.id)).toBeNull();
expect(await taskRepo.findById(tasks.task1.id)).toBeNull();
expect(await taskRepo.findById(tasks.task2.id)).toBeNull();
// Other parent tasks and their children still exist
expect(await taskRepo.findById(parentTasks.parentTask2.id)).not.toBeNull();
expect(await taskRepo.findById(parentTasks.parentTask3.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task3.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task4.id)).not.toBeNull();
});
});
});

View File

@@ -0,0 +1,110 @@
/**
* Drizzle Change Set Repository Adapter
*
* Implements ChangeSetRepository interface using Drizzle ORM.
*/
import { eq, desc, asc } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { changeSets, changeSetEntries, type ChangeSet } from '../../schema.js';
import type {
ChangeSetRepository,
CreateChangeSetData,
CreateChangeSetEntryData,
ChangeSetWithEntries,
} from '../change-set-repository.js';
export class DrizzleChangeSetRepository implements ChangeSetRepository {
constructor(private db: DrizzleDatabase) {}
async createWithEntries(data: CreateChangeSetData, entries: CreateChangeSetEntryData[]): Promise<ChangeSet> {
const id = nanoid();
const now = new Date();
// Use transaction for atomicity
return this.db.transaction(async (tx) => {
const [created] = await tx.insert(changeSets).values({
id,
agentId: data.agentId,
agentName: data.agentName,
initiativeId: data.initiativeId,
mode: data.mode,
summary: data.summary ?? null,
status: 'applied',
createdAt: now,
}).returning();
if (entries.length > 0) {
const entryRows = entries.map((e, i) => ({
id: nanoid(),
changeSetId: id,
entityType: e.entityType,
entityId: e.entityId,
action: e.action,
previousState: e.previousState ?? null,
newState: e.newState ?? null,
sortOrder: e.sortOrder ?? i,
createdAt: now,
}));
await tx.insert(changeSetEntries).values(entryRows);
}
return created;
});
}
async findById(id: string): Promise<ChangeSet | null> {
const result = await this.db
.select()
.from(changeSets)
.where(eq(changeSets.id, id))
.limit(1);
return result[0] ?? null;
}
async findByIdWithEntries(id: string): Promise<ChangeSetWithEntries | null> {
const cs = await this.findById(id);
if (!cs) return null;
const entries = await this.db
.select()
.from(changeSetEntries)
.where(eq(changeSetEntries.changeSetId, id))
.orderBy(asc(changeSetEntries.sortOrder));
return { ...cs, entries };
}
async findByInitiativeId(initiativeId: string): Promise<ChangeSet[]> {
return this.db
.select()
.from(changeSets)
.where(eq(changeSets.initiativeId, initiativeId))
.orderBy(desc(changeSets.createdAt));
}
async findByAgentId(agentId: string): Promise<ChangeSet[]> {
return this.db
.select()
.from(changeSets)
.where(eq(changeSets.agentId, agentId))
.orderBy(desc(changeSets.createdAt));
}
async markReverted(id: string): Promise<ChangeSet> {
const [updated] = await this.db
.update(changeSets)
.set({ status: 'reverted', revertedAt: new Date() })
.where(eq(changeSets.id, id))
.returning();
if (!updated) {
throw new Error(`ChangeSet not found: ${id}`);
}
return updated;
}
}

View File

@@ -0,0 +1,67 @@
/**
* Drizzle Conversation Repository Adapter
*
* Implements ConversationRepository interface using Drizzle ORM.
*/
import { eq, and, asc } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { conversations, type Conversation } from '../../schema.js';
import type { ConversationRepository, CreateConversationData } from '../conversation-repository.js';
export class DrizzleConversationRepository implements ConversationRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreateConversationData): Promise<Conversation> {
const now = new Date();
const id = nanoid();
await this.db.insert(conversations).values({
id,
fromAgentId: data.fromAgentId,
toAgentId: data.toAgentId,
initiativeId: data.initiativeId ?? null,
phaseId: data.phaseId ?? null,
taskId: data.taskId ?? null,
question: data.question,
status: 'pending',
createdAt: now,
updatedAt: now,
});
return this.findById(id) as Promise<Conversation>;
}
async findById(id: string): Promise<Conversation | null> {
const rows = await this.db
.select()
.from(conversations)
.where(eq(conversations.id, id))
.limit(1);
return rows[0] ?? null;
}
async findPendingForAgent(toAgentId: string): Promise<Conversation[]> {
return this.db
.select()
.from(conversations)
.where(
and(
eq(conversations.toAgentId, toAgentId),
eq(conversations.status, 'pending' as 'pending' | 'answered'),
),
)
.orderBy(asc(conversations.createdAt));
}
async answer(id: string, answer: string): Promise<Conversation | null> {
await this.db
.update(conversations)
.set({
answer,
status: 'answered' as 'pending' | 'answered',
updatedAt: new Date(),
})
.where(eq(conversations.id, id));
return this.findById(id);
}
}

View File

@@ -0,0 +1,18 @@
/**
* Drizzle Repository Adapters
*
* Re-exports all Drizzle implementations of repository interfaces.
* These are the ADAPTERS for the repository PORTS.
*/
export { DrizzleInitiativeRepository } from './initiative.js';
export { DrizzlePhaseRepository } from './phase.js';
export { DrizzleTaskRepository } from './task.js';
export { DrizzleAgentRepository } from './agent.js';
export { DrizzleMessageRepository } from './message.js';
export { DrizzlePageRepository } from './page.js';
export { DrizzleProjectRepository } from './project.js';
export { DrizzleAccountRepository } from './account.js';
export { DrizzleChangeSetRepository } from './change-set.js';
export { DrizzleLogChunkRepository } from './log-chunk.js';
export { DrizzleConversationRepository } from './conversation.js';

View File

@@ -0,0 +1,150 @@
/**
* DrizzleInitiativeRepository Tests
*
* Tests for the Initiative repository adapter.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { DrizzleInitiativeRepository } from './initiative.js';
import { createTestDatabase } from './test-helpers.js';
import type { DrizzleDatabase } from '../../index.js';
describe('DrizzleInitiativeRepository', () => {
let db: DrizzleDatabase;
let repo: DrizzleInitiativeRepository;
beforeEach(() => {
db = createTestDatabase();
repo = new DrizzleInitiativeRepository(db);
});
describe('create', () => {
it('should create an initiative with generated id and timestamps', async () => {
const initiative = await repo.create({
name: 'Test Initiative',
});
expect(initiative.id).toBeDefined();
expect(initiative.id.length).toBeGreaterThan(0);
expect(initiative.name).toBe('Test Initiative');
expect(initiative.status).toBe('active');
expect(initiative.createdAt).toBeInstanceOf(Date);
expect(initiative.updatedAt).toBeInstanceOf(Date);
});
it('should use provided status', async () => {
const initiative = await repo.create({
name: 'Completed Initiative',
status: 'completed',
});
expect(initiative.status).toBe('completed');
});
});
describe('findById', () => {
it('should return null for non-existent initiative', async () => {
const result = await repo.findById('non-existent-id');
expect(result).toBeNull();
});
it('should find an existing initiative', async () => {
const created = await repo.create({
name: 'Find Me',
});
const found = await repo.findById(created.id);
expect(found).not.toBeNull();
expect(found!.id).toBe(created.id);
expect(found!.name).toBe('Find Me');
});
});
describe('findAll', () => {
it('should return empty array initially', async () => {
const all = await repo.findAll();
expect(all).toEqual([]);
});
it('should return all initiatives', async () => {
await repo.create({ name: 'Initiative 1' });
await repo.create({ name: 'Initiative 2' });
await repo.create({ name: 'Initiative 3' });
const all = await repo.findAll();
expect(all.length).toBe(3);
});
});
describe('update', () => {
it('should update fields and updatedAt', async () => {
const created = await repo.create({
name: 'Original Name',
status: 'active',
});
// Small delay to ensure updatedAt differs
await new Promise((resolve) => setTimeout(resolve, 10));
const updated = await repo.update(created.id, {
name: 'Updated Name',
status: 'completed',
});
expect(updated.name).toBe('Updated Name');
expect(updated.status).toBe('completed');
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(created.updatedAt.getTime());
});
it('should throw for non-existent initiative', async () => {
await expect(
repo.update('non-existent-id', { name: 'New Name' })
).rejects.toThrow('Initiative not found');
});
});
describe('delete', () => {
it('should delete an existing initiative', async () => {
const created = await repo.create({ name: 'To Delete' });
await repo.delete(created.id);
const found = await repo.findById(created.id);
expect(found).toBeNull();
});
it('should throw for non-existent initiative', async () => {
await expect(repo.delete('non-existent-id')).rejects.toThrow(
'Initiative not found'
);
});
});
describe('findByStatus', () => {
it('should return empty array for no matches', async () => {
await repo.create({ name: 'Active 1', status: 'active' });
const completed = await repo.findByStatus('completed');
expect(completed).toEqual([]);
});
it('should filter by status', async () => {
await repo.create({ name: 'Active 1', status: 'active' });
await repo.create({ name: 'Active 2', status: 'active' });
await repo.create({ name: 'Completed', status: 'completed' });
await repo.create({ name: 'Archived', status: 'archived' });
const active = await repo.findByStatus('active');
expect(active).toHaveLength(2);
expect(active.every((i) => i.status === 'active')).toBe(true);
const completed = await repo.findByStatus('completed');
expect(completed).toHaveLength(1);
expect(completed[0].name).toBe('Completed');
const archived = await repo.findByStatus('archived');
expect(archived).toHaveLength(1);
expect(archived[0].name).toBe('Archived');
});
});
});

View File

@@ -0,0 +1,87 @@
/**
* Drizzle Initiative Repository Adapter
*
* Implements InitiativeRepository interface using Drizzle ORM.
*/
import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { agents, initiatives, type Initiative } from '../../schema.js';
import type {
InitiativeRepository,
CreateInitiativeData,
UpdateInitiativeData,
} from '../initiative-repository.js';
/**
* Drizzle adapter for InitiativeRepository.
*
* Uses dependency injection for database instance,
* enabling isolated test databases.
*/
export class DrizzleInitiativeRepository implements InitiativeRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreateInitiativeData): Promise<Initiative> {
const id = nanoid();
const now = new Date();
const [created] = await this.db.insert(initiatives).values({
id,
...data,
status: data.status ?? 'active',
createdAt: now,
updatedAt: now,
}).returning();
return created;
}
async findById(id: string): Promise<Initiative | null> {
const result = await this.db
.select()
.from(initiatives)
.where(eq(initiatives.id, id))
.limit(1);
return result[0] ?? null;
}
async findAll(): Promise<Initiative[]> {
return this.db.select().from(initiatives);
}
async findByStatus(status: 'active' | 'completed' | 'archived'): Promise<Initiative[]> {
return this.db
.select()
.from(initiatives)
.where(eq(initiatives.status, status));
}
async update(id: string, data: UpdateInitiativeData): Promise<Initiative> {
const [updated] = await this.db
.update(initiatives)
.set({ ...data, updatedAt: new Date() })
.where(eq(initiatives.id, id))
.returning();
if (!updated) {
throw new Error(`Initiative not found: ${id}`);
}
return updated;
}
async delete(id: string): Promise<void> {
// Detach agents before deleting — agents.initiative_id FK may lack ON DELETE SET NULL
// in databases that haven't applied migration 0025 yet.
await this.db.update(agents).set({ initiativeId: null }).where(eq(agents.initiativeId, id));
const [deleted] = await this.db.delete(initiatives).where(eq(initiatives.id, id)).returning();
if (!deleted) {
throw new Error(`Initiative not found: ${id}`);
}
}
}

View File

@@ -0,0 +1,58 @@
/**
* Drizzle Log Chunk Repository Adapter
*
* Implements LogChunkRepository interface using Drizzle ORM.
*/
import { eq, asc, max } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { agentLogChunks } from '../../schema.js';
import type { LogChunkRepository } from '../log-chunk-repository.js';
export class DrizzleLogChunkRepository implements LogChunkRepository {
constructor(private db: DrizzleDatabase) {}
async insertChunk(data: {
agentId: string;
agentName: string;
sessionNumber: number;
content: string;
}): Promise<void> {
await this.db.insert(agentLogChunks).values({
id: nanoid(),
agentId: data.agentId,
agentName: data.agentName,
sessionNumber: data.sessionNumber,
content: data.content,
createdAt: new Date(),
});
}
async findByAgentId(agentId: string): Promise<{ content: string; sessionNumber: number; createdAt: Date }[]> {
return this.db
.select({
content: agentLogChunks.content,
sessionNumber: agentLogChunks.sessionNumber,
createdAt: agentLogChunks.createdAt,
})
.from(agentLogChunks)
.where(eq(agentLogChunks.agentId, agentId))
.orderBy(asc(agentLogChunks.createdAt));
}
async deleteByAgentId(agentId: string): Promise<void> {
await this.db
.delete(agentLogChunks)
.where(eq(agentLogChunks.agentId, agentId));
}
async getSessionCount(agentId: string): Promise<number> {
const result = await this.db
.select({ maxSession: max(agentLogChunks.sessionNumber) })
.from(agentLogChunks)
.where(eq(agentLogChunks.agentId, agentId));
return result[0]?.maxSession ?? 0;
}
}

View File

@@ -0,0 +1,456 @@
/**
* DrizzleMessageRepository Tests
*
* Tests for the Message repository adapter.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { DrizzleMessageRepository } from './message.js';
import { DrizzleAgentRepository } from './agent.js';
import { DrizzleTaskRepository } from './task.js';
import { DrizzlePhaseRepository } from './phase.js';
import { DrizzleInitiativeRepository } from './initiative.js';
import { createTestDatabase } from './test-helpers.js';
import type { DrizzleDatabase } from '../../index.js';
describe('DrizzleMessageRepository', () => {
let db: DrizzleDatabase;
let messageRepo: DrizzleMessageRepository;
let agentRepo: DrizzleAgentRepository;
let testAgentId: string;
beforeEach(async () => {
db = createTestDatabase();
messageRepo = new DrizzleMessageRepository(db);
agentRepo = new DrizzleAgentRepository(db);
// Create required hierarchy for agent FK
const taskRepo = new DrizzleTaskRepository(db);
const phaseRepo = new DrizzlePhaseRepository(db);
const initiativeRepo = new DrizzleInitiativeRepository(db);
const initiative = await initiativeRepo.create({
name: 'Test Initiative',
});
const phase = await phaseRepo.create({
initiativeId: initiative.id,
name: 'Test Phase',
});
const task = await taskRepo.create({
phaseId: phase.id,
name: 'Test Task',
order: 1,
});
// Create a test agent
const agent = await agentRepo.create({
name: 'test-agent',
worktreeId: 'worktree-123',
taskId: task.id,
});
testAgentId = agent.id;
});
describe('create', () => {
it('should create agent→user message (question)', async () => {
const message = await messageRepo.create({
senderType: 'agent',
senderId: testAgentId,
recipientType: 'user',
type: 'question',
content: 'Do you want to proceed with deployment?',
requiresResponse: true,
});
expect(message.id).toBeDefined();
expect(message.id.length).toBeGreaterThan(0);
expect(message.senderType).toBe('agent');
expect(message.senderId).toBe(testAgentId);
expect(message.recipientType).toBe('user');
expect(message.recipientId).toBeNull();
expect(message.type).toBe('question');
expect(message.content).toBe('Do you want to proceed with deployment?');
expect(message.requiresResponse).toBe(true);
expect(message.status).toBe('pending');
expect(message.parentMessageId).toBeNull();
expect(message.createdAt).toBeInstanceOf(Date);
expect(message.updatedAt).toBeInstanceOf(Date);
});
it('should create agent→user message (notification, requiresResponse=false)', async () => {
const message = await messageRepo.create({
senderType: 'agent',
senderId: testAgentId,
recipientType: 'user',
type: 'info',
content: 'Build completed successfully.',
requiresResponse: false,
});
expect(message.type).toBe('info');
expect(message.requiresResponse).toBe(false);
expect(message.status).toBe('pending');
});
it('should create user→agent response', async () => {
// First create the question
const question = await messageRepo.create({
senderType: 'agent',
senderId: testAgentId,
recipientType: 'user',
type: 'question',
content: 'Which database?',
requiresResponse: true,
});
// Then create user response
const response = await messageRepo.create({
senderType: 'user',
recipientType: 'agent',
recipientId: testAgentId,
type: 'response',
content: 'Use PostgreSQL',
parentMessageId: question.id,
});
expect(response.senderType).toBe('user');
expect(response.senderId).toBeNull();
expect(response.recipientType).toBe('agent');
expect(response.recipientId).toBe(testAgentId);
expect(response.parentMessageId).toBe(question.id);
});
it('should default type to info', async () => {
const message = await messageRepo.create({
senderType: 'agent',
senderId: testAgentId,
recipientType: 'user',
content: 'Status update',
});
expect(message.type).toBe('info');
});
});
describe('findById', () => {
it('should return null for non-existent message', async () => {
const result = await messageRepo.findById('non-existent-id');
expect(result).toBeNull();
});
it('should find an existing message', async () => {
const created = await messageRepo.create({
senderType: 'agent',
senderId: testAgentId,
recipientType: 'user',
content: 'Test message',
});
const found = await messageRepo.findById(created.id);
expect(found).not.toBeNull();
expect(found!.id).toBe(created.id);
expect(found!.content).toBe('Test message');
});
});
describe('findBySender', () => {
it('should find messages by agent sender', async () => {
await messageRepo.create({
senderType: 'agent',
senderId: testAgentId,
recipientType: 'user',
content: 'Message 1',
});
await messageRepo.create({
senderType: 'agent',
senderId: testAgentId,
recipientType: 'user',
content: 'Message 2',
});
const messages = await messageRepo.findBySender('agent', testAgentId);
expect(messages.length).toBe(2);
});
it('should find messages by user sender', async () => {
await messageRepo.create({
senderType: 'user',
recipientType: 'agent',
recipientId: testAgentId,
content: 'User message 1',
});
await messageRepo.create({
senderType: 'user',
recipientType: 'agent',
recipientId: testAgentId,
content: 'User message 2',
});
const messages = await messageRepo.findBySender('user');
expect(messages.length).toBe(2);
});
it('should return empty array when no messages from sender', async () => {
const messages = await messageRepo.findBySender('agent', 'nonexistent-id');
expect(messages).toEqual([]);
});
});
describe('findByRecipient', () => {
it('should find messages by user recipient', async () => {
await messageRepo.create({
senderType: 'agent',
senderId: testAgentId,
recipientType: 'user',
content: 'For user 1',
});
await messageRepo.create({
senderType: 'agent',
senderId: testAgentId,
recipientType: 'user',
content: 'For user 2',
});
const messages = await messageRepo.findByRecipient('user');
expect(messages.length).toBe(2);
});
it('should find messages by agent recipient', async () => {
await messageRepo.create({
senderType: 'user',
recipientType: 'agent',
recipientId: testAgentId,
content: 'For agent',
});
const messages = await messageRepo.findByRecipient('agent', testAgentId);
expect(messages.length).toBe(1);
});
it('should return empty array when no messages for recipient', async () => {
const messages = await messageRepo.findByRecipient('agent', 'nonexistent-id');
expect(messages).toEqual([]);
});
});
describe('findPendingForUser', () => {
it('should return only user-targeted pending messages', async () => {
// Create pending message for user
await messageRepo.create({
senderType: 'agent',
senderId: testAgentId,
recipientType: 'user',
content: 'Pending for user',
status: 'pending',
});
// Create read message for user (should not be returned)
const readMessage = await messageRepo.create({
senderType: 'agent',
senderId: testAgentId,
recipientType: 'user',
content: 'Already read',
});
await messageRepo.update(readMessage.id, { status: 'read' });
// Create pending message for agent (should not be returned)
await messageRepo.create({
senderType: 'user',
recipientType: 'agent',
recipientId: testAgentId,
content: 'For agent not user',
});
const pending = await messageRepo.findPendingForUser();
expect(pending.length).toBe(1);
expect(pending[0].content).toBe('Pending for user');
});
it('should return empty array when no pending messages for user', async () => {
const pending = await messageRepo.findPendingForUser();
expect(pending).toEqual([]);
});
});
describe('findRequiringResponse', () => {
it('should return only messages needing response', async () => {
// Create message requiring response
await messageRepo.create({
senderType: 'agent',
senderId: testAgentId,
recipientType: 'user',
type: 'question',
content: 'Requires answer',
requiresResponse: true,
});
// Create message not requiring response
await messageRepo.create({
senderType: 'agent',
senderId: testAgentId,
recipientType: 'user',
type: 'info',
content: 'Just info',
requiresResponse: false,
});
// Create message that required response but was responded
const responded = await messageRepo.create({
senderType: 'agent',
senderId: testAgentId,
recipientType: 'user',
type: 'question',
content: 'Already answered',
requiresResponse: true,
});
await messageRepo.update(responded.id, { status: 'responded' });
const requiring = await messageRepo.findRequiringResponse();
expect(requiring.length).toBe(1);
expect(requiring[0].content).toBe('Requires answer');
});
it('should return empty array when no messages require response', async () => {
const requiring = await messageRepo.findRequiringResponse();
expect(requiring).toEqual([]);
});
});
describe('findReplies (message threading)', () => {
it('should find all replies to a parent message', async () => {
// Create original question
const question = await messageRepo.create({
senderType: 'agent',
senderId: testAgentId,
recipientType: 'user',
type: 'question',
content: 'Original question',
requiresResponse: true,
});
// Create two replies
await messageRepo.create({
senderType: 'user',
recipientType: 'agent',
recipientId: testAgentId,
type: 'response',
content: 'First reply',
parentMessageId: question.id,
});
await messageRepo.create({
senderType: 'user',
recipientType: 'agent',
recipientId: testAgentId,
type: 'response',
content: 'Second reply',
parentMessageId: question.id,
});
// Create unrelated message (should not be in replies)
await messageRepo.create({
senderType: 'agent',
senderId: testAgentId,
recipientType: 'user',
content: 'Unrelated message',
});
const replies = await messageRepo.findReplies(question.id);
expect(replies.length).toBe(2);
expect(replies.every((r) => r.parentMessageId === question.id)).toBe(true);
});
it('should return empty array when message has no replies', async () => {
const message = await messageRepo.create({
senderType: 'agent',
senderId: testAgentId,
recipientType: 'user',
content: 'No replies',
});
const replies = await messageRepo.findReplies(message.id);
expect(replies).toEqual([]);
});
});
describe('update status flow', () => {
it('should update status: pending → read → responded', async () => {
const message = await messageRepo.create({
senderType: 'agent',
senderId: testAgentId,
recipientType: 'user',
type: 'question',
content: 'Status flow test',
requiresResponse: true,
});
expect(message.status).toBe('pending');
// Wait to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 10));
// Update to read
const readMessage = await messageRepo.update(message.id, { status: 'read' });
expect(readMessage.status).toBe('read');
expect(readMessage.updatedAt.getTime()).toBeGreaterThanOrEqual(message.updatedAt.getTime());
// Wait again
await new Promise((resolve) => setTimeout(resolve, 10));
// Update to responded
const respondedMessage = await messageRepo.update(readMessage.id, {
status: 'responded',
});
expect(respondedMessage.status).toBe('responded');
expect(respondedMessage.updatedAt.getTime()).toBeGreaterThanOrEqual(
readMessage.updatedAt.getTime()
);
});
});
describe('update', () => {
it('should throw for non-existent message', async () => {
await expect(
messageRepo.update('non-existent-id', { status: 'read' })
).rejects.toThrow('Message not found');
});
it('should update content and updatedAt', async () => {
const created = await messageRepo.create({
senderType: 'agent',
senderId: testAgentId,
recipientType: 'user',
content: 'Original content',
});
await new Promise((resolve) => setTimeout(resolve, 10));
const updated = await messageRepo.update(created.id, {
content: 'Updated content',
});
expect(updated.content).toBe('Updated content');
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(created.updatedAt.getTime());
});
});
describe('delete', () => {
it('should delete an existing message', async () => {
const created = await messageRepo.create({
senderType: 'agent',
senderId: testAgentId,
recipientType: 'user',
content: 'To delete',
});
await messageRepo.delete(created.id);
const found = await messageRepo.findById(created.id);
expect(found).toBeNull();
});
it('should throw for non-existent message', async () => {
await expect(messageRepo.delete('non-existent-id')).rejects.toThrow(
'Message not found'
);
});
});
});

View File

@@ -0,0 +1,138 @@
/**
* Drizzle Message Repository Adapter
*
* Implements MessageRepository interface using Drizzle ORM.
*/
import { eq, and, desc } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { messages, type Message } from '../../schema.js';
import type {
MessageRepository,
CreateMessageData,
UpdateMessageData,
MessageParticipantType,
} from '../message-repository.js';
/**
* Drizzle adapter for MessageRepository.
*
* Uses dependency injection for database instance,
* enabling isolated test databases.
*/
export class DrizzleMessageRepository implements MessageRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreateMessageData): Promise<Message> {
const id = nanoid();
const now = new Date();
const [created] = await this.db.insert(messages).values({
id,
senderType: data.senderType,
senderId: data.senderId ?? null,
recipientType: data.recipientType,
recipientId: data.recipientId ?? null,
type: data.type ?? 'info',
content: data.content,
requiresResponse: data.requiresResponse ?? false,
status: data.status ?? 'pending',
parentMessageId: data.parentMessageId ?? null,
createdAt: now,
updatedAt: now,
}).returning();
return created;
}
async findById(id: string): Promise<Message | null> {
const result = await this.db
.select()
.from(messages)
.where(eq(messages.id, id))
.limit(1);
return result[0] ?? null;
}
async findBySender(type: MessageParticipantType, id?: string): Promise<Message[]> {
if (id) {
return this.db
.select()
.from(messages)
.where(and(eq(messages.senderType, type), eq(messages.senderId, id)))
.orderBy(desc(messages.createdAt));
}
// For user sender (no id), find where senderType='user' and senderId is null
return this.db
.select()
.from(messages)
.where(eq(messages.senderType, type))
.orderBy(desc(messages.createdAt));
}
async findByRecipient(type: MessageParticipantType, id?: string): Promise<Message[]> {
if (id) {
return this.db
.select()
.from(messages)
.where(and(eq(messages.recipientType, type), eq(messages.recipientId, id)))
.orderBy(desc(messages.createdAt));
}
// For user recipient (no id), find where recipientType='user' and recipientId is null
return this.db
.select()
.from(messages)
.where(eq(messages.recipientType, type))
.orderBy(desc(messages.createdAt));
}
async findPendingForUser(): Promise<Message[]> {
return this.db
.select()
.from(messages)
.where(and(eq(messages.recipientType, 'user'), eq(messages.status, 'pending')))
.orderBy(desc(messages.createdAt));
}
async findRequiringResponse(): Promise<Message[]> {
return this.db
.select()
.from(messages)
.where(and(eq(messages.requiresResponse, true), eq(messages.status, 'pending')))
.orderBy(desc(messages.createdAt));
}
async findReplies(parentMessageId: string): Promise<Message[]> {
return this.db
.select()
.from(messages)
.where(eq(messages.parentMessageId, parentMessageId))
.orderBy(desc(messages.createdAt));
}
async update(id: string, data: UpdateMessageData): Promise<Message> {
const [updated] = await this.db
.update(messages)
.set({ ...data, updatedAt: new Date() })
.where(eq(messages.id, id))
.returning();
if (!updated) {
throw new Error(`Message not found: ${id}`);
}
return updated;
}
async delete(id: string): Promise<void> {
const [deleted] = await this.db.delete(messages).where(eq(messages.id, id)).returning();
if (!deleted) {
throw new Error(`Message not found: ${id}`);
}
}
}

View File

@@ -0,0 +1,117 @@
/**
* Drizzle Page Repository Adapter
*
* Implements PageRepository interface using Drizzle ORM.
*/
import { eq, isNull, and, asc, inArray } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { pages, type Page } from '../../schema.js';
import type {
PageRepository,
CreatePageData,
UpdatePageData,
} from '../page-repository.js';
export class DrizzlePageRepository implements PageRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreatePageData): Promise<Page> {
const id = nanoid();
const now = new Date();
const [created] = await this.db.insert(pages).values({
id,
...data,
createdAt: now,
updatedAt: now,
}).returning();
return created;
}
async findById(id: string): Promise<Page | null> {
const result = await this.db
.select()
.from(pages)
.where(eq(pages.id, id))
.limit(1);
return result[0] ?? null;
}
async findByIds(ids: string[]): Promise<Page[]> {
if (ids.length === 0) return [];
return this.db
.select()
.from(pages)
.where(inArray(pages.id, ids));
}
async findByInitiativeId(initiativeId: string): Promise<Page[]> {
return this.db
.select()
.from(pages)
.where(eq(pages.initiativeId, initiativeId))
.orderBy(asc(pages.sortOrder));
}
async findByParentPageId(parentPageId: string): Promise<Page[]> {
return this.db
.select()
.from(pages)
.where(eq(pages.parentPageId, parentPageId))
.orderBy(asc(pages.sortOrder));
}
async findRootPage(initiativeId: string): Promise<Page | null> {
const result = await this.db
.select()
.from(pages)
.where(
and(
eq(pages.initiativeId, initiativeId),
isNull(pages.parentPageId),
),
)
.limit(1);
return result[0] ?? null;
}
async getOrCreateRootPage(initiativeId: string): Promise<Page> {
const existing = await this.findRootPage(initiativeId);
if (existing) return existing;
return this.create({
initiativeId,
parentPageId: null,
title: 'Untitled',
content: null,
sortOrder: 0,
});
}
async update(id: string, data: UpdatePageData): Promise<Page> {
const [updated] = await this.db
.update(pages)
.set({ ...data, updatedAt: new Date() })
.where(eq(pages.id, id))
.returning();
if (!updated) {
throw new Error(`Page not found: ${id}`);
}
return updated;
}
async delete(id: string): Promise<void> {
const [deleted] = await this.db.delete(pages).where(eq(pages.id, id)).returning();
if (!deleted) {
throw new Error(`Page not found: ${id}`);
}
}
}

View File

@@ -0,0 +1,408 @@
/**
* DrizzlePhaseRepository Tests
*
* Tests for the Phase repository adapter.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { DrizzlePhaseRepository } from './phase.js';
import { DrizzleInitiativeRepository } from './initiative.js';
import { createTestDatabase } from './test-helpers.js';
import type { DrizzleDatabase } from '../../index.js';
describe('DrizzlePhaseRepository', () => {
let db: DrizzleDatabase;
let phaseRepo: DrizzlePhaseRepository;
let initiativeRepo: DrizzleInitiativeRepository;
let testInitiativeId: string;
beforeEach(async () => {
db = createTestDatabase();
phaseRepo = new DrizzlePhaseRepository(db);
initiativeRepo = new DrizzleInitiativeRepository(db);
// Create a test initiative for FK constraint
const initiative = await initiativeRepo.create({
name: 'Test Initiative',
});
testInitiativeId = initiative.id;
});
describe('create', () => {
it('should create a phase with generated id and timestamps', async () => {
const phase = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Test Phase',
});
expect(phase.id).toBeDefined();
expect(phase.id.length).toBeGreaterThan(0);
expect(phase.initiativeId).toBe(testInitiativeId);
expect(phase.name).toBe('Test Phase');
expect(phase.status).toBe('pending');
expect(phase.createdAt).toBeInstanceOf(Date);
expect(phase.updatedAt).toBeInstanceOf(Date);
});
it('should throw for invalid initiativeId (FK constraint)', async () => {
await expect(
phaseRepo.create({
initiativeId: 'invalid-initiative-id',
name: 'Invalid Phase',
})
).rejects.toThrow();
});
});
describe('findById', () => {
it('should return null for non-existent phase', async () => {
const result = await phaseRepo.findById('non-existent-id');
expect(result).toBeNull();
});
it('should find an existing phase', async () => {
const created = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Find Me',
});
const found = await phaseRepo.findById(created.id);
expect(found).not.toBeNull();
expect(found!.id).toBe(created.id);
expect(found!.name).toBe('Find Me');
});
});
describe('findByInitiativeId', () => {
it('should return empty array for initiative with no phases', async () => {
const phases = await phaseRepo.findByInitiativeId(testInitiativeId);
expect(phases).toEqual([]);
});
it('should return only matching phases ordered by createdAt', async () => {
await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase A',
});
await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase B',
});
await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase C',
});
// Create another initiative with phases
const otherInitiative = await initiativeRepo.create({
name: 'Other Initiative',
});
await phaseRepo.create({
initiativeId: otherInitiative.id,
name: 'Other Phase',
});
const phases = await phaseRepo.findByInitiativeId(testInitiativeId);
expect(phases.length).toBe(3);
expect(phases[0].name).toBe('Phase A');
expect(phases[1].name).toBe('Phase B');
expect(phases[2].name).toBe('Phase C');
});
});
describe('update', () => {
it('should update fields and updatedAt', async () => {
const created = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Original Name',
status: 'pending',
});
await new Promise((resolve) => setTimeout(resolve, 10));
const updated = await phaseRepo.update(created.id, {
name: 'Updated Name',
status: 'in_progress',
});
expect(updated.name).toBe('Updated Name');
expect(updated.status).toBe('in_progress');
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(created.updatedAt.getTime());
});
it('should throw for non-existent phase', async () => {
await expect(
phaseRepo.update('non-existent-id', { name: 'New Name' })
).rejects.toThrow('Phase not found');
});
});
describe('delete', () => {
it('should delete an existing phase', async () => {
const created = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'To Delete',
});
await phaseRepo.delete(created.id);
const found = await phaseRepo.findById(created.id);
expect(found).toBeNull();
});
it('should throw for non-existent phase', async () => {
await expect(phaseRepo.delete('non-existent-id')).rejects.toThrow(
'Phase not found'
);
});
});
// ===========================================================================
// Phase Dependency Tests
// ===========================================================================
describe('createDependency', () => {
it('should create dependency between two phases', async () => {
const phase1 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 1',
});
const phase2 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 2',
});
await phaseRepo.createDependency(phase2.id, phase1.id);
const deps = await phaseRepo.getDependencies(phase2.id);
expect(deps).toContain(phase1.id);
});
it('should throw if phase does not exist', async () => {
const phase1 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 1',
});
// Creating dependency with non-existent phase should throw (FK constraint)
await expect(
phaseRepo.createDependency('non-existent-phase', phase1.id)
).rejects.toThrow();
});
it('should allow multiple dependencies for same phase', async () => {
const phase1 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 1',
});
const phase2 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 2',
});
const phase3 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 3',
});
// Phase 3 depends on both Phase 1 and Phase 2
await phaseRepo.createDependency(phase3.id, phase1.id);
await phaseRepo.createDependency(phase3.id, phase2.id);
const deps = await phaseRepo.getDependencies(phase3.id);
expect(deps.length).toBe(2);
expect(deps).toContain(phase1.id);
expect(deps).toContain(phase2.id);
});
});
describe('getDependencies', () => {
it('should return empty array for phase with no dependencies', async () => {
const phase = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 1',
});
const deps = await phaseRepo.getDependencies(phase.id);
expect(deps).toEqual([]);
});
it('should return dependency IDs for phase with dependencies', async () => {
const phase1 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 1',
});
const phase2 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 2',
});
const phase3 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 3',
});
// Phase 3 depends on Phase 1 and Phase 2
await phaseRepo.createDependency(phase3.id, phase1.id);
await phaseRepo.createDependency(phase3.id, phase2.id);
const deps = await phaseRepo.getDependencies(phase3.id);
expect(deps.length).toBe(2);
expect(deps).toContain(phase1.id);
expect(deps).toContain(phase2.id);
});
it('should return only direct dependencies (not transitive)', async () => {
// Phase 1 -> Phase 2 -> Phase 3 (linear chain)
const phase1 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 1',
});
const phase2 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 2',
});
const phase3 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 3',
});
// Phase 2 depends on Phase 1
await phaseRepo.createDependency(phase2.id, phase1.id);
// Phase 3 depends on Phase 2
await phaseRepo.createDependency(phase3.id, phase2.id);
// Phase 3's dependencies should only include Phase 2, not Phase 1
const depsPhase3 = await phaseRepo.getDependencies(phase3.id);
expect(depsPhase3.length).toBe(1);
expect(depsPhase3).toContain(phase2.id);
expect(depsPhase3).not.toContain(phase1.id);
});
});
describe('getDependents', () => {
it('should return empty array for phase with no dependents', async () => {
const phase = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 1',
});
const dependents = await phaseRepo.getDependents(phase.id);
expect(dependents).toEqual([]);
});
it('should return dependent phase IDs', async () => {
const phase1 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 1',
});
const phase2 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 2',
});
const phase3 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 3',
});
// Phase 2 and Phase 3 both depend on Phase 1
await phaseRepo.createDependency(phase2.id, phase1.id);
await phaseRepo.createDependency(phase3.id, phase1.id);
const dependents = await phaseRepo.getDependents(phase1.id);
expect(dependents.length).toBe(2);
expect(dependents).toContain(phase2.id);
expect(dependents).toContain(phase3.id);
});
it('should return only direct dependents', async () => {
// Phase 1 -> Phase 2 -> Phase 3 (linear chain)
const phase1 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 1',
});
const phase2 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 2',
});
const phase3 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 3',
});
// Phase 2 depends on Phase 1
await phaseRepo.createDependency(phase2.id, phase1.id);
// Phase 3 depends on Phase 2
await phaseRepo.createDependency(phase3.id, phase2.id);
// Phase 1's dependents should only include Phase 2, not Phase 3
const dependentsPhase1 = await phaseRepo.getDependents(phase1.id);
expect(dependentsPhase1.length).toBe(1);
expect(dependentsPhase1).toContain(phase2.id);
expect(dependentsPhase1).not.toContain(phase3.id);
});
});
describe('findDependenciesByInitiativeId', () => {
it('should return empty array for initiative with no dependencies', async () => {
await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 1',
});
const deps = await phaseRepo.findDependenciesByInitiativeId(testInitiativeId);
expect(deps).toEqual([]);
});
it('should return all dependency edges for an initiative', async () => {
const phase1 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 1',
});
const phase2 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 2',
});
const phase3 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 3',
});
await phaseRepo.createDependency(phase2.id, phase1.id);
await phaseRepo.createDependency(phase3.id, phase1.id);
await phaseRepo.createDependency(phase3.id, phase2.id);
const deps = await phaseRepo.findDependenciesByInitiativeId(testInitiativeId);
expect(deps.length).toBe(3);
expect(deps).toContainEqual({ phaseId: phase2.id, dependsOnPhaseId: phase1.id });
expect(deps).toContainEqual({ phaseId: phase3.id, dependsOnPhaseId: phase1.id });
expect(deps).toContainEqual({ phaseId: phase3.id, dependsOnPhaseId: phase2.id });
});
it('should not return dependencies from other initiatives', async () => {
const phase1 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 1',
});
const phase2 = await phaseRepo.create({
initiativeId: testInitiativeId,
name: 'Phase 2',
});
await phaseRepo.createDependency(phase2.id, phase1.id);
const otherInitiative = await initiativeRepo.create({ name: 'Other' });
const otherPhase1 = await phaseRepo.create({
initiativeId: otherInitiative.id,
name: 'Other Phase 1',
});
const otherPhase2 = await phaseRepo.create({
initiativeId: otherInitiative.id,
name: 'Other Phase 2',
});
await phaseRepo.createDependency(otherPhase2.id, otherPhase1.id);
const deps = await phaseRepo.findDependenciesByInitiativeId(testInitiativeId);
expect(deps.length).toBe(1);
expect(deps[0].phaseId).toBe(phase2.id);
expect(deps[0].dependsOnPhaseId).toBe(phase1.id);
});
});
});

View File

@@ -0,0 +1,130 @@
/**
* Drizzle Phase Repository Adapter
*
* Implements PhaseRepository interface using Drizzle ORM.
*/
import { eq, asc, and } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { phases, phaseDependencies, type Phase } from '../../schema.js';
import type {
PhaseRepository,
CreatePhaseData,
UpdatePhaseData,
} from '../phase-repository.js';
/**
* Drizzle adapter for PhaseRepository.
*
* Uses dependency injection for database instance,
* enabling isolated test databases.
*/
export class DrizzlePhaseRepository implements PhaseRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreatePhaseData): Promise<Phase> {
const { id: providedId, ...rest } = data;
const id = providedId ?? nanoid();
const now = new Date();
const [created] = await this.db.insert(phases).values({
id,
...rest,
status: data.status ?? 'pending',
createdAt: now,
updatedAt: now,
}).returning();
return created;
}
async findById(id: string): Promise<Phase | null> {
const result = await this.db
.select()
.from(phases)
.where(eq(phases.id, id))
.limit(1);
return result[0] ?? null;
}
async findByInitiativeId(initiativeId: string): Promise<Phase[]> {
return this.db
.select()
.from(phases)
.where(eq(phases.initiativeId, initiativeId))
.orderBy(asc(phases.createdAt));
}
async findDependenciesByInitiativeId(initiativeId: string): Promise<Array<{ phaseId: string; dependsOnPhaseId: string }>> {
return this.db
.select({ phaseId: phaseDependencies.phaseId, dependsOnPhaseId: phaseDependencies.dependsOnPhaseId })
.from(phaseDependencies)
.innerJoin(phases, eq(phaseDependencies.phaseId, phases.id))
.where(eq(phases.initiativeId, initiativeId));
}
async update(id: string, data: UpdatePhaseData): Promise<Phase> {
const [updated] = await this.db
.update(phases)
.set({ ...data, updatedAt: new Date() })
.where(eq(phases.id, id))
.returning();
if (!updated) {
throw new Error(`Phase not found: ${id}`);
}
return updated;
}
async delete(id: string): Promise<void> {
const [deleted] = await this.db.delete(phases).where(eq(phases.id, id)).returning();
if (!deleted) {
throw new Error(`Phase not found: ${id}`);
}
}
async createDependency(phaseId: string, dependsOnPhaseId: string): Promise<void> {
const id = nanoid();
const now = new Date();
await this.db.insert(phaseDependencies).values({
id,
phaseId,
dependsOnPhaseId,
createdAt: now,
});
}
async getDependencies(phaseId: string): Promise<string[]> {
const result = await this.db
.select({ dependsOnPhaseId: phaseDependencies.dependsOnPhaseId })
.from(phaseDependencies)
.where(eq(phaseDependencies.phaseId, phaseId));
return result.map((row) => row.dependsOnPhaseId);
}
async getDependents(phaseId: string): Promise<string[]> {
const result = await this.db
.select({ phaseId: phaseDependencies.phaseId })
.from(phaseDependencies)
.where(eq(phaseDependencies.dependsOnPhaseId, phaseId));
return result.map((row) => row.phaseId);
}
async removeDependency(phaseId: string, dependsOnPhaseId: string): Promise<void> {
await this.db
.delete(phaseDependencies)
.where(
and(
eq(phaseDependencies.phaseId, phaseId),
eq(phaseDependencies.dependsOnPhaseId, dependsOnPhaseId),
),
);
}
}

View File

@@ -0,0 +1,154 @@
/**
* Drizzle Project Repository Adapter
*
* Implements ProjectRepository interface using Drizzle ORM.
*/
import { eq, and, inArray } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { projects, initiativeProjects, type Project } from '../../schema.js';
import type {
ProjectRepository,
CreateProjectData,
UpdateProjectData,
} from '../project-repository.js';
export class DrizzleProjectRepository implements ProjectRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreateProjectData): Promise<Project> {
const id = nanoid();
const now = new Date();
const [created] = await this.db.insert(projects).values({
id,
...data,
createdAt: now,
updatedAt: now,
}).returning();
return created;
}
async findById(id: string): Promise<Project | null> {
const result = await this.db
.select()
.from(projects)
.where(eq(projects.id, id))
.limit(1);
return result[0] ?? null;
}
async findByName(name: string): Promise<Project | null> {
const result = await this.db
.select()
.from(projects)
.where(eq(projects.name, name))
.limit(1);
return result[0] ?? null;
}
async findAll(): Promise<Project[]> {
return this.db.select().from(projects);
}
async update(id: string, data: UpdateProjectData): Promise<Project> {
const [updated] = await this.db
.update(projects)
.set({ ...data, updatedAt: new Date() })
.where(eq(projects.id, id))
.returning();
if (!updated) {
throw new Error(`Project not found: ${id}`);
}
return updated;
}
async delete(id: string): Promise<void> {
const [deleted] = await this.db.delete(projects).where(eq(projects.id, id)).returning();
if (!deleted) {
throw new Error(`Project not found: ${id}`);
}
}
// Junction ops
async addProjectToInitiative(initiativeId: string, projectId: string): Promise<void> {
const id = nanoid();
const now = new Date();
await this.db.insert(initiativeProjects).values({
id,
initiativeId,
projectId,
createdAt: now,
});
}
async removeProjectFromInitiative(initiativeId: string, projectId: string): Promise<void> {
await this.db
.delete(initiativeProjects)
.where(
and(
eq(initiativeProjects.initiativeId, initiativeId),
eq(initiativeProjects.projectId, projectId),
),
);
}
async findProjectsByInitiativeId(initiativeId: string): Promise<Project[]> {
const rows = await this.db
.select({ project: projects })
.from(initiativeProjects)
.innerJoin(projects, eq(initiativeProjects.projectId, projects.id))
.where(eq(initiativeProjects.initiativeId, initiativeId));
return rows.map((r) => r.project);
}
async setInitiativeProjects(initiativeId: string, projectIds: string[]): Promise<void> {
// Get current associations
const currentRows = await this.db
.select({ projectId: initiativeProjects.projectId })
.from(initiativeProjects)
.where(eq(initiativeProjects.initiativeId, initiativeId));
const currentIds = new Set(currentRows.map((r) => r.projectId));
const desiredIds = new Set(projectIds);
// Compute diff
const toRemove = [...currentIds].filter((id) => !desiredIds.has(id));
const toAdd = [...desiredIds].filter((id) => !currentIds.has(id));
// Remove
if (toRemove.length > 0) {
await this.db
.delete(initiativeProjects)
.where(
and(
eq(initiativeProjects.initiativeId, initiativeId),
inArray(initiativeProjects.projectId, toRemove),
),
);
}
// Add
if (toAdd.length > 0) {
const now = new Date();
await this.db.insert(initiativeProjects).values(
toAdd.map((projectId) => ({
id: nanoid(),
initiativeId,
projectId,
createdAt: now,
})),
);
}
}
}

View File

@@ -0,0 +1,199 @@
/**
* DrizzleTaskRepository Tests
*
* Tests for the Task repository adapter.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { DrizzleTaskRepository } from './task.js';
import { DrizzlePhaseRepository } from './phase.js';
import { DrizzleInitiativeRepository } from './initiative.js';
import { createTestDatabase } from './test-helpers.js';
import type { DrizzleDatabase } from '../../index.js';
describe('DrizzleTaskRepository', () => {
let db: DrizzleDatabase;
let taskRepo: DrizzleTaskRepository;
let phaseRepo: DrizzlePhaseRepository;
let initiativeRepo: DrizzleInitiativeRepository;
let testPhaseId: string;
beforeEach(async () => {
db = createTestDatabase();
taskRepo = new DrizzleTaskRepository(db);
phaseRepo = new DrizzlePhaseRepository(db);
initiativeRepo = new DrizzleInitiativeRepository(db);
// Create full hierarchy for FK constraint
const initiative = await initiativeRepo.create({
name: 'Test Initiative',
});
const phase = await phaseRepo.create({
initiativeId: initiative.id,
name: 'Test Phase',
});
testPhaseId = phase.id;
});
describe('create', () => {
it('should create a task with generated id and timestamps', async () => {
const task = await taskRepo.create({
phaseId: testPhaseId,
name: 'Test Task',
description: 'A test task',
order: 1,
});
expect(task.id).toBeDefined();
expect(task.id.length).toBeGreaterThan(0);
expect(task.phaseId).toBe(testPhaseId);
expect(task.name).toBe('Test Task');
expect(task.type).toBe('auto');
expect(task.priority).toBe('medium');
expect(task.status).toBe('pending');
expect(task.order).toBe(1);
expect(task.createdAt).toBeInstanceOf(Date);
expect(task.updatedAt).toBeInstanceOf(Date);
});
it('should throw for invalid phaseId (FK constraint)', async () => {
await expect(
taskRepo.create({
phaseId: 'invalid-phase-id',
name: 'Invalid Task',
order: 1,
})
).rejects.toThrow();
});
it('should accept custom type and priority', async () => {
const task = await taskRepo.create({
phaseId: testPhaseId,
name: 'Checkpoint Task',
type: 'checkpoint:human-verify',
priority: 'high',
order: 1,
});
expect(task.type).toBe('checkpoint:human-verify');
expect(task.priority).toBe('high');
});
});
describe('findById', () => {
it('should return null for non-existent task', async () => {
const result = await taskRepo.findById('non-existent-id');
expect(result).toBeNull();
});
it('should find an existing task', async () => {
const created = await taskRepo.create({
phaseId: testPhaseId,
name: 'Find Me',
order: 1,
});
const found = await taskRepo.findById(created.id);
expect(found).not.toBeNull();
expect(found!.id).toBe(created.id);
expect(found!.name).toBe('Find Me');
});
});
describe('findByPhaseId', () => {
it('should return empty array for phase with no tasks', async () => {
const tasks = await taskRepo.findByPhaseId(testPhaseId);
expect(tasks).toEqual([]);
});
it('should return only matching tasks ordered by order field', async () => {
// Create tasks out of order
await taskRepo.create({
phaseId: testPhaseId,
name: 'Task 3',
order: 3,
});
await taskRepo.create({
phaseId: testPhaseId,
name: 'Task 1',
order: 1,
});
await taskRepo.create({
phaseId: testPhaseId,
name: 'Task 2',
order: 2,
});
const tasks = await taskRepo.findByPhaseId(testPhaseId);
expect(tasks.length).toBe(3);
expect(tasks[0].name).toBe('Task 1');
expect(tasks[1].name).toBe('Task 2');
expect(tasks[2].name).toBe('Task 3');
});
});
describe('update', () => {
it('should update status correctly', async () => {
const created = await taskRepo.create({
phaseId: testPhaseId,
name: 'Status Test',
status: 'pending',
order: 1,
});
const updated = await taskRepo.update(created.id, {
status: 'in_progress',
});
expect(updated.status).toBe('in_progress');
});
it('should update fields and updatedAt', async () => {
const created = await taskRepo.create({
phaseId: testPhaseId,
name: 'Original Name',
order: 1,
});
await new Promise((resolve) => setTimeout(resolve, 10));
const updated = await taskRepo.update(created.id, {
name: 'Updated Name',
priority: 'low',
});
expect(updated.name).toBe('Updated Name');
expect(updated.priority).toBe('low');
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(created.updatedAt.getTime());
});
it('should throw for non-existent task', async () => {
await expect(
taskRepo.update('non-existent-id', { name: 'New Name' })
).rejects.toThrow('Task not found');
});
});
describe('delete', () => {
it('should delete an existing task', async () => {
const created = await taskRepo.create({
phaseId: testPhaseId,
name: 'To Delete',
order: 1,
});
await taskRepo.delete(created.id);
const found = await taskRepo.findById(created.id);
expect(found).toBeNull();
});
it('should throw for non-existent task', async () => {
await expect(taskRepo.delete('non-existent-id')).rejects.toThrow(
'Task not found'
);
});
});
});

View File

@@ -0,0 +1,142 @@
/**
* Drizzle Task Repository Adapter
*
* Implements TaskRepository interface using Drizzle ORM.
*/
import { eq, asc, and } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { tasks, taskDependencies, type Task } from '../../schema.js';
import type {
TaskRepository,
CreateTaskData,
UpdateTaskData,
PendingApprovalFilters,
} from '../task-repository.js';
/**
* Drizzle adapter for TaskRepository.
*
* Uses dependency injection for database instance,
* enabling isolated test databases.
*/
export class DrizzleTaskRepository implements TaskRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreateTaskData): Promise<Task> {
const id = nanoid();
const now = new Date();
const [created] = await this.db.insert(tasks).values({
id,
...data,
type: data.type ?? 'auto',
category: data.category ?? 'execute',
priority: data.priority ?? 'medium',
status: data.status ?? 'pending',
order: data.order ?? 0,
createdAt: now,
updatedAt: now,
}).returning();
return created;
}
async findById(id: string): Promise<Task | null> {
const result = await this.db
.select()
.from(tasks)
.where(eq(tasks.id, id))
.limit(1);
return result[0] ?? null;
}
async findByParentTaskId(parentTaskId: string): Promise<Task[]> {
return this.db
.select()
.from(tasks)
.where(eq(tasks.parentTaskId, parentTaskId))
.orderBy(asc(tasks.order));
}
async findByInitiativeId(initiativeId: string): Promise<Task[]> {
return this.db
.select()
.from(tasks)
.where(eq(tasks.initiativeId, initiativeId))
.orderBy(asc(tasks.order));
}
async findByPhaseId(phaseId: string): Promise<Task[]> {
return this.db
.select()
.from(tasks)
.where(eq(tasks.phaseId, phaseId))
.orderBy(asc(tasks.order));
}
async findPendingApproval(filters?: PendingApprovalFilters): Promise<Task[]> {
const conditions = [eq(tasks.status, 'pending_approval')];
if (filters?.initiativeId) {
conditions.push(eq(tasks.initiativeId, filters.initiativeId));
}
if (filters?.phaseId) {
conditions.push(eq(tasks.phaseId, filters.phaseId));
}
if (filters?.category) {
conditions.push(eq(tasks.category, filters.category));
}
return this.db
.select()
.from(tasks)
.where(and(...conditions))
.orderBy(asc(tasks.createdAt));
}
async update(id: string, data: UpdateTaskData): Promise<Task> {
const [updated] = await this.db
.update(tasks)
.set({ ...data, updatedAt: new Date() })
.where(eq(tasks.id, id))
.returning();
if (!updated) {
throw new Error(`Task not found: ${id}`);
}
return updated;
}
async delete(id: string): Promise<void> {
const [deleted] = await this.db.delete(tasks).where(eq(tasks.id, id)).returning();
if (!deleted) {
throw new Error(`Task not found: ${id}`);
}
}
async createDependency(taskId: string, dependsOnTaskId: string): Promise<void> {
const id = nanoid();
const now = new Date();
await this.db.insert(taskDependencies).values({
id,
taskId,
dependsOnTaskId,
createdAt: now,
});
}
async getDependencies(taskId: string): Promise<string[]> {
const deps = await this.db
.select({ dependsOnTaskId: taskDependencies.dependsOnTaskId })
.from(taskDependencies)
.where(eq(taskDependencies.taskId, taskId));
return deps.map((d) => d.dependsOnTaskId);
}
}

View File

@@ -0,0 +1,30 @@
/**
* Test helpers for repository tests.
*
* Provides utilities for setting up in-memory test databases
* with schema applied.
*/
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import type { DrizzleDatabase } from '../../index.js';
import { ensureSchema } from '../../ensure-schema.js';
import * as schema from '../../schema.js';
/**
* Create an in-memory test database with schema applied.
* Returns a fresh Drizzle instance for each call.
*/
export function createTestDatabase(): DrizzleDatabase {
const sqlite = new Database(':memory:');
// Enable foreign keys
sqlite.pragma('foreign_keys = ON');
const db = drizzle(sqlite, { schema });
// Create all tables
ensureSchema(db);
return db;
}

View File

@@ -0,0 +1,74 @@
/**
* Repository Port Interfaces
*
* Re-exports all repository port interfaces.
* These are the PORTS in hexagonal architecture.
* Implementations in ./drizzle/ are ADAPTERS.
*/
export type {
InitiativeRepository,
CreateInitiativeData,
UpdateInitiativeData,
} from './initiative-repository.js';
export type {
PhaseRepository,
CreatePhaseData,
UpdatePhaseData,
} from './phase-repository.js';
export type {
TaskRepository,
CreateTaskData,
UpdateTaskData,
PendingApprovalFilters,
} from './task-repository.js';
export type {
AgentRepository,
AgentStatus,
CreateAgentData,
} from './agent-repository.js';
export type {
MessageRepository,
MessageParticipantType,
MessageType,
MessageStatus,
CreateMessageData,
UpdateMessageData,
} from './message-repository.js';
export type {
PageRepository,
CreatePageData,
UpdatePageData,
} from './page-repository.js';
export type {
ProjectRepository,
CreateProjectData,
UpdateProjectData,
} from './project-repository.js';
export type {
AccountRepository,
CreateAccountData,
} from './account-repository.js';
export type {
ChangeSetRepository,
CreateChangeSetData,
CreateChangeSetEntryData,
ChangeSetWithEntries,
} from './change-set-repository.js';
export type {
LogChunkRepository,
} from './log-chunk-repository.js';
export type {
ConversationRepository,
CreateConversationData,
} from './conversation-repository.js';

View File

@@ -0,0 +1,66 @@
/**
* Initiative Repository Port Interface
*
* Port for Initiative aggregate operations.
* Implementations (Drizzle, etc.) are adapters.
*/
import type { Initiative, NewInitiative } from '../schema.js';
/**
* Data for creating a new initiative.
* Omits system-managed fields (id, createdAt, updatedAt).
*/
export type CreateInitiativeData = Omit<NewInitiative, 'id' | 'createdAt' | 'updatedAt'>;
/**
* Data for updating an initiative.
* Partial of creation data - all fields optional.
*/
export type UpdateInitiativeData = Partial<CreateInitiativeData>;
/**
* Initiative Repository Port
*
* Defines operations for the Initiative aggregate.
* Only knows about initiatives - no knowledge of child entities.
*/
export interface InitiativeRepository {
/**
* Create a new initiative.
* Generates id and sets timestamps automatically.
*/
create(data: CreateInitiativeData): Promise<Initiative>;
/**
* Find an initiative by its ID.
* Returns null if not found.
*/
findById(id: string): Promise<Initiative | null>;
/**
* Find all initiatives.
* Returns empty array if none exist.
*/
findAll(): Promise<Initiative[]>;
/**
* Find all initiatives with a specific status.
* Returns empty array if none exist.
*/
findByStatus(status: 'active' | 'completed' | 'archived'): Promise<Initiative[]>;
/**
* Update an initiative.
* Throws if initiative not found.
* Updates updatedAt timestamp automatically.
*/
update(id: string, data: UpdateInitiativeData): Promise<Initiative>;
/**
* Delete an initiative.
* Throws if initiative not found.
* Cascades to child phases, plans, tasks via FK constraints.
*/
delete(id: string): Promise<void>;
}

View File

@@ -0,0 +1,23 @@
/**
* Log Chunk Repository Port Interface
*
* Port for agent log chunk persistence operations.
* No FK to agents — chunks survive agent deletion.
*/
import type { AgentLogChunk } from '../schema.js';
export interface LogChunkRepository {
insertChunk(data: {
agentId: string;
agentName: string;
sessionNumber: number;
content: string;
}): Promise<void>;
findByAgentId(agentId: string): Promise<Pick<AgentLogChunk, 'content' | 'sessionNumber' | 'createdAt'>[]>;
deleteByAgentId(agentId: string): Promise<void>;
getSessionCount(agentId: string): Promise<number>;
}

View File

@@ -0,0 +1,118 @@
/**
* Message Repository Port Interface
*
* Port for Message aggregate operations.
* Implementations (Drizzle, etc.) are adapters.
*
* Messages persist agent questions for users to query and respond later.
* Supports threading via parentMessageId for response linking.
*/
import type { Message } from '../schema.js';
/**
* Message sender/recipient type.
*/
export type MessageParticipantType = 'agent' | 'user';
/**
* Message type.
*/
export type MessageType = 'question' | 'info' | 'error' | 'response';
/**
* Message status.
*/
export type MessageStatus = 'pending' | 'read' | 'responded';
/**
* Data for creating a new message.
* Omits system-managed fields (id, createdAt, updatedAt).
*/
export interface CreateMessageData {
senderType: MessageParticipantType;
senderId?: string | null;
recipientType: MessageParticipantType;
recipientId?: string | null;
type?: MessageType;
content: string;
requiresResponse?: boolean;
status?: MessageStatus;
parentMessageId?: string | null;
}
/**
* Data for updating a message.
* Partial of create data - all fields optional.
*/
export type UpdateMessageData = Partial<CreateMessageData>;
/**
* Message Repository Port
*
* Defines operations for the Message aggregate.
* Enables message persistence for agent-user communication.
*/
export interface MessageRepository {
/**
* Create a new message.
* Generates id and sets timestamps automatically.
*/
create(data: CreateMessageData): Promise<Message>;
/**
* Find a message by its ID.
* Returns null if not found.
*/
findById(id: string): Promise<Message | null>;
/**
* Find messages by sender.
* @param type - 'agent' or 'user'
* @param id - Optional sender ID (agent ID if type='agent', omit for user)
* Returns messages ordered by createdAt DESC.
*/
findBySender(type: MessageParticipantType, id?: string): Promise<Message[]>;
/**
* Find messages by recipient.
* @param type - 'agent' or 'user'
* @param id - Optional recipient ID (agent ID if type='agent', omit for user)
* Returns messages ordered by createdAt DESC.
*/
findByRecipient(type: MessageParticipantType, id?: string): Promise<Message[]>;
/**
* Find all pending messages for user.
* Returns messages where recipientType='user' and status='pending'.
* Ordered by createdAt DESC.
*/
findPendingForUser(): Promise<Message[]>;
/**
* Find all messages requiring a response.
* Returns messages where requiresResponse=true and status='pending'.
* Ordered by createdAt DESC.
*/
findRequiringResponse(): Promise<Message[]>;
/**
* Find all replies to a message.
* @param parentMessageId - The ID of the parent message
* Returns messages ordered by createdAt DESC.
*/
findReplies(parentMessageId: string): Promise<Message[]>;
/**
* Update a message.
* Throws if message not found.
* Updates updatedAt timestamp automatically.
*/
update(id: string, data: UpdateMessageData): Promise<Message>;
/**
* Delete a message.
* Throws if message not found.
*/
delete(id: string): Promise<void>;
}

View File

@@ -0,0 +1,34 @@
/**
* Page Repository Port Interface
*
* Port for Page aggregate operations.
* Implementations (Drizzle, etc.) are adapters.
*/
import type { Page, NewPage } from '../schema.js';
/**
* Data for creating a new page.
* Omits system-managed fields (id, createdAt, updatedAt).
*/
export type CreatePageData = Omit<NewPage, 'id' | 'createdAt' | 'updatedAt'>;
/**
* Data for updating a page.
*/
export type UpdatePageData = Partial<Pick<NewPage, 'title' | 'content' | 'sortOrder'>>;
/**
* Page Repository Port
*/
export interface PageRepository {
create(data: CreatePageData): Promise<Page>;
findById(id: string): Promise<Page | null>;
findByIds(ids: string[]): Promise<Page[]>;
findByInitiativeId(initiativeId: string): Promise<Page[]>;
findByParentPageId(parentPageId: string): Promise<Page[]>;
findRootPage(initiativeId: string): Promise<Page | null>;
getOrCreateRootPage(initiativeId: string): Promise<Page>;
update(id: string, data: UpdatePageData): Promise<Page>;
delete(id: string): Promise<void>;
}

View File

@@ -0,0 +1,92 @@
/**
* Phase Repository Port Interface
*
* Port for Phase aggregate operations.
* Implementations (Drizzle, etc.) are adapters.
*/
import type { Phase, NewPhase } from '../schema.js';
/**
* Data for creating a new phase.
* Omits system-managed fields (id, createdAt, updatedAt).
*/
export type CreatePhaseData = Omit<NewPhase, 'id' | 'createdAt' | 'updatedAt'> & { id?: string };
/**
* Data for updating a phase.
* Partial of creation data - all fields optional.
*/
export type UpdatePhaseData = Partial<CreatePhaseData>;
/**
* Phase Repository Port
*
* Defines operations for the Phase aggregate.
* Only knows about phases - no knowledge of parent or child entities.
*/
export interface PhaseRepository {
/**
* Create a new phase.
* Generates id and sets timestamps automatically.
* Foreign key to initiative enforced by database.
*/
create(data: CreatePhaseData): Promise<Phase>;
/**
* Find a phase by its ID.
* Returns null if not found.
*/
findById(id: string): Promise<Phase | null>;
/**
* Find all phases for an initiative.
* Returns phases ordered by createdAt.
* Returns empty array if none exist.
*/
findByInitiativeId(initiativeId: string): Promise<Phase[]>;
/**
* Find all dependency edges for phases in an initiative.
* Returns array of { phaseId, dependsOnPhaseId } pairs.
*/
findDependenciesByInitiativeId(initiativeId: string): Promise<Array<{ phaseId: string; dependsOnPhaseId: string }>>;
/**
* Update a phase.
* Throws if phase not found.
* Updates updatedAt timestamp automatically.
*/
update(id: string, data: UpdatePhaseData): Promise<Phase>;
/**
* Delete a phase.
* Throws if phase not found.
* Cascades to child plans and tasks via FK constraints.
*/
delete(id: string): Promise<void>;
/**
* Create a dependency between two phases.
* The phase identified by phaseId will depend on dependsOnPhaseId.
* Both phases must exist.
*/
createDependency(phaseId: string, dependsOnPhaseId: string): Promise<void>;
/**
* Get IDs of phases that this phase depends on.
* Returns empty array if no dependencies.
*/
getDependencies(phaseId: string): Promise<string[]>;
/**
* Get IDs of phases that depend on this phase.
* Returns empty array if no dependents.
*/
getDependents(phaseId: string): Promise<string[]>;
/**
* Remove a dependency between two phases.
*/
removeDependency(phaseId: string, dependsOnPhaseId: string): Promise<void>;
}

View File

@@ -0,0 +1,38 @@
/**
* Project Repository Port Interface
*
* Port for Project aggregate operations and initiative-project junction.
* Implementations (Drizzle, etc.) are adapters.
*/
import type { Project, NewProject } from '../schema.js';
/**
* Data for creating a new project.
* Omits system-managed fields (id, createdAt, updatedAt).
*/
export type CreateProjectData = Omit<NewProject, 'id' | 'createdAt' | 'updatedAt'>;
/**
* Data for updating a project.
* Name is immutable (used as directory name for worktrees).
*/
export type UpdateProjectData = Omit<Partial<CreateProjectData>, 'name'>;
/**
* Project Repository Port
*/
export interface ProjectRepository {
create(data: CreateProjectData): Promise<Project>;
findById(id: string): Promise<Project | null>;
findByName(name: string): Promise<Project | null>;
findAll(): Promise<Project[]>;
update(id: string, data: UpdateProjectData): Promise<Project>;
delete(id: string): Promise<void>;
// Junction ops
addProjectToInitiative(initiativeId: string, projectId: string): Promise<void>;
removeProjectFromInitiative(initiativeId: string, projectId: string): Promise<void>;
findProjectsByInitiativeId(initiativeId: string): Promise<Project[]>;
setInitiativeProjects(initiativeId: string, projectIds: string[]): Promise<void>;
}

View File

@@ -0,0 +1,105 @@
/**
* Task Repository Port Interface
*
* Port for Task aggregate operations.
* Implementations (Drizzle, etc.) are adapters.
*/
import type { Task, NewTask, TaskCategory } from '../schema.js';
/**
* Data for creating a new task.
* Omits system-managed fields (id, createdAt, updatedAt).
* At least one of phaseId, initiativeId, or parentTaskId should be provided.
*/
export type CreateTaskData = Omit<NewTask, 'id' | 'createdAt' | 'updatedAt'>;
/**
* Data for updating a task.
* Partial of creation data - all fields optional.
*/
export type UpdateTaskData = Partial<CreateTaskData>;
/**
* Filters for finding pending approval tasks.
*/
export interface PendingApprovalFilters {
initiativeId?: string;
phaseId?: string;
category?: TaskCategory;
}
/**
* Task Repository Port
*
* Defines operations for the Task aggregate.
* Only knows about tasks - no knowledge of parent entities.
*/
export interface TaskRepository {
/**
* Create a new task.
* Generates id and sets timestamps automatically.
* At least one parent context (phaseId, initiativeId, or parentTaskId) should be set.
*/
create(data: CreateTaskData): Promise<Task>;
/**
* Find a task by its ID.
* Returns null if not found.
*/
findById(id: string): Promise<Task | null>;
/**
* Find all child tasks of a parent task.
* Returns tasks ordered by order field.
* Returns empty array if none exist.
*/
findByParentTaskId(parentTaskId: string): Promise<Task[]>;
/**
* Find all tasks directly linked to an initiative.
* Returns tasks ordered by order field.
* Returns empty array if none exist.
*/
findByInitiativeId(initiativeId: string): Promise<Task[]>;
/**
* Find all tasks directly linked to a phase.
* Returns tasks ordered by order field.
* Returns empty array if none exist.
*/
findByPhaseId(phaseId: string): Promise<Task[]>;
/**
* Find all tasks with status 'pending_approval'.
* Optional filters by initiative, phase, or category.
* Returns tasks ordered by createdAt.
*/
findPendingApproval(filters?: PendingApprovalFilters): Promise<Task[]>;
/**
* Update a task.
* Throws if task not found.
* Updates updatedAt timestamp automatically.
*/
update(id: string, data: UpdateTaskData): Promise<Task>;
/**
* Delete a task.
* Throws if task not found.
*/
delete(id: string): Promise<void>;
/**
* Create a dependency between two tasks.
* The task identified by taskId will depend on dependsOnTaskId.
* Both tasks must exist.
*/
createDependency(taskId: string, dependsOnTaskId: string): Promise<void>;
/**
* Get all task IDs that a task depends on.
* Returns empty array if no dependencies.
*/
getDependencies(taskId: string): Promise<string[]>;
}

537
apps/server/db/schema.ts Normal file
View File

@@ -0,0 +1,537 @@
/**
* Database schema for Codewalk District.
*
* Defines the three-level task hierarchy:
* - Initiative: Top-level project
* - Phase: Major milestone within initiative
* - Task: Individual work item (can have parentTaskId for decomposition relationships)
*
* Plus a task_dependencies table for task dependency relationships.
*/
import { sqliteTable, text, integer, uniqueIndex, index } from 'drizzle-orm/sqlite-core';
import { relations, type InferInsertModel, type InferSelectModel } from 'drizzle-orm';
// ============================================================================
// INITIATIVES
// ============================================================================
export const initiatives = sqliteTable('initiatives', {
id: text('id').primaryKey(),
name: text('name').notNull(),
status: text('status', { enum: ['active', 'completed', 'archived'] })
.notNull()
.default('active'),
mergeRequiresApproval: integer('merge_requires_approval', { mode: 'boolean' })
.notNull()
.default(true),
branch: text('branch'), // Auto-generated initiative branch (e.g., 'cw/user-auth')
executionMode: text('execution_mode', { enum: ['yolo', 'review_per_phase'] })
.notNull()
.default('review_per_phase'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const initiativesRelations = relations(initiatives, ({ many }) => ({
phases: many(phases),
pages: many(pages),
initiativeProjects: many(initiativeProjects),
tasks: many(tasks),
changeSets: many(changeSets),
}));
export type Initiative = InferSelectModel<typeof initiatives>;
export type NewInitiative = InferInsertModel<typeof initiatives>;
// ============================================================================
// PHASES
// ============================================================================
export const phases = sqliteTable('phases', {
id: text('id').primaryKey(),
initiativeId: text('initiative_id')
.notNull()
.references(() => initiatives.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
content: text('content'),
status: text('status', { enum: ['pending', 'approved', 'in_progress', 'completed', 'blocked', 'pending_review'] })
.notNull()
.default('pending'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const phasesRelations = relations(phases, ({ one, many }) => ({
initiative: one(initiatives, {
fields: [phases.initiativeId],
references: [initiatives.id],
}),
tasks: many(tasks),
// Dependencies: phases this phase depends on
dependsOn: many(phaseDependencies, { relationName: 'dependentPhase' }),
// Dependents: phases that depend on this phase
dependents: many(phaseDependencies, { relationName: 'dependencyPhase' }),
}));
export type Phase = InferSelectModel<typeof phases>;
export type NewPhase = InferInsertModel<typeof phases>;
// ============================================================================
// PHASE DEPENDENCIES
// ============================================================================
export const phaseDependencies = sqliteTable('phase_dependencies', {
id: text('id').primaryKey(),
phaseId: text('phase_id')
.notNull()
.references(() => phases.id, { onDelete: 'cascade' }),
dependsOnPhaseId: text('depends_on_phase_id')
.notNull()
.references(() => phases.id, { onDelete: 'cascade' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});
export const phaseDependenciesRelations = relations(phaseDependencies, ({ one }) => ({
phase: one(phases, {
fields: [phaseDependencies.phaseId],
references: [phases.id],
relationName: 'dependentPhase',
}),
dependsOnPhase: one(phases, {
fields: [phaseDependencies.dependsOnPhaseId],
references: [phases.id],
relationName: 'dependencyPhase',
}),
}));
export type PhaseDependency = InferSelectModel<typeof phaseDependencies>;
export type NewPhaseDependency = InferInsertModel<typeof phaseDependencies>;
// ============================================================================
// TASKS
// ============================================================================
/**
* Task category enum values.
* Defines what kind of work a task represents.
*/
export const TASK_CATEGORIES = [
'execute', // Standard execution task
'research', // Research/exploration task
'discuss', // Discussion/context gathering
'plan', // Plan initiative into phases
'detail', // Detail phase into tasks
'refine', // Refine/edit content
'verify', // Verification task
'merge', // Merge task
'review', // Review/approval task
] as const;
export type TaskCategory = (typeof TASK_CATEGORIES)[number];
export const tasks = sqliteTable('tasks', {
id: text('id').primaryKey(),
// Parent context - at least one should be set
phaseId: text('phase_id').references(() => phases.id, { onDelete: 'cascade' }),
initiativeId: text('initiative_id').references(() => initiatives.id, { onDelete: 'cascade' }),
// Parent task for detail hierarchy (child tasks link to parent detail task)
parentTaskId: text('parent_task_id').references((): ReturnType<typeof text> => tasks.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
type: text('type', {
enum: ['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action'],
})
.notNull()
.default('auto'),
category: text('category', {
enum: TASK_CATEGORIES,
})
.notNull()
.default('execute'),
priority: text('priority', { enum: ['low', 'medium', 'high'] })
.notNull()
.default('medium'),
status: text('status', {
enum: ['pending_approval', 'pending', 'in_progress', 'completed', 'blocked'],
})
.notNull()
.default('pending'),
requiresApproval: integer('requires_approval', { mode: 'boolean' }), // null = inherit from initiative
order: integer('order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const tasksRelations = relations(tasks, ({ one, many }) => ({
phase: one(phases, {
fields: [tasks.phaseId],
references: [phases.id],
}),
initiative: one(initiatives, {
fields: [tasks.initiativeId],
references: [initiatives.id],
}),
// Parent task (for detail hierarchy - child links to parent detail task)
parentTask: one(tasks, {
fields: [tasks.parentTaskId],
references: [tasks.id],
relationName: 'parentTask',
}),
// Child tasks (tasks created from decomposition of this task)
childTasks: many(tasks, { relationName: 'parentTask' }),
// Dependencies: tasks this task depends on
dependsOn: many(taskDependencies, { relationName: 'dependentTask' }),
// Dependents: tasks that depend on this task
dependents: many(taskDependencies, { relationName: 'dependencyTask' }),
}));
export type Task = InferSelectModel<typeof tasks>;
export type NewTask = InferInsertModel<typeof tasks>;
// ============================================================================
// TASK DEPENDENCIES
// ============================================================================
export const taskDependencies = sqliteTable('task_dependencies', {
id: text('id').primaryKey(),
taskId: text('task_id')
.notNull()
.references(() => tasks.id, { onDelete: 'cascade' }),
dependsOnTaskId: text('depends_on_task_id')
.notNull()
.references(() => tasks.id, { onDelete: 'cascade' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});
export const taskDependenciesRelations = relations(taskDependencies, ({ one }) => ({
task: one(tasks, {
fields: [taskDependencies.taskId],
references: [tasks.id],
relationName: 'dependentTask',
}),
dependsOnTask: one(tasks, {
fields: [taskDependencies.dependsOnTaskId],
references: [tasks.id],
relationName: 'dependencyTask',
}),
}));
export type TaskDependency = InferSelectModel<typeof taskDependencies>;
export type NewTaskDependency = InferInsertModel<typeof taskDependencies>;
// ============================================================================
// ACCOUNTS
// ============================================================================
export const accounts = sqliteTable('accounts', {
id: text('id').primaryKey(),
email: text('email').notNull(),
provider: text('provider').notNull().default('claude'),
configJson: text('config_json'), // .claude.json content (JSON string)
credentials: text('credentials'), // .credentials.json content (JSON string)
isExhausted: integer('is_exhausted', { mode: 'boolean' }).notNull().default(false),
exhaustedUntil: integer('exhausted_until', { mode: 'timestamp' }),
lastUsedAt: integer('last_used_at', { mode: 'timestamp' }),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const accountsRelations = relations(accounts, ({ many }) => ({
agents: many(agents),
}));
export type Account = InferSelectModel<typeof accounts>;
export type NewAccount = InferInsertModel<typeof accounts>;
// ============================================================================
// AGENTS
// ============================================================================
export const agents = sqliteTable('agents', {
id: text('id').primaryKey(),
name: text('name').notNull().unique(), // Human-readable alias (e.g., 'jolly-penguin')
taskId: text('task_id').references(() => tasks.id, { onDelete: 'set null' }), // Task may be deleted
initiativeId: text('initiative_id').references(() => initiatives.id, { onDelete: 'set null' }),
sessionId: text('session_id'), // Claude CLI session ID for resumption (null until first run completes)
worktreeId: text('worktree_id').notNull(), // Agent alias (deterministic path: agent-workdirs/<alias>/)
provider: text('provider').notNull().default('claude'),
accountId: text('account_id').references(() => accounts.id, { onDelete: 'set null' }),
status: text('status', {
enum: ['idle', 'running', 'waiting_for_input', 'stopped', 'crashed'],
})
.notNull()
.default('idle'),
mode: text('mode', { enum: ['execute', 'discuss', 'plan', 'detail', 'refine'] })
.notNull()
.default('execute'),
pid: integer('pid'),
exitCode: integer('exit_code'), // Process exit code for debugging crashes
outputFilePath: text('output_file_path'),
result: text('result'),
pendingQuestions: text('pending_questions'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
userDismissedAt: integer('user_dismissed_at', { mode: 'timestamp' }),
});
export const agentsRelations = relations(agents, ({ one, many }) => ({
task: one(tasks, {
fields: [agents.taskId],
references: [tasks.id],
}),
initiative: one(initiatives, {
fields: [agents.initiativeId],
references: [initiatives.id],
}),
account: one(accounts, {
fields: [agents.accountId],
references: [accounts.id],
}),
changeSets: many(changeSets),
}));
export type Agent = InferSelectModel<typeof agents>;
export type NewAgent = InferInsertModel<typeof agents>;
// ============================================================================
// CHANGE SETS
// ============================================================================
export const changeSets = sqliteTable('change_sets', {
id: text('id').primaryKey(),
agentId: text('agent_id')
.references(() => agents.id, { onDelete: 'set null' }),
agentName: text('agent_name').notNull(),
initiativeId: text('initiative_id')
.notNull()
.references(() => initiatives.id, { onDelete: 'cascade' }),
mode: text('mode', { enum: ['plan', 'detail', 'refine'] }).notNull(),
summary: text('summary'),
status: text('status', { enum: ['applied', 'reverted'] })
.notNull()
.default('applied'),
revertedAt: integer('reverted_at', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
}, (table) => [
index('change_sets_initiative_id_idx').on(table.initiativeId),
]);
export const changeSetsRelations = relations(changeSets, ({ one, many }) => ({
agent: one(agents, {
fields: [changeSets.agentId],
references: [agents.id],
}),
initiative: one(initiatives, {
fields: [changeSets.initiativeId],
references: [initiatives.id],
}),
entries: many(changeSetEntries),
}));
export type ChangeSet = InferSelectModel<typeof changeSets>;
export type NewChangeSet = InferInsertModel<typeof changeSets>;
export const changeSetEntries = sqliteTable('change_set_entries', {
id: text('id').primaryKey(),
changeSetId: text('change_set_id')
.notNull()
.references(() => changeSets.id, { onDelete: 'cascade' }),
entityType: text('entity_type', { enum: ['page', 'phase', 'task', 'phase_dependency'] }).notNull(),
entityId: text('entity_id').notNull(),
action: text('action', { enum: ['create', 'update', 'delete'] }).notNull(),
previousState: text('previous_state'), // JSON snapshot, null for creates
newState: text('new_state'), // JSON snapshot, null for deletes
sortOrder: integer('sort_order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
}, (table) => [
index('change_set_entries_change_set_id_idx').on(table.changeSetId),
]);
export const changeSetEntriesRelations = relations(changeSetEntries, ({ one }) => ({
changeSet: one(changeSets, {
fields: [changeSetEntries.changeSetId],
references: [changeSets.id],
}),
}));
export type ChangeSetEntry = InferSelectModel<typeof changeSetEntries>;
export type NewChangeSetEntry = InferInsertModel<typeof changeSetEntries>;
// ============================================================================
// MESSAGES
// ============================================================================
export const messages = sqliteTable('messages', {
id: text('id').primaryKey(),
senderType: text('sender_type', { enum: ['agent', 'user'] }).notNull(),
senderId: text('sender_id').references(() => agents.id, { onDelete: 'set null' }), // Agent ID if senderType='agent', null for user
recipientType: text('recipient_type', { enum: ['agent', 'user'] }).notNull(),
recipientId: text('recipient_id').references(() => agents.id, { onDelete: 'set null' }), // Agent ID if recipientType='agent', null for user
type: text('type', { enum: ['question', 'info', 'error', 'response'] })
.notNull()
.default('info'),
content: text('content').notNull(),
requiresResponse: integer('requires_response', { mode: 'boolean' })
.notNull()
.default(false),
status: text('status', { enum: ['pending', 'read', 'responded'] })
.notNull()
.default('pending'),
parentMessageId: text('parent_message_id').references((): ReturnType<typeof text> => messages.id, { onDelete: 'set null' }), // Links response to original question
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const messagesRelations = relations(messages, ({ one, many }) => ({
// Sender agent (optional - null for user senders)
senderAgent: one(agents, {
fields: [messages.senderId],
references: [agents.id],
relationName: 'senderAgent',
}),
// Recipient agent (optional - null for user recipients)
recipientAgent: one(agents, {
fields: [messages.recipientId],
references: [agents.id],
relationName: 'recipientAgent',
}),
// Parent message (for threading responses)
parentMessage: one(messages, {
fields: [messages.parentMessageId],
references: [messages.id],
relationName: 'parentMessage',
}),
// Child messages (replies to this message)
childMessages: many(messages, { relationName: 'parentMessage' }),
}));
export type Message = InferSelectModel<typeof messages>;
export type NewMessage = InferInsertModel<typeof messages>;
// ============================================================================
// PAGES
// ============================================================================
export const pages = sqliteTable('pages', {
id: text('id').primaryKey(),
initiativeId: text('initiative_id')
.notNull()
.references(() => initiatives.id, { onDelete: 'cascade' }),
parentPageId: text('parent_page_id').references((): ReturnType<typeof text> => pages.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
content: text('content'), // JSON string from Tiptap
sortOrder: integer('sort_order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const pagesRelations = relations(pages, ({ one, many }) => ({
initiative: one(initiatives, {
fields: [pages.initiativeId],
references: [initiatives.id],
}),
parentPage: one(pages, {
fields: [pages.parentPageId],
references: [pages.id],
relationName: 'parentPage',
}),
childPages: many(pages, { relationName: 'parentPage' }),
}));
export type Page = InferSelectModel<typeof pages>;
export type NewPage = InferInsertModel<typeof pages>;
// ============================================================================
// PROJECTS
// ============================================================================
export const projects = sqliteTable('projects', {
id: text('id').primaryKey(),
name: text('name').notNull().unique(),
url: text('url').notNull().unique(),
defaultBranch: text('default_branch').notNull().default('main'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const projectsRelations = relations(projects, ({ many }) => ({
initiativeProjects: many(initiativeProjects),
}));
export type Project = InferSelectModel<typeof projects>;
export type NewProject = InferInsertModel<typeof projects>;
// ============================================================================
// INITIATIVE PROJECTS (junction)
// ============================================================================
export const initiativeProjects = sqliteTable('initiative_projects', {
id: text('id').primaryKey(),
initiativeId: text('initiative_id')
.notNull()
.references(() => initiatives.id, { onDelete: 'cascade' }),
projectId: text('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
}, (table) => [
uniqueIndex('initiative_project_unique').on(table.initiativeId, table.projectId),
]);
export const initiativeProjectsRelations = relations(initiativeProjects, ({ one }) => ({
initiative: one(initiatives, {
fields: [initiativeProjects.initiativeId],
references: [initiatives.id],
}),
project: one(projects, {
fields: [initiativeProjects.projectId],
references: [projects.id],
}),
}));
export type InitiativeProject = InferSelectModel<typeof initiativeProjects>;
export type NewInitiativeProject = InferInsertModel<typeof initiativeProjects>;
// ============================================================================
// AGENT LOG CHUNKS
// ============================================================================
export const agentLogChunks = sqliteTable('agent_log_chunks', {
id: text('id').primaryKey(),
agentId: text('agent_id').notNull(), // NO FK — survives agent deletion
agentName: text('agent_name').notNull(), // Snapshot for display after deletion
sessionNumber: integer('session_number').notNull().default(1),
content: text('content').notNull(), // Raw JSONL chunk from file
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
}, (table) => [
index('agent_log_chunks_agent_id_idx').on(table.agentId),
]);
export type AgentLogChunk = InferSelectModel<typeof agentLogChunks>;
export type NewAgentLogChunk = InferInsertModel<typeof agentLogChunks>;
// ============================================================================
// CONVERSATIONS (inter-agent communication)
// ============================================================================
export const conversations = sqliteTable('conversations', {
id: text('id').primaryKey(),
fromAgentId: text('from_agent_id').notNull().references(() => agents.id, { onDelete: 'cascade' }),
toAgentId: text('to_agent_id').notNull().references(() => agents.id, { onDelete: 'cascade' }),
initiativeId: text('initiative_id').references(() => initiatives.id, { onDelete: 'set null' }),
phaseId: text('phase_id').references(() => phases.id, { onDelete: 'set null' }),
taskId: text('task_id').references(() => tasks.id, { onDelete: 'set null' }),
question: text('question').notNull(),
answer: text('answer'),
status: text('status', { enum: ['pending', 'answered'] }).notNull().default('pending'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}, (table) => [
index('conversations_to_agent_status_idx').on(table.toAgentId, table.status),
index('conversations_from_agent_idx').on(table.fromAgentId),
]);
export type Conversation = InferSelectModel<typeof conversations>;
export type NewConversation = InferInsertModel<typeof conversations>;