/** * Database schema for Codewalkers. * * 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; export type NewInitiative = InferInsertModel; // ============================================================================ // 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; export type NewPhase = InferInsertModel; // ============================================================================ // 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; export type NewPhaseDependency = InferInsertModel; // ============================================================================ // 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 => 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), summary: text('summary'), // Agent result summary — propagated to dependent tasks as context 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; export type NewTask = InferInsertModel; // ============================================================================ // 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; export type NewTaskDependency = InferInsertModel; // ============================================================================ // 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; export type NewAccount = InferInsertModel; // ============================================================================ // 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//) 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', 'chat'] }) .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; export type NewAgent = InferInsertModel; // ============================================================================ // 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', 'chat'] }).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; export type NewChangeSet = InferInsertModel; 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', 'task_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; export type NewChangeSetEntry = InferInsertModel; // ============================================================================ // 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 => 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; export type NewMessage = InferInsertModel; // ============================================================================ // 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 => 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; export type NewPage = InferInsertModel; // ============================================================================ // 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'), lastFetchedAt: integer('last_fetched_at', { mode: 'timestamp' }), 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; export type NewProject = InferInsertModel; // ============================================================================ // 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; export type NewInitiativeProject = InferInsertModel; // ============================================================================ // 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; export type NewAgentLogChunk = InferInsertModel; // ============================================================================ // 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; export type NewConversation = InferInsertModel; // ============================================================================ // CHAT SESSIONS // ============================================================================ export const chatSessions = sqliteTable('chat_sessions', { id: text('id').primaryKey(), targetType: text('target_type', { enum: ['phase', 'task'] }).notNull(), targetId: text('target_id').notNull(), initiativeId: text('initiative_id') .notNull() .references(() => initiatives.id, { onDelete: 'cascade' }), agentId: text('agent_id').references(() => agents.id, { onDelete: 'set null' }), status: text('status', { enum: ['active', 'closed'] }) .notNull() .default('active'), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }, (table) => [ index('chat_sessions_target_idx').on(table.targetType, table.targetId), index('chat_sessions_initiative_id_idx').on(table.initiativeId), ]); export const chatSessionsRelations = relations(chatSessions, ({ one, many }) => ({ initiative: one(initiatives, { fields: [chatSessions.initiativeId], references: [initiatives.id], }), agent: one(agents, { fields: [chatSessions.agentId], references: [agents.id], }), messages: many(chatMessages), })); export type ChatSession = InferSelectModel; export type NewChatSession = InferInsertModel; // ============================================================================ // CHAT MESSAGES // ============================================================================ export const chatMessages = sqliteTable('chat_messages', { id: text('id').primaryKey(), chatSessionId: text('chat_session_id') .notNull() .references(() => chatSessions.id, { onDelete: 'cascade' }), role: text('role', { enum: ['user', 'assistant', 'system'] }).notNull(), content: text('content').notNull(), changeSetId: text('change_set_id').references(() => changeSets.id, { onDelete: 'set null' }), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), }, (table) => [ index('chat_messages_session_id_idx').on(table.chatSessionId), ]); export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({ chatSession: one(chatSessions, { fields: [chatMessages.chatSessionId], references: [chatSessions.id], }), changeSet: one(changeSets, { fields: [chatMessages.changeSetId], references: [changeSets.id], }), })); export type ChatMessage = InferSelectModel; export type NewChatMessage = InferInsertModel; // ============================================================================ // REVIEW COMMENTS // ============================================================================ export const reviewComments = sqliteTable('review_comments', { id: text('id').primaryKey(), phaseId: text('phase_id') .notNull() .references(() => phases.id, { onDelete: 'cascade' }), filePath: text('file_path').notNull(), lineNumber: integer('line_number').notNull(), lineType: text('line_type', { enum: ['added', 'removed', 'context'] }).notNull(), body: text('body').notNull(), author: text('author').notNull().default('you'), resolved: integer('resolved', { mode: 'boolean' }).notNull().default(false), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }, (table) => [ index('review_comments_phase_id_idx').on(table.phaseId), ]); export type ReviewComment = InferSelectModel; export type NewReviewComment = InferInsertModel;