Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,28 @@ All configuration lives in **GitHub Settings -> Environments -> production**:
- `POST /api/workspaces/:id/restart` — Restart a workspace
- `DELETE /api/workspaces/:id` — Delete a workspace

### Project Management

- `POST /api/projects` — Create project
- `GET /api/projects` — List user's projects (supports `limit` and `cursor`)
- `GET /api/projects/:id` — Get project detail (includes task status counts and linked workspace count)
- `PATCH /api/projects/:id` — Update project metadata (`name`, `description`, `defaultBranch`)
- `DELETE /api/projects/:id` — Delete project (cascades project tasks/dependencies/events)

### Task Management (Project Scoped)

- `POST /api/projects/:projectId/tasks` — Create task
- `GET /api/projects/:projectId/tasks` — List tasks (supports `status`, `minPriority`, `sort`, `limit`, `cursor`)
- `GET /api/projects/:projectId/tasks/:taskId` — Get task detail (includes dependencies + blocked state)
- `PATCH /api/projects/:projectId/tasks/:taskId` — Update task fields (`title`, `description`, `priority`, `parentTaskId`)
- `DELETE /api/projects/:projectId/tasks/:taskId` — Delete task
- `POST /api/projects/:projectId/tasks/:taskId/status` — Transition task status
- `POST /api/projects/:projectId/tasks/:taskId/status/callback` — Trusted callback status update for delegated tasks
- `POST /api/projects/:projectId/tasks/:taskId/dependencies` — Add dependency edge (`dependsOnTaskId`)
- `DELETE /api/projects/:projectId/tasks/:taskId/dependencies?dependsOnTaskId=...` — Remove dependency edge
- `POST /api/projects/:projectId/tasks/:taskId/delegate` — Delegate ready+unblocked task to owned running workspace
- `GET /api/projects/:projectId/tasks/:taskId/events` — List append-only task status events

### Agent Sessions

- `GET /api/workspaces/:id/agent-sessions` — List workspace agent sessions
Expand Down Expand Up @@ -699,6 +721,13 @@ See `apps/api/.env.example`:
- `MAX_WORKSPACES_PER_USER` - Optional runtime workspace cap
- `MAX_WORKSPACES_PER_NODE` - Optional runtime per-node workspace cap
- `MAX_AGENT_SESSIONS_PER_WORKSPACE` - Optional runtime session cap
- `MAX_PROJECTS_PER_USER` - Optional runtime project cap
- `MAX_TASKS_PER_PROJECT` - Optional runtime task cap per project
- `MAX_TASK_DEPENDENCIES_PER_TASK` - Optional runtime dependency-edge cap per task
- `TASK_LIST_DEFAULT_PAGE_SIZE` - Optional default task/project list page size
- `TASK_LIST_MAX_PAGE_SIZE` - Optional maximum task/project list page size
- `TASK_CALLBACK_TIMEOUT_MS` - Optional timeout budget for delegated-task callback processing
- `TASK_CALLBACK_RETRY_MAX_ATTEMPTS` - Optional retry budget for delegated-task callback processing
- `NODE_HEARTBEAT_STALE_SECONDS` - Optional staleness threshold for node health
- `NODE_AGENT_READY_TIMEOUT_MS` - Optional max wait for freshly provisioned node-agent health before first workspace create
- `NODE_AGENT_READY_POLL_INTERVAL_MS` - Optional polling interval for fresh-node readiness checks
Expand Down
29 changes: 29 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,28 @@ All configuration lives in **GitHub Settings -> Environments -> production**:
- `POST /api/workspaces/:id/restart` — Restart a workspace
- `DELETE /api/workspaces/:id` — Delete a workspace

### Project Management

- `POST /api/projects` — Create project
- `GET /api/projects` — List user's projects (supports `limit` and `cursor`)
- `GET /api/projects/:id` — Get project detail (includes task status counts and linked workspace count)
- `PATCH /api/projects/:id` — Update project metadata (`name`, `description`, `defaultBranch`)
- `DELETE /api/projects/:id` — Delete project (cascades project tasks/dependencies/events)

### Task Management (Project Scoped)

- `POST /api/projects/:projectId/tasks` — Create task
- `GET /api/projects/:projectId/tasks` — List tasks (supports `status`, `minPriority`, `sort`, `limit`, `cursor`)
- `GET /api/projects/:projectId/tasks/:taskId` — Get task detail (includes dependencies + blocked state)
- `PATCH /api/projects/:projectId/tasks/:taskId` — Update task fields (`title`, `description`, `priority`, `parentTaskId`)
- `DELETE /api/projects/:projectId/tasks/:taskId` — Delete task
- `POST /api/projects/:projectId/tasks/:taskId/status` — Transition task status
- `POST /api/projects/:projectId/tasks/:taskId/status/callback` — Trusted callback status update for delegated tasks
- `POST /api/projects/:projectId/tasks/:taskId/dependencies` — Add dependency edge (`dependsOnTaskId`)
- `DELETE /api/projects/:projectId/tasks/:taskId/dependencies?dependsOnTaskId=...` — Remove dependency edge
- `POST /api/projects/:projectId/tasks/:taskId/delegate` — Delegate ready+unblocked task to owned running workspace
- `GET /api/projects/:projectId/tasks/:taskId/events` — List append-only task status events

### Agent Sessions

- `GET /api/workspaces/:id/agent-sessions` — List workspace agent sessions
Expand Down Expand Up @@ -699,6 +721,13 @@ See `apps/api/.env.example`:
- `MAX_WORKSPACES_PER_USER` - Optional runtime workspace cap
- `MAX_WORKSPACES_PER_NODE` - Optional runtime per-node workspace cap
- `MAX_AGENT_SESSIONS_PER_WORKSPACE` - Optional runtime session cap
- `MAX_PROJECTS_PER_USER` - Optional runtime project cap
- `MAX_TASKS_PER_PROJECT` - Optional runtime task cap per project
- `MAX_TASK_DEPENDENCIES_PER_TASK` - Optional runtime dependency-edge cap per task
- `TASK_LIST_DEFAULT_PAGE_SIZE` - Optional default task/project list page size
- `TASK_LIST_MAX_PAGE_SIZE` - Optional maximum task/project list page size
- `TASK_CALLBACK_TIMEOUT_MS` - Optional timeout budget for delegated-task callback processing
- `TASK_CALLBACK_RETRY_MAX_ATTEMPTS` - Optional retry budget for delegated-task callback processing
- `NODE_HEARTBEAT_STALE_SECONDS` - Optional staleness threshold for node health
- `NODE_AGENT_READY_TIMEOUT_MS` - Optional max wait for freshly provisioned node-agent health before first workspace create
- `NODE_AGENT_READY_POLL_INTERVAL_MS` - Optional polling interval for fresh-node readiness checks
Expand Down
7 changes: 7 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ BASE_DOMAIN=simple-agent-manager.org
# MAX_WORKSPACES_PER_USER=10
# MAX_WORKSPACES_PER_NODE=10
# MAX_AGENT_SESSIONS_PER_WORKSPACE=10
# MAX_PROJECTS_PER_USER=25
# MAX_TASKS_PER_PROJECT=500
# MAX_TASK_DEPENDENCIES_PER_TASK=25
# TASK_LIST_DEFAULT_PAGE_SIZE=50
# TASK_LIST_MAX_PAGE_SIZE=200
# TASK_CALLBACK_TIMEOUT_MS=10000
# TASK_CALLBACK_RETRY_MAX_ATTEMPTS=3
# NODE_HEARTBEAT_STALE_SECONDS=180

# Fresh-node readiness wait before first workspace provisioning
Expand Down
74 changes: 74 additions & 0 deletions apps/api/src/db/migrations/0011_projects_tasks_foundation.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
normalized_name TEXT NOT NULL,
description TEXT,
installation_id TEXT NOT NULL REFERENCES github_installations(id) ON DELETE CASCADE,
repository TEXT NOT NULL,
default_branch TEXT NOT NULL DEFAULT 'main',
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_projects_user_id ON projects(user_id);
CREATE INDEX IF NOT EXISTS idx_projects_installation_id ON projects(installation_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_user_normalized_name
ON projects(user_id, normalized_name);
CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_user_installation_repository
ON projects(user_id, installation_id, repository);

CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
parent_task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'draft',
priority INTEGER NOT NULL DEFAULT 0,
agent_profile_hint TEXT,
started_at TEXT,
completed_at TEXT,
error_message TEXT,
output_summary TEXT,
output_branch TEXT,
output_pr_url TEXT,
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_tasks_project_status_priority_updated
ON tasks(project_id, status, priority, updated_at);
CREATE INDEX IF NOT EXISTS idx_tasks_project_created_at
ON tasks(project_id, created_at);
CREATE INDEX IF NOT EXISTS idx_tasks_project_user
ON tasks(project_id, user_id);

CREATE TABLE IF NOT EXISTS task_dependencies (
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
depends_on_task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (task_id, depends_on_task_id)
);

CREATE INDEX IF NOT EXISTS idx_task_dependencies_depends_on
ON task_dependencies(depends_on_task_id);

CREATE TABLE IF NOT EXISTS task_status_events (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
from_status TEXT,
to_status TEXT NOT NULL,
actor_type TEXT NOT NULL,
actor_id TEXT,
reason TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_task_status_events_task_created_at
ON task_status_events(task_id, created_at);
144 changes: 143 additions & 1 deletion apps/api/src/db/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { sqliteTable, text, integer, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
import { sqliteTable, text, integer, index, uniqueIndex, primaryKey } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';

// =============================================================================
Expand Down Expand Up @@ -155,6 +155,140 @@ export const githubInstallations = sqliteTable('github_installations', {
.default(sql`CURRENT_TIMESTAMP`),
});

// =============================================================================
// Projects
// =============================================================================
export const projects = sqliteTable(
'projects',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
normalizedName: text('normalized_name').notNull(),
description: text('description'),
installationId: text('installation_id')
.notNull()
.references(() => githubInstallations.id, { onDelete: 'cascade' }),
repository: text('repository').notNull(),
defaultBranch: text('default_branch').notNull().default('main'),
createdBy: text('created_by')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
createdAt: text('created_at')
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at')
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => ({
userIdIdx: index('idx_projects_user_id').on(table.userId),
installationIdIdx: index('idx_projects_installation_id').on(table.installationId),
userNormalizedNameUnique: uniqueIndex('idx_projects_user_normalized_name').on(
table.userId,
table.normalizedName
),
userInstallationRepoUnique: uniqueIndex('idx_projects_user_installation_repository').on(
table.userId,
table.installationId,
table.repository
),
})
);

// =============================================================================
// Tasks
// =============================================================================
export const tasks = sqliteTable(
'tasks',
{
id: text('id').primaryKey(),
projectId: text('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
parentTaskId: text('parent_task_id'),
workspaceId: text('workspace_id'),
title: text('title').notNull(),
description: text('description'),
status: text('status').notNull().default('draft'),
priority: integer('priority').notNull().default(0),
agentProfileHint: text('agent_profile_hint'),
startedAt: text('started_at'),
completedAt: text('completed_at'),
errorMessage: text('error_message'),
outputSummary: text('output_summary'),
outputBranch: text('output_branch'),
outputPrUrl: text('output_pr_url'),
createdBy: text('created_by')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
createdAt: text('created_at')
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at')
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => ({
projectStatusPriorityUpdatedIdx: index('idx_tasks_project_status_priority_updated').on(
table.projectId,
table.status,
table.priority,
table.updatedAt
),
projectCreatedAtIdx: index('idx_tasks_project_created_at').on(table.projectId, table.createdAt),
projectUserIdx: index('idx_tasks_project_user').on(table.projectId, table.userId),
})
);

export const taskDependencies = sqliteTable(
'task_dependencies',
{
taskId: text('task_id')
.notNull()
.references(() => tasks.id, { onDelete: 'cascade' }),
dependsOnTaskId: text('depends_on_task_id')
.notNull()
.references(() => tasks.id, { onDelete: 'cascade' }),
createdBy: text('created_by')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
createdAt: text('created_at')
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => ({
pk: primaryKey({ columns: [table.taskId, table.dependsOnTaskId] }),
dependsOnIdx: index('idx_task_dependencies_depends_on').on(table.dependsOnTaskId),
})
);

export const taskStatusEvents = sqliteTable(
'task_status_events',
{
id: text('id').primaryKey(),
taskId: text('task_id')
.notNull()
.references(() => tasks.id, { onDelete: 'cascade' }),
fromStatus: text('from_status'),
toStatus: text('to_status').notNull(),
actorType: text('actor_type').notNull(),
actorId: text('actor_id'),
reason: text('reason'),
createdAt: text('created_at')
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => ({
taskCreatedAtIdx: index('idx_task_status_events_task_created_at').on(table.taskId, table.createdAt),
})
);

// =============================================================================
// Nodes
// =============================================================================
Expand Down Expand Up @@ -508,6 +642,14 @@ export type Credential = typeof credentials.$inferSelect;
export type NewCredential = typeof credentials.$inferInsert;
export type GitHubInstallation = typeof githubInstallations.$inferSelect;
export type NewGitHubInstallation = typeof githubInstallations.$inferInsert;
export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert;
export type Task = typeof tasks.$inferSelect;
export type NewTask = typeof tasks.$inferInsert;
export type TaskDependency = typeof taskDependencies.$inferSelect;
export type NewTaskDependency = typeof taskDependencies.$inferInsert;
export type TaskStatusEvent = typeof taskStatusEvents.$inferSelect;
export type NewTaskStatusEvent = typeof taskStatusEvents.$inferInsert;
export type Node = typeof nodes.$inferSelect;
export type NewNode = typeof nodes.$inferInsert;
export type Workspace = typeof workspaces.$inferSelect;
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { uiGovernanceRoutes } from './routes/ui-governance';
import { transcribeRoutes } from './routes/transcribe';
import { agentSettingsRoutes } from './routes/agent-settings';
import { clientErrorsRoutes } from './routes/client-errors';
import { projectsRoutes } from './routes/projects';
import { tasksRoutes } from './routes/tasks';
import { checkProvisioningTimeouts } from './services/timeout';
import { getRuntimeLimits } from './services/limits';
import { recordNodeRoutingMetric } from './services/telemetry';
Expand Down Expand Up @@ -65,6 +67,13 @@ export interface Env {
MAX_WORKSPACES_PER_USER?: string;
MAX_WORKSPACES_PER_NODE?: string;
MAX_AGENT_SESSIONS_PER_WORKSPACE?: string;
MAX_PROJECTS_PER_USER?: string;
MAX_TASKS_PER_PROJECT?: string;
MAX_TASK_DEPENDENCIES_PER_TASK?: string;
TASK_LIST_DEFAULT_PAGE_SIZE?: string;
TASK_LIST_MAX_PAGE_SIZE?: string;
TASK_CALLBACK_TIMEOUT_MS?: string;
TASK_CALLBACK_RETRY_MAX_ATTEMPTS?: string;
NODE_HEARTBEAT_STALE_SECONDS?: string;
NODE_AGENT_READY_TIMEOUT_MS?: string;
NODE_AGENT_READY_POLL_INTERVAL_MS?: string;
Expand Down Expand Up @@ -262,6 +271,8 @@ app.route('/api/ui-governance', uiGovernanceRoutes);
app.route('/api/transcribe', transcribeRoutes);
app.route('/api/agent-settings', agentSettingsRoutes);
app.route('/api/client-errors', clientErrorsRoutes);
app.route('/api/projects', projectsRoutes);
app.route('/api/projects/:projectId/tasks', tasksRoutes);

// 404 handler
app.notFound((c) => {
Expand Down
Loading
Loading