Files
Codewalkers/apps/server/db/schema.ts
Lukas May 0ff65b0b02 feat: Rename application from "Codewalk District" to "Codewalkers"
Update all user-facing strings (HTML title, manifest, header logo,
browser title updater), code comments, and documentation references.
Folder name retained as-is.
2026-03-05 12:05:08 +01:00

632 lines
25 KiB
TypeScript

/**
* 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<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),
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<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', '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<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', '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<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', '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<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'),
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<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>;
// ============================================================================
// 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<typeof chatSessions>;
export type NewChatSession = InferInsertModel<typeof chatSessions>;
// ============================================================================
// 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<typeof chatMessages>;
export type NewChatMessage = InferInsertModel<typeof chatMessages>;
// ============================================================================
// 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<typeof reviewComments>;
export type NewReviewComment = InferInsertModel<typeof reviewComments>;