diff --git a/AGENTS.md b/AGENTS.md index 099a761..a792a6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 099a761..a792a6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 diff --git a/apps/api/.env.example b/apps/api/.env.example index 9b646e7..7ad0df5 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -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 diff --git a/apps/api/src/db/migrations/0011_projects_tasks_foundation.sql b/apps/api/src/db/migrations/0011_projects_tasks_foundation.sql new file mode 100644 index 0000000..b771576 --- /dev/null +++ b/apps/api/src/db/migrations/0011_projects_tasks_foundation.sql @@ -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); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index e26daaa..88f4713 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -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'; // ============================================================================= @@ -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 // ============================================================================= @@ -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; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 77ad21d..adaeff8 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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'; @@ -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; @@ -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) => { diff --git a/apps/api/src/middleware/project-auth.ts b/apps/api/src/middleware/project-auth.ts new file mode 100644 index 0000000..13f2ab1 --- /dev/null +++ b/apps/api/src/middleware/project-auth.ts @@ -0,0 +1,70 @@ +import { and, eq } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/d1'; +import * as schema from '../db/schema'; +import { errors } from './error'; + +export type AppDb = ReturnType>; + +export async function requireOwnedProject( + db: AppDb, + projectId: string, + userId: string +): Promise { + const rows = await db + .select() + .from(schema.projects) + .where(and(eq(schema.projects.id, projectId), eq(schema.projects.userId, userId))) + .limit(1); + + const project = rows[0]; + if (!project) { + throw errors.notFound('Project'); + } + + return project; +} + +export async function requireOwnedTask( + db: AppDb, + projectId: string, + taskId: string, + userId: string +): Promise { + const rows = await db + .select() + .from(schema.tasks) + .where( + and( + eq(schema.tasks.id, taskId), + eq(schema.tasks.projectId, projectId), + eq(schema.tasks.userId, userId) + ) + ) + .limit(1); + + const task = rows[0]; + if (!task) { + throw errors.notFound('Task'); + } + + return task; +} + +export async function requireOwnedWorkspace( + db: AppDb, + workspaceId: string, + userId: string +): Promise { + const rows = await db + .select() + .from(schema.workspaces) + .where(and(eq(schema.workspaces.id, workspaceId), eq(schema.workspaces.userId, userId))) + .limit(1); + + const workspace = rows[0]; + if (!workspace) { + throw errors.notFound('Workspace'); + } + + return workspace; +} diff --git a/apps/api/src/routes/projects.ts b/apps/api/src/routes/projects.ts new file mode 100644 index 0000000..f22ac37 --- /dev/null +++ b/apps/api/src/routes/projects.ts @@ -0,0 +1,350 @@ +import { Hono } from 'hono'; +import { and, count, desc, eq, isNotNull, lt, ne, sql } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/d1'; +import type { + CreateProjectRequest, + ListProjectsResponse, + Project, + ProjectDetailResponse, + TaskStatus, + UpdateProjectRequest, +} from '@simple-agent-manager/shared'; +import type { Env } from '../index'; +import * as schema from '../db/schema'; +import { ulid } from '../lib/ulid'; +import { getUserId, requireAuth } from '../middleware/auth'; +import { errors } from '../middleware/error'; +import { requireOwnedProject } from '../middleware/project-auth'; +import { getRuntimeLimits } from '../services/limits'; +import { getInstallationRepositories } from '../services/github-app'; + +const projectsRoutes = new Hono<{ Bindings: Env }>(); + +projectsRoutes.use('/*', requireAuth()); + +function normalizeProjectName(name: string): string { + return name.trim().replace(/\s+/g, ' ').toLowerCase(); +} + +function normalizeRepository(repository: string): string { + return repository.trim().toLowerCase(); +} + +function isValidRepositoryFormat(repository: string): boolean { + return /^[^/\s]+\/[^/\s]+$/.test(repository); +} + +function parsePositiveInt(value: string | undefined, fallback: number): number { + if (!value) { + return fallback; + } + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + return parsed; +} + +function toProjectResponse(project: schema.Project): Project { + return { + id: project.id, + userId: project.userId, + name: project.name, + description: project.description, + installationId: project.installationId, + repository: project.repository, + defaultBranch: project.defaultBranch, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + }; +} + +async function requireOwnedInstallation( + db: ReturnType>, + installationRowId: string, + userId: string +): Promise { + const rows = await db + .select() + .from(schema.githubInstallations) + .where( + and( + eq(schema.githubInstallations.id, installationRowId), + eq(schema.githubInstallations.userId, userId) + ) + ) + .limit(1); + + const installation = rows[0]; + if (!installation) { + throw errors.notFound('Installation'); + } + + return installation; +} + +async function assertRepositoryAccess( + installationExternalId: string, + repository: string, + env: Env +): Promise { + const repositories = await getInstallationRepositories(installationExternalId, env); + const hasAccess = repositories.some((repo) => repo.fullName.toLowerCase() === repository); + if (!hasAccess) { + throw errors.forbidden('Repository is not accessible through the selected installation'); + } +} + +projectsRoutes.post('/', async (c) => { + const userId = getUserId(c); + const db = drizzle(c.env.DATABASE, { schema }); + const limits = getRuntimeLimits(c.env); + const body = await c.req.json(); + + const name = body.name?.trim(); + const installationId = body.installationId?.trim(); + const repository = normalizeRepository(body.repository ?? ''); + const defaultBranch = body.defaultBranch?.trim(); + const description = body.description?.trim() || null; + + if (!name || !installationId || !repository || !defaultBranch) { + throw errors.badRequest('name, installationId, repository, and defaultBranch are required'); + } + + if (!isValidRepositoryFormat(repository)) { + throw errors.badRequest('repository must be in owner/repo format'); + } + + const [projectCountRow] = await db + .select({ count: count() }) + .from(schema.projects) + .where(eq(schema.projects.userId, userId)); + + if ((projectCountRow?.count ?? 0) >= limits.maxProjectsPerUser) { + throw errors.badRequest(`Maximum ${limits.maxProjectsPerUser} projects allowed`); + } + + const installation = await requireOwnedInstallation(db, installationId, userId); + await assertRepositoryAccess(installation.installationId, repository, c.env); + + const normalizedName = normalizeProjectName(name); + + const duplicateNameRows = await db + .select({ id: schema.projects.id }) + .from(schema.projects) + .where( + and( + eq(schema.projects.userId, userId), + eq(schema.projects.normalizedName, normalizedName) + ) + ) + .limit(1); + if (duplicateNameRows[0]) { + throw errors.conflict('Project name must be unique per user'); + } + + const duplicateRepositoryRows = await db + .select({ id: schema.projects.id }) + .from(schema.projects) + .where( + and( + eq(schema.projects.userId, userId), + eq(schema.projects.installationId, installation.id), + eq(schema.projects.repository, repository) + ) + ) + .limit(1); + if (duplicateRepositoryRows[0]) { + throw errors.conflict('Project repository is already linked'); + } + + const now = new Date().toISOString(); + const projectId = ulid(); + + await db.insert(schema.projects).values({ + id: projectId, + userId, + name, + normalizedName, + description, + installationId: installation.id, + repository, + defaultBranch, + createdBy: userId, + createdAt: now, + updatedAt: now, + }); + + const rows = await db + .select() + .from(schema.projects) + .where(eq(schema.projects.id, projectId)) + .limit(1); + + const project = rows[0]; + if (!project) { + throw errors.internal('Failed to load created project'); + } + + return c.json(toProjectResponse(project), 201); +}); + +projectsRoutes.get('/', async (c) => { + const userId = getUserId(c); + const db = drizzle(c.env.DATABASE, { schema }); + const limits = getRuntimeLimits(c.env); + + const requestedLimit = parsePositiveInt(c.req.query('limit'), limits.taskListDefaultPageSize); + const limit = Math.min(requestedLimit, limits.taskListMaxPageSize); + const cursor = c.req.query('cursor')?.trim(); + + const conditions = [eq(schema.projects.userId, userId)]; + if (cursor) { + conditions.push(lt(schema.projects.id, cursor)); + } + + const rows = await db + .select() + .from(schema.projects) + .where(and(...conditions)) + .orderBy(desc(schema.projects.id)) + .limit(limit + 1); + + const hasNextPage = rows.length > limit; + const projects = hasNextPage ? rows.slice(0, limit) : rows; + const nextCursor = hasNextPage ? (projects[projects.length - 1]?.id ?? null) : null; + + const response: ListProjectsResponse = { + projects: projects.map(toProjectResponse), + nextCursor, + }; + + return c.json(response); +}); + +projectsRoutes.get('/:id', async (c) => { + const userId = getUserId(c); + const projectId = c.req.param('id'); + const db = drizzle(c.env.DATABASE, { schema }); + + const project = await requireOwnedProject(db, projectId, userId); + + const taskCountsRows = await db + .select({ status: schema.tasks.status, count: count() }) + .from(schema.tasks) + .where(eq(schema.tasks.projectId, project.id)) + .groupBy(schema.tasks.status); + + const taskCountsByStatus: Partial> = {}; + for (const row of taskCountsRows) { + taskCountsByStatus[row.status as TaskStatus] = Number(row.count); + } + + const linkedWorkspacesRow = await db + .select({ count: sql`count(distinct ${schema.tasks.workspaceId})` }) + .from(schema.tasks) + .where(and(eq(schema.tasks.projectId, project.id), isNotNull(schema.tasks.workspaceId))) + .limit(1); + + const response: ProjectDetailResponse = { + ...toProjectResponse(project), + summary: { + taskCountsByStatus, + linkedWorkspaces: linkedWorkspacesRow[0]?.count ?? 0, + }, + }; + + return c.json(response); +}); + +projectsRoutes.patch('/:id', async (c) => { + const userId = getUserId(c); + const projectId = c.req.param('id'); + const db = drizzle(c.env.DATABASE, { schema }); + const body = await c.req.json(); + + const existing = await requireOwnedProject(db, projectId, userId); + + if ( + body.name === undefined && + body.description === undefined && + body.defaultBranch === undefined + ) { + throw errors.badRequest('At least one field is required'); + } + + const nextName = body.name === undefined ? existing.name : body.name.trim(); + const nextDefaultBranch = + body.defaultBranch === undefined ? existing.defaultBranch : body.defaultBranch.trim(); + + if (!nextName) { + throw errors.badRequest('name cannot be empty'); + } + if (!nextDefaultBranch) { + throw errors.badRequest('defaultBranch cannot be empty'); + } + + await assertRepositoryAccess( + (await requireOwnedInstallation(db, existing.installationId, userId)).installationId, + existing.repository, + c.env + ); + + const normalizedName = normalizeProjectName(nextName); + + const duplicateRows = await db + .select({ id: schema.projects.id }) + .from(schema.projects) + .where( + and( + eq(schema.projects.userId, userId), + eq(schema.projects.normalizedName, normalizedName), + ne(schema.projects.id, projectId) + ) + ) + .limit(1); + + if (duplicateRows[0]) { + throw errors.conflict('Project name must be unique per user'); + } + + await db + .update(schema.projects) + .set({ + name: nextName, + normalizedName, + description: body.description === undefined ? existing.description : body.description?.trim() || null, + defaultBranch: nextDefaultBranch, + updatedAt: new Date().toISOString(), + }) + .where(and(eq(schema.projects.id, projectId), eq(schema.projects.userId, userId))); + + const rows = await db + .select() + .from(schema.projects) + .where(and(eq(schema.projects.id, projectId), eq(schema.projects.userId, userId))) + .limit(1); + + const updated = rows[0]; + if (!updated) { + throw errors.notFound('Project'); + } + + return c.json(toProjectResponse(updated)); +}); + +projectsRoutes.delete('/:id', async (c) => { + const userId = getUserId(c); + const projectId = c.req.param('id'); + const db = drizzle(c.env.DATABASE, { schema }); + + await requireOwnedProject(db, projectId, userId); + + await db + .delete(schema.projects) + .where(and(eq(schema.projects.id, projectId), eq(schema.projects.userId, userId))); + + return c.json({ success: true }); +}); + +export { projectsRoutes }; diff --git a/apps/api/src/routes/tasks.ts b/apps/api/src/routes/tasks.ts new file mode 100644 index 0000000..148c7ce --- /dev/null +++ b/apps/api/src/routes/tasks.ts @@ -0,0 +1,859 @@ +import { Hono } from 'hono'; +import { + and, + count, + desc, + eq, + gte, + inArray, + lt, +} from 'drizzle-orm'; +import type { SQL } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/d1'; +import type { + CreateTaskDependencyRequest, + CreateTaskRequest, + DelegateTaskRequest, + ListTaskEventsResponse, + ListTasksResponse, + Task, + TaskActorType, + TaskDependency, + TaskDetailResponse, + TaskSortOrder, + TaskStatus, + UpdateTaskRequest, + UpdateTaskStatusRequest, +} from '@simple-agent-manager/shared'; +import type { Env } from '../index'; +import * as schema from '../db/schema'; +import { ulid } from '../lib/ulid'; +import { getUserId, requireAuth } from '../middleware/auth'; +import { errors } from '../middleware/error'; +import { requireOwnedProject, requireOwnedTask, requireOwnedWorkspace } from '../middleware/project-auth'; +import { + canTransitionTaskStatus, + getAllowedTaskTransitions, + isExecutableTaskStatus, + isTaskStatus, +} from '../services/task-status'; +import { + getBlockedTaskIds, + isTaskBlocked, + wouldCreateTaskDependencyCycle, + type TaskDependencyEdge, +} from '../services/task-graph'; +import { getRuntimeLimits } from '../services/limits'; +import { verifyCallbackToken } from '../services/jwt'; + +const tasksRoutes = new Hono<{ Bindings: Env }>(); + +tasksRoutes.use('/*', async (c, next) => { + if (c.req.path.endsWith('/status/callback')) { + return next(); + } + return requireAuth()(c, next); +}); + +function requireRouteParam( + c: { req: { param: (name: string) => string | undefined } }, + name: string +): string { + const value = c.req.param(name); + if (!value) { + throw errors.badRequest(`${name} is required`); + } + return value; +} + +function parsePositiveInt(value: string | undefined, fallback: number): number { + if (!value) { + return fallback; + } + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + return parsed; +} + +function parseTaskSortOrder(value: string | undefined): TaskSortOrder { + if (value === 'updatedAtDesc' || value === 'priorityDesc') { + return value; + } + return 'createdAtDesc'; +} + +function toTaskResponse(task: schema.Task, blocked = false): Task { + return { + id: task.id, + projectId: task.projectId, + userId: task.userId, + parentTaskId: task.parentTaskId, + workspaceId: task.workspaceId, + title: task.title, + description: task.description, + status: task.status as TaskStatus, + priority: task.priority, + agentProfileHint: task.agentProfileHint, + blocked, + startedAt: task.startedAt, + completedAt: task.completedAt, + errorMessage: task.errorMessage, + outputSummary: task.outputSummary, + outputBranch: task.outputBranch, + outputPrUrl: task.outputPrUrl, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + }; +} + +function toDependencyResponse(dependency: schema.TaskDependency): TaskDependency { + return { + taskId: dependency.taskId, + dependsOnTaskId: dependency.dependsOnTaskId, + createdAt: dependency.createdAt, + }; +} + +async function requireOwnedTaskById( + db: ReturnType>, + taskId: string, + userId: string +): Promise { + const rows = await db + .select() + .from(schema.tasks) + .where(and(eq(schema.tasks.id, taskId), eq(schema.tasks.userId, userId))) + .limit(1); + + const task = rows[0]; + if (!task) { + throw errors.notFound('Task'); + } + + return task; +} + +async function appendStatusEvent( + db: ReturnType>, + taskId: string, + fromStatus: TaskStatus | null, + toStatus: TaskStatus, + actorType: TaskActorType, + actorId: string | null, + reason?: string +): Promise { + await db.insert(schema.taskStatusEvents).values({ + id: ulid(), + taskId, + fromStatus, + toStatus, + actorType, + actorId, + reason: reason ?? null, + createdAt: new Date().toISOString(), + }); +} + +async function getTaskDependencies( + db: ReturnType>, + taskId: string +): Promise { + return db + .select() + .from(schema.taskDependencies) + .where(eq(schema.taskDependencies.taskId, taskId)); +} + +async function computeBlockedForTask( + db: ReturnType>, + taskId: string +): Promise { + const dependencies = await getTaskDependencies(db, taskId); + if (dependencies.length === 0) { + return false; + } + + const dependencyIds = dependencies.map((dependency) => dependency.dependsOnTaskId); + const dependencyTasks = await db + .select({ id: schema.tasks.id, status: schema.tasks.status }) + .from(schema.tasks) + .where(inArray(schema.tasks.id, dependencyIds)); + + const statusMap: Record = {}; + for (const dependencyTask of dependencyTasks) { + statusMap[dependencyTask.id] = dependencyTask.status as TaskStatus; + } + + return isTaskBlocked(taskId, dependencies, statusMap); +} + +async function computeBlockedSet( + db: ReturnType>, + taskIds: string[] +): Promise> { + if (taskIds.length === 0) { + return new Set(); + } + + const dependencies = await db + .select({ + taskId: schema.taskDependencies.taskId, + dependsOnTaskId: schema.taskDependencies.dependsOnTaskId, + }) + .from(schema.taskDependencies) + .where(inArray(schema.taskDependencies.taskId, taskIds)); + + if (dependencies.length === 0) { + return new Set(); + } + + const dependencyTaskIds = [...new Set(dependencies.map((dependency) => dependency.dependsOnTaskId))]; + const dependencyTasks = dependencyTaskIds.length === 0 + ? [] + : await db + .select({ id: schema.tasks.id, status: schema.tasks.status }) + .from(schema.tasks) + .where(inArray(schema.tasks.id, dependencyTaskIds)); + + const statusMap: Record = {}; + for (const dependencyTask of dependencyTasks) { + statusMap[dependencyTask.id] = dependencyTask.status as TaskStatus; + } + + return getBlockedTaskIds(taskIds, dependencies, statusMap); +} + +async function setTaskStatus( + db: ReturnType>, + task: schema.Task, + toStatus: TaskStatus, + actorType: TaskActorType, + actorId: string | null, + options: { + reason?: string; + outputSummary?: string; + outputBranch?: string; + outputPrUrl?: string; + errorMessage?: string; + } = {} +): Promise { + const now = new Date().toISOString(); + + const nextValues: Partial = { + status: toStatus, + updatedAt: now, + }; + + if (toStatus === 'in_progress' && !task.startedAt) { + nextValues.startedAt = now; + } + + if (toStatus === 'completed' || toStatus === 'failed' || toStatus === 'cancelled') { + nextValues.completedAt = now; + } + + if (toStatus === 'ready') { + nextValues.workspaceId = null; + nextValues.startedAt = null; + nextValues.completedAt = null; + nextValues.errorMessage = null; + } + + if (options.outputSummary !== undefined) { + nextValues.outputSummary = options.outputSummary?.trim() || null; + } + + if (options.outputBranch !== undefined) { + nextValues.outputBranch = options.outputBranch?.trim() || null; + } + + if (options.outputPrUrl !== undefined) { + nextValues.outputPrUrl = options.outputPrUrl?.trim() || null; + } + + if (toStatus === 'failed') { + nextValues.errorMessage = options.errorMessage?.trim() || task.errorMessage || 'Task failed'; + } else if (options.errorMessage !== undefined) { + nextValues.errorMessage = options.errorMessage?.trim() || null; + } + + await db + .update(schema.tasks) + .set(nextValues) + .where(eq(schema.tasks.id, task.id)); + + await appendStatusEvent( + db, + task.id, + task.status as TaskStatus, + toStatus, + actorType, + actorId, + options.reason + ); + + const rows = await db + .select() + .from(schema.tasks) + .where(eq(schema.tasks.id, task.id)) + .limit(1); + const updatedTask = rows[0]; + if (!updatedTask) { + throw errors.notFound('Task'); + } + + return updatedTask; +} + +tasksRoutes.post('/', async (c) => { + const userId = getUserId(c); + const projectId = requireRouteParam(c, 'projectId'); + const db = drizzle(c.env.DATABASE, { schema }); + const limits = getRuntimeLimits(c.env); + const body = await c.req.json(); + + const project = await requireOwnedProject(db, projectId, userId); + + const title = body.title?.trim(); + if (!title) { + throw errors.badRequest('title is required'); + } + + const [taskCountRow] = await db + .select({ count: count() }) + .from(schema.tasks) + .where(eq(schema.tasks.projectId, project.id)); + + if ((taskCountRow?.count ?? 0) >= limits.maxTasksPerProject) { + throw errors.badRequest(`Maximum ${limits.maxTasksPerProject} tasks allowed per project`); + } + + let parentTaskId: string | null = null; + if (body.parentTaskId) { + const parent = await requireOwnedTaskById(db, body.parentTaskId, userId); + if (parent.projectId !== project.id) { + throw errors.badRequest('parentTaskId must reference a task in the same project'); + } + parentTaskId = parent.id; + } + + const now = new Date().toISOString(); + const taskId = ulid(); + + await db.insert(schema.tasks).values({ + id: taskId, + projectId: project.id, + userId, + parentTaskId, + workspaceId: null, + title, + description: body.description?.trim() || null, + status: 'draft', + priority: body.priority ?? 0, + agentProfileHint: body.agentProfileHint?.trim() || null, + createdBy: userId, + createdAt: now, + updatedAt: now, + }); + + await appendStatusEvent(db, taskId, null, 'draft', 'user', userId, 'Task created'); + + const rows = await db + .select() + .from(schema.tasks) + .where(eq(schema.tasks.id, taskId)) + .limit(1); + + const task = rows[0]; + if (!task) { + throw errors.internal('Failed to load created task'); + } + + return c.json(toTaskResponse(task, false), 201); +}); + +tasksRoutes.get('/', async (c) => { + const userId = getUserId(c); + const projectId = requireRouteParam(c, 'projectId'); + const db = drizzle(c.env.DATABASE, { schema }); + const limits = getRuntimeLimits(c.env); + + await requireOwnedProject(db, projectId, userId); + + const requestedStatus = c.req.query('status'); + if (requestedStatus && !isTaskStatus(requestedStatus)) { + throw errors.badRequest('Invalid status filter'); + } + + const minPriorityQuery = c.req.query('minPriority'); + const minPriority = minPriorityQuery ? Number.parseInt(minPriorityQuery, 10) : undefined; + if (minPriorityQuery && (!Number.isFinite(minPriority) || Number.isNaN(minPriority))) { + throw errors.badRequest('minPriority must be an integer'); + } + + const sort = parseTaskSortOrder(c.req.query('sort')); + const requestedLimit = parsePositiveInt(c.req.query('limit'), limits.taskListDefaultPageSize); + const limit = Math.min(requestedLimit, limits.taskListMaxPageSize); + const cursor = c.req.query('cursor')?.trim(); + + const conditions: SQL[] = [ + eq(schema.tasks.projectId, projectId), + eq(schema.tasks.userId, userId), + ]; + + if (requestedStatus) { + conditions.push(eq(schema.tasks.status, requestedStatus)); + } + + if (minPriority !== undefined) { + conditions.push(gte(schema.tasks.priority, minPriority)); + } + + if (cursor) { + conditions.push(lt(schema.tasks.id, cursor)); + } + + let query = db + .select() + .from(schema.tasks) + .where(and(...conditions)) + .$dynamic(); + + if (sort === 'updatedAtDesc') { + query = query.orderBy(desc(schema.tasks.updatedAt), desc(schema.tasks.id)); + } else if (sort === 'priorityDesc') { + query = query.orderBy(desc(schema.tasks.priority), desc(schema.tasks.updatedAt), desc(schema.tasks.id)); + } else { + query = query.orderBy(desc(schema.tasks.createdAt), desc(schema.tasks.id)); + } + + const rows = await query.limit(limit + 1); + + const hasNextPage = rows.length > limit; + const tasks = hasNextPage ? rows.slice(0, limit) : rows; + const taskIds = tasks.map((task) => task.id); + const blockedSet = await computeBlockedSet(db, taskIds); + + const response: ListTasksResponse = { + tasks: tasks.map((task) => toTaskResponse(task, blockedSet.has(task.id))), + nextCursor: hasNextPage ? (tasks[tasks.length - 1]?.id ?? null) : null, + }; + + return c.json(response); +}); + +tasksRoutes.get('/:taskId', async (c) => { + const userId = getUserId(c); + const projectId = requireRouteParam(c, 'projectId'); + const taskId = requireRouteParam(c, 'taskId'); + const db = drizzle(c.env.DATABASE, { schema }); + + await requireOwnedProject(db, projectId, userId); + const task = await requireOwnedTask(db, projectId, taskId, userId); + const dependencies = await getTaskDependencies(db, task.id); + const blocked = await computeBlockedForTask(db, task.id); + + const response: TaskDetailResponse = { + ...toTaskResponse(task, blocked), + dependencies: dependencies.map(toDependencyResponse), + blocked, + }; + + return c.json(response); +}); + +tasksRoutes.patch('/:taskId', async (c) => { + const userId = getUserId(c); + const projectId = requireRouteParam(c, 'projectId'); + const taskId = requireRouteParam(c, 'taskId'); + const db = drizzle(c.env.DATABASE, { schema }); + const body = await c.req.json(); + + await requireOwnedProject(db, projectId, userId); + const task = await requireOwnedTask(db, projectId, taskId, userId); + + if ( + body.title === undefined && + body.description === undefined && + body.priority === undefined && + body.parentTaskId === undefined + ) { + throw errors.badRequest('At least one field is required'); + } + + const nextValues: Partial = { + updatedAt: new Date().toISOString(), + }; + + if (body.title !== undefined) { + const title = body.title.trim(); + if (!title) { + throw errors.badRequest('title cannot be empty'); + } + nextValues.title = title; + } + + if (body.description !== undefined) { + nextValues.description = body.description?.trim() || null; + } + + if (body.priority !== undefined) { + if (!Number.isInteger(body.priority)) { + throw errors.badRequest('priority must be an integer'); + } + nextValues.priority = body.priority; + } + + if (body.parentTaskId !== undefined) { + if (body.parentTaskId === null) { + nextValues.parentTaskId = null; + } else { + const parentTaskId = body.parentTaskId.trim(); + if (!parentTaskId) { + throw errors.badRequest('parentTaskId cannot be empty'); + } + if (parentTaskId === task.id) { + throw errors.badRequest('Task cannot be its own parent'); + } + const parent = await requireOwnedTaskById(db, parentTaskId, userId); + if (parent.projectId !== projectId) { + throw errors.badRequest('parentTaskId must reference a task in the same project'); + } + nextValues.parentTaskId = parent.id; + } + } + + await db + .update(schema.tasks) + .set(nextValues) + .where(eq(schema.tasks.id, task.id)); + + const rows = await db + .select() + .from(schema.tasks) + .where(eq(schema.tasks.id, task.id)) + .limit(1); + + const updatedTask = rows[0]; + if (!updatedTask) { + throw errors.notFound('Task'); + } + + const blocked = await computeBlockedForTask(db, updatedTask.id); + return c.json(toTaskResponse(updatedTask, blocked)); +}); + +tasksRoutes.delete('/:taskId', async (c) => { + const userId = getUserId(c); + const projectId = requireRouteParam(c, 'projectId'); + const taskId = requireRouteParam(c, 'taskId'); + const db = drizzle(c.env.DATABASE, { schema }); + + await requireOwnedProject(db, projectId, userId); + const task = await requireOwnedTask(db, projectId, taskId, userId); + + const [dependentCountRow] = await db + .select({ count: count() }) + .from(schema.taskDependencies) + .where(eq(schema.taskDependencies.dependsOnTaskId, task.id)); + + if ((dependentCountRow?.count ?? 0) > 0) { + throw errors.conflict('Cannot delete task while other tasks depend on it'); + } + + await db.delete(schema.tasks).where(eq(schema.tasks.id, task.id)); + + return c.json({ success: true }); +}); + +tasksRoutes.post('/:taskId/status', async (c) => { + const userId = getUserId(c); + const projectId = requireRouteParam(c, 'projectId'); + const taskId = requireRouteParam(c, 'taskId'); + const db = drizzle(c.env.DATABASE, { schema }); + const body = await c.req.json(); + + await requireOwnedProject(db, projectId, userId); + const task = await requireOwnedTask(db, projectId, taskId, userId); + + if (!isTaskStatus(body.toStatus)) { + throw errors.badRequest('Invalid toStatus value'); + } + + const blocked = await computeBlockedForTask(db, task.id); + if (blocked && isExecutableTaskStatus(body.toStatus)) { + throw errors.conflict('Task is blocked by unresolved dependencies'); + } + + if (!canTransitionTaskStatus(task.status as TaskStatus, body.toStatus)) { + throw errors.conflict( + `Invalid transition ${task.status} -> ${body.toStatus}. Allowed: ${getAllowedTaskTransitions(task.status as TaskStatus).join(', ') || 'none'}` + ); + } + + const updatedTask = await setTaskStatus(db, task, body.toStatus, 'user', userId, { + reason: body.reason, + outputSummary: body.outputSummary, + outputBranch: body.outputBranch, + outputPrUrl: body.outputPrUrl, + errorMessage: body.errorMessage, + }); + + const nextBlocked = await computeBlockedForTask(db, updatedTask.id); + return c.json(toTaskResponse(updatedTask, nextBlocked)); +}); + +tasksRoutes.post('/:taskId/status/callback', async (c) => { + const projectId = requireRouteParam(c, 'projectId'); + const taskId = requireRouteParam(c, 'taskId'); + const db = drizzle(c.env.DATABASE, { schema }); + const body = await c.req.json(); + + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + throw errors.unauthorized('Missing or invalid Authorization header'); + } + + const token = authHeader.slice(7); + const payload = await verifyCallbackToken(token, c.env); + + const rows = await db + .select() + .from(schema.tasks) + .where(and(eq(schema.tasks.id, taskId), eq(schema.tasks.projectId, projectId))) + .limit(1); + + const task = rows[0]; + if (!task) { + throw errors.notFound('Task'); + } + + if (!task.workspaceId || payload.workspace !== task.workspaceId) { + throw errors.forbidden('Token workspace mismatch'); + } + + if (!isTaskStatus(body.toStatus)) { + throw errors.badRequest('Invalid toStatus value'); + } + + if (!canTransitionTaskStatus(task.status as TaskStatus, body.toStatus)) { + throw errors.conflict( + `Invalid transition ${task.status} -> ${body.toStatus}. Allowed: ${getAllowedTaskTransitions(task.status as TaskStatus).join(', ') || 'none'}` + ); + } + + const updatedTask = await setTaskStatus(db, task, body.toStatus, 'workspace_callback', payload.workspace, { + reason: body.reason, + outputSummary: body.outputSummary, + outputBranch: body.outputBranch, + outputPrUrl: body.outputPrUrl, + errorMessage: body.errorMessage, + }); + + const blocked = await computeBlockedForTask(db, updatedTask.id); + return c.json(toTaskResponse(updatedTask, blocked)); +}); + +tasksRoutes.post('/:taskId/dependencies', async (c) => { + const userId = getUserId(c); + const projectId = requireRouteParam(c, 'projectId'); + const taskId = requireRouteParam(c, 'taskId'); + const db = drizzle(c.env.DATABASE, { schema }); + const limits = getRuntimeLimits(c.env); + const body = await c.req.json(); + + await requireOwnedProject(db, projectId, userId); + const task = await requireOwnedTask(db, projectId, taskId, userId); + const dependsOnTaskId = body.dependsOnTaskId?.trim(); + + if (!dependsOnTaskId) { + throw errors.badRequest('dependsOnTaskId is required'); + } + + if (dependsOnTaskId === task.id) { + throw errors.badRequest('Task cannot depend on itself'); + } + + const dependencyTask = await requireOwnedTaskById(db, dependsOnTaskId, userId); + if (dependencyTask.projectId !== projectId) { + throw errors.badRequest('Dependency task must belong to the same project'); + } + + const [dependencyCountRow] = await db + .select({ count: count() }) + .from(schema.taskDependencies) + .where(eq(schema.taskDependencies.taskId, task.id)); + + if ((dependencyCountRow?.count ?? 0) >= limits.maxTaskDependenciesPerTask) { + throw errors.badRequest( + `Maximum ${limits.maxTaskDependenciesPerTask} dependencies allowed per task` + ); + } + + const projectEdges = await db + .select({ + taskId: schema.taskDependencies.taskId, + dependsOnTaskId: schema.taskDependencies.dependsOnTaskId, + }) + .from(schema.taskDependencies) + .innerJoin(schema.tasks, eq(schema.tasks.id, schema.taskDependencies.taskId)) + .where(eq(schema.tasks.projectId, projectId)); + + const edges: TaskDependencyEdge[] = projectEdges.map((edge) => ({ + taskId: edge.taskId, + dependsOnTaskId: edge.dependsOnTaskId, + })); + + if (wouldCreateTaskDependencyCycle(task.id, dependencyTask.id, edges)) { + throw errors.conflict('Dependency would create a cycle'); + } + + const now = new Date().toISOString(); + try { + await db.insert(schema.taskDependencies).values({ + taskId: task.id, + dependsOnTaskId: dependencyTask.id, + createdBy: userId, + createdAt: now, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.toLowerCase().includes('unique')) { + throw errors.conflict('Dependency already exists'); + } + throw error; + } + + return c.json({ + taskId: task.id, + dependsOnTaskId: dependencyTask.id, + createdAt: now, + }, 201); +}); + +tasksRoutes.delete('/:taskId/dependencies', async (c) => { + const userId = getUserId(c); + const projectId = requireRouteParam(c, 'projectId'); + const taskId = requireRouteParam(c, 'taskId'); + const dependsOnTaskId = c.req.query('dependsOnTaskId')?.trim(); + const db = drizzle(c.env.DATABASE, { schema }); + + await requireOwnedProject(db, projectId, userId); + await requireOwnedTask(db, projectId, taskId, userId); + + if (!dependsOnTaskId) { + throw errors.badRequest('dependsOnTaskId query parameter is required'); + } + + const result = await db + .delete(schema.taskDependencies) + .where( + and( + eq(schema.taskDependencies.taskId, taskId), + eq(schema.taskDependencies.dependsOnTaskId, dependsOnTaskId) + ) + ) + .returning(); + + if (result.length === 0) { + throw errors.notFound('Task dependency'); + } + + return c.json({ success: true }); +}); + +tasksRoutes.post('/:taskId/delegate', async (c) => { + const userId = getUserId(c); + const projectId = requireRouteParam(c, 'projectId'); + const taskId = requireRouteParam(c, 'taskId'); + const db = drizzle(c.env.DATABASE, { schema }); + const body = await c.req.json(); + + await requireOwnedProject(db, projectId, userId); + const task = await requireOwnedTask(db, projectId, taskId, userId); + + if (task.status !== 'ready') { + throw errors.conflict('Only ready tasks can be delegated'); + } + + const blocked = await computeBlockedForTask(db, task.id); + if (blocked) { + throw errors.conflict('Blocked tasks cannot be delegated'); + } + + const workspaceId = body.workspaceId?.trim(); + if (!workspaceId) { + throw errors.badRequest('workspaceId is required'); + } + + const workspace = await requireOwnedWorkspace(db, workspaceId, userId); + if (workspace.status !== 'running') { + throw errors.badRequest('Workspace must be running to accept delegated tasks'); + } + + const now = new Date().toISOString(); + + await db + .update(schema.tasks) + .set({ + workspaceId: workspace.id, + status: 'delegated', + updatedAt: now, + }) + .where(eq(schema.tasks.id, task.id)); + + await appendStatusEvent(db, task.id, task.status as TaskStatus, 'delegated', 'user', userId, 'Delegated to workspace'); + + const rows = await db + .select() + .from(schema.tasks) + .where(eq(schema.tasks.id, task.id)) + .limit(1); + + const updatedTask = rows[0]; + if (!updatedTask) { + throw errors.notFound('Task'); + } + + return c.json(toTaskResponse(updatedTask, false)); +}); + +tasksRoutes.get('/:taskId/events', async (c) => { + const userId = getUserId(c); + const projectId = requireRouteParam(c, 'projectId'); + const taskId = requireRouteParam(c, 'taskId'); + const db = drizzle(c.env.DATABASE, { schema }); + const limits = getRuntimeLimits(c.env); + + await requireOwnedProject(db, projectId, userId); + await requireOwnedTask(db, projectId, taskId, userId); + + const requestedLimit = parsePositiveInt(c.req.query('limit'), limits.taskListDefaultPageSize); + const limit = Math.min(requestedLimit, limits.taskListMaxPageSize); + + const events = await db + .select() + .from(schema.taskStatusEvents) + .where(eq(schema.taskStatusEvents.taskId, taskId)) + .orderBy(desc(schema.taskStatusEvents.createdAt)) + .limit(limit); + + const response: ListTaskEventsResponse = { + events: events.map((event) => ({ + id: event.id, + taskId: event.taskId, + fromStatus: (event.fromStatus as TaskStatus | null) ?? null, + toStatus: event.toStatus as TaskStatus, + actorType: event.actorType as TaskActorType, + actorId: event.actorId, + reason: event.reason, + createdAt: event.createdAt, + })), + }; + + return c.json(response); +}); + +export { tasksRoutes }; diff --git a/apps/api/src/services/limits.ts b/apps/api/src/services/limits.ts index 059e3d7..f728830 100644 --- a/apps/api/src/services/limits.ts +++ b/apps/api/src/services/limits.ts @@ -1,9 +1,16 @@ import { DEFAULT_MAX_AGENT_SESSIONS_PER_WORKSPACE, DEFAULT_MAX_NODES_PER_USER, + DEFAULT_MAX_PROJECTS_PER_USER, + DEFAULT_MAX_TASK_DEPENDENCIES_PER_TASK, + DEFAULT_MAX_TASKS_PER_PROJECT, DEFAULT_MAX_WORKSPACES_PER_NODE, DEFAULT_MAX_WORKSPACES_PER_USER, DEFAULT_NODE_HEARTBEAT_STALE_SECONDS, + DEFAULT_TASK_CALLBACK_RETRY_MAX_ATTEMPTS, + DEFAULT_TASK_CALLBACK_TIMEOUT_MS, + DEFAULT_TASK_LIST_DEFAULT_PAGE_SIZE, + DEFAULT_TASK_LIST_MAX_PAGE_SIZE, } from '@simple-agent-manager/shared'; export interface RuntimeLimits { @@ -12,6 +19,13 @@ export interface RuntimeLimits { maxWorkspacesPerNode: number; maxAgentSessionsPerWorkspace: number; nodeHeartbeatStaleSeconds: number; + maxProjectsPerUser: number; + maxTasksPerProject: number; + maxTaskDependenciesPerTask: number; + taskListDefaultPageSize: number; + taskListMaxPageSize: number; + taskCallbackTimeoutMs: number; + taskCallbackRetryMaxAttempts: number; } function parsePositiveInt(value: string | undefined, fallback: number): number { @@ -29,6 +43,13 @@ export function getRuntimeLimits(env: { MAX_WORKSPACES_PER_NODE?: string; MAX_AGENT_SESSIONS_PER_WORKSPACE?: string; NODE_HEARTBEAT_STALE_SECONDS?: 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; }): RuntimeLimits { return { maxNodesPerUser: parsePositiveInt(env.MAX_NODES_PER_USER, DEFAULT_MAX_NODES_PER_USER), @@ -42,5 +63,27 @@ export function getRuntimeLimits(env: { env.NODE_HEARTBEAT_STALE_SECONDS, DEFAULT_NODE_HEARTBEAT_STALE_SECONDS ), + maxProjectsPerUser: parsePositiveInt(env.MAX_PROJECTS_PER_USER, DEFAULT_MAX_PROJECTS_PER_USER), + maxTasksPerProject: parsePositiveInt(env.MAX_TASKS_PER_PROJECT, DEFAULT_MAX_TASKS_PER_PROJECT), + maxTaskDependenciesPerTask: parsePositiveInt( + env.MAX_TASK_DEPENDENCIES_PER_TASK, + DEFAULT_MAX_TASK_DEPENDENCIES_PER_TASK + ), + taskListDefaultPageSize: parsePositiveInt( + env.TASK_LIST_DEFAULT_PAGE_SIZE, + DEFAULT_TASK_LIST_DEFAULT_PAGE_SIZE + ), + taskListMaxPageSize: parsePositiveInt( + env.TASK_LIST_MAX_PAGE_SIZE, + DEFAULT_TASK_LIST_MAX_PAGE_SIZE + ), + taskCallbackTimeoutMs: parsePositiveInt( + env.TASK_CALLBACK_TIMEOUT_MS, + DEFAULT_TASK_CALLBACK_TIMEOUT_MS + ), + taskCallbackRetryMaxAttempts: parsePositiveInt( + env.TASK_CALLBACK_RETRY_MAX_ATTEMPTS, + DEFAULT_TASK_CALLBACK_RETRY_MAX_ATTEMPTS + ), }; } diff --git a/apps/api/src/services/task-graph.ts b/apps/api/src/services/task-graph.ts new file mode 100644 index 0000000..1fccb4a --- /dev/null +++ b/apps/api/src/services/task-graph.ts @@ -0,0 +1,89 @@ +import type { TaskStatus } from '@simple-agent-manager/shared'; + +export interface TaskDependencyEdge { + taskId: string; + dependsOnTaskId: string; +} + +export function wouldCreateTaskDependencyCycle( + taskId: string, + dependsOnTaskId: string, + edges: TaskDependencyEdge[] +): boolean { + if (taskId === dependsOnTaskId) { + return true; + } + + const adjacency = new Map(); + for (const edge of edges) { + const current = adjacency.get(edge.taskId) ?? []; + current.push(edge.dependsOnTaskId); + adjacency.set(edge.taskId, current); + } + + const nextEdges = adjacency.get(taskId) ?? []; + nextEdges.push(dependsOnTaskId); + adjacency.set(taskId, nextEdges); + + const visited = new Set(); + const stack = [dependsOnTaskId]; + + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + + if (current === taskId) { + return true; + } + + if (visited.has(current)) { + continue; + } + visited.add(current); + + const neighbors = adjacency.get(current) ?? []; + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + stack.push(neighbor); + } + } + } + + return false; +} + +export function isTaskBlocked( + taskId: string, + edges: TaskDependencyEdge[], + statusesByTaskId: Record +): boolean { + for (const edge of edges) { + if (edge.taskId !== taskId) { + continue; + } + + if (statusesByTaskId[edge.dependsOnTaskId] !== 'completed') { + return true; + } + } + + return false; +} + +export function getBlockedTaskIds( + taskIds: string[], + edges: TaskDependencyEdge[], + statusesByTaskId: Record +): Set { + const blocked = new Set(); + + for (const taskId of taskIds) { + if (isTaskBlocked(taskId, edges, statusesByTaskId)) { + blocked.add(taskId); + } + } + + return blocked; +} diff --git a/apps/api/src/services/task-status.ts b/apps/api/src/services/task-status.ts new file mode 100644 index 0000000..6e3ca35 --- /dev/null +++ b/apps/api/src/services/task-status.ts @@ -0,0 +1,44 @@ +import type { TaskStatus } from '@simple-agent-manager/shared'; + +export const TASK_STATUSES: TaskStatus[] = [ + 'draft', + 'ready', + 'queued', + 'delegated', + 'in_progress', + 'completed', + 'failed', + 'cancelled', +]; + +export const TASK_EXECUTION_STATUSES: TaskStatus[] = ['queued', 'delegated', 'in_progress']; + +const TRANSITIONS: Record = { + draft: ['ready', 'cancelled'], + ready: ['queued', 'delegated', 'cancelled'], + queued: ['delegated', 'failed', 'cancelled'], + delegated: ['in_progress', 'failed', 'cancelled'], + in_progress: ['completed', 'failed', 'cancelled'], + completed: [], + failed: ['ready', 'cancelled'], + cancelled: ['ready'], +}; + +export function isTaskStatus(value: string): value is TaskStatus { + return TASK_STATUSES.includes(value as TaskStatus); +} + +export function getAllowedTaskTransitions(from: TaskStatus): TaskStatus[] { + return TRANSITIONS[from]; +} + +export function canTransitionTaskStatus(from: TaskStatus, to: TaskStatus): boolean { + if (from === to) { + return true; + } + return TRANSITIONS[from].includes(to); +} + +export function isExecutableTaskStatus(status: TaskStatus): boolean { + return TASK_EXECUTION_STATUSES.includes(status); +} diff --git a/apps/api/tests/unit/middleware/project-auth.test.ts b/apps/api/tests/unit/middleware/project-auth.test.ts new file mode 100644 index 0000000..70b0db5 --- /dev/null +++ b/apps/api/tests/unit/middleware/project-auth.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('project-auth middleware helpers', () => { + const file = readFileSync(resolve(process.cwd(), 'src/middleware/project-auth.ts'), 'utf8'); + + it('provides ownership-scoped lookup helpers for projects, tasks, and workspaces', () => { + expect(file).toContain('requireOwnedProject'); + expect(file).toContain('requireOwnedTask'); + expect(file).toContain('requireOwnedWorkspace'); + expect(file).toContain('errors.notFound'); + }); +}); diff --git a/apps/api/tests/unit/routes/projects.test.ts b/apps/api/tests/unit/routes/projects.test.ts new file mode 100644 index 0000000..ecc6278 --- /dev/null +++ b/apps/api/tests/unit/routes/projects.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('projects routes source contract', () => { + const file = readFileSync(resolve(process.cwd(), 'src/routes/projects.ts'), 'utf8'); + + it('defines authenticated CRUD endpoints for projects', () => { + expect(file).toContain("projectsRoutes.use('/*', requireAuth())"); + expect(file).toContain("projectsRoutes.post('/',"); + expect(file).toContain("projectsRoutes.get('/',"); + expect(file).toContain("projectsRoutes.get('/:id',"); + expect(file).toContain("projectsRoutes.patch('/:id',"); + expect(file).toContain("projectsRoutes.delete('/:id',"); + }); + + it('enforces normalized name uniqueness and per-user project limits', () => { + expect(file).toContain('normalizeProjectName'); + expect(file).toContain('maxProjectsPerUser'); + expect(file).toContain('Project name must be unique per user'); + }); + + it('validates installation ownership and repository access on create/update', () => { + expect(file).toContain('requireOwnedInstallation'); + expect(file).toContain('assertRepositoryAccess'); + expect(file).toContain('Repository is not accessible through the selected installation'); + }); + + it('returns project detail summaries with task counts and linked workspace count', () => { + expect(file).toContain('taskCountsByStatus'); + expect(file).toContain('linkedWorkspaces'); + expect(file).toContain('count(distinct'); + }); + + it('supports cursor pagination for project listing', () => { + expect(file).toContain("const cursor = c.req.query('cursor')"); + expect(file).toContain('nextCursor'); + expect(file).toContain('limit + 1'); + }); +}); diff --git a/apps/api/tests/unit/routes/tasks.test.ts b/apps/api/tests/unit/routes/tasks.test.ts new file mode 100644 index 0000000..e42e408 --- /dev/null +++ b/apps/api/tests/unit/routes/tasks.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('tasks routes source contract', () => { + const file = readFileSync(resolve(process.cwd(), 'src/routes/tasks.ts'), 'utf8'); + + it('defines project-scoped task CRUD and list endpoints', () => { + expect(file).toContain("tasksRoutes.post('/',"); + expect(file).toContain("tasksRoutes.get('/',"); + expect(file).toContain("tasksRoutes.get('/:taskId',"); + expect(file).toContain("tasksRoutes.patch('/:taskId',"); + expect(file).toContain("tasksRoutes.delete('/:taskId',"); + }); + + it('supports filtering, sorting, and pagination for task lists', () => { + expect(file).toContain("const requestedStatus = c.req.query('status')"); + expect(file).toContain("const minPriorityQuery = c.req.query('minPriority')"); + expect(file).toContain('parseTaskSortOrder'); + expect(file).toContain('nextCursor'); + }); + + it('enforces status transitions with blocked-state guards', () => { + expect(file).toContain("tasksRoutes.post('/:taskId/status',"); + expect(file).toContain('canTransitionTaskStatus'); + expect(file).toContain('isExecutableTaskStatus'); + expect(file).toContain('Task is blocked by unresolved dependencies'); + }); + + it('records append-only task status events', () => { + expect(file).toContain('appendStatusEvent'); + expect(file).toContain('taskStatusEvents'); + expect(file).toContain("tasksRoutes.get('/:taskId/events',"); + }); + + it('implements dependency management with cycle prevention', () => { + expect(file).toContain("tasksRoutes.post('/:taskId/dependencies',"); + expect(file).toContain("tasksRoutes.delete('/:taskId/dependencies',"); + expect(file).toContain('wouldCreateTaskDependencyCycle'); + expect(file).toContain('Task cannot depend on itself'); + expect(file).toContain('Dependency would create a cycle'); + }); + + it('implements manual delegation eligibility and workspace ownership checks', () => { + expect(file).toContain("tasksRoutes.post('/:taskId/delegate',"); + expect(file).toContain('Only ready tasks can be delegated'); + expect(file).toContain('Blocked tasks cannot be delegated'); + expect(file).toContain('requireOwnedWorkspace'); + expect(file).toContain("workspace.status !== 'running'"); + }); + + it('supports trusted callback status updates for delegated tasks', () => { + expect(file).toContain("tasksRoutes.post('/:taskId/status/callback',"); + expect(file).toContain('verifyCallbackToken'); + expect(file).toContain('Token workspace mismatch'); + expect(file).toContain("'workspace_callback'"); + }); +}); diff --git a/apps/api/tests/unit/services/limits.test.ts b/apps/api/tests/unit/services/limits.test.ts new file mode 100644 index 0000000..fa70c55 --- /dev/null +++ b/apps/api/tests/unit/services/limits.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { getRuntimeLimits } from '../../../src/services/limits'; + +describe('getRuntimeLimits', () => { + it('parses project and task limits with defaults', () => { + const defaults = getRuntimeLimits({}); + expect(defaults.maxProjectsPerUser).toBe(25); + expect(defaults.maxTasksPerProject).toBe(500); + expect(defaults.maxTaskDependenciesPerTask).toBe(25); + expect(defaults.taskListDefaultPageSize).toBe(50); + expect(defaults.taskListMaxPageSize).toBe(200); + expect(defaults.taskCallbackTimeoutMs).toBe(10000); + expect(defaults.taskCallbackRetryMaxAttempts).toBe(3); + }); + + it('uses valid env overrides and falls back on invalid values', () => { + const parsed = getRuntimeLimits({ + MAX_PROJECTS_PER_USER: '40', + MAX_TASKS_PER_PROJECT: '1000', + MAX_TASK_DEPENDENCIES_PER_TASK: '12', + TASK_LIST_DEFAULT_PAGE_SIZE: '75', + TASK_LIST_MAX_PAGE_SIZE: '500', + TASK_CALLBACK_TIMEOUT_MS: '15000', + TASK_CALLBACK_RETRY_MAX_ATTEMPTS: '-1', + }); + + expect(parsed.maxProjectsPerUser).toBe(40); + expect(parsed.maxTasksPerProject).toBe(1000); + expect(parsed.maxTaskDependenciesPerTask).toBe(12); + expect(parsed.taskListDefaultPageSize).toBe(75); + expect(parsed.taskListMaxPageSize).toBe(500); + expect(parsed.taskCallbackTimeoutMs).toBe(15000); + expect(parsed.taskCallbackRetryMaxAttempts).toBe(3); + }); +}); diff --git a/apps/api/tests/unit/services/task-graph.test.ts b/apps/api/tests/unit/services/task-graph.test.ts new file mode 100644 index 0000000..1724bf1 --- /dev/null +++ b/apps/api/tests/unit/services/task-graph.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { getBlockedTaskIds, isTaskBlocked, wouldCreateTaskDependencyCycle } from '../../../src/services/task-graph'; + +describe('task-graph service', () => { + it('rejects self-edge dependencies as cycles', () => { + expect( + wouldCreateTaskDependencyCycle('task-a', 'task-a', []) + ).toBe(true); + }); + + it('detects cycle creation in an existing graph', () => { + const edges = [ + { taskId: 'task-b', dependsOnTaskId: 'task-a' }, + { taskId: 'task-c', dependsOnTaskId: 'task-b' }, + ]; + + expect(wouldCreateTaskDependencyCycle('task-a', 'task-c', edges)).toBe(true); + expect(wouldCreateTaskDependencyCycle('task-c', 'task-a', edges)).toBe(false); + }); + + it('marks a task blocked when prerequisite is not completed', () => { + const edges = [{ taskId: 'task-b', dependsOnTaskId: 'task-a' }]; + expect( + isTaskBlocked('task-b', edges, { 'task-a': 'ready' }) + ).toBe(true); + expect( + isTaskBlocked('task-b', edges, { 'task-a': 'completed' }) + ).toBe(false); + }); + + it('computes blocked task set for multiple tasks', () => { + const edges = [ + { taskId: 'task-b', dependsOnTaskId: 'task-a' }, + { taskId: 'task-c', dependsOnTaskId: 'task-b' }, + ]; + + const blocked = getBlockedTaskIds( + ['task-a', 'task-b', 'task-c'], + edges, + { + 'task-a': 'completed', + 'task-b': 'in_progress', + } + ); + + expect(blocked.has('task-a')).toBe(false); + expect(blocked.has('task-b')).toBe(false); + expect(blocked.has('task-c')).toBe(true); + }); +}); diff --git a/apps/api/tests/unit/services/task-status.test.ts b/apps/api/tests/unit/services/task-status.test.ts new file mode 100644 index 0000000..1d3f058 --- /dev/null +++ b/apps/api/tests/unit/services/task-status.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { + canTransitionTaskStatus, + getAllowedTaskTransitions, + isExecutableTaskStatus, + isTaskStatus, +} from '../../../src/services/task-status'; + +describe('task-status service', () => { + it('validates known status names', () => { + expect(isTaskStatus('draft')).toBe(true); + expect(isTaskStatus('in_progress')).toBe(true); + expect(isTaskStatus('unknown')).toBe(false); + }); + + it('exposes allowed transitions from each status', () => { + expect(getAllowedTaskTransitions('draft')).toEqual(['ready', 'cancelled']); + expect(getAllowedTaskTransitions('ready')).toEqual(['queued', 'delegated', 'cancelled']); + expect(getAllowedTaskTransitions('completed')).toEqual([]); + }); + + it('accepts valid transitions and rejects invalid ones', () => { + expect(canTransitionTaskStatus('draft', 'ready')).toBe(true); + expect(canTransitionTaskStatus('ready', 'draft')).toBe(false); + expect(canTransitionTaskStatus('in_progress', 'completed')).toBe(true); + expect(canTransitionTaskStatus('completed', 'ready')).toBe(false); + }); + + it('identifies executable statuses for dependency gating', () => { + expect(isExecutableTaskStatus('queued')).toBe(true); + expect(isExecutableTaskStatus('delegated')).toBe(true); + expect(isExecutableTaskStatus('in_progress')).toBe(true); + expect(isExecutableTaskStatus('ready')).toBe(false); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 1ca1a18..c2f4c0d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -11,6 +11,8 @@ import { Workspace } from './pages/Workspace'; import { Nodes } from './pages/Nodes'; import { Node } from './pages/Node'; import { UiStandards } from './pages/UiStandards'; +import { Projects } from './pages/Projects'; +import { Project } from './pages/Project'; export default function App() { return ( @@ -71,6 +73,22 @@ export default function App() { } /> + + + + } + /> + + + + } + /> ( diff --git a/apps/web/src/components/project/ProjectForm.tsx b/apps/web/src/components/project/ProjectForm.tsx new file mode 100644 index 0000000..e043cdf --- /dev/null +++ b/apps/web/src/components/project/ProjectForm.tsx @@ -0,0 +1,184 @@ +import { useMemo, useState, type FormEvent } from 'react'; +import type { GitHubInstallation } from '@simple-agent-manager/shared'; +import { Button, Input } from '@simple-agent-manager/ui'; + +export interface ProjectFormValues { + name: string; + description: string; + installationId: string; + repository: string; + defaultBranch: string; +} + +interface ProjectFormProps { + mode: 'create' | 'edit'; + installations: GitHubInstallation[]; + initialValues?: Partial; + submitting?: boolean; + onSubmit: (values: ProjectFormValues) => Promise | void; + onCancel?: () => void; + submitLabel?: string; +} + +export function ProjectForm({ + mode, + installations, + initialValues, + submitting = false, + onSubmit, + onCancel, + submitLabel, +}: ProjectFormProps) { + const defaultInstallationId = useMemo(() => { + if (initialValues?.installationId) { + return initialValues.installationId; + } + return installations[0]?.id ?? ''; + }, [initialValues?.installationId, installations]); + + const [values, setValues] = useState({ + name: initialValues?.name ?? '', + description: initialValues?.description ?? '', + installationId: defaultInstallationId, + repository: initialValues?.repository ?? '', + defaultBranch: initialValues?.defaultBranch ?? 'main', + }); + const [error, setError] = useState(null); + + const isEditMode = mode === 'edit'; + + const handleChange = (field: keyof ProjectFormValues, value: string) => { + setValues((current) => ({ ...current, [field]: value })); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + + if (!values.name.trim()) { + setError('Project name is required'); + return; + } + + if (!values.defaultBranch.trim()) { + setError('Default branch is required'); + return; + } + + if (!values.repository.trim()) { + setError('Repository is required'); + return; + } + + if (!values.installationId.trim()) { + setError('Installation is required'); + return; + } + + await onSubmit({ + name: values.name.trim(), + description: values.description.trim(), + installationId: values.installationId, + repository: values.repository.trim(), + defaultBranch: values.defaultBranch.trim(), + }); + }; + + return ( +
+ + +