Files
Codewalkers/apps/server/db/schema.ts
Lukas May 536cdf08a1 feat: Propagate task summaries and input context to execution agents
Execution agents were spawning blind — no input files, no knowledge of
what predecessor tasks accomplished. This adds three capabilities:

1. summary column on tasks table — completeTask() reads the finishing
   agent's result.message and stores it on the task record
2. dispatchNext() gathers full initiative context (initiative, phase,
   sibling tasks, pages) and passes it as inputContext so agents get
   .cw/input/task.md, initiative.md, phase.md, and context directories
3. context/tasks/*.md files now include the summary field in frontmatter
   so dependent agents can see what prior agents accomplished
2026-03-03 13:42:37 +01:00

539 lines
21 KiB
TypeScript

/**
* 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),
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'] })
.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>;