From 6c215e32288967134d4666bcbb9dbe821e49a997 Mon Sep 17 00:00:00 2001 From: Maksymilian Majer <1215868+maksymilian-majer@users.noreply.github.com> Date: Sun, 10 May 2026 16:01:46 +0200 Subject: [PATCH 01/24] feat(linear): support custom workflow statuses --- src/api/router.ts | 2 + src/api/routers/workflowStatuses.ts | 122 ++++++++ .../0052_workflow_status_definitions.sql | 11 + src/db/migrations/meta/_journal.json | 7 + .../workflowStatusDefinitionsRepository.ts | 104 +++++++ src/db/schema/index.ts | 1 + src/db/schema/workflowStatusDefinitions.ts | 13 + src/triggers/linear/label-added.ts | 7 +- src/triggers/linear/status-changed.ts | 4 +- src/triggers/shared/pm-label.ts | 16 +- src/triggers/shared/pm-status.ts | 31 ++ src/workflow/statusDefinitions.ts | 73 +++++ tests/helpers/mockDb.ts | 2 + ...orkflowStatusDefinitionsRepository.test.ts | 152 ++++++++++ tests/integration/helpers/db.ts | 1 + .../unit/api/routers/workflowStatuses.test.ts | 171 +++++++++++ ...orkflowStatusDefinitionsRepository.test.ts | 104 +++++++ .../unit/triggers/linear-label-added.test.ts | 36 +++ .../triggers/linear-status-changed.test.ts | 36 +++ tests/unit/triggers/shared/pm-status.test.ts | 50 +++- .../unit/web/global-definitions-route.test.ts | 8 + tests/unit/web/pm-wizard-hooks.test.ts | 37 +++ .../projects/pm-providers/linear/wizard.ts | 9 +- .../components/projects/pm-wizard-hooks.ts | 61 +++- web/src/routes/global/definitions-tabs.ts | 3 + web/src/routes/global/definitions.tsx | 277 +++++++++++++++++- 26 files changed, 1324 insertions(+), 14 deletions(-) create mode 100644 src/api/routers/workflowStatuses.ts create mode 100644 src/db/migrations/0052_workflow_status_definitions.sql create mode 100644 src/db/repositories/workflowStatusDefinitionsRepository.ts create mode 100644 src/db/schema/workflowStatusDefinitions.ts create mode 100644 src/workflow/statusDefinitions.ts create mode 100644 tests/integration/db/workflowStatusDefinitionsRepository.test.ts create mode 100644 tests/unit/api/routers/workflowStatuses.test.ts create mode 100644 tests/unit/db/repositories/workflowStatusDefinitionsRepository.test.ts create mode 100644 tests/unit/web/global-definitions-route.test.ts create mode 100644 web/src/routes/global/definitions-tabs.ts diff --git a/src/api/router.ts b/src/api/router.ts index 515e1f6a5..9982ebd60 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -12,6 +12,7 @@ import { runsRouter } from './routers/runs.js'; import { usersRouter } from './routers/users.js'; import { webhookLogsRouter } from './routers/webhookLogs.js'; import { webhooksRouter } from './routers/webhooks.js'; +import { workflowStatusesRouter } from './routers/workflowStatuses.js'; import { workItemsRouter } from './routers/workItems.js'; import { router } from './trpc.js'; @@ -33,6 +34,7 @@ export const appRouter = router({ prs: prsRouter, workItems: workItemsRouter, users: usersRouter, + workflowStatuses: workflowStatusesRouter, }); export type AppRouter = typeof appRouter; diff --git a/src/api/routers/workflowStatuses.ts b/src/api/routers/workflowStatuses.ts new file mode 100644 index 000000000..fa98396c1 --- /dev/null +++ b/src/api/routers/workflowStatuses.ts @@ -0,0 +1,122 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { resolveAgentDefinition } from '../../agents/definitions/loader.js'; +import { + createCustomWorkflowStatusDefinition, + deleteCustomWorkflowStatusDefinition, + getCustomWorkflowStatusDefinition, + updateCustomWorkflowStatusDefinition, +} from '../../db/repositories/workflowStatusDefinitionsRepository.js'; +import { + BUILTIN_WORKFLOW_STATUS_KEYS, + listWorkflowStatusDefinitions, +} from '../../workflow/statusDefinitions.js'; +import { protectedProcedure, router, superAdminProcedure } from '../trpc.js'; + +const StatusKeySchema = z + .string() + .trim() + .regex(/^[a-z][a-z0-9-]*$/, 'Status key must be a lowercase slug'); + +const AgentTypeSchema = z + .preprocess((value) => (value === '' ? null : value), z.string().trim().min(1).nullable()) + .optional(); + +async function assertCustomStatusKeyAvailable(key: string) { + if (BUILTIN_WORKFLOW_STATUS_KEYS.has(key)) { + throw new TRPCError({ + code: 'CONFLICT', + message: `Cannot override builtin workflow status: ${key}`, + }); + } + + const existing = await getCustomWorkflowStatusDefinition(key); + if (existing) { + throw new TRPCError({ + code: 'CONFLICT', + message: `Workflow status already exists: ${key}`, + }); + } +} + +function assertCustomStatusKey(key: string) { + if (BUILTIN_WORKFLOW_STATUS_KEYS.has(key)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: `Builtin workflow status cannot be modified: ${key}`, + }); + } +} + +async function validateAgentType(agentType: string | null | undefined) { + if (!agentType) return; + try { + await resolveAgentDefinition(agentType); + } catch { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Unknown agent type: ${agentType}`, + }); + } +} + +export const workflowStatusesRouter = router({ + list: protectedProcedure.query(async () => { + return listWorkflowStatusDefinitions(); + }), + + create: superAdminProcedure + .input( + z.object({ + key: StatusKeySchema, + label: z.string().trim().min(1), + agentType: AgentTypeSchema, + sortOrder: z.number().int().optional(), + }), + ) + .mutation(async ({ input }) => { + await assertCustomStatusKeyAvailable(input.key); + await validateAgentType(input.agentType); + return createCustomWorkflowStatusDefinition(input); + }), + + update: superAdminProcedure + .input( + z.object({ + key: StatusKeySchema, + label: z.string().trim().min(1).optional(), + agentType: AgentTypeSchema, + sortOrder: z.number().int().optional(), + }), + ) + .mutation(async ({ input }) => { + assertCustomStatusKey(input.key); + await validateAgentType(input.agentType); + const updated = await updateCustomWorkflowStatusDefinition(input.key, { + ...(input.label !== undefined ? { label: input.label } : {}), + ...(input.agentType !== undefined ? { agentType: input.agentType } : {}), + ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}), + }); + if (!updated) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `Workflow status not found: ${input.key}`, + }); + } + return updated; + }), + + delete: superAdminProcedure + .input(z.object({ key: StatusKeySchema })) + .mutation(async ({ input }) => { + assertCustomStatusKey(input.key); + const deleted = await deleteCustomWorkflowStatusDefinition(input.key); + if (!deleted) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `Workflow status not found: ${input.key}`, + }); + } + return { key: input.key }; + }), +}); diff --git a/src/db/migrations/0052_workflow_status_definitions.sql b/src/db/migrations/0052_workflow_status_definitions.sql new file mode 100644 index 000000000..de6616ff9 --- /dev/null +++ b/src/db/migrations/0052_workflow_status_definitions.sql @@ -0,0 +1,11 @@ +CREATE TABLE workflow_status_definitions ( + id SERIAL PRIMARY KEY, + status_key TEXT NOT NULL, + label TEXT NOT NULL, + agent_type TEXT, + sort_order INT NOT NULL DEFAULT 1000, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT uq_workflow_status_definitions_status_key UNIQUE (status_key) +); + diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 34d4aa3ec..68ee1526c 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -365,6 +365,13 @@ "when": 1786000000000, "tag": "0051_pr_work_items_external_source", "breakpoints": false + }, + { + "idx": 52, + "version": "7", + "when": 1787000000000, + "tag": "0052_workflow_status_definitions", + "breakpoints": false } ] } diff --git a/src/db/repositories/workflowStatusDefinitionsRepository.ts b/src/db/repositories/workflowStatusDefinitionsRepository.ts new file mode 100644 index 000000000..f065ac92e --- /dev/null +++ b/src/db/repositories/workflowStatusDefinitionsRepository.ts @@ -0,0 +1,104 @@ +import { asc, eq } from 'drizzle-orm'; +import { getDb } from '../client.js'; +import { workflowStatusDefinitions } from '../schema/index.js'; + +export interface CustomWorkflowStatusDefinition { + id: number; + key: string; + label: string; + agentType: string | null; + sortOrder: number; + createdAt: Date | null; + updatedAt: Date | null; +} + +export interface CreateWorkflowStatusDefinitionInput { + key: string; + label: string; + agentType?: string | null; + sortOrder?: number; +} + +export interface UpdateWorkflowStatusDefinitionInput { + label?: string; + agentType?: string | null; + sortOrder?: number; +} + +export async function listCustomWorkflowStatusDefinitions(): Promise< + CustomWorkflowStatusDefinition[] +> { + const db = getDb(); + const rows = await db + .select() + .from(workflowStatusDefinitions) + .orderBy(asc(workflowStatusDefinitions.sortOrder), asc(workflowStatusDefinitions.statusKey)); + return rows.map(mapRow); +} + +export async function getCustomWorkflowStatusDefinition( + key: string, +): Promise { + const db = getDb(); + const [row] = await db + .select() + .from(workflowStatusDefinitions) + .where(eq(workflowStatusDefinitions.statusKey, key)); + return row ? mapRow(row) : null; +} + +export async function createCustomWorkflowStatusDefinition( + input: CreateWorkflowStatusDefinitionInput, +): Promise { + const db = getDb(); + const [row] = await db + .insert(workflowStatusDefinitions) + .values({ + statusKey: input.key, + label: input.label, + agentType: input.agentType ?? null, + sortOrder: input.sortOrder ?? 1000, + }) + .returning(); + return mapRow(row); +} + +export async function updateCustomWorkflowStatusDefinition( + key: string, + input: UpdateWorkflowStatusDefinitionInput, +): Promise { + const db = getDb(); + const [row] = await db + .update(workflowStatusDefinitions) + .set({ + ...(input.label !== undefined ? { label: input.label } : {}), + ...(input.agentType !== undefined ? { agentType: input.agentType } : {}), + ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}), + updatedAt: new Date(), + }) + .where(eq(workflowStatusDefinitions.statusKey, key)) + .returning(); + return row ? mapRow(row) : null; +} + +export async function deleteCustomWorkflowStatusDefinition(key: string): Promise { + const db = getDb(); + const result = await db + .delete(workflowStatusDefinitions) + .where(eq(workflowStatusDefinitions.statusKey, key)); + return (result.rowCount ?? 0) > 0; +} + +function mapRow( + row: typeof workflowStatusDefinitions.$inferSelect, +): CustomWorkflowStatusDefinition { + return { + id: row.id, + key: row.statusKey, + label: row.label, + agentType: row.agentType, + sortOrder: row.sortOrder, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index 9bf5dcff6..43cc428b8 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -10,3 +10,4 @@ export { prWorkItems } from './prWorkItems.js'; export { agentRunLlmCalls, agentRunLogs, agentRuns, debugAnalyses } from './runs.js'; export { sessions, users } from './users.js'; export { webhookLogs } from './webhookLogs.js'; +export { workflowStatusDefinitions } from './workflowStatusDefinitions.js'; diff --git a/src/db/schema/workflowStatusDefinitions.ts b/src/db/schema/workflowStatusDefinitions.ts new file mode 100644 index 000000000..be676515d --- /dev/null +++ b/src/db/schema/workflowStatusDefinitions.ts @@ -0,0 +1,13 @@ +import { integer, pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core'; + +export const workflowStatusDefinitions = pgTable('workflow_status_definitions', { + id: serial('id').primaryKey(), + statusKey: text('status_key').notNull().unique(), + label: text('label').notNull(), + agentType: text('agent_type'), + sortOrder: integer('sort_order').notNull().default(1000), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()), +}); diff --git a/src/triggers/linear/label-added.ts b/src/triggers/linear/label-added.ts index caa567dbc..ac543c9f7 100644 --- a/src/triggers/linear/label-added.ts +++ b/src/triggers/linear/label-added.ts @@ -17,7 +17,10 @@ import { getLinearConfig } from '../../pm/config.js'; import { resolveProjectPMConfig } from '../../pm/lifecycle.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; -import { buildPMLabelDispatchResult, resolvePMLabelAgentByStatusId } from '../shared/pm-label.js'; +import { + buildPMLabelDispatchResult, + resolvePMLabelAgentByStatusIdFromWorkflowDefinitions, +} from '../shared/pm-label.js'; import { checkTriggerEnabled } from '../shared/trigger-check.js'; import type { LinearWebhookIssueLabelData, LinearWebhookTriggerPayload } from './types.js'; @@ -73,7 +76,7 @@ export class LinearReadyToProcessLabelTrigger implements TriggerHandler { return null; } - const resolved = resolvePMLabelAgentByStatusId({ + const resolved = await resolvePMLabelAgentByStatusIdFromWorkflowDefinitions({ statusId: issueStateId, configuredStatuses: linearConfig.statuses, }); diff --git a/src/triggers/linear/status-changed.ts b/src/triggers/linear/status-changed.ts index b52badec8..6dc2bcd31 100644 --- a/src/triggers/linear/status-changed.ts +++ b/src/triggers/linear/status-changed.ts @@ -19,7 +19,7 @@ import { logger } from '../../utils/logging.js'; import { shouldBlockForPipelineCapacity } from '../shared/pipeline-capacity-gate.js'; import { buildPMStatusDispatchResult, - resolvePMStatusAgentById, + resolvePMStatusAgentByIdFromWorkflowDefinitions, shouldFirePMStatusEvent, } from '../shared/pm-status.js'; import { checkTriggerEnabledWithParams } from '../shared/trigger-check.js'; @@ -72,7 +72,7 @@ export class LinearStatusChangedTrigger implements TriggerHandler { return null; } - const resolved = resolvePMStatusAgentById({ + const resolved = await resolvePMStatusAgentByIdFromWorkflowDefinitions({ statusId: newStateId, configuredStatuses: linearConfig.statuses, }); diff --git a/src/triggers/shared/pm-label.ts b/src/triggers/shared/pm-label.ts index 0d89edf39..9e7573907 100644 --- a/src/triggers/shared/pm-label.ts +++ b/src/triggers/shared/pm-label.ts @@ -1,6 +1,10 @@ import type { AgentInput, TriggerResult } from '../../types/index.js'; import { TRIGGER_EVENTS } from './events.js'; -import { resolvePMStatusAgentById, resolvePMStatusAgentByName } from './pm-status.js'; +import { + resolvePMStatusAgentById, + resolvePMStatusAgentByIdFromWorkflowDefinitions, + resolvePMStatusAgentByName, +} from './pm-status.js'; import { buildPMDispatchResult } from './result-builders.js'; export function resolvePMLabelAgentByList(args: { @@ -33,6 +37,16 @@ export function resolvePMLabelAgentByStatusId(args: { }); } +export function resolvePMLabelAgentByStatusIdFromWorkflowDefinitions(args: { + statusId: string; + configuredStatuses: Record; +}): Promise<{ agentType: string; cascadeStatus: string } | undefined> { + return resolvePMStatusAgentByIdFromWorkflowDefinitions({ + statusId: args.statusId, + configuredStatuses: args.configuredStatuses, + }); +} + export function buildPMLabelDispatchResult(args: { agentType: string; workItemId: string; diff --git a/src/triggers/shared/pm-status.ts b/src/triggers/shared/pm-status.ts index 8045ad052..e77b5dc3e 100644 --- a/src/triggers/shared/pm-status.ts +++ b/src/triggers/shared/pm-status.ts @@ -1,4 +1,5 @@ import type { AgentInput, TriggerResult } from '../../types/index.js'; +import { resolveWorkflowStatusDefinition } from '../../workflow/statusDefinitions.js'; import { TRIGGER_EVENTS } from './events.js'; import { buildPMDispatchResult } from './result-builders.js'; import { STATUS_TO_AGENT } from './status-to-agent.js'; @@ -63,6 +64,36 @@ export function resolvePMStatusAgentById(args: { }); } +export async function resolvePMStatusAgentFromWorkflowDefinitions(args: { + incomingStatus: string; + configuredStatuses: Record; + matcher?: StatusMatcher; +}): Promise { + const matcher = args.matcher ?? exactStatusMatcher; + + for (const [cascadeStatus, configuredStatus] of Object.entries(args.configuredStatuses)) { + if (matcher(configuredStatus, args.incomingStatus)) { + const definition = await resolveWorkflowStatusDefinition(cascadeStatus); + if (definition?.agentType) { + return { agentType: definition.agentType, cascadeStatus }; + } + } + } + + return undefined; +} + +export function resolvePMStatusAgentByIdFromWorkflowDefinitions(args: { + statusId: string; + configuredStatuses: Record; +}): Promise { + return resolvePMStatusAgentFromWorkflowDefinitions({ + incomingStatus: args.statusId, + configuredStatuses: args.configuredStatuses, + matcher: exactStatusMatcher, + }); +} + export function buildPMStatusCoalesceKey(projectId: string, workItemId: string): string { return `${projectId}:${workItemId}`; } diff --git a/src/workflow/statusDefinitions.ts b/src/workflow/statusDefinitions.ts new file mode 100644 index 000000000..58b2d091f --- /dev/null +++ b/src/workflow/statusDefinitions.ts @@ -0,0 +1,73 @@ +import { + getCustomWorkflowStatusDefinition, + listCustomWorkflowStatusDefinitions, +} from '../db/repositories/workflowStatusDefinitionsRepository.js'; + +export interface WorkflowStatusDefinition { + key: string; + label: string; + agentType: string | null; + sortOrder: number; + isBuiltin: boolean; +} + +export const BUILTIN_WORKFLOW_STATUSES: readonly WorkflowStatusDefinition[] = [ + { + key: 'backlog', + label: 'Backlog', + agentType: 'backlog-manager', + sortOrder: 10, + isBuiltin: true, + }, + { key: 'splitting', label: 'Splitting', agentType: 'splitting', sortOrder: 20, isBuiltin: true }, + { key: 'planning', label: 'Planning', agentType: 'planning', sortOrder: 30, isBuiltin: true }, + { key: 'todo', label: 'Todo', agentType: 'implementation', sortOrder: 40, isBuiltin: true }, + { key: 'inProgress', label: 'In Progress', agentType: null, sortOrder: 50, isBuiltin: true }, + { key: 'inReview', label: 'In Review', agentType: null, sortOrder: 60, isBuiltin: true }, + { key: 'done', label: 'Done', agentType: null, sortOrder: 70, isBuiltin: true }, + { key: 'merged', label: 'Merged', agentType: null, sortOrder: 80, isBuiltin: true }, + { key: 'alerts', label: 'Alerts', agentType: null, sortOrder: 90, isBuiltin: true }, +] as const; + +export const BUILTIN_WORKFLOW_STATUS_KEYS = new Set( + BUILTIN_WORKFLOW_STATUSES.map((status) => status.key), +); + +export function getBuiltinWorkflowStatusDefinition( + key: string, +): WorkflowStatusDefinition | undefined { + return BUILTIN_WORKFLOW_STATUSES.find((status) => status.key === key); +} + +export async function listWorkflowStatusDefinitions(): Promise { + const custom = await listCustomWorkflowStatusDefinitions(); + const customDefinitions = custom + .filter((status) => !BUILTIN_WORKFLOW_STATUS_KEYS.has(status.key)) + .map((status) => ({ + key: status.key, + label: status.label, + agentType: status.agentType, + sortOrder: status.sortOrder, + isBuiltin: false, + })); + + return [...BUILTIN_WORKFLOW_STATUSES, ...customDefinitions]; +} + +export async function resolveWorkflowStatusDefinition( + key: string, +): Promise { + const builtin = getBuiltinWorkflowStatusDefinition(key); + if (builtin) return builtin; + + const custom = await getCustomWorkflowStatusDefinition(key); + if (!custom) return undefined; + + return { + key: custom.key, + label: custom.label, + agentType: custom.agentType, + sortOrder: custom.sortOrder, + isBuiltin: false, + }; +} diff --git a/tests/helpers/mockDb.ts b/tests/helpers/mockDb.ts index 3825f07c2..b3771ce36 100644 --- a/tests/helpers/mockDb.ts +++ b/tests/helpers/mockDb.ts @@ -41,6 +41,7 @@ export function createMockDb( // Terminal methods that return results chain.returning = vi.fn().mockResolvedValue([]); + chain.orderBy = vi.fn().mockResolvedValue([]); // Limit support — limit is the terminal when present, where is a chaining step if (opts.withLimit) { @@ -61,6 +62,7 @@ export function createMockDb( chain.from = vi.fn().mockReturnValue({ where: chain.where, innerJoin: chain.innerJoin, + orderBy: chain.orderBy, }); // Update chain diff --git a/tests/integration/db/workflowStatusDefinitionsRepository.test.ts b/tests/integration/db/workflowStatusDefinitionsRepository.test.ts new file mode 100644 index 000000000..5732e7c4f --- /dev/null +++ b/tests/integration/db/workflowStatusDefinitionsRepository.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + createCustomWorkflowStatusDefinition, + deleteCustomWorkflowStatusDefinition, + getCustomWorkflowStatusDefinition, + listCustomWorkflowStatusDefinitions, + updateCustomWorkflowStatusDefinition, +} from '../../../src/db/repositories/workflowStatusDefinitionsRepository.js'; +import { + getBuiltinWorkflowStatusDefinition, + listWorkflowStatusDefinitions, + resolveWorkflowStatusDefinition, +} from '../../../src/workflow/statusDefinitions.js'; +import { truncateAll } from '../helpers/db.js'; + +describe('workflowStatusDefinitionsRepository (integration)', () => { + beforeEach(async () => { + await truncateAll(); + }); + + it('persists custom workflow statuses and lists them by sort order then key', async () => { + await createCustomWorkflowStatusDefinition({ + key: 'story', + label: 'Story', + agentType: 'story', + sortOrder: 20, + }); + await createCustomWorkflowStatusDefinition({ + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 10, + }); + await createCustomWorkflowStatusDefinition({ + key: 'qa', + label: 'QA', + agentType: null, + sortOrder: 20, + }); + + const statuses = await listCustomWorkflowStatusDefinitions(); + + expect(statuses.map((status) => status.key)).toEqual(['prd', 'qa', 'story']); + expect(statuses[0]).toMatchObject({ + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 10, + }); + expect(statuses[0].createdAt).toBeInstanceOf(Date); + expect(statuses[0].updatedAt).toBeInstanceOf(Date); + }); + + it('enforces unique status keys at the database level', async () => { + await createCustomWorkflowStatusDefinition({ + key: 'prd', + label: 'PRD', + agentType: 'prd', + }); + + await expect( + createCustomWorkflowStatusDefinition({ + key: 'prd', + label: 'Duplicate PRD', + agentType: 'other-agent', + }), + ).rejects.toThrow(); + }); + + it('updates nullable agent mappings and deletes rows', async () => { + await createCustomWorkflowStatusDefinition({ + key: 'story', + label: 'Story', + agentType: 'story', + sortOrder: 20, + }); + + const updated = await updateCustomWorkflowStatusDefinition('story', { + label: 'User Story', + agentType: null, + sortOrder: 15, + }); + + expect(updated).toMatchObject({ + key: 'story', + label: 'User Story', + agentType: null, + sortOrder: 15, + }); + expect(await getCustomWorkflowStatusDefinition('story')).toMatchObject({ + label: 'User Story', + agentType: null, + }); + expect(await deleteCustomWorkflowStatusDefinition('story')).toBe(true); + expect(await getCustomWorkflowStatusDefinition('story')).toBeNull(); + expect(await deleteCustomWorkflowStatusDefinition('story')).toBe(false); + }); + + it('merges built-in workflow statuses with custom database statuses', async () => { + await createCustomWorkflowStatusDefinition({ + key: 'prd', + label: 'PRD', + agentType: 'prd-agent', + sortOrder: 5, + }); + + const statuses = await listWorkflowStatusDefinitions(); + const backlog = statuses.find((status) => status.key === 'backlog'); + const prd = statuses.find((status) => status.key === 'prd'); + + expect(backlog).toMatchObject({ + key: 'backlog', + label: 'Backlog', + agentType: 'backlog-manager', + isBuiltin: true, + }); + expect(prd).toMatchObject({ + key: 'prd', + label: 'PRD', + agentType: 'prd-agent', + sortOrder: 5, + isBuiltin: false, + }); + expect(statuses.at(0)?.key).toBe('backlog'); + expect(statuses.at(-1)?.key).toBe('prd'); + }); + + it('resolves built-in statuses from code and custom statuses from the database', async () => { + await createCustomWorkflowStatusDefinition({ + key: 'story', + label: 'Story', + agentType: 'story-agent', + }); + + expect(getBuiltinWorkflowStatusDefinition('todo')).toMatchObject({ + key: 'todo', + agentType: 'implementation', + isBuiltin: true, + }); + await expect(resolveWorkflowStatusDefinition('todo')).resolves.toMatchObject({ + key: 'todo', + agentType: 'implementation', + isBuiltin: true, + }); + await expect(resolveWorkflowStatusDefinition('story')).resolves.toMatchObject({ + key: 'story', + agentType: 'story-agent', + isBuiltin: false, + }); + await expect(resolveWorkflowStatusDefinition('missing-status')).resolves.toBeUndefined(); + }); +}); diff --git a/tests/integration/helpers/db.ts b/tests/integration/helpers/db.ts index 4fb2caf68..ddef06435 100644 --- a/tests/integration/helpers/db.ts +++ b/tests/integration/helpers/db.ts @@ -121,6 +121,7 @@ export async function truncateAll() { agent_trigger_configs, agent_configs, agent_definitions, + workflow_status_definitions, prompt_partials, sessions, users, diff --git a/tests/unit/api/routers/workflowStatuses.test.ts b/tests/unit/api/routers/workflowStatuses.test.ts new file mode 100644 index 000000000..b1e9cc807 --- /dev/null +++ b/tests/unit/api/routers/workflowStatuses.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AgentDefinition } from '../../../../src/agents/definitions/schema.js'; +import { createMockSuperAdmin, createMockUser } from '../../../helpers/factories.js'; +import { createCallerFor, expectTRPCError } from '../../../helpers/trpcTestHarness.js'; + +const { + mockResolveAgentDefinition, + mockCreateCustomWorkflowStatusDefinition, + mockDeleteCustomWorkflowStatusDefinition, + mockGetCustomWorkflowStatusDefinition, + mockListCustomWorkflowStatusDefinitions, + mockUpdateCustomWorkflowStatusDefinition, +} = vi.hoisted(() => ({ + mockResolveAgentDefinition: vi.fn(), + mockCreateCustomWorkflowStatusDefinition: vi.fn(), + mockDeleteCustomWorkflowStatusDefinition: vi.fn(), + mockGetCustomWorkflowStatusDefinition: vi.fn(), + mockListCustomWorkflowStatusDefinitions: vi.fn(), + mockUpdateCustomWorkflowStatusDefinition: vi.fn(), +})); + +vi.mock('../../../../src/agents/definitions/loader.js', () => ({ + resolveAgentDefinition: mockResolveAgentDefinition, +})); + +vi.mock('../../../../src/db/repositories/workflowStatusDefinitionsRepository.js', () => ({ + createCustomWorkflowStatusDefinition: mockCreateCustomWorkflowStatusDefinition, + deleteCustomWorkflowStatusDefinition: mockDeleteCustomWorkflowStatusDefinition, + getCustomWorkflowStatusDefinition: mockGetCustomWorkflowStatusDefinition, + listCustomWorkflowStatusDefinitions: mockListCustomWorkflowStatusDefinitions, + updateCustomWorkflowStatusDefinition: mockUpdateCustomWorkflowStatusDefinition, +})); + +import { workflowStatusesRouter } from '../../../../src/api/routers/workflowStatuses.js'; + +const createCaller = createCallerFor(workflowStatusesRouter); +const user = createMockUser(); +const superAdmin = createMockSuperAdmin(); + +function mockAgentDefinition(): AgentDefinition { + return { + identity: { + emoji: 'P', + label: 'PRD', + roleHint: 'Writes PRDs', + initialMessage: 'Writing PRD', + }, + integrations: { required: ['pm'], optional: [] }, + capabilities: { + required: ['fs:read', 'shell:exec', 'session:ctrl', 'pm:read', 'pm:write'], + optional: [], + }, + triggers: [], + strategies: {}, + hint: 'Write a PRD.', + prompts: { taskPrompt: 'Write a PRD for <%= it.workItemId %>.' }, + requiredContext: [], + }; +} + +describe('workflowStatusesRouter', () => { + beforeEach(() => { + vi.resetAllMocks(); + mockListCustomWorkflowStatusDefinitions.mockResolvedValue([]); + mockGetCustomWorkflowStatusDefinition.mockResolvedValue(null); + mockResolveAgentDefinition.mockResolvedValue(mockAgentDefinition()); + }); + + it('lists builtin statuses and custom statuses', async () => { + mockListCustomWorkflowStatusDefinitions.mockResolvedValue([ + { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }, + ]); + + const caller = createCaller({ user, effectiveOrgId: user.orgId }); + const result = await caller.list(); + + expect(result[0]).toMatchObject({ key: 'backlog', isBuiltin: true }); + expect(result).toContainEqual({ + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + isBuiltin: false, + }); + }); + + it('creates a custom workflow status', async () => { + mockCreateCustomWorkflowStatusDefinition.mockResolvedValue({ + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }); + + const caller = createCaller({ user: superAdmin, effectiveOrgId: superAdmin.orgId }); + await caller.create({ key: 'prd', label: 'PRD', agentType: 'prd', sortOrder: 1000 }); + + expect(mockResolveAgentDefinition).toHaveBeenCalledWith('prd'); + expect(mockCreateCustomWorkflowStatusDefinition).toHaveBeenCalledWith({ + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + }); + }); + + it('rejects builtin key collisions', async () => { + const caller = createCaller({ user: superAdmin, effectiveOrgId: superAdmin.orgId }); + + await expectTRPCError( + caller.create({ key: 'todo', label: 'Todo override', agentType: 'prd' }), + 'CONFLICT', + ); + }); + + it('rejects unknown agent types', async () => { + mockResolveAgentDefinition.mockRejectedValue(new Error('not found')); + const caller = createCaller({ user: superAdmin, effectiveOrgId: superAdmin.orgId }); + + await expectTRPCError( + caller.create({ key: 'prd', label: 'PRD', agentType: 'missing-agent' }), + 'BAD_REQUEST', + ); + }); + + it('rejects mutation from non-superadmin users', async () => { + const caller = createCaller({ user, effectiveOrgId: user.orgId }); + + await expectTRPCError(caller.create({ key: 'prd', label: 'PRD' }), 'FORBIDDEN'); + }); + + it('updates a custom workflow status', async () => { + mockUpdateCustomWorkflowStatusDefinition.mockResolvedValue({ + id: 1, + key: 'prd', + label: 'Product Requirements', + agentType: null, + sortOrder: 1010, + createdAt: null, + updatedAt: null, + }); + const caller = createCaller({ user: superAdmin, effectiveOrgId: superAdmin.orgId }); + + const result = await caller.update({ + key: 'prd', + label: 'Product Requirements', + agentType: null, + sortOrder: 1010, + }); + + expect(result.label).toBe('Product Requirements'); + }); + + it('deletes a custom workflow status', async () => { + mockDeleteCustomWorkflowStatusDefinition.mockResolvedValue(true); + const caller = createCaller({ user: superAdmin, effectiveOrgId: superAdmin.orgId }); + + await expect(caller.delete({ key: 'prd' })).resolves.toEqual({ key: 'prd' }); + }); +}); diff --git a/tests/unit/db/repositories/workflowStatusDefinitionsRepository.test.ts b/tests/unit/db/repositories/workflowStatusDefinitionsRepository.test.ts new file mode 100644 index 000000000..173dacca1 --- /dev/null +++ b/tests/unit/db/repositories/workflowStatusDefinitionsRepository.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockDbWithGetDb } from '../../../helpers/mockDb.js'; +import { mockDbClientModule } from '../../../helpers/sharedMocks.js'; + +vi.mock('../../../../src/db/client.js', () => mockDbClientModule); + +import { + createCustomWorkflowStatusDefinition, + deleteCustomWorkflowStatusDefinition, + getCustomWorkflowStatusDefinition, + listCustomWorkflowStatusDefinitions, + updateCustomWorkflowStatusDefinition, +} from '../../../../src/db/repositories/workflowStatusDefinitionsRepository.js'; + +const now = new Date('2026-05-01T00:00:00.000Z'); + +const dbRow = { + id: 1, + statusKey: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: now, + updatedAt: now, +}; + +describe('workflowStatusDefinitionsRepository', () => { + let mockDb: ReturnType; + + beforeEach(() => { + mockDb = createMockDbWithGetDb(); + }); + + it('lists custom workflow statuses ordered by sort order and key', async () => { + mockDb.chain.orderBy.mockResolvedValueOnce([dbRow]); + + const result = await listCustomWorkflowStatusDefinitions(); + + expect(result).toEqual([ + { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: now, + updatedAt: now, + }, + ]); + expect(mockDb.chain.orderBy).toHaveBeenCalledTimes(1); + }); + + it('gets one custom workflow status by key', async () => { + mockDb.chain.where.mockResolvedValueOnce([dbRow]); + + const result = await getCustomWorkflowStatusDefinition('prd'); + + expect(result?.key).toBe('prd'); + }); + + it('creates a custom workflow status', async () => { + mockDb.chain.returning.mockResolvedValueOnce([dbRow]); + + const result = await createCustomWorkflowStatusDefinition({ + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + }); + + expect(result.key).toBe('prd'); + expect(mockDb.chain.values).toHaveBeenCalledWith({ + statusKey: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + }); + }); + + it('updates a custom workflow status', async () => { + mockDb.chain.where.mockReturnValueOnce({ returning: mockDb.chain.returning }); + mockDb.chain.returning.mockResolvedValueOnce([{ ...dbRow, label: 'Product Requirements' }]); + + const result = await updateCustomWorkflowStatusDefinition('prd', { + label: 'Product Requirements', + agentType: null, + }); + + expect(result?.label).toBe('Product Requirements'); + expect(mockDb.chain.set).toHaveBeenCalledWith( + expect.objectContaining({ + label: 'Product Requirements', + agentType: null, + updatedAt: expect.any(Date), + }), + ); + }); + + it('deletes a custom workflow status', async () => { + mockDb.chain.where.mockResolvedValueOnce({ rowCount: 1 }); + + await expect(deleteCustomWorkflowStatusDefinition('prd')).resolves.toBe(true); + }); +}); diff --git a/tests/unit/triggers/linear-label-added.test.ts b/tests/unit/triggers/linear-label-added.test.ts index 6427aefbf..5657c9dd3 100644 --- a/tests/unit/triggers/linear-label-added.test.ts +++ b/tests/unit/triggers/linear-label-added.test.ts @@ -4,6 +4,15 @@ import { mockLogger, mockTriggerCheckModule } from '../../helpers/sharedMocks.js vi.mock('../../../src/utils/logging.js', () => ({ logger: mockLogger })); vi.mock('../../../src/triggers/shared/trigger-check.js', () => mockTriggerCheckModule); +const { mockGetCustomWorkflowStatusDefinition } = vi.hoisted(() => ({ + mockGetCustomWorkflowStatusDefinition: vi.fn(), +})); + +vi.mock('../../../src/db/repositories/workflowStatusDefinitionsRepository.js', () => ({ + getCustomWorkflowStatusDefinition: mockGetCustomWorkflowStatusDefinition, + listCustomWorkflowStatusDefinitions: vi.fn().mockResolvedValue([]), +})); + const mockGetLinearConfig = vi.fn(); vi.mock('../../../src/pm/config.js', () => ({ getLinearConfig: (...args: unknown[]) => mockGetLinearConfig(...args), @@ -114,6 +123,7 @@ describe('LinearReadyToProcessLabelTrigger', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(checkTriggerEnabled).mockResolvedValue(true); + mockGetCustomWorkflowStatusDefinition.mockResolvedValue(null); mockGetLinearConfig.mockReturnValue(baseLinearConfig); mockResolveProjectPMConfig.mockReturnValue(baseProjectPMConfig); trigger = new LinearReadyToProcessLabelTrigger(); @@ -210,6 +220,32 @@ describe('LinearReadyToProcessLabelTrigger', () => { expect(result).toBeNull(); }); + it('returns custom agent when issue state maps to a custom workflow status', async () => { + mockGetLinearConfig.mockReturnValue({ + ...baseLinearConfig, + statuses: { ...baseLinearConfig.statuses, story: 'state-story' }, + }); + mockGetCustomWorkflowStatusDefinition.mockResolvedValue({ + id: 2, + key: 'story', + label: 'Story', + agentType: 'story', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }); + + const result = await trigger.handle(buildCtx({ issueStateId: 'state-story' })); + + expect(result?.agentType).toBe('story'); + expect(checkTriggerEnabled).toHaveBeenCalledWith( + 'proj-linear', + 'story', + 'pm:label-added', + 'linear-ready-to-process-label-added', + ); + }); + it('returns null when issue identifier is missing', async () => { const ctx = buildCtx(); const data = ctx.payload as Record; diff --git a/tests/unit/triggers/linear-status-changed.test.ts b/tests/unit/triggers/linear-status-changed.test.ts index 8025cc5f3..f14c4f963 100644 --- a/tests/unit/triggers/linear-status-changed.test.ts +++ b/tests/unit/triggers/linear-status-changed.test.ts @@ -12,6 +12,15 @@ vi.mock('../../../src/triggers/shared/pipeline-capacity-gate.js', () => ({ shouldBlockForPipelineCapacity: vi.fn().mockResolvedValue(false), })); +const { mockGetCustomWorkflowStatusDefinition } = vi.hoisted(() => ({ + mockGetCustomWorkflowStatusDefinition: vi.fn(), +})); + +vi.mock('../../../src/db/repositories/workflowStatusDefinitionsRepository.js', () => ({ + getCustomWorkflowStatusDefinition: mockGetCustomWorkflowStatusDefinition, + listCustomWorkflowStatusDefinitions: vi.fn().mockResolvedValue([]), +})); + const mockGetLinearConfig = vi.fn(); vi.mock('../../../src/pm/config.js', () => ({ getLinearConfig: (...args: unknown[]) => mockGetLinearConfig(...args), @@ -111,6 +120,7 @@ describe('LinearStatusChangedTrigger', () => { vi.resetAllMocks(); // Default: trigger enabled with YAML-default params (onCreate: false, onMove: true) mockTriggerConfig(true); + mockGetCustomWorkflowStatusDefinition.mockResolvedValue(null); mockGetLinearConfig.mockReturnValue(baseLinearConfig); trigger = new LinearStatusChangedTrigger(); }); @@ -200,6 +210,32 @@ describe('LinearStatusChangedTrigger', () => { expect(result).toBeNull(); }); + it('returns custom agent when moved to a custom workflow status', async () => { + mockGetLinearConfig.mockReturnValue({ + ...baseLinearConfig, + statuses: { ...baseLinearConfig.statuses, prd: 'state-prd' }, + }); + mockGetCustomWorkflowStatusDefinition.mockResolvedValue({ + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }); + + const result = await trigger.handle(buildCtx({ newStateId: 'state-prd' })); + + expect(result?.agentType).toBe('prd'); + expect(checkTriggerEnabledWithParams).toHaveBeenCalledWith( + 'proj-linear', + 'prd', + 'pm:status-changed', + 'linear-status-changed', + ); + }); + it('returns null when data.stateId is missing', async () => { const ctx = buildCtx(); (ctx.payload as Record).data = { diff --git a/tests/unit/triggers/shared/pm-status.test.ts b/tests/unit/triggers/shared/pm-status.test.ts index 3d9c1f497..ca05c80e5 100644 --- a/tests/unit/triggers/shared/pm-status.test.ts +++ b/tests/unit/triggers/shared/pm-status.test.ts @@ -1,13 +1,29 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockGetCustomWorkflowStatusDefinition } = vi.hoisted(() => ({ + mockGetCustomWorkflowStatusDefinition: vi.fn(), +})); + +vi.mock('../../../../src/db/repositories/workflowStatusDefinitionsRepository.js', () => ({ + getCustomWorkflowStatusDefinition: mockGetCustomWorkflowStatusDefinition, + listCustomWorkflowStatusDefinitions: vi.fn().mockResolvedValue([]), +})); + import { buildPMStatusCoalesceKey, buildPMStatusDispatchResult, resolvePMStatusAgentById, + resolvePMStatusAgentByIdFromWorkflowDefinitions, resolvePMStatusAgentByName, shouldFirePMStatusEvent, } from '../../../../src/triggers/shared/pm-status.js'; describe('PM status helpers', () => { + beforeEach(() => { + mockGetCustomWorkflowStatusDefinition.mockReset(); + mockGetCustomWorkflowStatusDefinition.mockResolvedValue(null); + }); + it('resolves provider status names to agent types case-insensitively', () => { expect( resolvePMStatusAgentByName({ @@ -43,6 +59,38 @@ describe('PM status helpers', () => { ).toBeUndefined(); }); + it('resolves custom workflow status IDs to custom agents', async () => { + mockGetCustomWorkflowStatusDefinition.mockResolvedValue({ + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }); + + await expect( + resolvePMStatusAgentByIdFromWorkflowDefinitions({ + statusId: 'state-prd', + configuredStatuses: { + prd: 'state-prd', + }, + }), + ).resolves.toEqual({ agentType: 'prd', cascadeStatus: 'prd' }); + }); + + it('ignores workflow statuses with no dispatch agent', async () => { + await expect( + resolvePMStatusAgentByIdFromWorkflowDefinitions({ + statusId: 'state-done', + configuredStatuses: { + done: 'state-done', + }, + }), + ).resolves.toBeUndefined(); + }); + it('applies shared onCreate/onMove trigger parameter semantics', () => { expect(shouldFirePMStatusEvent(true, { onCreate: true })).toBe(true); expect(shouldFirePMStatusEvent(true, {})).toBe(false); diff --git a/tests/unit/web/global-definitions-route.test.ts b/tests/unit/web/global-definitions-route.test.ts new file mode 100644 index 000000000..1de93b8c1 --- /dev/null +++ b/tests/unit/web/global-definitions-route.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; +import { AGENT_DEFINITIONS_TABS } from '../../../web/src/routes/global/definitions-tabs.js'; + +describe('global definitions route', () => { + it('exposes the workflow statuses tab in the tab bar', () => { + expect(AGENT_DEFINITIONS_TABS).toEqual(['definitions', 'partials', 'workflow-statuses']); + }); +}); diff --git a/tests/unit/web/pm-wizard-hooks.test.ts b/tests/unit/web/pm-wizard-hooks.test.ts index f85e66ffe..21d84adf4 100644 --- a/tests/unit/web/pm-wizard-hooks.test.ts +++ b/tests/unit/web/pm-wizard-hooks.test.ts @@ -11,6 +11,7 @@ import { trelloProviderWizard } from '../../../web/src/components/projects/pm-pr import { buildCurrentUserDiscoveryRequest, buildIntegrationUpsertInput, + buildMissingStatusTriggerConfigs, buildPersistedCredentialInputs, buildProviderAuthArgFromMetadata, buildProviderCustomFieldCreationRequest, @@ -149,6 +150,42 @@ describe('provider credential metadata', () => { }); }); +describe('buildMissingStatusTriggerConfigs', () => { + it('builds trigger configs for mapped workflow statuses with dispatch agents', () => { + const result = buildMissingStatusTriggerConfigs({ + statusMappings: { + prd: 'state-prd', + done: 'state-done', + story: '', + }, + workflowStatuses: [ + { key: 'prd', agentType: 'prd' }, + { key: 'done', agentType: null }, + { key: 'story', agentType: 'story' }, + ], + existingConfigs: [], + }); + + expect(result).toEqual([ + { + agentType: 'prd', + triggerEvent: 'pm:status-changed', + enabled: true, + }, + ]); + }); + + it('does not overwrite existing status trigger configs', () => { + const result = buildMissingStatusTriggerConfigs({ + statusMappings: { prd: 'state-prd' }, + workflowStatuses: [{ key: 'prd', agentType: 'prd' }], + existingConfigs: [{ agentType: 'prd', triggerEvent: 'pm:status-changed' }], + }); + + expect(result).toEqual([]); + }); +}); + // ============================================================================ // Metadata-driven verification // ============================================================================ diff --git a/web/src/components/projects/pm-providers/linear/wizard.ts b/web/src/components/projects/pm-providers/linear/wizard.ts index 92983781c..22a454d1e 100644 --- a/web/src/components/projects/pm-providers/linear/wizard.ts +++ b/web/src/components/projects/pm-providers/linear/wizard.ts @@ -135,6 +135,7 @@ interface LinearProviderHooks { readonly webhookUrl: string; readonly projectIdForSecret: string; readonly webhookSecretCredential: ProjectCredentialMeta | undefined; + readonly workflowStatuses: ReadonlyArray<{ readonly key: string; readonly label: string }>; } function asLinearHooks(providerHooks: Record | undefined): LinearProviderHooks { @@ -179,7 +180,7 @@ function LinearStatusMappingAdapter({ return StatusMappingStep({ step: { kind: 'status-mapping', id: 'linear-statuses' }, providerId: 'linear', - cascadeStatuses: LINEAR_STATUS_SLOTS, + cascadeStatuses: h.workflowStatuses.length > 0 ? h.workflowStatuses : LINEAR_STATUS_SLOTS, providerStates: h.providerStates, mappings: state.linearStatusMappings, onMappingChange: (key, value) => dispatch({ type: 'SET_LINEAR_STATUS_MAPPING', key, value }), @@ -311,6 +312,7 @@ export const linearProviderWizard: ProviderWizardDefinition = { const credentialsQuery = useQuery( trpc.projects.credentials.list.queryOptions({ projectId: projectId ?? '' }), ); + const workflowStatusesQuery = useQuery(trpc.workflowStatuses.list.queryOptions()); const webhookSecretCredential = credentialsQuery.data?.find( (c) => c.envVarKey === 'LINEAR_WEBHOOK_SECRET', ); @@ -370,6 +372,11 @@ export const linearProviderWizard: ProviderWizardDefinition = { webhookUrl, projectIdForSecret: projectId ?? '', webhookSecretCredential, + workflowStatuses: + workflowStatusesQuery.data?.map((status) => ({ + key: status.key, + label: status.label, + })) ?? LINEAR_STATUS_SLOTS, } satisfies LinearProviderHooks & Record; }, }; diff --git a/web/src/components/projects/pm-wizard-hooks.ts b/web/src/components/projects/pm-wizard-hooks.ts index 27061fa2c..0dc919353 100644 --- a/web/src/components/projects/pm-wizard-hooks.ts +++ b/web/src/components/projects/pm-wizard-hooks.ts @@ -351,6 +351,50 @@ export function buildPersistedCredentialInputs( }); } +interface StatusTriggerInput { + readonly key: string; + readonly agentType: string | null; +} + +interface ExistingTriggerInput { + readonly agentType: string; + readonly triggerEvent: string; +} + +export function buildMissingStatusTriggerConfigs(args: { + readonly statusMappings: Readonly>; + readonly workflowStatuses: ReadonlyArray; + readonly existingConfigs: ReadonlyArray; +}): Array<{ + agentType: string; + triggerEvent: 'pm:status-changed'; + enabled: true; +}> { + const mappedKeys = new Set( + Object.entries(args.statusMappings) + .filter(([, providerStateId]) => providerStateId) + .map(([key]) => key), + ); + const existing = new Set( + args.existingConfigs.map((config) => `${config.agentType}:${config.triggerEvent}`), + ); + const seenAgents = new Set(); + + return args.workflowStatuses.flatMap((status) => { + if (!status.agentType || !mappedKeys.has(status.key)) return []; + if (seenAgents.has(status.agentType)) return []; + seenAgents.add(status.agentType); + if (existing.has(`${status.agentType}:pm:status-changed`)) return []; + return [ + { + agentType: status.agentType, + triggerEvent: 'pm:status-changed', + enabled: true, + }, + ]; + }); +} + export function buildIntegrationUpsertInput( projectId: string, state: WizardState, @@ -387,8 +431,21 @@ export function useSaveMutation( await trpcClient.projects.credentials.set.mutate({ projectId, ...cred }); } - // On first-time setup, auto-enable default PM triggers for the three main agents - if (!state.isEditing) { + if (state.provider === 'linear') { + const [workflowStatuses, existingConfigs] = await Promise.all([ + trpcClient.workflowStatuses.list.query(), + trpcClient.agentTriggerConfigs.listByProject.query({ projectId }), + ]); + const configs = buildMissingStatusTriggerConfigs({ + statusMappings: state.linearStatusMappings, + workflowStatuses, + existingConfigs, + }); + if (configs.length > 0) { + await trpcClient.agentTriggerConfigs.bulkUpsert.mutate({ projectId, configs }); + } + } else if (!state.isEditing) { + // On first-time setup, preserve legacy default PM triggers for Trello/JIRA. await trpcClient.agentTriggerConfigs.bulkUpsert.mutate({ projectId, configs: [ diff --git a/web/src/routes/global/definitions-tabs.ts b/web/src/routes/global/definitions-tabs.ts new file mode 100644 index 000000000..726f62cbf --- /dev/null +++ b/web/src/routes/global/definitions-tabs.ts @@ -0,0 +1,3 @@ +export const AGENT_DEFINITIONS_TABS = ['definitions', 'partials', 'workflow-statuses'] as const; + +export type AgentDefinitionsTab = (typeof AGENT_DEFINITIONS_TABS)[number]; diff --git a/web/src/routes/global/definitions.tsx b/web/src/routes/global/definitions.tsx index c0f481987..ec192d844 100644 --- a/web/src/routes/global/definitions.tsx +++ b/web/src/routes/global/definitions.tsx @@ -1,7 +1,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { createRoute } from '@tanstack/react-router'; -import { ArrowLeft, Pencil, Trash2 } from 'lucide-react'; +import type { inferRouterOutputs } from '@trpc/server'; +import { ArrowLeft, Pencil, Plus, Save, Trash2, X } from 'lucide-react'; import { useState } from 'react'; +import type { AppRouter } from '@/../../src/api/router.js'; import { AgentDefinitionEditor } from '@/components/settings/agent-definition-editor.js'; import type { DefinitionRow } from '@/components/settings/agent-definition-table.js'; import { AgentDefinitionsTable } from '@/components/settings/agent-definition-table.js'; @@ -17,8 +19,9 @@ import { } from '@/components/ui/table.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; import { rootRoute } from '../__root.js'; +import { AGENT_DEFINITIONS_TABS, type AgentDefinitionsTab } from './definitions-tabs.js'; -type Tab = 'definitions' | 'partials'; +type Tab = AgentDefinitionsTab; type EditTarget = | { type: 'definition'; existing?: DefinitionRow } | { type: 'partial'; name: string }; @@ -70,7 +73,8 @@ function AgentDefinitionsPage() {

Agent Definitions

- View and edit agent definitions, system prompts, and reusable partials. + View and edit agent definitions, system prompts, reusable partials, and workflow + statuses.

{tab === 'definitions' && ( @@ -86,7 +90,7 @@ function AgentDefinitionsPage() { {/* Tab bar */}
- {(['definitions', 'partials'] as Tab[]).map((t) => ( + {AGENT_DEFINITIONS_TABS.map((t) => ( ))}
@@ -128,6 +136,8 @@ function AgentDefinitionsPage() { {tab === 'partials' && ( setEditTarget({ type: 'partial', name })} /> )} + + {tab === 'workflow-statuses' && } ); } @@ -209,6 +219,263 @@ function PartialsTab({ onEdit }: { onEdit: (name: string) => void }) { ); } +// ───────────────────────────────────────────────────────────────────────────── +// Workflow statuses tab +// ───────────────────────────────────────────────────────────────────────────── + +type RouterOutput = inferRouterOutputs; +type WorkflowStatusRow = RouterOutput['workflowStatuses']['list'][number]; + +function WorkflowStatusesTab() { + const queryClient = useQueryClient(); + const statusesQuery = useQuery(trpc.workflowStatuses.list.queryOptions()); + const agentTypesQuery = useQuery(trpc.agentDefinitions.knownTypes.queryOptions()); + const queryKey = trpc.workflowStatuses.list.queryOptions().queryKey; + + const [draft, setDraft] = useState({ + key: '', + label: '', + agentType: '', + sortOrder: 1000, + }); + const [editingKey, setEditingKey] = useState(null); + const [editDraft, setEditDraft] = useState({ + label: '', + agentType: '', + sortOrder: 1000, + }); + + const createMutation = useMutation({ + mutationFn: () => + trpcClient.workflowStatuses.create.mutate({ + key: draft.key, + label: draft.label, + agentType: draft.agentType || null, + sortOrder: draft.sortOrder, + }), + onSuccess: () => { + setDraft({ key: '', label: '', agentType: '', sortOrder: 1000 }); + queryClient.invalidateQueries({ queryKey }); + }, + }); + + const updateMutation = useMutation({ + mutationFn: (key: string) => + trpcClient.workflowStatuses.update.mutate({ + key, + label: editDraft.label, + agentType: editDraft.agentType || null, + sortOrder: editDraft.sortOrder, + }), + onSuccess: () => { + setEditingKey(null); + queryClient.invalidateQueries({ queryKey }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (key: string) => trpcClient.workflowStatuses.delete.mutate({ key }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + const agentTypes = agentTypesQuery.data ?? []; + const statuses = statusesQuery.data ?? []; + + function beginEdit(row: WorkflowStatusRow) { + setEditingKey(row.key); + setEditDraft({ + label: row.label, + agentType: row.agentType ?? '', + sortOrder: row.sortOrder, + }); + } + + return ( +
+
+ + + + Key + Label + Agent + Order + Type + + + + + {statusesQuery.isLoading && ( + + + Loading workflow statuses... + + + )} + {statuses.map((row) => { + const isEditing = editingKey === row.key; + return ( + + {row.key} + + {isEditing ? ( + + setEditDraft((prev) => ({ ...prev, label: e.target.value })) + } + className="h-8 w-full rounded-md border border-input bg-background px-2" + /> + ) : ( + row.label + )} + + + {isEditing ? ( + + ) : ( + {row.agentType ?? 'none'} + )} + + + {isEditing ? ( + + setEditDraft((prev) => ({ + ...prev, + sortOrder: Number(e.target.value), + })) + } + className="h-8 w-full rounded-md border border-input bg-background px-2" + /> + ) : ( + row.sortOrder + )} + + + {row.isBuiltin ? ( + Built-in + ) : ( + Custom + )} + + + {!row.isBuiltin && ( +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+ )} +
+
+ ); + })} +
+
+
+ +
+ setDraft((prev) => ({ ...prev, key: e.target.value }))} + placeholder="status-key" + className="h-9 rounded-md border border-input bg-background px-3" + /> + setDraft((prev) => ({ ...prev, label: e.target.value }))} + placeholder="Status label" + className="h-9 rounded-md border border-input bg-background px-3" + /> + + setDraft((prev) => ({ ...prev, sortOrder: Number(e.target.value) }))} + className="h-9 rounded-md border border-input bg-background px-3" + /> + +
+
+ ); +} + export const globalDefinitionsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/global/definitions', From 322d176a0bb9bb9bf41f9890ab3b87f34742b0f0 Mon Sep 17 00:00:00 2001 From: Maksymilian Majer <1215868+maksymilian-majer@users.noreply.github.com> Date: Sun, 10 May 2026 18:39:53 +0200 Subject: [PATCH 02/24] fix: support custom agent prompt defaults --- src/api/routers/agentDefinitions.ts | 8 ++++- src/api/routers/prompts.ts | 21 ++++++++---- tests/unit/agents/prompts.test.ts | 21 ++++++++++++ .../unit/api/routers/agentDefinitions.test.ts | 15 +++++++++ tests/unit/api/routers/prompts.test.ts | 33 +++++++++++++++++-- .../settings/agent-definition-prompts.tsx | 10 ++++-- 6 files changed, 97 insertions(+), 11 deletions(-) diff --git a/src/api/routers/agentDefinitions.ts b/src/api/routers/agentDefinitions.ts index d60d3efb7..3cfb10091 100644 --- a/src/api/routers/agentDefinitions.ts +++ b/src/api/routers/agentDefinitions.ts @@ -305,6 +305,13 @@ export const agentDefinitionsRouter = router({ .input(z.object({ agentType: z.string().min(1) })) .mutation(async ({ input }) => { const current = await resolveDefinitionOrThrow(input.agentType); + const isBuiltin = isBuiltinAgentType(input.agentType); + if (!isBuiltin) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `No built-in prompt defaults exist for custom agent: ${input.agentType}`, + }); + } // Load YAML defaults and use its prompts section let yamlDefault: AgentDefinition; @@ -334,7 +341,6 @@ export const agentDefinitionsRouter = router({ }; const validated = AgentDefinitionSchema.parse(updated); - const isBuiltin = isBuiltinAgentType(input.agentType); await upsertAgentDefinition(input.agentType, validated, isBuiltin); invalidateDefinitionCache(); return { agentType: input.agentType }; diff --git a/src/api/routers/prompts.ts b/src/api/routers/prompts.ts index f2f087919..2e11915ab 100644 --- a/src/api/routers/prompts.ts +++ b/src/api/routers/prompts.ts @@ -1,5 +1,6 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; +import { resolveAgentDefinition } from '../../agents/definitions/loader.js'; import { getAvailablePartialNames, getRawPartial, @@ -29,14 +30,22 @@ export const promptsRouter = router({ getDefault: protectedProcedure .input(z.object({ agentType: z.string().min(1) })) - .query(({ input }) => { + .query(async ({ input }) => { try { - return { content: getRawTemplate(input.agentType) }; + return { content: getRawTemplate(input.agentType), source: 'disk' as const }; } catch { - throw new TRPCError({ - code: 'NOT_FOUND', - message: `Unknown agent type: ${input.agentType}`, - }); + try { + const definition = await resolveAgentDefinition(input.agentType); + return { + content: definition.prompts.systemPrompt ?? '', + source: 'definition' as const, + }; + } catch { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `Unknown agent type: ${input.agentType}`, + }); + } } }), diff --git a/tests/unit/agents/prompts.test.ts b/tests/unit/agents/prompts.test.ts index 1db4f58eb..cc12c9654 100644 --- a/tests/unit/agents/prompts.test.ts +++ b/tests/unit/agents/prompts.test.ts @@ -458,6 +458,27 @@ describe('renderCustomPrompt', () => { }); }); +describe('buildTaskPromptContext', () => { + it('preserves prompt context fields in addition to normalized trigger aliases', () => { + const context = buildTaskPromptContext({ + workItemId: 'ATS-123', + pmName: 'Linear', + workItemNoun: 'issue', + workItemTitle: 'Create PRD', + triggerCommentBody: 'Please update', + }); + + expect(context).toMatchObject({ + workItemId: 'ATS-123', + pmName: 'Linear', + workItemNoun: 'issue', + workItemTitle: 'Create PRD', + commentText: 'Please update', + commentBody: 'Please update', + }); + }); +}); + describe('validateTemplate', () => { it('returns valid for correct Eta syntax', () => { const result = validateTemplate('Hello <%= it.baseBranch %>'); diff --git a/tests/unit/api/routers/agentDefinitions.test.ts b/tests/unit/api/routers/agentDefinitions.test.ts index fd760b710..ccf3504cd 100644 --- a/tests/unit/api/routers/agentDefinitions.test.ts +++ b/tests/unit/api/routers/agentDefinitions.test.ts @@ -671,6 +671,21 @@ describe('agentDefinitionsRouter', () => { }); }); + it('throws BAD_REQUEST for custom agents without built-in prompt defaults', async () => { + const current = createMockDefinition({ + prompts: { systemPrompt: 'custom system', taskPrompt: 'custom task' }, + }); + mockResolveAgentDefinition.mockResolvedValue(current); + mockIsBuiltinAgentType.mockReturnValue(false); + + const caller = createCaller({ user: mockSuperAdmin, effectiveOrgId: mockSuperAdmin.orgId }); + await expect(caller.resetPrompt({ agentType: 'phased-plan' })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'No built-in prompt defaults exist for custom agent: phased-plan', + }); + expect(mockLoadBuiltinDefinition).not.toHaveBeenCalled(); + }); + it('throws FORBIDDEN for non-superadmin', async () => { const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); await expect(caller.resetPrompt({ agentType: 'implementation' })).rejects.toMatchObject({ diff --git a/tests/unit/api/routers/prompts.test.ts b/tests/unit/api/routers/prompts.test.ts index f4ca89b62..0b84e5a37 100644 --- a/tests/unit/api/routers/prompts.test.ts +++ b/tests/unit/api/routers/prompts.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createMockSuperAdmin, createMockUser } from '../../../helpers/factories.js'; import { createCallerFor, expectTRPCError } from '../../../helpers/trpcTestHarness.js'; @@ -15,6 +15,7 @@ const { mockGetPartial, mockUpsertPartial, mockDeletePartial, + mockResolveAgentDefinition, } = vi.hoisted(() => ({ mockGetValidAgentTypes: vi.fn(), mockGetRawTemplate: vi.fn(), @@ -27,6 +28,7 @@ const { mockGetPartial: vi.fn(), mockUpsertPartial: vi.fn(), mockDeletePartial: vi.fn(), + mockResolveAgentDefinition: vi.fn(), })); vi.mock('../../../../src/agents/prompts/index.js', () => ({ @@ -38,6 +40,10 @@ vi.mock('../../../../src/agents/prompts/index.js', () => ({ getRawPartial: mockGetRawPartial, })); +vi.mock('../../../../src/agents/definitions/loader.js', () => ({ + resolveAgentDefinition: mockResolveAgentDefinition, +})); + // Mock partials repository vi.mock('../../../../src/db/repositories/partialsRepository.js', () => ({ loadPartials: mockLoadPartials, @@ -55,6 +61,10 @@ const mockUser = createMockSuperAdmin(); const mockAdminUser = createMockUser({ role: 'admin' }); describe('promptsRouter', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe('agentTypes', () => { it('returns list of agent types', async () => { const types = ['splitting', 'planning', 'implementation']; @@ -80,14 +90,33 @@ describe('promptsRouter', () => { const result = await caller.getDefault({ agentType: 'splitting' }); - expect(result).toEqual({ content: 'Template content: <%= it.baseBranch %>' }); + expect(result).toEqual({ + content: 'Template content: <%= it.baseBranch %>', + source: 'disk', + }); expect(mockGetRawTemplate).toHaveBeenCalledWith('splitting'); }); + it('falls back to DB definition prompt for custom agents without disk templates', async () => { + mockGetRawTemplate.mockImplementation(() => { + throw new Error('no disk template'); + }); + mockResolveAgentDefinition.mockResolvedValue({ + prompts: { systemPrompt: 'custom system', taskPrompt: 'custom task' }, + }); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + const result = await caller.getDefault({ agentType: 'phased-plan' }); + + expect(result).toEqual({ content: 'custom system', source: 'definition' }); + expect(mockResolveAgentDefinition).toHaveBeenCalledWith('phased-plan'); + }); + it('throws NOT_FOUND for unknown agent type', async () => { mockGetRawTemplate.mockImplementation(() => { throw new Error('Unknown'); }); + mockResolveAgentDefinition.mockRejectedValue(new Error('Unknown')); const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); await expect(caller.getDefault({ agentType: 'unknown' })).rejects.toMatchObject({ diff --git a/web/src/components/settings/agent-definition-prompts.tsx b/web/src/components/settings/agent-definition-prompts.tsx index 0f62a894c..706097c84 100644 --- a/web/src/components/settings/agent-definition-prompts.tsx +++ b/web/src/components/settings/agent-definition-prompts.tsx @@ -102,6 +102,7 @@ export function PromptEditorHeader({ agentType, hasCustom, hasAnyCustom, + canReset, onReset, onSave, resetPending, @@ -111,6 +112,7 @@ export function PromptEditorHeader({ agentType: string; hasCustom: boolean; hasAnyCustom: boolean; + canReset: boolean; onReset: () => void; onSave: () => void; resetPending: boolean; @@ -128,7 +130,7 @@ export function PromptEditorHeader({