diff --git a/CLAUDE.md b/CLAUDE.md index 08671a8c3..c4e4b9c42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,6 +121,8 @@ Some triggers take params (e.g. `review` + `scm:check-suite-success` accepts `{" **Work-item concurrency lock** — the router prevents duplicate agent runs via a per-agent-type lock on `(projectId, workItemId, agentType)`. Only same-type duplicates are blocked; **different agent types can run concurrently** on the same work item (e.g. review starts while implementation's container is still cleaning up). The lock has a 30-minute TTL hard ceiling that auto-clears stale entries after router restart. +**Implementation freshness gate** — MNG-1053. PM router adapters intentionally embed a pre-resolved `TriggerResult` for delayed/coalesced PM jobs, so the work-item lock alone cannot prevent a stale implementation snapshot from running. The shared execution pipeline at `src/triggers/shared/agent-execution.ts` now runs a worker-side freshness gate (`src/triggers/shared/implementation-freshness-gate.ts`) before `persistAgentWorkItemLinks()` / `prepareForAgent()`. The gate only fires for `agentType === 'implementation'` with a resolved `workItemId` — review/respond-to-* and follow-up agents bypass it. It reloads live PM work-item state and terminal checklists (`Implementation Steps`, `Acceptance Criteria`), counts active same-type runs, and verifies linked PRs by resolving the implementer GitHub persona token before calling `githubClient.getPR()` (so manual/retry pipeline callers do not depend on ambient GitHub scope). Open or merged PRs and fully-complete terminal checklists block dispatch with a durable `Implementation not started:` PM comment (updating the existing ack comment when present). Checklist read uncertainty always falls into `needs_human_reconciliation`; PR lookup uncertainty does the same when a DB/run-linked PR candidate exists. Closed-unmerged PRs do NOT permanently block reimplementation. + **Post-completion review dispatch** — when an implementation agent succeeds with a PR, the execution pipeline checks CI status and fires the review agent deterministically (before the container exits). This guarantees review dispatch within seconds of implementation completion, regardless of GitHub webhook timing. Uses the same `claimReviewDispatch` dedup key as the `check-suite-success` trigger, so the two paths cannot double-enqueue. **Deferred re-check** — a trigger handler can return `TriggerResult.deferredRecheck: { delayMs, coalesceKey, recheckKind? }` (with `agentType: null`) to schedule a bare delayed job via `scheduleCoalescedJob`. The router scheduling is adapter-agnostic, but **bare re-dispatch is currently GitHub-only**: `GitHubRouterAdapter.buildJob()` strips `triggerResult` from the job so the GitHub worker re-dispatches through the trigger registry for fresh provider state. Non-GitHub adapters (Trello, JIRA, Linear, Sentry) embed `triggerResult` in the job; their workers pass it to `resolveTriggerResult()`, which returns the pre-resolved `agentType: null` result without re-dispatching — a non-GitHub handler using this field would schedule a job that reuses the same result rather than re-evaluating provider state. There are two recheck kinds, controlled by the optional `recheckKind` field on `deferredRecheck`: **mergeability re-check** (no `recheckKind`, sets `mergeabilityRecheckAttempt: 1` on the job) — one-shot; if the re-check still cannot resolve state, the worker Sentry-captures under `mergeability_recheck_exhausted` and stops without re-queueing. **Check-suite re-check** (`recheckKind: 'check-suite'`, sets `checkSuiteRecheckAttempt: 1` on the job) — safe rescheduling; if the Actions API is still stale when the job fires, the worker reschedules another coalesced delayed job instead of exhausting, so review/respond-to-ci dispatch stays alive until the API catches up. Used by `check-suite-success` and `check-suite-failure` handlers for the Actions-API-lag case (ucho PR #394/MNG-683, 2026-05-11). diff --git a/docs/architecture/03-trigger-system.md b/docs/architecture/03-trigger-system.md index 9820e89b0..1f19b3db4 100644 --- a/docs/architecture/03-trigger-system.md +++ b/docs/architecture/03-trigger-system.md @@ -218,7 +218,9 @@ flowchart TD A[Trigger matched] --> B[Guard and context setup] B --> C[Validation and budget preflight] C -->|Blocked| D[Notify PM/callbacks and stop] - C -->|Allowed| E[Persist work-item and PR links] + C -->|Allowed| FG[Implementation freshness gate] + FG -->|Blocked| FGSKIP[Post durable PM skip comment and stop] + FG -->|Passed| E[Persist work-item and PR links] E --> F[PM lifecycle: prepareForAgent] F --> G[Run agent via engine] G --> H[Post-run side effects] @@ -231,6 +233,7 @@ flowchart TD This includes: - Context setup in `agent-execution-runtime.ts`: build the `PMLifecycleManager`, load agent lifecycle hooks, and re-resolve `workItemId` from PR links when a webhook arrived before the DB mapping existed. - Validation and lifecycle preflight in `agent-execution-lifecycle.ts`: validate PM/SCM integrations, notify PM/callbacks on validation failure, check `workItemBudgetUsd`, and run `prepareForAgent`. +- Implementation freshness gate in `implementation-freshness-gate.ts` (MNG-1053): only fires for `agentType === 'implementation'` with a resolved `workItemId`. Reloads the live PM work item, terminal checklists (`Implementation Steps`, `Acceptance Criteria`), and `agent_runs` ownership state, then verifies any linked PRs through GitHub using the implementer persona token. Open or merged PRs, fully-completed terminal checklists, and active same-type runs short-circuit dispatch with a stable `Implementation not started:` PM comment (updating the existing ack comment when present, posting a new comment otherwise). Checklist read uncertainty always falls into `needs_human_reconciliation`; PR lookup uncertainty does the same when a DB/run-linked PR candidate exists. Closed-unmerged PRs do NOT permanently block reimplementation. - Work-item and PR traceability in `agent-work-items.ts`: create/update work-item records, maintain PR/work-item links before and after execution, fetch PR titles, and backfill run PR numbers. - Agent execution in `agent-execution-runtime.ts`: call `runAgent()` with the resolved input plus project, config, and remaining budget. - Post-run PM behavior in `agent-pm-summary.ts` and `agent-execution-lifecycle.ts`: post review/output summaries to the PM work item, handle artifacts, post budget warnings, clean up processing state, and call `handleSuccess` or `handleFailure`. diff --git a/docs/architecture/04-agent-system.md b/docs/architecture/04-agent-system.md index 6b22a5c0b..3c3552c59 100644 --- a/docs/architecture/04-agent-system.md +++ b/docs/architecture/04-agent-system.md @@ -103,6 +103,51 @@ Key functions: - `resolveAllAgentDefinitions()` — merge DB + YAML - `resolveKnownAgentTypes()` — list all known types +### CLI management + +Custom agent definitions and custom workflow status definitions can be managed +without the dashboard UI: + +```bash +# Register a custom agent definition from YAML or JSON +cascade definitions create --agent-type prd --file prd-agent.yaml + +# Import-or-update an agent definition from an exported file +cascade definitions import --file prd-agent.yaml --update + +# Register a custom workflow status that dispatches that agent +cascade workflow-statuses create \ + --key prd \ + --label PRD \ + --agent-type prd \ + --sort-order 1000 + +# Inspect or update workflow status dispatch +cascade workflow-statuses list +cascade workflow-statuses update prd --agent-type story +cascade workflow-statuses update prd --no-agent +``` + +Built-in workflow statuses cannot be modified through the CLI; create custom +statuses for project-specific workflows and map them in the PM integration. + +### Custom workflow statuses across PM providers + +CASCADE separates two concepts that custom workflows need both of: + +1. **The status definition itself** — `key`, `label`, dispatch `agentType`, and `sortOrder`. Built-in definitions live in `BUILTIN_WORKFLOW_STATUSES` (`src/workflow/statusDefinitions.ts`); custom ones live in the `workflow_status_definitions` table and are managed via `cascade workflow-statuses {create,list,update,delete}` or the superadmin tRPC router at `src/api/routers/workflowStatuses.ts`. +2. **The provider-native mapping** — the actual Trello list, JIRA status, or Linear workflow state that the custom status corresponds to on the board. This lives in the PM integration config (`project_integrations.config`) under the same provider-native key shape used for built-in statuses; see [`08-config-credentials.md`](./08-config-credentials.md#custom-workflow-status-mappings) for the per-provider storage layout. + +All three production providers (Trello, JIRA, Linear) support custom statuses with the same dispatch contract: + +- **Trello** (`src/triggers/trello/status-changed.ts`) — `TrelloCustomStatusChangedTrigger` matches `createCard` / `updateCard` events whose destination list ID maps to a custom (non-built-in) key in `trello.lists.`, then resolves the dispatch agent through `resolvePMStatusAgentByIdFromWorkflowDefinitions`. Built-in keys (e.g. `todo`, `planning`) continue to flow through the per-list `TrelloStatusChanged*Trigger` handlers. +- **JIRA** (`src/triggers/jira/status-changed.ts`) — `JiraStatusChangedTrigger` resolves the new status name against `jira.statuses` via `resolvePMStatusAgentByNameFromWorkflowDefinitions`, picking up custom keys alongside built-ins. +- **Linear** (`src/triggers/linear/status-changed.ts`) — `LinearStatusChangedTrigger` resolves the new state UUID against `linear.statuses` via `resolvePMStatusAgentByIdFromWorkflowDefinitions`. + +All three paths share `resolvePMStatusAgentFromWorkflowDefinitions` in `src/triggers/shared/pm-status.ts` and obey the same dispatch precondition: a custom status only dispatches an agent when its definition has a non-null `agentType` AND a `pm:status-changed` trigger config is enabled for that agent. A custom status with `agentType: null` (created via `cascade workflow-statuses update --no-agent` or set without `--agent-type`) renders in the wizard and persists in the provider config, but the trigger handlers return `null` instead of dispatching — useful for board columns that should appear in CASCADE's wizard without spawning agents. + +The PM wizards (Trello, JIRA, Linear) pull the full definition list via `trpc.workflowStatuses.list` and render mapping rows for every key — built-in and custom alike. Saving the wizard auto-enables the `pm:status-changed` trigger config for any custom-status agent the operator mapped, through `buildMissingStatusTriggerConfigs` (`web/src/components/projects/pm-providers/save-trigger-configs.ts`). See `src/integrations/README.md` for the provider parity contract. + ## Built-in Agents | Agent | Capabilities | Persona | Key Triggers | diff --git a/docs/architecture/08-config-credentials.md b/docs/architecture/08-config-credentials.md index 6c376c1aa..a0b6ae6d2 100644 --- a/docs/architecture/08-config-credentials.md +++ b/docs/architecture/08-config-credentials.md @@ -96,6 +96,45 @@ active PM provider; if that scope is missing the gate fails closed and captures Sentry under `pipeline_capacity_gate_no_pm_provider`. See `src/triggers/shared/pipeline-capacity-gate.ts`. +### Custom workflow status mappings + +Operators can register custom workflow statuses (e.g. `prd`, `story`, +`phased-plan`) through `cascade workflow-statuses create` or the superadmin +`workflowStatuses.create` tRPC mutation. The definition itself — `key`, +`label`, optional dispatch `agentType`, `sortOrder` — lives in the +`workflow_status_definitions` table alongside the built-in catalog +(`BUILTIN_WORKFLOW_STATUSES` in `src/workflow/statusDefinitions.ts`). See +[`04-agent-system.md`](./04-agent-system.md#custom-workflow-statuses-across-pm-providers) +for the dispatch contract. + +The provider-native mapping for each custom key is stored in +`project_integrations.config` under the same key shape used for built-in +slots — there is no separate side table for custom keys: + +| Provider | Custom key location | Value shape | +|---|---|---| +| Trello | `lists.` | Trello list ID | +| JIRA | `statuses.` | JIRA status name | +| Linear | `statuses.` | Linear workflow state UUID | + +For example, after `cascade workflow-statuses create --key prd --label PRD +--agent-type prd`, a Trello project might persist `lists.prd: "5f8a..."` +in the integration config, while the equivalent JIRA project persists +`statuses.prd: "PRD Ready"` and the Linear project persists +`statuses.prd: "f3c1-..."`. The lifecycle config resolvers in +`src/pm/trello/integration.ts`, `src/pm/jira/integration.ts`, and +`src/pm/linear/integration.ts` spread the full `lists` / `statuses` record so +custom keys survive normalization and reach the `moveOnPrepare` / +`moveOnSuccess` lifecycle hooks for custom agents. + +A custom mapped status only dispatches an agent when its definition has a +non-null `agentType` AND the project has an enabled `pm:status-changed` +trigger config for that agent. The PM wizard's save path auto-creates the +missing trigger config when the operator maps a dispatch-capable custom +status, via `buildMissingStatusTriggerConfigs`. Custom statuses with +`agentType: null` render and save normally but the trigger handlers return +`null` instead of dispatching. + ## Credential Resolution CASCADE uses a two-tier credential resolution system, selecting the appropriate resolver based on execution context. diff --git a/docs/architecture/09-database.md b/docs/architecture/09-database.md index abcbe238d..9bd92f08c 100644 --- a/docs/architecture/09-database.md +++ b/docs/architecture/09-database.md @@ -130,6 +130,7 @@ erDiagram | `agent_configs` | Per-agent-type overrides per project | UNIQUE(`project_id`, `agent_type`), `project_id NOT NULL` | | `agent_definitions` | Agent YAML definitions (built-in + custom) | UNIQUE(`agent_type`) | | `agent_trigger_configs` | Trigger enable/disable + parameters per project/agent/event | UNIQUE(`project_id`, `agent_type`, `event`) | +| `workflow_status_definitions` | Custom workflow status definitions (key, label, optional dispatch `agent_type`, sort order). Built-in statuses live in code (`BUILTIN_WORKFLOW_STATUSES`); this table only stores custom definitions. | UNIQUE(`status_key`) | | `agent_runs` | Agent execution records with status, cost, duration | Indexed on `project_id`, `status`, `started_at` | | `agent_run_logs` | Cascade log + engine log per run | One-to-one with `agent_runs` | | `agent_run_llm_calls` | LLM request/response pairs with token/cost tracking | — | @@ -157,6 +158,7 @@ Each table has a dedicated repository providing typed query methods. Key reposit | `agentConfigsRepository` | Per-agent settings CRUD | | `agentDefinitionsRepository` | Agent definition CRUD (YAML ↔ JSONB) | | `agentTriggerConfigsRepository` | Trigger enable/disable/params per project/agent/event | +| `workflowStatusDefinitionsRepository` | Custom workflow status definition CRUD; backs `cascade workflow-statuses *` and the `workflowStatuses` tRPC router | | `integrationsRepository` | Query integration configuration | | `projectsRepository` | Project CRUD | | `organizationsRepository` | Organization CRUD | diff --git a/docs/architecture/10-resilience.md b/docs/architecture/10-resilience.md index b0713e315..9b626cd2e 100644 --- a/docs/architecture/10-resilience.md +++ b/docs/architecture/10-resilience.md @@ -31,6 +31,14 @@ Prevents multiple agents from working on the same card/issue simultaneously. The - Key: `(projectId, workItemId, agentType)` - Only same-agent duplicates are blocked; different agent types may run concurrently on the same work item +### Implementation freshness gate (worker-side) + +`src/triggers/shared/implementation-freshness-gate.ts` + +The router-level work-item lock catches in-flight duplicates of the same agent type, but cannot see (a) a sibling implementation run that already completed via post-completion review chaining or a manual run, (b) a terminal checklist (`Implementation Steps`, `Acceptance Criteria`) that an operator finished while the dispatch sat in the PM coalesce window, or (c) a PR that already exists for the work item. PM router adapters intentionally embed a pre-resolved `TriggerResult` for delayed/coalesced PM jobs, so the freshness gate runs the last-mile check inside the worker execution pipeline before `persistAgentWorkItemLinks()` and `prepareForAgent()`. + +The gate is implementation-only (`agentType === 'implementation'` plus a resolved `workItemId`); follow-up agents — `review`, `respond-to-review`, `respond-to-ci`, `respond-to-pr-comment` — bypass it and keep their existing dispatch path. When the gate fires, it reloads the live PM work item / checklists, counts active same-type runs, looks up linked PR candidates from `pr_work_items` + recent runs, and verifies PR state through `githubClient.getPR()` inside a freshly resolved implementer-token scope. Open or merged PRs and fully-complete terminal checklists return `already_implemented` or `implementation_pr_exists`; checklist read uncertainty always returns `needs_human_reconciliation`, and PR lookup uncertainty does the same when a DB/run-linked PR candidate exists. The pipeline posts a durable PM comment (updating the existing ack comment when present, otherwise adding a new one) prefixed with `Implementation not started:` and exits normally so router cleanup releases locks without retrying. + ### Agent-type concurrency limit `src/router/agent-type-lock.ts` diff --git a/docs/getting-started.md b/docs/getting-started.md index b0ef84090..68da41ef3 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -353,6 +353,42 @@ node bin/cascade.js projects trigger-set my-project \ node bin/cascade.js projects trigger-discover --agent implementation ``` +### Map a custom workflow status + +You can add columns/states beyond the built-in CASCADE stages (Backlog → Splitting → Planning → Todo → In Progress → In Review → Done → Merged) and have them dispatch any custom or built-in agent. This works for Trello, Jira, and Linear with the same wiring: + +1. **Register the custom status definition** (superadmin) so the wizard offers a mapping row for it: + + ```bash + # Dispatches the `prd` agent — the agent must declare a `pm:status-changed` trigger + node bin/cascade.js workflow-statuses create \ + --key prd --label PRD --agent-type prd --sort-order 1000 + + # Or render-only — no agent dispatches when work items land in this status + node bin/cascade.js workflow-statuses create --key icebox --label Icebox + + node bin/cascade.js workflow-statuses list + ``` + +2. **Map the custom key to a provider-native list/status** in the PM wizard's **Status Mapping** step. The wizard renders a row for every registered status (built-in + custom) and saves the mapping in the same provider-native shape: + + - **Trello** stores the list ID under `lists.prd` + - **Jira** stores the status name under `statuses.prd` + - **Linear** stores the workflow state UUID under `statuses.prd` + +3. **Verify trigger config readiness**. Saving the wizard auto-enables `pm:status-changed` for any custom-status agent the operator just mapped. Confirm via the dashboard's **Agent Configs** tab, or: + + ```bash + node bin/cascade.js projects trigger-list my-project + # Look for: agent=prd event=pm:status-changed enabled=true + + # If missing, enable manually: + node bin/cascade.js projects trigger-set my-project \ + --agent prd --event pm:status-changed --enable + ``` + +A custom status only dispatches when its definition has an `agent-type` AND the project trigger config for `(agent, pm:status-changed)` is enabled. Statuses created without `--agent-type` (or updated with `--no-agent`) render in the wizard and persist in the provider config, but the trigger handlers return `null` instead of dispatching — useful for board columns CASCADE should know about but never act on. + --- ## 11. Test It 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/agentDefinitions.ts b/src/api/routers/agentDefinitions.ts index d60d3efb7..45a014553 100644 --- a/src/api/routers/agentDefinitions.ts +++ b/src/api/routers/agentDefinitions.ts @@ -22,9 +22,14 @@ import { upsertAgentDefinition, } from '../../db/repositories/agentDefinitionsRepository.js'; import { loadPartials } from '../../db/repositories/partialsRepository.js'; +import { clearAgentTypeReferences } from '../../db/repositories/workflowStatusDefinitionsRepository.js'; +import { logger } from '../../utils/logging.js'; +import { listWorkflowStatusDefinitions } from '../../workflow/statusDefinitions.js'; import { publicProcedure, router, superAdminProcedure } from '../trpc.js'; import { TRIGGER_REGISTRY } from './_shared/triggerTypes.js'; +const STATUS_CHANGED_TRIGGER_EVENT = 'pm:status-changed'; + async function validatePromptIfPresent(prompt: string | null | undefined) { if (!prompt) return; const dbPartials = await loadPartials(); @@ -52,6 +57,25 @@ async function resolveDefinitionOrThrow(agentType: string) { } } +async function assertWorkflowStatusDispatchCompatibility( + agentType: string, + definition: AgentDefinition, +) { + if (definition.triggers.some((trigger) => trigger.event === STATUS_CHANGED_TRIGGER_EVENT)) { + return; + } + + const referencingStatuses = (await listWorkflowStatusDefinitions()).filter( + (status) => status.agentType === agentType, + ); + if (referencingStatuses.length === 0) return; + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Agent definition '${agentType}' is used by workflow status dispatch and must declare ${STATUS_CHANGED_TRIGGER_EVENT}`, + }); +} + export const agentDefinitionsRouter = router({ /** * Returns all definitions (YAML + DB merged), with agentType, definition, and isBuiltin flag. @@ -154,6 +178,7 @@ export const agentDefinitionsRouter = router({ }); } const isBuiltin = isBuiltinAgentType(input.agentType); + await assertWorkflowStatusDispatchCompatibility(input.agentType, input.definition); await upsertAgentDefinition(input.agentType, input.definition, isBuiltin); invalidateDefinitionCache(); return { agentType: input.agentType }; @@ -178,6 +203,9 @@ export const agentDefinitionsRouter = router({ const merged = { ...current, ...input.patch }; // Full-schema validate the merged result const validated = AgentDefinitionSchema.parse(merged); + if ('triggers' in input.patch) { + await assertWorkflowStatusDispatchCompatibility(input.agentType, validated); + } const isBuiltin = isBuiltinAgentType(input.agentType); await upsertAgentDefinition(input.agentType, validated, isBuiltin); @@ -209,7 +237,17 @@ export const agentDefinitionsRouter = router({ }); } + // Keep this sequential in the same delete code path. A hard FK is not possible + // because YAML-only agent types are valid workflow status references but have + // no DB row; the cleanup update is idempotent and safe if it matches zero rows. await deleteAgentDefinition(input.agentType); + const clearedWorkflowStatuses = await clearAgentTypeReferences(input.agentType); + if (clearedWorkflowStatuses > 0) { + logger.info('Cleared workflow status agent references after agent definition delete', { + agentType: input.agentType, + clearedWorkflowStatuses, + }); + } invalidateDefinitionCache(); return { agentType: input.agentType }; }), @@ -235,6 +273,7 @@ export const agentDefinitionsRouter = router({ // restore the hard-coded YAML defaults. invalidateDefinitionCache(); const yamlDefinition = loadBuiltinDefinition(input.agentType); + await assertWorkflowStatusDispatchCompatibility(input.agentType, yamlDefinition); await upsertAgentDefinition(input.agentType, yamlDefinition, true); invalidateDefinitionCache(); return { agentType: input.agentType }; @@ -305,6 +344,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 +380,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/src/api/routers/workflowStatuses.ts b/src/api/routers/workflowStatuses.ts new file mode 100644 index 000000000..fbfe046da --- /dev/null +++ b/src/api/routers/workflowStatuses.ts @@ -0,0 +1,131 @@ +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 STATUS_CHANGED_TRIGGER_EVENT = 'pm:status-changed'; + +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; + let definition: Awaited>; + try { + definition = await resolveAgentDefinition(agentType); + } catch { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Unknown agent type: ${agentType}`, + }); + } + if (!definition.triggers.some((trigger) => trigger.event === STATUS_CHANGED_TRIGGER_EVENT)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Agent type does not support ${STATUS_CHANGED_TRIGGER_EVENT}: ${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/cli/dashboard/workflow-statuses/create.ts b/src/cli/dashboard/workflow-statuses/create.ts new file mode 100644 index 000000000..d7927ef14 --- /dev/null +++ b/src/cli/dashboard/workflow-statuses/create.ts @@ -0,0 +1,49 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class WorkflowStatusesCreate extends DashboardCommand { + static override description = 'Create a custom workflow status definition.'; + + static override flags = { + ...DashboardCommand.baseFlags, + key: Flags.string({ + description: 'Workflow status key, e.g. prd or phased-plan', + required: true, + }), + label: Flags.string({ + description: 'Human-readable status label, e.g. PRD', + required: true, + }), + 'agent-type': Flags.string({ + description: 'Agent type to dispatch for this status. Omit for no dispatch.', + }), + 'sort-order': Flags.integer({ + description: 'Display order after built-in statuses', + }), + }; + + async run(): Promise { + const { flags } = await this.parse(WorkflowStatusesCreate); + + try { + const result = await this.withSpinner('Creating workflow status...', () => + this.client.workflowStatuses.create.mutate({ + key: flags.key, + label: flags.label, + agentType: flags['agent-type'], + sortOrder: flags['sort-order'], + }), + ); + + if (flags.json) { + this.outputJson(result); + return; + } + + const agent = result.agentType ? ` -> ${result.agentType}` : ' -> no dispatch'; + this.success(`Created workflow status '${result.key}'${agent}`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/workflow-statuses/delete.ts b/src/cli/dashboard/workflow-statuses/delete.ts new file mode 100644 index 000000000..4e616d55f --- /dev/null +++ b/src/cli/dashboard/workflow-statuses/delete.ts @@ -0,0 +1,38 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class WorkflowStatusesDelete extends DashboardCommand { + static override description = 'Delete a custom workflow status definition.'; + + static override args = { + key: Args.string({ description: 'Workflow status key', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + yes: Flags.boolean({ description: 'Skip confirmation', char: 'y', default: false }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(WorkflowStatusesDelete); + + if (!flags.yes) { + this.error('Pass --yes to confirm deletion.'); + } + + try { + const result = await this.withSpinner('Deleting workflow status...', () => + this.client.workflowStatuses.delete.mutate({ key: args.key }), + ); + + if (flags.json) { + this.outputJson(result); + return; + } + + this.success(`Deleted workflow status '${result.key}'`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/workflow-statuses/list.ts b/src/cli/dashboard/workflow-statuses/list.ts new file mode 100644 index 000000000..9d10c2851 --- /dev/null +++ b/src/cli/dashboard/workflow-statuses/list.ts @@ -0,0 +1,39 @@ +import { DashboardCommand } from '../_shared/base.js'; + +export default class WorkflowStatusesList extends DashboardCommand { + static override description = 'List built-in and custom workflow statuses.'; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(WorkflowStatusesList); + + try { + const statuses = await this.client.workflowStatuses.list.query(); + const rows = statuses.map((status) => ({ + key: status.key, + label: status.label, + agentType: status.agentType ?? '', + sortOrder: status.sortOrder, + isBuiltin: status.isBuiltin, + })); + const columns = [ + { key: 'key', header: 'Key' }, + { key: 'label', header: 'Label' }, + { key: 'agentType', header: 'Agent Type' }, + { key: 'sortOrder', header: 'Order' }, + { + key: 'isBuiltin', + header: 'Built-in', + format: (value: unknown) => (value ? 'yes' : 'no'), + }, + ]; + + this.outputFormatted(rows, columns, flags, statuses, 'No workflow statuses found.'); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/workflow-statuses/update.ts b/src/cli/dashboard/workflow-statuses/update.ts new file mode 100644 index 000000000..cf2ab8781 --- /dev/null +++ b/src/cli/dashboard/workflow-statuses/update.ts @@ -0,0 +1,59 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class WorkflowStatusesUpdate extends DashboardCommand { + static override description = 'Update a custom workflow status definition.'; + + static override args = { + key: Args.string({ description: 'Workflow status key', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + label: Flags.string({ description: 'New human-readable status label' }), + 'agent-type': Flags.string({ + description: 'Agent type to dispatch for this status', + exclusive: ['no-agent'], + }), + 'no-agent': Flags.boolean({ + description: 'Clear the dispatch agent for this status', + exclusive: ['agent-type'], + default: false, + }), + 'sort-order': Flags.integer({ description: 'Display order' }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(WorkflowStatusesUpdate); + + if ( + flags.label === undefined && + flags['agent-type'] === undefined && + !flags['no-agent'] && + flags['sort-order'] === undefined + ) { + this.error('Provide at least one of --label, --agent-type, --no-agent, or --sort-order.'); + } + + try { + const result = await this.withSpinner('Updating workflow status...', () => + this.client.workflowStatuses.update.mutate({ + key: args.key, + label: flags.label, + agentType: flags['no-agent'] ? null : flags['agent-type'], + sortOrder: flags['sort-order'], + }), + ); + + if (flags.json) { + this.outputJson(result); + return; + } + + const agent = result.agentType ? ` -> ${result.agentType}` : ' -> no dispatch'; + this.success(`Updated workflow status '${result.key}'${agent}`); + } catch (err) { + this.handleError(err); + } + } +} 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..f1466604f --- /dev/null +++ b/src/db/repositories/workflowStatusDefinitionsRepository.ts @@ -0,0 +1,113 @@ +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; +} + +export async function clearAgentTypeReferences(agentType: string): Promise { + const db = getDb(); + const result = await db + .update(workflowStatusDefinitions) + .set({ agentType: null }) + .where(eq(workflowStatusDefinitions.agentType, agentType)); + return result.rowCount ?? 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/gadgets/pm/core/createWorkItem.ts b/src/gadgets/pm/core/createWorkItem.ts index b67d663fc..ebe66108f 100644 --- a/src/gadgets/pm/core/createWorkItem.ts +++ b/src/gadgets/pm/core/createWorkItem.ts @@ -13,5 +13,5 @@ export async function createWorkItem(params: CreateWorkItemParams): Promise` → Trello list ID | `src/pm/trello/integration.ts` | +| JIRA provider-native value | `statuses.` → JIRA status name | `src/pm/jira/integration.ts` | +| Linear provider-native value | `statuses.` → Linear workflow state UUID | `src/pm/linear/integration.ts` | + +The lifecycle config resolver on each `PMIntegration` (`resolveLifecycleConfig`) **must** spread the full `lists` / `statuses` record so custom keys survive normalization and are available to `moveOnPrepare` / `moveOnSuccess` lifecycle hooks for custom agents. Look at `LinearIntegration.resolveLifecycleConfig` for the canonical shape — `statuses: { ...(linearConfig?.statuses ?? {}) }` rather than handpicked built-in keys. + +### Wizard path — metadata-driven, shared between providers + +The PM wizards consume the workflow status definition list through a single tRPC query (`trpc.workflowStatuses.list`) and render mapping rows for every key — built-in and custom alike. The provider's `useProviderHooks` resolves the list and forwards it as `workflowStatuses` on the hook return; the shared `StatusMappingStep` renders rows in the returned order. Reference implementations: + +- **Trello** — `web/src/components/projects/pm-providers/trello/wizard.ts` (`TrelloStatusMappingAdapter`, `useProviderHooks` → `workflowStatuses`) +- **JIRA** — `web/src/components/projects/pm-providers/jira/wizard.ts` (`JiraStatusMappingAdapter`, `useProviderHooks` → `workflowStatuses`) +- **Linear** — `web/src/components/projects/pm-providers/linear/wizard.ts` (`LinearStatusMappingAdapter`, `useProviderHooks` → `workflowStatuses`) + +Save-time trigger-config materialization is shared too. Every `ProviderWizardDefinition.buildSaveTriggerConfigs` delegates to `buildMissingStatusTriggerConfigs` (`web/src/components/projects/pm-providers/save-trigger-configs.ts`), which: + +1. Walks the workflow status list returned by `trpc.workflowStatuses.list`. +2. For every custom (or auto-enabled built-in) status the operator just mapped to a provider-native value, emits a `{ agentType, triggerEvent: 'pm:status-changed', enabled: true }` entry — if not already present in `agent_trigger_configs`. + +This means the operator never has to manually run `cascade projects trigger-set --agent --event pm:status-changed --enable` after mapping a custom dispatch-capable status in the wizard. The wizard does it. New PM providers inherit this for free by wiring `buildSaveTriggerConfigs: ({ state, workflowStatuses, existingConfigs }) => buildMissingStatusTriggerConfigs({ statusMappings: state.StatusMappings, workflowStatuses, existingConfigs })`. + +### Dispatch path — three converging routes, one resolver + +Custom-status dispatch reuses the same `pm:status-changed` trigger registry that built-in statuses use: + +- **Trello** (`src/triggers/trello/status-changed.ts`) — `TrelloCustomStatusChangedTrigger` claims `createCard` / `updateCard` events whose destination list ID maps to a custom (non-built-in) key in `trello.lists`. Built-in keys are still handled by the per-list triggers (`TrelloStatusChangedTodoTrigger`, etc.). +- **JIRA** (`src/triggers/jira/status-changed.ts`) — `JiraStatusChangedTrigger` resolves the new status name against `jira.statuses` via `resolvePMStatusAgentByNameFromWorkflowDefinitions`, picking up custom keys alongside built-ins in a single handler. +- **Linear** (`src/triggers/linear/status-changed.ts`) — `LinearStatusChangedTrigger` resolves the new state UUID against `linear.statuses` via `resolvePMStatusAgentByIdFromWorkflowDefinitions`, also a single handler. + +All three resolve through the shared `resolvePMStatusAgentFromWorkflowDefinitions` in `src/triggers/shared/pm-status.ts` and obey one dispatch precondition: a status only dispatches an agent when **both** of the following hold: + +1. The workflow status definition has a non-null `agentType`. +2. The project has an enabled `agent_trigger_configs` row for `(agentType, pm:status-changed)`. + +A custom status with `agentType: null` (created without `--agent-type` or updated with `--no-agent`) **renders in the wizard and persists in the provider config but does not dispatch any agent** — the trigger handler returns `null` and the registry continues. Use this for board columns CASCADE should know about for wizard parity but never act on. + +### What a new PM provider needs to do for parity + +1. **Lifecycle resolver** — `PMIntegration.resolveLifecycleConfig` must spread the full provider-native status/list record (custom keys included), not handpicked built-in keys. Mirror `LinearIntegration.resolveLifecycleConfig`. +2. **Status-changed trigger** — call the workflow-definition-aware resolver (`resolvePMStatusAgentByName*FromWorkflowDefinitions` or `resolvePMStatusAgentById*FromWorkflowDefinitions`) instead of the built-in `STATUS_TO_AGENT` map. Built-in providers above are the reference. Optionally add a dedicated custom-status trigger like `TrelloCustomStatusChangedTrigger` if your provider's matching semantics need it. +3. **Wizard hook** — return `workflowStatuses` from `useProviderHooks` (sourced via `trpc.workflowStatuses.list`) and pass it as `cascadeStatuses` into the shared `StatusMappingStep` (falling back to your provider's built-in slot array when the query is still loading). +4. **Save path** — declare `buildSaveTriggerConfigs` on the `ProviderWizardDefinition` delegating to `buildMissingStatusTriggerConfigs`. + +With those four pieces in place, custom workflow statuses get full wizard mapping + auto trigger-config + dispatch with zero additional code per provider. + +--- + ## Adding a new PM provider (step by step) Spec 009 AC #10: **a new PM provider PR should not need to edit shared router / worker / CLI / dashboard / configMapper / central schema files**. The orchestration and schema work lives in your provider folder + your wizard folder + one import in `src/integrations/pm/index.ts` + one import in `web/src/components/projects/pm-providers/index.ts`. The shared wizard orchestration files (`pm-wizard.tsx`, `pm-wizard-hooks.ts`, `pm-wizard-common-steps.tsx`) are guarded shared surface and should not change for a new provider. The one shared dashboard file that still requires a new-provider edit is `pm-wizard-state.ts` (step 4 below) — it composes the provider's state slice into `WizardState`, `WizardAction`, initial state, and reducer delegation. Edit-mode config hydration belongs on the provider's `ProviderWizardDefinition.buildEditState`; save-payload serialization belongs on `ProviderWizardDefinition.buildIntegrationConfig`; provider hook auth and mutation wiring belong in provider-owned metadata/hooks. The `tests/unit/integrations/new-provider-surface.test.ts` guard enforces the shared-file invariant for orchestration and schema files; `pm-wizard-state.ts` is the deliberate exception. diff --git a/src/integrations/pm/trello/manifest.ts b/src/integrations/pm/trello/manifest.ts index 75a40b1f7..645b2a3b0 100644 --- a/src/integrations/pm/trello/manifest.ts +++ b/src/integrations/pm/trello/manifest.ts @@ -28,6 +28,7 @@ import { trelloClient, withTrelloCredentials } from '../../../trello/client.js'; import { TrelloCommentMentionTrigger } from '../../../triggers/trello/comment-mention.js'; import { ReadyToProcessLabelTrigger } from '../../../triggers/trello/label-added.js'; import { + TrelloCustomStatusChangedTrigger, TrelloStatusChangedBacklogTrigger, TrelloStatusChangedMergedTrigger, TrelloStatusChangedPlanningTrigger, @@ -94,6 +95,7 @@ export const trelloManifest: PMProviderManifest = { TrelloStatusChangedTodoTrigger, TrelloStatusChangedBacklogTrigger, TrelloStatusChangedMergedTrigger, + new TrelloCustomStatusChangedTrigger(), new ReadyToProcessLabelTrigger(), ], diff --git a/src/pm/jira/integration.ts b/src/pm/jira/integration.ts index 0bb4c2e97..cb1856a3b 100644 --- a/src/pm/jira/integration.ts +++ b/src/pm/jira/integration.ts @@ -71,6 +71,11 @@ export class JiraIntegration implements PMIntegration { resolveLifecycleConfig(project: ProjectConfig): ProjectPMConfig { const jiraConfig = getJiraConfig(project); const jiraLabels = jiraConfig?.labels; + // Spread the full statuses record so custom workflow keys (e.g. `prd`, + // `story`, `phased-plan`) configured on the JIRA project survive + // normalization and are available to lifecycle hooks like + // `moveOnPrepare` / `moveOnSuccess`. Mirrors + // LinearIntegration.resolveLifecycleConfig. return { labels: { processing: jiraLabels?.processing ?? 'cascade-processing', @@ -79,16 +84,7 @@ export class JiraIntegration implements PMIntegration { readyToProcess: jiraLabels?.readyToProcess ?? 'cascade-ready', auto: jiraLabels?.auto ?? 'cascade-auto', }, - statuses: { - backlog: jiraConfig?.statuses?.backlog, - splitting: jiraConfig?.statuses?.splitting, - planning: jiraConfig?.statuses?.planning, - todo: jiraConfig?.statuses?.todo, - inProgress: jiraConfig?.statuses?.inProgress, - inReview: jiraConfig?.statuses?.inReview, - done: jiraConfig?.statuses?.done, - merged: jiraConfig?.statuses?.merged, - }, + statuses: { ...(jiraConfig?.statuses ?? {}) }, }; } diff --git a/src/pm/lifecycle.ts b/src/pm/lifecycle.ts index 8070e675b..5f6fe9ac9 100644 --- a/src/pm/lifecycle.ts +++ b/src/pm/lifecycle.ts @@ -33,7 +33,7 @@ export interface ProjectPMConfig { done?: string; merged?: string; debug?: string; - }; + } & Record; } /** @@ -78,8 +78,7 @@ export class PMLifecycleManager { await this.safeRemoveLabel(workItemId, this.pmConfig.labels.processed); if (hooks.moveOnPrepare) { - const destination = - this.pmConfig.statuses[hooks.moveOnPrepare as keyof typeof this.pmConfig.statuses]; + const destination = this.pmConfig.statuses[hooks.moveOnPrepare]; await this.safeMove(workItemId, destination); } } @@ -93,8 +92,7 @@ export class PMLifecycleManager { await this.safeAddLabel(workItemId, this.pmConfig.labels.processed); if (hooks.moveOnSuccess) { - const destination = - this.pmConfig.statuses[hooks.moveOnSuccess as keyof typeof this.pmConfig.statuses]; + const destination = this.pmConfig.statuses[hooks.moveOnSuccess]; await this.safeMove(workItemId, destination); } diff --git a/src/pm/linear/integration.ts b/src/pm/linear/integration.ts index df8e5edfd..f02f44d98 100644 --- a/src/pm/linear/integration.ts +++ b/src/pm/linear/integration.ts @@ -89,16 +89,7 @@ export class LinearIntegration implements PMIntegration { readyToProcess: labels?.readyToProcess, auto: labels?.auto, }, - statuses: { - backlog: linearConfig?.statuses?.backlog, - splitting: linearConfig?.statuses?.splitting, - planning: linearConfig?.statuses?.planning, - todo: linearConfig?.statuses?.todo, - inProgress: linearConfig?.statuses?.inProgress, - inReview: linearConfig?.statuses?.inReview, - done: linearConfig?.statuses?.done, - merged: linearConfig?.statuses?.merged, - }, + statuses: { ...(linearConfig?.statuses ?? {}) }, }; } diff --git a/src/pm/trello/integration.ts b/src/pm/trello/integration.ts index 1ac791fee..1dce391d2 100644 --- a/src/pm/trello/integration.ts +++ b/src/pm/trello/integration.ts @@ -69,6 +69,10 @@ export class TrelloIntegration implements PMIntegration { resolveLifecycleConfig(project: ProjectConfig): ProjectPMConfig { const trelloConfig = getTrelloConfig(project); + // Spread the full lists record so custom workflow keys (e.g. `prd`, `story`, + // `phased-plan`) configured on the Trello board survive normalization and + // are available to lifecycle hooks like `moveOnPrepare` / `moveOnSuccess`. + // Mirrors LinearIntegration.resolveLifecycleConfig. return { labels: { processing: trelloConfig?.labels?.processing, @@ -77,13 +81,7 @@ export class TrelloIntegration implements PMIntegration { readyToProcess: trelloConfig?.labels?.readyToProcess, auto: trelloConfig?.labels?.auto, }, - statuses: { - backlog: trelloConfig?.lists?.backlog, - inProgress: trelloConfig?.lists?.inProgress, - inReview: trelloConfig?.lists?.inReview, - done: trelloConfig?.lists?.done, - merged: trelloConfig?.lists?.merged, - }, + statuses: { ...(trelloConfig?.lists ?? {}) }, }; } diff --git a/src/router/agent-type-lock.ts b/src/router/agent-type-lock.ts index 177056cd3..a01404a09 100644 --- a/src/router/agent-type-lock.ts +++ b/src/router/agent-type-lock.ts @@ -45,8 +45,10 @@ export async function isAgentTypeLocked( projectId: string, agentType: string, maxConcurrency: number, + options: { ignoreInMemoryCount?: number } = {}, ): Promise<{ locked: boolean; reason?: string }> { const key = makeKey(projectId, agentType); + const ignoreInMemoryCount = options.ignoreInMemoryCount ?? 0; // Lazy TTL cleanup const entry = concurrencyMap.get(key); @@ -57,10 +59,10 @@ export async function isAgentTypeLocked( projectId, agentType, }); - } else if (entry.count >= maxConcurrency) { + } else if (Math.max(0, entry.count - ignoreInMemoryCount) >= maxConcurrency) { return { locked: true, - reason: `in-memory: ${entry.count} enqueued (max ${maxConcurrency})`, + reason: `in-memory: ${Math.max(0, entry.count - ignoreInMemoryCount)} enqueued (max ${maxConcurrency})`, }; } } @@ -69,7 +71,9 @@ export async function isAgentTypeLocked( const maxAgeMs = 2 * routerConfig.workerTimeoutMs; const activeCount = await countActiveRuns({ projectId, agentType, maxAgeMs }); const inMemoryCount = - entry && Date.now() - entry.timestamp <= CONCURRENCY_TTL_MS ? entry.count : 0; + entry && Date.now() - entry.timestamp <= CONCURRENCY_TTL_MS + ? Math.max(0, entry.count - ignoreInMemoryCount) + : 0; const effectiveCount = Math.max(activeCount, inMemoryCount); if (effectiveCount >= maxConcurrency) { @@ -201,6 +205,7 @@ export async function checkAgentTypeConcurrency( agentType: string, logLabel?: string, dedupScope?: string, + options: { ignoreInMemoryCount?: number; ignoreRecentDispatch?: boolean } = {}, ): Promise<{ maxConcurrency: number | null; blocked: boolean }> { let maxConcurrency: number | null; try { @@ -215,7 +220,7 @@ export async function checkAgentTypeConcurrency( } if (maxConcurrency === null) return { maxConcurrency: null, blocked: false }; - if (wasRecentlyDispatched(projectId, agentType, dedupScope)) { + if (!options.ignoreRecentDispatch && wasRecentlyDispatched(projectId, agentType, dedupScope)) { logger.info(`${logLabel ?? 'Agent'} recently dispatched, skipping (dedup)`, { projectId, agentType, @@ -223,7 +228,9 @@ export async function checkAgentTypeConcurrency( }); return { maxConcurrency, blocked: true }; } - const lockStatus = await isAgentTypeLocked(projectId, agentType, maxConcurrency); + const lockStatus = await isAgentTypeLocked(projectId, agentType, maxConcurrency, { + ignoreInMemoryCount: options.ignoreInMemoryCount, + }); if (lockStatus.locked) { logger.info(`${logLabel ?? 'Agent'} type concurrency limit reached, skipping`, { projectId, diff --git a/src/router/queue.ts b/src/router/queue.ts index 2fada3c16..2c4aa28a0 100644 --- a/src/router/queue.ts +++ b/src/router/queue.ts @@ -153,6 +153,18 @@ export async function addJob(job: CascadeJob): Promise { return result.id ?? jobId; } +export async function hasPendingCoalescedJob(coalesceKey: string): Promise { + return (await getPendingCoalescedJobData(coalesceKey)) !== undefined; +} + +export async function getPendingCoalescedJobData( + coalesceKey: string, +): Promise { + const [delayed, waiting] = await Promise.all([jobQueue.getDelayed(), jobQueue.getWaiting()]); + const pending = [...delayed, ...waiting].find((j) => j.name === coalesceKey); + return pending?.data as CascadeJob | undefined; +} + export interface ScheduleCoalescedJobResult { /** The unique BullMQ job id for the newly-scheduled delayed job. */ jobId: string; diff --git a/src/router/webhook-dispatch-locks.ts b/src/router/webhook-dispatch-locks.ts index a1843773d..fb6e64c88 100644 --- a/src/router/webhook-dispatch-locks.ts +++ b/src/router/webhook-dispatch-locks.ts @@ -20,14 +20,18 @@ export async function checkDispatchLocks({ adapterType, projectId, result, + ignorePendingOwnLock = false, }: { adapterType: string; projectId: string; result: TriggerResult & { agentType: string }; + ignorePendingOwnLock?: boolean; }): Promise { const effectiveLockKey = result.lockKey ?? result.workItemId; if (effectiveLockKey) { - const lockStatus = await isWorkItemLocked(projectId, effectiveLockKey, result.agentType); + const lockStatus = await isWorkItemLocked(projectId, effectiveLockKey, result.agentType, { + ignoreInMemoryCount: ignorePendingOwnLock ? 1 : 0, + }); if (lockStatus.locked) { result.onBlocked?.(); logger.info(`Skipping ${adapterType} job — work item already locked`, { @@ -77,6 +81,10 @@ export async function checkDispatchLocks({ result.agentType, adapterType, result.workItemId, + { + ignoreInMemoryCount: ignorePendingOwnLock ? 1 : 0, + ignoreRecentDispatch: ignorePendingOwnLock, + }, ); if (concurrencyCheck.blocked) { result.onBlocked?.(); diff --git a/src/router/webhook-trigger-outcomes.ts b/src/router/webhook-trigger-outcomes.ts index 2ad464b5a..53f9cfe0c 100644 --- a/src/router/webhook-trigger-outcomes.ts +++ b/src/router/webhook-trigger-outcomes.ts @@ -5,7 +5,7 @@ import { logger } from '../utils/logging.js'; import { clearAgentTypeEnqueued, clearRecentlyDispatched } from './agent-type-lock.js'; import type { RouterProjectConfig } from './config.js'; import type { ParsedWebhookEvent, RouterPlatformAdapter } from './platform-adapter.js'; -import { addJob, scheduleCoalescedJob } from './queue.js'; +import { addJob, getPendingCoalescedJobData, scheduleCoalescedJob } from './queue.js'; import { checkDispatchLocks, markCoalescedDispatchEnqueued, @@ -190,6 +190,27 @@ async function maybeHandleCoalescedDispatch({ } try { + const pendingJobData = await getPendingCoalescedJobData(result.coalesceKey); + const shouldIgnorePendingOwnLock = shouldIgnorePendingOwnLocks({ + pendingJobData, + projectId: project.id, + result: result as TriggerResult & { agentType: string }, + }); + + const lockCheck = await checkDispatchLocks({ + adapterType: adapter.type, + projectId: project.id, + result: result as TriggerResult & { agentType: string }, + ignorePendingOwnLock: shouldIgnorePendingOwnLock, + }); + if (lockCheck.blocked) { + return { + shouldProcess: true, + projectId: project.id, + decisionReason: lockCheck.decisionReason, + }; + } + const { superseded, supersededJobData } = await scheduleCoalescedJob( job, result.coalesceKey, @@ -249,6 +270,26 @@ async function maybeHandleCoalescedDispatch({ }; } +function shouldIgnorePendingOwnLocks({ + pendingJobData, + projectId, + result, +}: { + pendingJobData: Awaited>; + projectId: string; + result: TriggerResult & { agentType: string }; +}): boolean { + if (!pendingJobData || pendingJobData.type === 'github') return false; + if (pendingJobData.projectId !== projectId) return false; + + const pendingResult = pendingJobData.triggerResult; + if (pendingResult?.agentType !== result.agentType) return false; + + const pendingLockKey = pendingResult.lockKey ?? pendingResult.workItemId; + const newLockKey = result.lockKey ?? result.workItemId; + return pendingLockKey !== undefined && pendingLockKey === newLockKey; +} + function releaseSupersededJobLocks( supersededJobData: Awaited>['supersededJobData'], ): void { diff --git a/src/router/work-item-lock.ts b/src/router/work-item-lock.ts index 6bdc7d80f..2622a1405 100644 --- a/src/router/work-item-lock.ts +++ b/src/router/work-item-lock.ts @@ -91,8 +91,10 @@ export async function isWorkItemLocked( projectId: string, workItemId: string, agentType: string, + options: { ignoreInMemoryCount?: number } = {}, ): Promise<{ locked: boolean; reason?: string }> { - const inMemorySameType = getInMemorySameTypeCount(projectId, workItemId, agentType); + const rawInMemorySameType = getInMemorySameTypeCount(projectId, workItemId, agentType); + const inMemorySameType = Math.max(0, rawInMemorySameType - (options.ignoreInMemoryCount ?? 0)); // Short-circuit: in-memory alone proves locked for same type if (inMemorySameType >= MAX_SAME_TYPE_PER_WORK_ITEM) { diff --git a/src/triggers/jira/label-added.ts b/src/triggers/jira/label-added.ts index 66a6b8eff..2e73c3cc0 100644 --- a/src/triggers/jira/label-added.ts +++ b/src/triggers/jira/label-added.ts @@ -14,7 +14,10 @@ import { getJiraConfig } 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, resolvePMLabelAgentByStatusName } from '../shared/pm-label.js'; +import { + buildPMLabelDispatchResult, + resolvePMLabelAgentByStatusNameFromWorkflowDefinitions, +} from '../shared/pm-label.js'; import { checkTriggerEnabled } from '../shared/trigger-check.js'; import type { JiraWebhookPayload } from './types.js'; @@ -85,12 +88,12 @@ export class JiraReadyToProcessLabelTrigger implements TriggerHandler { return null; } - const agentType = resolvePMLabelAgentByStatusName({ + const resolved = await resolvePMLabelAgentByStatusNameFromWorkflowDefinitions({ statusName: currentStatus, configuredStatuses: jiraConfig.statuses, }); - if (!agentType) { + if (!resolved) { logger.debug('JIRA issue status does not map to any agent', { issueKey, currentStatus, @@ -98,6 +101,7 @@ export class JiraReadyToProcessLabelTrigger implements TriggerHandler { }); return null; } + const { agentType, cascadeStatus: matchedCascadeStatus } = resolved; // Check per-agent ready-to-process toggle via new DB-driven system if (!(await checkTriggerEnabled(ctx.project.id, agentType, 'pm:label-added', this.name))) { @@ -107,6 +111,7 @@ export class JiraReadyToProcessLabelTrigger implements TriggerHandler { logger.info('JIRA "Ready to Process" label added, triggering agent', { issueKey, currentStatus, + cascadeStatus: matchedCascadeStatus, agentType, }); diff --git a/src/triggers/jira/status-changed.ts b/src/triggers/jira/status-changed.ts index 79e995fa6..41f3154c1 100644 --- a/src/triggers/jira/status-changed.ts +++ b/src/triggers/jira/status-changed.ts @@ -15,7 +15,7 @@ import { logger } from '../../utils/logging.js'; import { shouldBlockForPipelineCapacity } from '../shared/pipeline-capacity-gate.js'; import { buildPMStatusDispatchResult, - resolvePMStatusAgentByName, + resolvePMStatusAgentByNameFromWorkflowDefinitions, shouldFirePMStatusEvent, } from '../shared/pm-status.js'; import { checkTriggerEnabledWithParams } from '../shared/trigger-check.js'; @@ -83,7 +83,7 @@ export class JiraStatusChangedTrigger implements TriggerHandler { return null; } - const resolved = resolvePMStatusAgentByName({ + const resolved = await resolvePMStatusAgentByNameFromWorkflowDefinitions({ statusName: newStatus, configuredStatuses: jiraConfig.statuses, }); @@ -95,7 +95,7 @@ export class JiraStatusChangedTrigger implements TriggerHandler { }); return null; } - const { agentType } = resolved; + const { agentType, cascadeStatus: matchedCascadeStatus } = resolved; const { enabled, parameters } = await checkTriggerEnabledWithParams( ctx.project.id, @@ -133,6 +133,7 @@ export class JiraStatusChangedTrigger implements TriggerHandler { eventKind: isCreate ? 'create' : 'move', ...(isCreate ? {} : { fromStatus: statusChange?.fromString }), toStatus: newStatus, + cascadeStatus: matchedCascadeStatus, agentType, }); 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/agent-execution-runtime.ts b/src/triggers/shared/agent-execution-runtime.ts index 3f83a272e..ee83dcb40 100644 --- a/src/triggers/shared/agent-execution-runtime.ts +++ b/src/triggers/shared/agent-execution-runtime.ts @@ -49,6 +49,7 @@ export async function createAgentExecutionContext( executionConfig, agentType: result.agentType, logLabel: executionConfig.logLabel ?? 'Agent', + pmProvider, lifecycle, lifecycleHooks, workItemId, diff --git a/src/triggers/shared/agent-execution-types.ts b/src/triggers/shared/agent-execution-types.ts index 2ef7b1d40..4c9b4defd 100644 --- a/src/triggers/shared/agent-execution-types.ts +++ b/src/triggers/shared/agent-execution-types.ts @@ -1,5 +1,5 @@ import type { LifecycleHooks } from '../../agents/definitions/schema.js'; -import type { PMLifecycleManager } from '../../pm/index.js'; +import type { PMLifecycleManager, PMProvider } from '../../pm/index.js'; import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../../types/index.js'; import type { TriggerResult } from '../types.js'; @@ -56,6 +56,12 @@ export interface AgentExecutionContext { executionConfig: AgentExecutionConfig; agentType: string; logLabel: string; + /** + * Active PM provider for the project. Reused by the freshness gate so it + * does not have to instantiate a parallel provider state. Mirrors what + * the lifecycle manager already wraps internally. + */ + pmProvider: PMProvider; lifecycle: PMLifecycleManager; lifecycleHooks: LifecycleHooks; workItemId: string | undefined; diff --git a/src/triggers/shared/agent-execution.ts b/src/triggers/shared/agent-execution.ts index f0e8bc64d..3ffee3627 100644 --- a/src/triggers/shared/agent-execution.ts +++ b/src/triggers/shared/agent-execution.ts @@ -16,7 +16,12 @@ import { runAgentForContext, runPostAgentSideEffects, } from './agent-execution-runtime.js'; -import type { AgentExecutionConfig } from './agent-execution-types.js'; +import type { AgentExecutionConfig, AgentExecutionContext } from './agent-execution-types.js'; +import { + evaluateImplementationFreshness, + type FreshnessGateOutcome, + postFreshnessSkipNotice, +} from './implementation-freshness-gate.js'; export type { AgentExecutionConfig } from './agent-execution-types.js'; @@ -69,6 +74,15 @@ export async function runAgentExecutionPipeline( return; } + // Last-mile freshness check. Only fires for `implementation` runs with a + // resolved work item — review/respond-to-* and follow-up agents bypass + // the gate. Stale / duplicate dispatches stop here without throwing so + // router locks are released by the normal worker cleanup path. + const blockedByFreshness = await runFreshnessGate(executionContext); + if (blockedByFreshness) { + return; + } + let remainingBudgetUsd: number | undefined; if (executionContext.workItemId) { const budgetResult = await checkPreRunBudget( @@ -109,3 +123,54 @@ export async function runAgentExecutionPipeline( await triggerAutoDebugIfNeeded(agentResult, executionContext.project, executionContext.config); } + +/** + * Run the implementation-only freshness gate. Returns `true` when the + * pipeline must stop before mutating work-item state or starting the + * agent. Stale/duplicate skips post a durable PM comment; uncertain + * fail-closed skips do the same and additionally log structured + * evidence for operators. + */ +async function runFreshnessGate(context: AgentExecutionContext): Promise { + if (context.agentType !== 'implementation' || !context.workItemId) { + return false; + } + + let outcome: FreshnessGateOutcome; + try { + outcome = await evaluateImplementationFreshness({ + agentType: context.agentType, + workItemId: context.workItemId, + project: context.project, + provider: context.pmProvider, + }); + } catch (err) { + // The gate itself should never throw — if it does, treat as + // dispatchable rather than blocking on a bug in the gate. + logger.warn('[freshness-gate] evaluator threw — proceeding with dispatch', { + projectId: context.project.id, + workItemId: context.workItemId, + error: String(err), + }); + return false; + } + + if (outcome.kind === 'dispatchable') { + return false; + } + + logger.info(`${context.logLabel} freshness gate blocked implementation`, { + projectId: context.project.id, + workItemId: context.workItemId, + outcome: outcome.kind, + evidence: outcome.evidence, + }); + + await postFreshnessSkipNotice( + context.pmProvider, + context.workItemId, + context.agentInput, + outcome, + ); + return true; +} diff --git a/src/triggers/shared/implementation-freshness-gate.ts b/src/triggers/shared/implementation-freshness-gate.ts new file mode 100644 index 000000000..4e09e56b0 --- /dev/null +++ b/src/triggers/shared/implementation-freshness-gate.ts @@ -0,0 +1,571 @@ +/** + * Implementation freshness gate. + * + * Spec: MNG-1053. The PM router intentionally embeds a pre-resolved + * `TriggerResult` for delayed / coalesced PM jobs, so an implementation + * dispatch can fire against a snapshot that was correct when the webhook + * arrived but stale by the time the worker actually starts. The + * router-level work-item lock catches in-flight duplicates of the same + * agent type, but it cannot see: + * + * - a sibling implementation run that already completed (e.g. via the + * post-completion review chain or a manual run), + * - a checklist that an operator finished while the job was sitting + * in the coalesce window, + * - a PR that already exists for this work item. + * + * This gate runs the last-mile check inside the worker execution + * pipeline, just before `persistAgentWorkItemLinks` and + * `prepareForAgent`. It only fires for `implementation` runs with a + * resolved `workItemId`; follow-up agents (review, respond-to-review, + * respond-to-ci, respond-to-pr-comment, …) keep their existing dispatch + * path. + * + * The gate is *fail-closed*: checklist read uncertainty always returns + * `needs_human_reconciliation`, and PR lookup uncertainty does the same + * when a DB/run-linked candidate exists. We would rather stop for human + * reconciliation than let a stale implementation start. + */ + +import { listPRsForWorkItem } from '../../db/repositories/prWorkItemsRepository.js'; +import { + countActiveRuns, + DEFAULT_STALE_RUN_THRESHOLD_MS, + getRunsByWorkItem, +} from '../../db/repositories/runsRepository.js'; +import { githubClient, withGitHubToken } from '../../github/client.js'; +import { getPersonaToken } from '../../github/personas.js'; +import type { PMProvider } from '../../pm/index.js'; +import type { AgentInput, ProjectConfig } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { extractPRNumber } from '../../utils/prUrl.js'; +import { parseRepoFullName } from '../../utils/repo.js'; + +/** + * Terminal-or-near-terminal outcome shapes the gate can emit. The + * pipeline maps `dispatchable` → continue, everything else → stop and + * post a durable PM comment. + */ +export type FreshnessOutcomeKind = + | 'dispatchable' + | 'already_implemented' + | 'active_implementation' + | 'implementation_pr_exists' + | 'needs_human_reconciliation'; + +export interface FreshnessEvidence { + /** Names of completed checklists that pushed the decision. */ + completedChecklists?: string[]; + /** Active running `agent_runs.id` values within the stale-run window. */ + activeRunIds?: string[]; + /** Completed `agent_runs.id` values from recent successful implementations. */ + completedRunIds?: string[]; + /** PR numbers (with state) the gate inspected. */ + pullRequests?: Array<{ + prNumber: number; + prUrl?: string | null; + state?: string; + merged?: boolean; + }>; + /** Optional human description of why uncertainty fell into fail-closed mode. */ + uncertaintyReason?: string; +} + +export interface FreshnessGateOutcome { + kind: FreshnessOutcomeKind; + /** Human-readable summary suitable for PM comment text. */ + message: string; + /** Structured details for logs/tests. */ + evidence: FreshnessEvidence; +} + +export interface FreshnessGateInput { + agentType: string; + workItemId: string | undefined; + project: ProjectConfig; + provider: PMProvider; + /** Window matching the router's stale-run sweep. Defaults to 2h. */ + staleRunWindowMs?: number; +} + +/** + * Names that signal the agent's planned work is done. We deliberately + * keep this list narrow so unrelated checklists (e.g. dependency + * tracking, friction lists) cannot accidentally block reimplementation. + */ +const TERMINAL_CHECKLIST_NAMES = ['implementation steps', 'acceptance criteria'] as const; + +function isTerminalChecklistName(name: string): boolean { + const normalised = name.trim().toLowerCase(); + return TERMINAL_CHECKLIST_NAMES.some((candidate) => normalised.includes(candidate)); +} + +/** + * Live PM checklist read. Returns the named checklists that are + * fully complete + non-empty, or `null` if the provider read failed. + * The pipeline treats `null` as "uncertain" so it can fail closed + * when other ownership evidence exists. + */ +async function readCompletedTerminalChecklists( + provider: PMProvider, + workItemId: string, +): Promise<{ completedNames: string[] } | null> { + try { + const checklists = await provider.getChecklists(workItemId); + const completed: string[] = []; + for (const checklist of checklists) { + if (!isTerminalChecklistName(checklist.name)) continue; + if (checklist.items.length === 0) continue; + if (checklist.items.every((item) => item.complete)) { + completed.push(checklist.name); + } + } + return { completedNames: completed }; + } catch (err) { + logger.warn('[freshness-gate] failed to read live checklists', { + workItemId, + error: String(err), + }); + return null; + } +} + +interface RecentRunSummary { + id: string; + prUrl: string | null; + success: boolean | null; + completedAt: Date | null; +} + +/** Recent completed implementation runs for the work item — newest first. */ +async function readRecentImplementationRuns( + projectId: string, + workItemId: string, + windowMs: number, +): Promise<{ runs: RecentRunSummary[] } | null> { + try { + const rows = await getRunsByWorkItem(projectId, workItemId); + const cutoff = Date.now() - windowMs; + const completed = rows + .filter( + (row) => + row.agentType === 'implementation' && + row.status !== 'running' && + row.completedAt instanceof Date && + row.completedAt.getTime() >= cutoff, + ) + .map((row) => ({ + id: row.id, + prUrl: row.prUrl ?? null, + success: row.success ?? null, + completedAt: row.completedAt ?? null, + })); + return { runs: completed }; + } catch (err) { + logger.warn('[freshness-gate] failed to read recent implementation runs', { + projectId, + workItemId, + error: String(err), + }); + return null; + } +} + +interface ActiveRunsSummary { + count: number; +} + +async function readActiveImplementationRuns( + projectId: string, + workItemId: string, + windowMs: number, +): Promise { + try { + const count = await countActiveRuns({ + projectId, + workItemId, + agentType: 'implementation', + maxAgeMs: windowMs, + }); + return { count }; + } catch (err) { + logger.warn('[freshness-gate] failed to count active implementation runs', { + projectId, + workItemId, + error: String(err), + }); + return null; + } +} + +interface CandidatePR { + prNumber: number; + prUrl: string | null; +} + +async function loadPRRowsAsCandidates( + projectId: string, + workItemId: string, + seen: Map, +): Promise<{ failed: boolean }> { + try { + const prRows = await listPRsForWorkItem(projectId, workItemId); + for (const row of prRows) { + if (typeof row.prNumber !== 'number') continue; + if (seen.has(row.prNumber)) continue; + seen.set(row.prNumber, { prNumber: row.prNumber, prUrl: row.prUrl ?? null }); + } + return { failed: false }; + } catch (err) { + logger.warn('[freshness-gate] failed to list pr_work_items rows', { + projectId, + workItemId, + error: String(err), + }); + return { failed: true }; + } +} + +function addRunPRCandidates(recentRuns: RecentRunSummary[], seen: Map): void { + for (const run of recentRuns) { + if (!run.prUrl) continue; + const prNumber = extractPRNumber(run.prUrl); + if (typeof prNumber !== 'number') continue; + if (seen.has(prNumber)) continue; + seen.set(prNumber, { prNumber, prUrl: run.prUrl }); + } +} + +/** + * Merge PR candidates from `pr_work_items` and recent agent run rows. + * De-duplicated by PR number. + */ +async function collectPRCandidates( + projectId: string, + workItemId: string, + recentRuns: RecentRunSummary[], +): Promise<{ prs: CandidatePR[] } | null> { + const seen = new Map(); + const { failed: prRowsFailed } = await loadPRRowsAsCandidates(projectId, workItemId, seen); + addRunPRCandidates(recentRuns, seen); + + if (prRowsFailed && seen.size === 0) { + // We were unable to read pr_work_items AND have no run-derived + // candidates. Surface uncertainty so the caller can fail closed + // when other ownership evidence exists. + return null; + } + + return { prs: Array.from(seen.values()) }; +} + +interface InspectedPR { + prNumber: number; + prUrl: string | null; + state: string; + merged: boolean; +} + +/** + * Verify each candidate PR via GitHub. The shared execution pipeline has + * callers that do not establish an ambient GitHub scope (manual/retry jobs), + * so this function resolves and scopes the implementation persona token + * itself before touching the scoped singleton. + * + * `errored` collects PRs whose metadata could not be retrieved despite + * being credible candidates. The pipeline fails closed when this list is + * non-empty rather than assuming the linked PR is safe to ignore. + */ +async function inspectPullRequests( + projectId: string, + repo: string, + candidates: CandidatePR[], +): Promise<{ inspected: InspectedPR[]; errored: CandidatePR[] }> { + const inspected: InspectedPR[] = []; + const errored: CandidatePR[] = []; + let owner: string; + let repoName: string; + try { + ({ owner, repo: repoName } = parseRepoFullName(repo)); + } catch (err) { + // Misconfigured repo — every candidate becomes uncertain. + logger.warn('[freshness-gate] invalid repo full name; cannot verify PR state', { + repo, + error: String(err), + }); + return { inspected: [], errored: [...candidates] }; + } + + let githubToken: string; + try { + githubToken = await getPersonaToken(projectId, 'implementation'); + } catch (err) { + logger.warn('[freshness-gate] failed to resolve GitHub token for PR verification', { + projectId, + error: String(err), + }); + return { inspected: [], errored: [...candidates] }; + } + + await withGitHubToken(githubToken, async () => { + for (const candidate of candidates) { + try { + const pr = await githubClient.getPR(owner, repoName, candidate.prNumber); + inspected.push({ + prNumber: candidate.prNumber, + prUrl: pr.htmlUrl ?? candidate.prUrl, + state: pr.state, + merged: pr.merged, + }); + } catch (err) { + errored.push(candidate); + logger.warn('[freshness-gate] failed to load PR metadata', { + prNumber: candidate.prNumber, + error: String(err), + }); + } + } + }); + + return { inspected, errored }; +} + +function formatPRDescriptor(pr: InspectedPR): string { + return pr.prUrl ? `${pr.prUrl} (#${pr.prNumber})` : `PR #${pr.prNumber}`; +} + +interface FreshnessSignals { + checklistsResult: { completedNames: string[] } | null; + completedChecklistNames: string[]; + activeRuns: ActiveRunsSummary | null; + successfulRunsWithPR: RecentRunSummary[]; + successfulRunsWithoutPR: RecentRunSummary[]; + inspectedPRs: InspectedPR[]; + erroredPRs: CandidatePR[]; + openPRs: InspectedPR[]; + mergedPRs: InspectedPR[]; +} + +async function gatherFreshnessSignals( + input: FreshnessGateInput, + workItemId: string, + windowMs: number, +): Promise { + const projectId = input.project.id; + + const checklistsResult = await readCompletedTerminalChecklists(input.provider, workItemId); + const completedChecklistNames = checklistsResult?.completedNames ?? []; + + const activeRuns = await readActiveImplementationRuns(projectId, workItemId, windowMs); + + const recentRunsResult = await readRecentImplementationRuns(projectId, workItemId, windowMs); + const recentRuns = recentRunsResult?.runs ?? []; + const successfulRunsWithPR = recentRuns.filter((r) => r.success === true && !!r.prUrl); + const successfulRunsWithoutPR = recentRuns.filter((r) => r.success === true && !r.prUrl); + + const candidatesResult = await collectPRCandidates(projectId, workItemId, recentRuns); + const candidates = candidatesResult?.prs ?? []; + + let inspectedPRs: InspectedPR[] = []; + let erroredPRs: CandidatePR[] = []; + if (input.project.repo && candidates.length > 0) { + const verification = await inspectPullRequests(projectId, input.project.repo, candidates); + inspectedPRs = verification.inspected; + erroredPRs = verification.errored; + } + + return { + checklistsResult, + completedChecklistNames, + activeRuns, + successfulRunsWithPR, + successfulRunsWithoutPR, + inspectedPRs, + erroredPRs, + openPRs: inspectedPRs.filter((pr) => pr.state === 'open' && !pr.merged), + mergedPRs: inspectedPRs.filter((pr) => pr.merged), + }; +} + +function buildEvidence(signals: FreshnessSignals): FreshnessEvidence { + return { + completedChecklists: + signals.completedChecklistNames.length > 0 ? signals.completedChecklistNames : undefined, + activeRunIds: undefined, + completedRunIds: + signals.successfulRunsWithPR.length > 0 + ? signals.successfulRunsWithPR.map((r) => r.id) + : undefined, + pullRequests: + signals.inspectedPRs.length > 0 + ? signals.inspectedPRs.map((pr) => ({ + prNumber: pr.prNumber, + prUrl: pr.prUrl, + state: pr.state, + merged: pr.merged, + })) + : undefined, + }; +} + +function decideTerminalOutcome( + signals: FreshnessSignals, + evidence: FreshnessEvidence, +): FreshnessGateOutcome | null { + if (signals.mergedPRs.length > 0) { + const descriptor = formatPRDescriptor(signals.mergedPRs[0]); + return { + kind: 'already_implemented', + message: `Implementation not started: already implemented — merged ${descriptor}.`, + evidence, + }; + } + + if (signals.openPRs.length > 0) { + const descriptor = formatPRDescriptor(signals.openPRs[0]); + return { + kind: 'implementation_pr_exists', + message: `Implementation not started: existing PR ${descriptor} is open for this work item.`, + evidence, + }; + } + + if (signals.completedChecklistNames.length > 0) { + const names = signals.completedChecklistNames.map((n) => `"${n}"`).join(', '); + return { + kind: 'already_implemented', + message: `Implementation not started: already implemented — checklist(s) ${names} are fully complete.`, + evidence, + }; + } + + if (signals.activeRuns && signals.activeRuns.count > 0) { + return { + kind: 'active_implementation', + message: + 'Implementation not started: active implementation is already running for this work item.', + evidence, + }; + } + + return null; +} + +function decideFailClosedOutcome( + signals: FreshnessSignals, + evidence: FreshnessEvidence, +): FreshnessGateOutcome | null { + // A successful implementation run without a PR URL is unexpected (an + // implementation should always produce a PR). Treat as needs-reconciliation. + if (signals.successfulRunsWithoutPR.length > 0) { + const augmented: FreshnessEvidence = { + ...evidence, + uncertaintyReason: 'successful_implementation_without_pr', + completedRunIds: signals.successfulRunsWithoutPR.map((r) => r.id), + }; + return { + kind: 'needs_human_reconciliation', + message: + 'Implementation not started: needs human reconciliation — found a recent successful implementation run without a PR URL.', + evidence: augmented, + }; + } + + if (signals.erroredPRs.length > 0) { + return { + kind: 'needs_human_reconciliation', + message: + 'Implementation not started: needs human reconciliation — could not verify the state of an existing PR linked to this work item.', + evidence: { ...evidence, uncertaintyReason: 'pr_lookup_failed' }, + }; + } + + if (!signals.checklistsResult) { + return { + kind: 'needs_human_reconciliation', + message: + 'Implementation not started: needs human reconciliation — could not read live work-item checklists to confirm freshness.', + evidence: { ...evidence, uncertaintyReason: 'checklist_read_failed' }, + }; + } + + return null; +} + +/** + * Decide the freshness outcome. + * + * The contract is intentionally narrow: + * - implementation-only, with a resolved workItemId + * - reload live PM checklists (fail closed when the read is uncertain) + * - count active same-type runs, recent completed implementations + * - inspect linked open / merged PRs through GitHub + */ +export async function evaluateImplementationFreshness( + input: FreshnessGateInput, +): Promise { + if (input.agentType !== 'implementation' || !input.workItemId) { + return { + kind: 'dispatchable', + message: 'Freshness gate skipped: not an implementation run with workItemId', + evidence: {}, + }; + } + + const windowMs = input.staleRunWindowMs ?? DEFAULT_STALE_RUN_THRESHOLD_MS; + const signals = await gatherFreshnessSignals(input, input.workItemId, windowMs); + const evidence = buildEvidence(signals); + + const terminal = decideTerminalOutcome(signals, evidence); + if (terminal) return terminal; + + const failClosed = decideFailClosedOutcome(signals, evidence); + if (failClosed) return failClosed; + + return { + kind: 'dispatchable', + message: 'Freshness gate passed: dispatchable.', + evidence, + }; +} + +/** + * Persist the freshness-gate decision as a PM comment. + * + * Prefer updating the deferred/coalesced "starting" ack comment if the + * agent input carries one; fall back to a fresh comment otherwise. + * Failures are logged but never thrown — a comment-write hiccup must + * not crash the worker on a normal skip. + */ +export async function postFreshnessSkipNotice( + provider: PMProvider, + workItemId: string, + agentInput: AgentInput, + outcome: FreshnessGateOutcome, +): Promise { + const ackCommentId = agentInput.ackCommentId; + const message = outcome.message; + + if (ackCommentId !== undefined && ackCommentId !== null && ackCommentId !== '') { + try { + await provider.updateComment(workItemId, String(ackCommentId), message); + return; + } catch (err) { + logger.warn('[freshness-gate] failed to update ack comment; falling back to addComment', { + workItemId, + ackCommentId: String(ackCommentId), + error: String(err), + }); + } + } + + try { + await provider.addComment(workItemId, message); + } catch (err) { + logger.warn('[freshness-gate] failed to post skip comment on work item', { + workItemId, + outcome: outcome.kind, + error: String(err), + }); + } +} diff --git a/src/triggers/shared/manual-runner.ts b/src/triggers/shared/manual-runner.ts index 508641c4b..7e93b0959 100644 --- a/src/triggers/shared/manual-runner.ts +++ b/src/triggers/shared/manual-runner.ts @@ -1,12 +1,13 @@ -import { runAgent } from '../../agents/registry.js'; +import { isPMFocusedAgent } from '../../agents/definitions/loader.js'; import { isAgentEnabledForProject } from '../../db/repositories/agentConfigsRepository.js'; -import { createWorkItem } from '../../db/repositories/prWorkItemsRepository.js'; import { getRunById } from '../../db/repositories/runsRepository.js'; import { withPMCredentials } from '../../pm/context.js'; import { createPMProvider, pmRegistry, withPMProvider } from '../../pm/index.js'; -import type { AgentInput, CascadeConfig, ProjectConfig } from '../../types/index.js'; +import type { AgentInput, CascadeConfig, ProjectConfig, TriggerResult } from '../../types/index.js'; import { startWatchdog } from '../../utils/lifecycle.js'; import { logger } from '../../utils/logging.js'; +import { runAgentExecutionPipeline } from './agent-execution.js'; +import type { AgentExecutionConfig } from './agent-execution-types.js'; import { formatValidationErrors, validateIntegrations } from './integration-validation.js'; /** @@ -42,6 +43,20 @@ export function clearTriggerTracking(): void { runningTriggers.clear(); } +async function resolveManualExecutionConfig( + input: ManualTriggerInput, +): Promise { + if (!input.prNumber) return undefined; + if (await isPMFocusedAgent(input.agentType)) return undefined; + + return { + skipPrepareForAgent: true, + skipHandleFailure: true, + handleSuccessOnlyForAgentType: 'implementation', + logLabel: 'GitHub manual agent', + }; +} + /** * Input for manual agent triggers. */ @@ -116,7 +131,7 @@ export async function triggerManualRun( markTriggerRunning(triggerKey); - const agentInput: AgentInput & { project: ProjectConfig; config: CascadeConfig } = { + const agentInput: AgentInput = { workItemId: input.workItemId, workItemUrl: input.workItemUrl, workItemTitle: input.workItemTitle, @@ -131,43 +146,36 @@ export async function triggerManualRun( triggerCommentUrl: input.triggerCommentUrl, triggerCommentPath: input.triggerCommentPath, triggerCommentAuthor: input.triggerCommentAuthor, - project, - config, + }; + const triggerResult: TriggerResult = { + agentType: input.agentType, + agentInput, + workItemId: input.workItemId, + workItemUrl: input.workItemUrl, + workItemTitle: input.workItemTitle, + prNumber: input.prNumber, }; try { startWatchdog(project.watchdogTimeoutMs); - if (input.workItemId && (input.workItemUrl || input.workItemTitle)) { - try { - await createWorkItem(project.id, input.workItemId, { - workItemUrl: input.workItemUrl, - workItemTitle: input.workItemTitle, - }); - } catch (err) { - logger.warn('Failed to persist work-item row for manual run', { - projectId: project.id, - workItemId: input.workItemId, - error: String(err), - }); - } - } - const pmProvider = createPMProvider(project); - const result = await withPMCredentials( + const executionConfig = await resolveManualExecutionConfig(input); + await withPMCredentials( project.id, project.pm?.type, (t) => pmRegistry.getOrNull(t), - () => withPMProvider(pmProvider, () => runAgent(input.agentType, agentInput)), + () => + withPMProvider(pmProvider, () => + executionConfig + ? runAgentExecutionPipeline(triggerResult, project, config, executionConfig) + : runAgentExecutionPipeline(triggerResult, project, config), + ), ); - if (result !== undefined) { - logger.info('Manual agent run completed', { - projectId: input.projectId, - agentType: input.agentType, - success: result.success, - runId: result.runId, - }); - } + logger.info('Manual agent run completed', { + projectId: input.projectId, + agentType: input.agentType, + }); } catch (err) { logger.error('Manual agent run failed', { projectId: input.projectId, diff --git a/src/triggers/shared/pm-label.ts b/src/triggers/shared/pm-label.ts index 0d89edf39..cf03ff221 100644 --- a/src/triggers/shared/pm-label.ts +++ b/src/triggers/shared/pm-label.ts @@ -1,6 +1,11 @@ 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, + resolvePMStatusAgentByNameFromWorkflowDefinitions, +} from './pm-status.js'; import { buildPMDispatchResult } from './result-builders.js'; export function resolvePMLabelAgentByList(args: { @@ -33,6 +38,26 @@ 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 resolvePMLabelAgentByStatusNameFromWorkflowDefinitions(args: { + statusName: string; + configuredStatuses: Record; +}): Promise<{ agentType: string; cascadeStatus: string } | undefined> { + return resolvePMStatusAgentByNameFromWorkflowDefinitions({ + statusName: args.statusName, + 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..07e56bef5 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,47 @@ 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 resolvePMStatusAgentByNameFromWorkflowDefinitions(args: { + statusName: string; + configuredStatuses: Record; +}): Promise { + return resolvePMStatusAgentFromWorkflowDefinitions({ + incomingStatus: args.statusName, + configuredStatuses: args.configuredStatuses, + matcher: caseInsensitiveStatusMatcher, + }); +} + export function buildPMStatusCoalesceKey(projectId: string, workItemId: string): string { return `${projectId}:${workItemId}`; } diff --git a/src/triggers/trello/label-added.ts b/src/triggers/trello/label-added.ts index 4ff5911f0..5511f85ec 100644 --- a/src/triggers/trello/label-added.ts +++ b/src/triggers/trello/label-added.ts @@ -1,7 +1,10 @@ import { getTrelloConfig } from '../../pm/config.js'; import { trelloClient } from '../../trello/client.js'; import { logger } from '../../utils/logging.js'; -import { buildPMLabelDispatchResult, resolvePMLabelAgentByList } from '../shared/pm-label.js'; +import { + buildPMLabelDispatchResult, + resolvePMLabelAgentByStatusIdFromWorkflowDefinitions, +} from '../shared/pm-label.js'; import { checkTriggerEnabled } from '../shared/trigger-check.js'; import type { TrelloWebhookPayload, @@ -43,18 +46,30 @@ export class ReadyToProcessLabelTrigger implements TriggerHandler { logger.info('Determining agent type from list', { cardId, currentListId }); - // Determine agent type based on current list + // Resolve agent type by looking up the current list ID against the + // configured lists and workflow status definitions. This covers both + // built-in (splitting/planning/todo/backlog) and custom mapped lists + // without per-status branching. const lists = getTrelloConfig(ctx.project)?.lists ?? {}; - const agentType = resolvePMLabelAgentByList({ currentListId, lists }); - if (!agentType) { + const resolved = await resolvePMLabelAgentByStatusIdFromWorkflowDefinitions({ + statusId: currentListId, + configuredStatuses: lists, + }); + if (!resolved) { logger.info('Card not in a trigger-eligible list, skipping ready-to-process label', { currentListId, lists, }); return null; } + const { agentType, cascadeStatus: matchedCascadeStatus } = resolved; - logger.info('Agent type determined', { agentType, cardId, listId: currentListId }); + logger.info('Agent type determined', { + agentType, + cardId, + listId: currentListId, + cascadeStatus: matchedCascadeStatus, + }); // Check per-agent ready-to-process toggle via new DB-driven system if (!(await checkTriggerEnabled(ctx.project.id, agentType, 'pm:label-added', this.name))) { diff --git a/src/triggers/trello/register.ts b/src/triggers/trello/register.ts index 938d7cd48..31e88fdb0 100644 --- a/src/triggers/trello/register.ts +++ b/src/triggers/trello/register.ts @@ -13,6 +13,7 @@ import type { TriggerRegistry } from '../registry.js'; import { TrelloCommentMentionTrigger } from './comment-mention.js'; import { ReadyToProcessLabelTrigger } from './label-added.js'; import { + TrelloCustomStatusChangedTrigger, TrelloStatusChangedBacklogTrigger, TrelloStatusChangedMergedTrigger, TrelloStatusChangedPlanningTrigger, @@ -25,6 +26,10 @@ import { * * Order matters: TrelloCommentMentionTrigger must be registered before * status-changed triggers so it gets first crack at comment events. + * TrelloCustomStatusChangedTrigger is registered after the built-in + * per-list triggers (so they get first crack at their hardcoded lists) + * and before the ready-label trigger (so status changes are handled + * before label-driven dispatch). */ export function registerTrelloTriggers(registry: TriggerRegistry): void { // Must be registered before status-changed triggers @@ -35,6 +40,7 @@ export function registerTrelloTriggers(registry: TriggerRegistry): void { registry.register(TrelloStatusChangedTodoTrigger); registry.register(TrelloStatusChangedBacklogTrigger); registry.register(TrelloStatusChangedMergedTrigger); + registry.register(new TrelloCustomStatusChangedTrigger()); registry.register(new ReadyToProcessLabelTrigger()); } diff --git a/src/triggers/trello/status-changed.ts b/src/triggers/trello/status-changed.ts index ea29d516c..e0f6ccff1 100644 --- a/src/triggers/trello/status-changed.ts +++ b/src/triggers/trello/status-changed.ts @@ -1,8 +1,13 @@ import { getTrelloConfig } from '../../pm/config.js'; import { invalidateSnapshot } from '../../router/snapshot-manager.js'; import { logger } from '../../utils/logging.js'; +import { BUILTIN_WORKFLOW_STATUS_KEYS } from '../../workflow/statusDefinitions.js'; import { shouldBlockForPipelineCapacity } from '../shared/pipeline-capacity-gate.js'; -import { buildPMStatusDispatchResult, shouldFirePMStatusEvent } from '../shared/pm-status.js'; +import { + buildPMStatusDispatchResult, + resolvePMStatusAgentByIdFromWorkflowDefinitions, + shouldFirePMStatusEvent, +} from '../shared/pm-status.js'; import { checkTriggerEnabledWithParams } from '../shared/trigger-check.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../types.js'; import { isTrelloWebhookPayload, type TrelloWebhookPayload } from './types.js'; @@ -155,3 +160,153 @@ export const TrelloStatusChangedMergedTrigger = createStatusChangedTrigger({ agentType: 'backlog-manager', invalidateSnapshotOnMove: true, }); + +// ============================================================================ +// Custom Status Changed Trigger (Trello) +// +// Companion to the built-in per-list triggers above. Matches createCard / +// updateCard events whose destination list ID is configured under a CUSTOM +// (non-built-in) workflow status key. Built-in keys (backlog/splitting/ +// planning/todo/inProgress/inReview/done/merged/alerts/friction) are +// excluded — the per-list triggers above already handle the ones that +// dispatch, and the others have a null agentType so dispatch would be a +// no-op anyway. +// +// Custom statuses are resolved through `resolveWorkflowStatusDefinition`, +// matching the JIRA / Linear pattern (MNG-1066). The trigger preserves the +// existing enablement, `onCreate` / `onMove` gating, capacity gate, coalesce +// key, URL/title extraction, and logging conventions of the built-in +// triggers. +// ============================================================================ + +function findCascadeStatusKeyForListId( + listId: string, + lists: Record, +): string | undefined { + for (const [cascadeStatus, configuredListId] of Object.entries(lists)) { + if (configuredListId === listId) return cascadeStatus; + } + return undefined; +} + +function resolveDestinationListId(payload: TrelloWebhookPayload): string | undefined { + if (payload.action.type === 'createCard') return payload.action.data.list?.id; + if (payload.action.type === 'updateCard') return payload.action.data.listAfter?.id; + return undefined; +} + +function isDestinationListChange(payload: TrelloWebhookPayload, destListId: string): boolean { + if (payload.action.type === 'createCard') return true; + if (payload.action.type === 'updateCard') { + return payload.action.data.listBefore?.id !== destListId; + } + return false; +} + +export class TrelloCustomStatusChangedTrigger implements TriggerHandler { + name = 'trello-status-changed-custom'; + description = + 'Triggers custom agent when a card is moved or created in a list mapped to a custom workflow status'; + + matches(ctx: TriggerContext): boolean { + if (ctx.source !== 'trello') return false; + if (!isTrelloWebhookPayload(ctx.payload)) return false; + + const trelloConfig = getTrelloConfig(ctx.project); + if (!trelloConfig?.lists) return false; + + const payload = ctx.payload; + const destListId = resolveDestinationListId(payload); + if (!destListId) return false; + + // Only handle real destination changes (createCard, or updateCard with a + // different listBefore). + if (!isDestinationListChange(payload, destListId)) return false; + + const cascadeStatus = findCascadeStatusKeyForListId(destListId, trelloConfig.lists); + if (!cascadeStatus) return false; + + // Built-in cascade status keys are handled by the per-list triggers + // above (or have no agentType). Only claim custom keys here. + return !BUILTIN_WORKFLOW_STATUS_KEYS.has(cascadeStatus); + } + + async handle(ctx: TriggerContext): Promise { + const payload = ctx.payload as TrelloWebhookPayload; + const trelloConfig = getTrelloConfig(ctx.project); + if (!trelloConfig?.lists) return null; + + const destListId = resolveDestinationListId(payload); + if (!destListId) return null; + + const resolved = await resolvePMStatusAgentByIdFromWorkflowDefinitions({ + statusId: destListId, + configuredStatuses: trelloConfig.lists, + }); + if (!resolved) { + logger.debug('Trello custom status-change does not map to any agent', { + trigger: this.name, + destListId, + configuredLists: trelloConfig.lists, + }); + return null; + } + const { agentType, cascadeStatus: matchedCascadeStatus } = resolved; + + const { enabled, parameters } = await checkTriggerEnabledWithParams( + ctx.project.id, + agentType, + 'pm:status-changed', + this.name, + ); + if (!enabled) return null; + + const isCreate = payload.action.type === 'createCard'; + if (!shouldFirePMStatusEvent(isCreate, parameters)) { + logger.debug('Trello custom status-changed event gated by trigger params', { + trigger: this.name, + eventKind: isCreate ? 'create' : 'move', + parameters, + }); + return null; + } + + const cardId = payload.action.data.card?.id; + if (!cardId) { + logger.warn('No card ID in Trello custom status-changed payload', { trigger: this.name }); + return null; + } + + if ( + await shouldBlockForPipelineCapacity({ + project: ctx.project, + agentType, + workItemId: cardId, + source: 'trello', + }) + ) { + return null; + } + + const cardShortLink = payload.action.data.card?.shortLink; + const cardName = payload.action.data.card?.name; + const workItemUrl = cardShortLink ? `https://trello.com/c/${cardShortLink}` : undefined; + const workItemTitle = cardName ?? undefined; + + logger.info('Trello card entered custom agent-triggering list', { + cardId, + destListId, + eventKind: isCreate ? 'create' : 'move', + cascadeStatus: matchedCascadeStatus, + agentType, + }); + + return buildPMStatusDispatchResult({ + projectId: ctx.project.id, + agentType, + workItemId: cardId, + workItemUrl, + workItemTitle, + }); + } +} diff --git a/src/workflow/statusDefinitions.ts b/src/workflow/statusDefinitions.ts new file mode 100644 index 000000000..dfb5d85eb --- /dev/null +++ b/src/workflow/statusDefinitions.ts @@ -0,0 +1,74 @@ +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 }, + { key: 'friction', label: 'Friction', agentType: null, sortOrder: 100, 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..56a31da3c --- /dev/null +++ b/tests/integration/db/workflowStatusDefinitionsRepository.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + clearAgentTypeReferences, + 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('clears matching agent type references without touching other statuses', async () => { + await createCustomWorkflowStatusDefinition({ + key: 'story', + label: 'Story', + agentType: 'story-agent', + }); + await createCustomWorkflowStatusDefinition({ + key: 'prd', + label: 'PRD', + agentType: 'story-agent', + }); + await createCustomWorkflowStatusDefinition({ + key: 'qa', + label: 'QA', + agentType: null, + }); + await createCustomWorkflowStatusDefinition({ + key: 'plan', + label: 'Plan', + agentType: 'plan-agent', + }); + + await expect(clearAgentTypeReferences('story-agent')).resolves.toBe(2); + + await expect(getCustomWorkflowStatusDefinition('story')).resolves.toMatchObject({ + agentType: null, + }); + await expect(getCustomWorkflowStatusDefinition('prd')).resolves.toMatchObject({ + agentType: null, + }); + await expect(getCustomWorkflowStatusDefinition('qa')).resolves.toMatchObject({ + agentType: null, + }); + await expect(getCustomWorkflowStatusDefinition('plan')).resolves.toMatchObject({ + agentType: 'plan-agent', + }); + await expect(clearAgentTypeReferences('story-agent')).resolves.toBe(0); + }); + + 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/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..91b8a3adb 100644 --- a/tests/unit/api/routers/agentDefinitions.test.ts +++ b/tests/unit/api/routers/agentDefinitions.test.ts @@ -18,9 +18,12 @@ const { mockGetAgentDefinitionMetadata, mockUpsertAgentDefinition, mockDeleteAgentDefinition, + mockClearAgentTypeReferences, mockGetRawTemplate, mockValidateTemplate, mockLoadPartials, + mockLoggerInfo, + mockListWorkflowStatusDefinitions, } = vi.hoisted(() => ({ mockGetBuiltinAgentTypes: vi.fn<() => string[]>(), mockIsBuiltinAgentType: vi.fn<(agentType: string) => boolean>(), @@ -32,9 +35,12 @@ const { mockGetAgentDefinitionMetadata: vi.fn(), mockUpsertAgentDefinition: vi.fn(), mockDeleteAgentDefinition: vi.fn(), + mockClearAgentTypeReferences: vi.fn(), mockGetRawTemplate: vi.fn<(agentType: string) => string>(), mockValidateTemplate: vi.fn(), mockLoadPartials: vi.fn(), + mockLoggerInfo: vi.fn(), + mockListWorkflowStatusDefinitions: vi.fn(), })); vi.mock('../../../../src/agents/definitions/loader.js', () => ({ @@ -53,6 +59,10 @@ vi.mock('../../../../src/db/repositories/agentDefinitionsRepository.js', () => ( deleteAgentDefinition: mockDeleteAgentDefinition, })); +vi.mock('../../../../src/db/repositories/workflowStatusDefinitionsRepository.js', () => ({ + clearAgentTypeReferences: mockClearAgentTypeReferences, +})); + vi.mock('../../../../src/agents/prompts/index.js', () => ({ getRawTemplate: mockGetRawTemplate, validateTemplate: mockValidateTemplate, @@ -62,6 +72,19 @@ vi.mock('../../../../src/db/repositories/partialsRepository.js', () => ({ loadPartials: mockLoadPartials, })); +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { + info: mockLoggerInfo, + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../../../../src/workflow/statusDefinitions.js', () => ({ + listWorkflowStatusDefinitions: mockListWorkflowStatusDefinitions, +})); + // Re-export schema values (these are pure constants, not functions to mock) vi.mock('../../../../src/agents/definitions/schema.js', async (importOriginal) => { const original = (await importOriginal()) as Record; @@ -118,6 +141,8 @@ describe('agentDefinitionsRouter', () => { ); mockValidateTemplate.mockReturnValue({ valid: true }); mockLoadPartials.mockResolvedValue(new Map()); + mockClearAgentTypeReferences.mockResolvedValue(0); + mockListWorkflowStatusDefinitions.mockResolvedValue([]); }); // ===================================================================== @@ -270,6 +295,28 @@ describe('agentDefinitionsRouter', () => { expect(mockUpsertAgentDefinition).toHaveBeenCalledWith('implementation', def, true); }); + it('rejects creating a builtin override without status-changed support while referenced by a workflow status', async () => { + mockGetAgentDefinitionMetadata.mockResolvedValue(null); + mockListWorkflowStatusDefinitions.mockResolvedValue([ + { + key: 'todo', + label: 'Todo', + agentType: 'implementation', + sortOrder: 40, + isBuiltin: true, + }, + ]); + const def = createMockDefinition({ triggers: [] }); + + const caller = createCaller({ user: mockSuperAdmin, effectiveOrgId: mockSuperAdmin.orgId }); + await expectTRPCError( + caller.create({ agentType: 'implementation', definition: def }), + 'BAD_REQUEST', + ); + + expect(mockUpsertAgentDefinition).not.toHaveBeenCalled(); + }); + it('throws CONFLICT when agent type already exists', async () => { mockGetAgentDefinitionMetadata.mockResolvedValue({ agentType: 'existing', isBuiltin: false }); const def = createMockDefinition(); @@ -319,6 +366,21 @@ describe('agentDefinitionsRouter', () => { expect(mockInvalidateDefinitionCache).toHaveBeenCalled(); }); + it('does not query workflow statuses when updating fields other than triggers', async () => { + const current = createMockDefinition(); + mockResolveAgentDefinition.mockResolvedValue(current); + mockUpsertAgentDefinition.mockResolvedValue(undefined); + mockListWorkflowStatusDefinitions.mockClear(); + + const caller = createCaller({ user: mockSuperAdmin, effectiveOrgId: mockSuperAdmin.orgId }); + await caller.update({ + agentType: 'implementation', + patch: { hint: 'updated hint' }, + }); + + expect(mockListWorkflowStatusDefinitions).not.toHaveBeenCalled(); + }); + it('throws NOT_FOUND when definition does not exist', async () => { mockResolveAgentDefinition.mockRejectedValue(new Error('not found')); @@ -328,6 +390,38 @@ describe('agentDefinitionsRouter', () => { ).rejects.toMatchObject({ code: 'NOT_FOUND' }); }); + it('rejects removing status-changed support while referenced by a workflow status', async () => { + mockResolveAgentDefinition.mockResolvedValue( + createMockDefinition({ + triggers: [ + { + event: 'pm:status-changed', + label: 'Status Changed', + defaultEnabled: false, + parameters: [], + }, + ], + }), + ); + mockListWorkflowStatusDefinitions.mockResolvedValue([ + { + key: 'story', + label: 'Story', + agentType: 'story-agent', + sortOrder: 1000, + isBuiltin: false, + }, + ]); + + const caller = createCaller({ user: mockSuperAdmin, effectiveOrgId: mockSuperAdmin.orgId }); + await expectTRPCError( + caller.update({ agentType: 'story-agent', patch: { triggers: [] } }), + 'BAD_REQUEST', + ); + + expect(mockUpsertAgentDefinition).not.toHaveBeenCalled(); + }); + it('throws FORBIDDEN when non-superadmin tries to update', async () => { const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); await expect( @@ -360,9 +454,33 @@ describe('agentDefinitionsRouter', () => { expect(result).toEqual({ agentType: 'custom-agent' }); expect(mockDeleteAgentDefinition).toHaveBeenCalledWith('custom-agent'); + expect(mockClearAgentTypeReferences).toHaveBeenCalledWith('custom-agent'); expect(mockInvalidateDefinitionCache).toHaveBeenCalled(); }); + it('clears workflow status references when deleting a referenced custom agent definition', async () => { + mockGetAgentDefinitionMetadata.mockResolvedValue({ + agentType: 'story-agent', + isBuiltin: false, + }); + mockDeleteAgentDefinition.mockResolvedValue(undefined); + mockClearAgentTypeReferences.mockResolvedValue(2); + + const caller = createCaller({ user: mockSuperAdmin, effectiveOrgId: mockSuperAdmin.orgId }); + const result = await caller.delete({ agentType: 'story-agent' }); + + expect(result).toEqual({ agentType: 'story-agent' }); + expect(mockDeleteAgentDefinition).toHaveBeenCalledWith('story-agent'); + expect(mockClearAgentTypeReferences).toHaveBeenCalledWith('story-agent'); + expect(mockLoggerInfo).toHaveBeenCalledWith( + 'Cleared workflow status agent references after agent definition delete', + { + agentType: 'story-agent', + clearedWorkflowStatuses: 2, + }, + ); + }); + it('deletes an invalid DB-only definition without parsing it', async () => { mockGetAgentDefinitionMetadata.mockResolvedValue({ agentType: 'email-joke', @@ -375,6 +493,7 @@ describe('agentDefinitionsRouter', () => { expect(result).toEqual({ agentType: 'email-joke' }); expect(mockDeleteAgentDefinition).toHaveBeenCalledWith('email-joke'); + expect(mockClearAgentTypeReferences).toHaveBeenCalledWith('email-joke'); }); it('throws NOT_FOUND when definition not in DB', async () => { @@ -437,6 +556,25 @@ describe('agentDefinitionsRouter', () => { }); }); + it('rejects resetting a referenced builtin to YAML defaults without status-changed support', async () => { + mockLoadBuiltinDefinition.mockReturnValue(createMockDefinition({ triggers: [] })); + mockListWorkflowStatusDefinitions.mockResolvedValue([ + { + key: 'review-ready', + label: 'Review Ready', + agentType: 'review', + sortOrder: 1000, + isBuiltin: false, + }, + ]); + mockUpsertAgentDefinition.mockClear(); + + const caller = createCaller({ user: mockSuperAdmin, effectiveOrgId: mockSuperAdmin.orgId }); + await expectTRPCError(caller.reset({ agentType: 'review' }), 'BAD_REQUEST'); + + expect(mockUpsertAgentDefinition).not.toHaveBeenCalled(); + }); + it('throws FORBIDDEN when non-superadmin tries to reset', async () => { const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); await expect(caller.reset({ agentType: 'implementation' })).rejects.toMatchObject({ @@ -671,6 +809,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/tests/unit/api/routers/workflowStatuses.test.ts b/tests/unit/api/routers/workflowStatuses.test.ts new file mode 100644 index 000000000..bbcf86960 --- /dev/null +++ b/tests/unit/api/routers/workflowStatuses.test.ts @@ -0,0 +1,294 @@ +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: [ + { + event: 'pm:status-changed', + label: 'Status Changed', + defaultEnabled: false, + parameters: [], + }, + ], + strategies: {}, + hint: 'Write a PRD.', + prompts: { taskPrompt: 'Write a PRD for <%= it.workItemId %>.' }, + requiredContext: [], + }; +} + +function mockAgentDefinitionWithoutStatusTrigger(): AgentDefinition { + return { + ...mockAgentDefinition(), + triggers: [], + }; +} + +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('creates a custom workflow status without a dispatch agent', async () => { + mockCreateCustomWorkflowStatusDefinition.mockResolvedValue({ + id: 1, + key: 'qa', + label: 'QA', + agentType: null, + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }); + + const caller = createCaller({ user: superAdmin, effectiveOrgId: superAdmin.orgId }); + await caller.create({ key: 'qa', label: 'QA', agentType: '' }); + + expect(mockResolveAgentDefinition).not.toHaveBeenCalled(); + expect(mockCreateCustomWorkflowStatusDefinition).toHaveBeenCalledWith({ + key: 'qa', + label: 'QA', + agentType: null, + }); + }); + + 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 duplicate custom status keys', async () => { + mockGetCustomWorkflowStatusDefinition.mockResolvedValue({ + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }); + const caller = createCaller({ user: superAdmin, effectiveOrgId: superAdmin.orgId }); + + await expectTRPCError(caller.create({ key: 'prd', label: 'PRD' }), 'CONFLICT'); + expect(mockCreateCustomWorkflowStatusDefinition).not.toHaveBeenCalled(); + }); + + 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 agents that do not support status-changed dispatch on create', async () => { + mockResolveAgentDefinition.mockResolvedValue(mockAgentDefinitionWithoutStatusTrigger()); + const caller = createCaller({ user: superAdmin, effectiveOrgId: superAdmin.orgId }); + + await expectTRPCError( + caller.create({ key: 'prd', label: 'PRD', agentType: 'prd' }), + 'BAD_REQUEST', + ); + expect(mockCreateCustomWorkflowStatusDefinition).not.toHaveBeenCalled(); + }); + + 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'); + expect(mockUpdateCustomWorkflowStatusDefinition).toHaveBeenCalledWith('prd', { + label: 'Product Requirements', + agentType: null, + sortOrder: 1010, + }); + }); + + it('updates only provided custom workflow status fields', async () => { + mockUpdateCustomWorkflowStatusDefinition.mockResolvedValue({ + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd-v2', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }); + const caller = createCaller({ user: superAdmin, effectiveOrgId: superAdmin.orgId }); + + await caller.update({ key: 'prd', agentType: 'prd-v2' }); + + expect(mockUpdateCustomWorkflowStatusDefinition).toHaveBeenCalledWith('prd', { + agentType: 'prd-v2', + }); + }); + + it('rejects agents that do not support status-changed dispatch on update', async () => { + mockResolveAgentDefinition.mockResolvedValue(mockAgentDefinitionWithoutStatusTrigger()); + const caller = createCaller({ user: superAdmin, effectiveOrgId: superAdmin.orgId }); + + await expectTRPCError(caller.update({ key: 'prd', agentType: 'debug' }), 'BAD_REQUEST'); + expect(mockUpdateCustomWorkflowStatusDefinition).not.toHaveBeenCalled(); + }); + + it('rejects builtin workflow status updates', async () => { + const caller = createCaller({ user: superAdmin, effectiveOrgId: superAdmin.orgId }); + + await expectTRPCError(caller.update({ key: 'todo', label: 'Todo' }), 'FORBIDDEN'); + expect(mockUpdateCustomWorkflowStatusDefinition).not.toHaveBeenCalled(); + }); + + it('returns not found when updating a missing custom status', async () => { + mockUpdateCustomWorkflowStatusDefinition.mockResolvedValue(null); + const caller = createCaller({ user: superAdmin, effectiveOrgId: superAdmin.orgId }); + + await expectTRPCError(caller.update({ key: 'prd', label: 'PRD' }), 'NOT_FOUND'); + }); + + 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' }); + }); + + it('rejects builtin workflow status deletes', async () => { + const caller = createCaller({ user: superAdmin, effectiveOrgId: superAdmin.orgId }); + + await expectTRPCError(caller.delete({ key: 'todo' }), 'FORBIDDEN'); + expect(mockDeleteCustomWorkflowStatusDefinition).not.toHaveBeenCalled(); + }); + + it('returns not found when deleting a missing custom status', async () => { + mockDeleteCustomWorkflowStatusDefinition.mockResolvedValue(false); + const caller = createCaller({ user: superAdmin, effectiveOrgId: superAdmin.orgId }); + + await expectTRPCError(caller.delete({ key: 'prd' }), 'NOT_FOUND'); + }); +}); diff --git a/tests/unit/cli/dashboard/workflow-statuses/workflow-statuses.test.ts b/tests/unit/cli/dashboard/workflow-statuses/workflow-statuses.test.ts new file mode 100644 index 000000000..11b3e868a --- /dev/null +++ b/tests/unit/cli/dashboard/workflow-statuses/workflow-statuses.test.ts @@ -0,0 +1,292 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockLoadConfig = vi.fn(); +const mockCreateDashboardClient = vi.fn(); + +vi.mock('../../../../../src/cli/dashboard/_shared/config.js', () => ({ + loadConfig: (...args: unknown[]) => mockLoadConfig(...args), +})); + +vi.mock('../../../../../src/cli/dashboard/_shared/client.js', () => ({ + createDashboardClient: (...args: unknown[]) => mockCreateDashboardClient(...args), +})); + +vi.mock('chalk', () => ({ + default: { + bold: (s: string) => s, + blue: (s: string) => s, + green: (s: string) => s, + red: (s: string) => s, + yellow: (s: string) => s, + dim: (s: string) => s, + }, +})); + +import WorkflowStatusesCreate from '../../../../../src/cli/dashboard/workflow-statuses/create.js'; +import WorkflowStatusesDelete from '../../../../../src/cli/dashboard/workflow-statuses/delete.js'; +import WorkflowStatusesList from '../../../../../src/cli/dashboard/workflow-statuses/list.js'; +import WorkflowStatusesUpdate from '../../../../../src/cli/dashboard/workflow-statuses/update.js'; + +const oclifConfig = { + runHook: vi.fn().mockResolvedValue({ successes: [], failures: [] }), +}; + +const baseConfig = { serverUrl: 'http://localhost:3000', sessionToken: 'tok' }; + +const statusRow = { + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + isBuiltin: false, +}; + +function makeClient(overrides: Record = {}) { + return { + workflowStatuses: { + list: { query: vi.fn().mockResolvedValue([statusRow]) }, + create: { mutate: vi.fn().mockResolvedValue(statusRow) }, + update: { mutate: vi.fn().mockResolvedValue(statusRow) }, + delete: { mutate: vi.fn().mockResolvedValue({ key: 'prd' }) }, + }, + ...overrides, + }; +} + +describe('workflow-statuses CLI', () => { + beforeEach(() => { + vi.resetAllMocks(); + oclifConfig.runHook.mockResolvedValue({ successes: [], failures: [] }); + mockLoadConfig.mockReturnValue(baseConfig); + }); + + it('lists workflow statuses', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WorkflowStatusesList([], oclifConfig as never); + await cmd.run(); + + expect(client.workflowStatuses.list.query).toHaveBeenCalledWith(); + }); + + it('outputs JSON when listing with --json', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WorkflowStatusesList(['--json'], oclifConfig as never); + await cmd.run(); + + expect(client.workflowStatuses.list.query).toHaveBeenCalledWith(); + }); + + it('creates a workflow status with a dispatch agent', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WorkflowStatusesCreate( + ['--key', 'prd', '--label', 'PRD', '--agent-type', 'prd', '--sort-order', '1000'], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.workflowStatuses.create.mutate).toHaveBeenCalledWith({ + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + }); + }); + + it('creates a workflow status with no dispatch agent', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WorkflowStatusesCreate(['--key', 'qa', '--label', 'QA'], oclifConfig as never); + await cmd.run(); + + expect(client.workflowStatuses.create.mutate).toHaveBeenCalledWith({ + key: 'qa', + label: 'QA', + agentType: undefined, + sortOrder: undefined, + }); + }); + + it('outputs JSON when creating with --json', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WorkflowStatusesCreate( + ['--key', 'prd', '--label', 'PRD', '--agent-type', 'prd', '--json'], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.workflowStatuses.create.mutate).toHaveBeenCalledWith({ + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: undefined, + }); + }); + + it('surfaces create errors', async () => { + const client = makeClient({ + workflowStatuses: { + list: { query: vi.fn().mockResolvedValue([statusRow]) }, + create: { mutate: vi.fn().mockRejectedValue(new Error('duplicate key')) }, + update: { mutate: vi.fn().mockResolvedValue(statusRow) }, + delete: { mutate: vi.fn().mockResolvedValue({ key: 'prd' }) }, + }, + }); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WorkflowStatusesCreate( + ['--key', 'prd', '--label', 'PRD'], + oclifConfig as never, + ); + await expect(cmd.run()).rejects.toThrow('duplicate key'); + }); + + it('updates a workflow status', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WorkflowStatusesUpdate( + ['prd', '--label', 'Product Requirements', '--agent-type', 'prd-v2'], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.workflowStatuses.update.mutate).toHaveBeenCalledWith({ + key: 'prd', + label: 'Product Requirements', + agentType: 'prd-v2', + sortOrder: undefined, + }); + }); + + it('updates only sort order', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WorkflowStatusesUpdate(['prd', '--sort-order', '1100'], oclifConfig as never); + await cmd.run(); + + expect(client.workflowStatuses.update.mutate).toHaveBeenCalledWith({ + key: 'prd', + label: undefined, + agentType: undefined, + sortOrder: 1100, + }); + }); + + it('clears a workflow status dispatch agent', async () => { + const client = makeClient({ + workflowStatuses: { + list: { query: vi.fn().mockResolvedValue([statusRow]) }, + create: { mutate: vi.fn().mockResolvedValue(statusRow) }, + update: { mutate: vi.fn().mockResolvedValue({ ...statusRow, agentType: null }) }, + delete: { mutate: vi.fn().mockResolvedValue({ key: 'prd' }) }, + }, + }); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WorkflowStatusesUpdate(['prd', '--no-agent'], oclifConfig as never); + await cmd.run(); + + expect(client.workflowStatuses.update.mutate).toHaveBeenCalledWith({ + key: 'prd', + label: undefined, + agentType: null, + sortOrder: undefined, + }); + }); + + it('outputs JSON when updating with --json', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WorkflowStatusesUpdate( + ['prd', '--label', 'Product Requirements', '--json'], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.workflowStatuses.update.mutate).toHaveBeenCalledWith({ + key: 'prd', + label: 'Product Requirements', + agentType: undefined, + sortOrder: undefined, + }); + }); + + it('surfaces update errors', async () => { + const client = makeClient({ + workflowStatuses: { + list: { query: vi.fn().mockResolvedValue([statusRow]) }, + create: { mutate: vi.fn().mockResolvedValue(statusRow) }, + update: { mutate: vi.fn().mockRejectedValue(new Error('missing status')) }, + delete: { mutate: vi.fn().mockResolvedValue({ key: 'prd' }) }, + }, + }); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WorkflowStatusesUpdate(['prd', '--label', 'PRD'], oclifConfig as never); + await expect(cmd.run()).rejects.toThrow('missing status'); + }); + + it('rejects update without changes', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WorkflowStatusesUpdate(['prd'], oclifConfig as never); + await expect(cmd.run()).rejects.toThrow(); + expect(client.workflowStatuses.update.mutate).not.toHaveBeenCalled(); + }); + + it('deletes a workflow status when confirmed', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WorkflowStatusesDelete(['prd', '--yes'], oclifConfig as never); + await cmd.run(); + + expect(client.workflowStatuses.delete.mutate).toHaveBeenCalledWith({ key: 'prd' }); + }); + + it('outputs JSON when deleting with --json', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WorkflowStatusesDelete(['prd', '--yes', '--json'], oclifConfig as never); + await cmd.run(); + + expect(client.workflowStatuses.delete.mutate).toHaveBeenCalledWith({ key: 'prd' }); + }); + + it('surfaces delete errors', async () => { + const client = makeClient({ + workflowStatuses: { + list: { query: vi.fn().mockResolvedValue([statusRow]) }, + create: { mutate: vi.fn().mockResolvedValue(statusRow) }, + update: { mutate: vi.fn().mockResolvedValue(statusRow) }, + delete: { mutate: vi.fn().mockRejectedValue(new Error('missing status')) }, + }, + }); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WorkflowStatusesDelete(['prd', '--yes'], oclifConfig as never); + await expect(cmd.run()).rejects.toThrow('missing status'); + }); + + it('requires --yes for delete', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new WorkflowStatusesDelete(['prd'], oclifConfig as never); + await expect(cmd.run()).rejects.toThrow(); + expect(client.workflowStatuses.delete.mutate).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/db/repositories/workflowStatusDefinitionsRepository.test.ts b/tests/unit/db/repositories/workflowStatusDefinitionsRepository.test.ts new file mode 100644 index 000000000..b6eb82b15 --- /dev/null +++ b/tests/unit/db/repositories/workflowStatusDefinitionsRepository.test.ts @@ -0,0 +1,196 @@ +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 { + clearAgentTypeReferences, + 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('returns null when custom workflow status is missing', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + await expect(getCustomWorkflowStatusDefinition('missing')).resolves.toBeNull(); + }); + + 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('creates a custom workflow status with default agent and sort order', async () => { + mockDb.chain.returning.mockResolvedValueOnce([ + { + ...dbRow, + agentType: null, + sortOrder: 1000, + }, + ]); + + const result = await createCustomWorkflowStatusDefinition({ + key: 'qa', + label: 'QA', + }); + + expect(result.agentType).toBeNull(); + expect(mockDb.chain.values).toHaveBeenCalledWith({ + statusKey: 'qa', + label: 'QA', + agentType: null, + 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('updates only the provided custom workflow status fields', async () => { + mockDb.chain.where.mockReturnValueOnce({ returning: mockDb.chain.returning }); + mockDb.chain.returning.mockResolvedValueOnce([{ ...dbRow, sortOrder: 1100 }]); + + const result = await updateCustomWorkflowStatusDefinition('prd', { sortOrder: 1100 }); + + expect(result?.sortOrder).toBe(1100); + expect(mockDb.chain.set).toHaveBeenCalledWith( + expect.objectContaining({ + sortOrder: 1100, + updatedAt: expect.any(Date), + }), + ); + }); + + it('returns null when updating a missing custom workflow status', async () => { + mockDb.chain.where.mockReturnValueOnce({ returning: mockDb.chain.returning }); + mockDb.chain.returning.mockResolvedValueOnce([]); + + await expect( + updateCustomWorkflowStatusDefinition('missing', { label: 'Missing' }), + ).resolves.toBeNull(); + }); + + it('deletes a custom workflow status', async () => { + mockDb.chain.where.mockResolvedValueOnce({ rowCount: 1 }); + + await expect(deleteCustomWorkflowStatusDefinition('prd')).resolves.toBe(true); + }); + + it('returns false when deleting a missing custom workflow status', async () => { + mockDb.chain.where.mockResolvedValueOnce({ rowCount: 0 }); + + await expect(deleteCustomWorkflowStatusDefinition('missing')).resolves.toBe(false); + }); + + it('returns false when delete result omits rowCount', async () => { + mockDb.chain.where.mockResolvedValueOnce({}); + + await expect(deleteCustomWorkflowStatusDefinition('missing')).resolves.toBe(false); + }); + + it('clears matching agent type references and returns affected row count', async () => { + mockDb.chain.where.mockResolvedValueOnce({ rowCount: 2 }); + + await expect(clearAgentTypeReferences('prd-agent')).resolves.toBe(2); + expect(mockDb.db.update).toHaveBeenCalled(); + expect(mockDb.chain.set).toHaveBeenCalledWith({ + agentType: null, + }); + expect(mockDb.chain.where).toHaveBeenCalledTimes(1); + }); + + it('returns zero when no workflow status references match the agent type', async () => { + mockDb.chain.where.mockResolvedValueOnce({ rowCount: 0 }); + + await expect(clearAgentTypeReferences('missing-agent')).resolves.toBe(0); + }); + + it('does not touch null agent type rows because cleanup filters by exact agent type', async () => { + mockDb.chain.where.mockResolvedValueOnce({ rowCount: 1 }); + + await clearAgentTypeReferences('story-agent'); + + expect(mockDb.chain.set).toHaveBeenCalledWith(expect.objectContaining({ agentType: null })); + expect(mockDb.chain.where).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/gadgets/pm/core/createWorkItem.test.ts b/tests/unit/gadgets/pm/core/createWorkItem.test.ts index 3ad63ae3b..99bb22030 100644 --- a/tests/unit/gadgets/pm/core/createWorkItem.test.ts +++ b/tests/unit/gadgets/pm/core/createWorkItem.test.ts @@ -32,7 +32,7 @@ describe('createWorkItem', () => { description: 'A new feature', }); expect(result).toBe( - 'Work item created successfully: "New Feature" - https://trello.com/c/item1', + 'Work item created successfully: "New Feature" [id: item1] - https://trello.com/c/item1', ); }); @@ -51,7 +51,7 @@ describe('createWorkItem', () => { }); expect(result).toBe( - 'Work item created successfully: "Simple Item" - https://trello.com/c/item2', + 'Work item created successfully: "Simple Item" [id: item2] - https://trello.com/c/item2', ); }); diff --git a/tests/unit/integrations/new-provider-surface.test.ts b/tests/unit/integrations/new-provider-surface.test.ts index 665c5f658..1b50441bc 100644 --- a/tests/unit/integrations/new-provider-surface.test.ts +++ b/tests/unit/integrations/new-provider-surface.test.ts @@ -106,7 +106,7 @@ const GUARDED_WIZARD_FILE_HASHES: Record = { 'web/src/components/projects/pm-wizard.tsx': '402cc6829689f34dfec940034f8ba014fe14425671018d0455ad67145e6a0fb9', 'web/src/components/projects/pm-wizard-hooks.ts': - '7aa07ab092695afdfd2ecc78345c097b569adce46e3eb928e8ee0bfa5d4131dd', + '7eab3a6cdf2657116658d111ca238f98444ce569e7f2bc61e3acd70b71113913', 'web/src/components/projects/pm-wizard-common-steps.tsx': '0d9ca8bb56036687aed695b502be75ebf2a753195decb2e6b58f440c2abaa7c9', }; diff --git a/tests/unit/pm/jira-integration.test.ts b/tests/unit/pm/jira-integration.test.ts index 6269a1b1f..e2ac1150e 100644 --- a/tests/unit/pm/jira-integration.test.ts +++ b/tests/unit/pm/jira-integration.test.ts @@ -58,4 +58,22 @@ describe('JiraIntegration.resolveLifecycleConfig', () => { expect(cfg.statuses.done).toBe('DN'); expect(cfg.statuses.merged).toBe('MG'); }); + + it('preserves custom status keys (prd, story, phased-plan) needed by lifecycle moveOnPrepare / moveOnSuccess hooks', () => { + const project = makeProject({ + backlog: 'BL', + inProgress: 'IP', + prd: 'PRD', + story: 'Story Refinement', + 'phased-plan': 'Phased Planning', + }); + const cfg = integration.resolveLifecycleConfig(project); + // Built-in keys still resolve. + expect(cfg.statuses.backlog).toBe('BL'); + expect(cfg.statuses.inProgress).toBe('IP'); + // Custom workflow keys survive normalization. + expect(cfg.statuses.prd).toBe('PRD'); + expect(cfg.statuses.story).toBe('Story Refinement'); + expect(cfg.statuses['phased-plan']).toBe('Phased Planning'); + }); }); diff --git a/tests/unit/pm/jira/integration.test.ts b/tests/unit/pm/jira/integration.test.ts index 1c174b2b8..0a179d5cc 100644 --- a/tests/unit/pm/jira/integration.test.ts +++ b/tests/unit/pm/jira/integration.test.ts @@ -304,6 +304,64 @@ describe('JiraIntegration', () => { expect(config.statuses.backlog).toBeUndefined(); }); + + it('preserves custom status keys like prd, story, and phased-plan from jira.statuses', () => { + mockGetJiraConfig.mockReturnValue( + makeJiraConfig({ + statuses: { + backlog: 'Backlog', + inProgress: 'In Progress', + done: 'Done', + prd: 'PRD', + story: 'Story Refinement', + 'phased-plan': 'Phased Planning', + }, + }), + ); + + const project = makeProject(); + const config = integration.resolveLifecycleConfig(project); + + // Built-in statuses still resolve correctly (regression safety) + expect(config.statuses.backlog).toBe('Backlog'); + expect(config.statuses.inProgress).toBe('In Progress'); + expect(config.statuses.done).toBe('Done'); + // Custom statuses survive normalization so lifecycle hooks like + // moveOnPrepare/moveOnSuccess can resolve them. + expect(config.statuses.prd).toBe('PRD'); + expect(config.statuses.story).toBe('Story Refinement'); + expect(config.statuses['phased-plan']).toBe('Phased Planning'); + }); + + it('returns an empty statuses object when jira.statuses is missing entirely', () => { + mockGetJiraConfig.mockReturnValue({ + projectKey: 'PROJ', + baseUrl: 'https://example.atlassian.net', + }); + const project = makeProject(); + const config = integration.resolveLifecycleConfig(project); + + expect(config.statuses).toEqual({}); + }); + + it('preserves default labels when only custom statuses are provided', () => { + mockGetJiraConfig.mockReturnValue({ + projectKey: 'PROJ', + baseUrl: 'https://example.atlassian.net', + statuses: { story: 'Story' }, + }); + const project = makeProject(); + const config = integration.resolveLifecycleConfig(project); + + // JIRA labels still fall back to their cascade-* defaults. + expect(config.labels.processing).toBe('cascade-processing'); + expect(config.labels.processed).toBe('cascade-processed'); + expect(config.labels.error).toBe('cascade-error'); + expect(config.labels.readyToProcess).toBe('cascade-ready'); + expect(config.labels.auto).toBe('cascade-auto'); + // Custom status survives. + expect(config.statuses.story).toBe('Story'); + }); }); // ========================================================================= diff --git a/tests/unit/pm/lifecycle-config-shape.test.ts b/tests/unit/pm/lifecycle-config-shape.test.ts index 82246ca2e..2a94bd52a 100644 --- a/tests/unit/pm/lifecycle-config-shape.test.ts +++ b/tests/unit/pm/lifecycle-config-shape.test.ts @@ -35,11 +35,25 @@ describe('ProjectPMConfig.statuses shape', () => { expect(cfg.statuses).toEqual({}); }); + it('accepts custom workflow status keys for provider lifecycle moves', () => { + const cfg: ProjectPMConfig = { + labels: {}, + statuses: { + prd: 'state-prd', + story: 'state-story', + 'phased-plan': 'state-phased-plan', + }, + }; + + expect(cfg.statuses.story).toBe('state-story'); + expect(cfg.statuses['phased-plan']).toBe('state-phased-plan'); + }); + it('every key in STATUS_TO_AGENT is a declared key of ProjectPMConfig.statuses', () => { // Construct a fully populated statuses object; if STATUS_TO_AGENT contains // any key that isn't assignable to ProjectPMConfig.statuses, this ceases to // type-check. - const allStatuses: Required = { + const allStatuses = { backlog: '', splitting: '', planning: '', @@ -49,7 +63,7 @@ describe('ProjectPMConfig.statuses shape', () => { done: '', merged: '', debug: '', - }; + } satisfies Required>; for (const agentKey of Object.keys(STATUS_TO_AGENT)) { expect(agentKey in allStatuses).toBe(true); diff --git a/tests/unit/pm/lifecycle.test.ts b/tests/unit/pm/lifecycle.test.ts index 9d779414a..1f4aae361 100644 --- a/tests/unit/pm/lifecycle.test.ts +++ b/tests/unit/pm/lifecycle.test.ts @@ -140,8 +140,12 @@ describe('pm/lifecycle', () => { error: 'label-err-id', readyToProcess: 'label-ready-id', }, + // Trello's lifecycle config now spreads the full lists record so + // every configured key (including custom ones like `todo`) survives + // normalization for use by `moveOnPrepare` / `moveOnSuccess` hooks. statuses: { backlog: 'list-backlog-id', + todo: 'list-todo-id', inProgress: 'list-progress-id', inReview: 'list-review-id', done: 'list-done-id', @@ -278,6 +282,9 @@ describe('pm/lifecycle', () => { const config = resolveProjectPMConfig(project); + // Trello statuses are now spread from the configured lists record; + // keys that were never configured are simply absent rather than + // explicitly `undefined`. expect(config).toEqual({ labels: { processing: undefined, @@ -286,11 +293,7 @@ describe('pm/lifecycle', () => { readyToProcess: undefined, }, statuses: { - backlog: undefined, - inProgress: undefined, - inReview: undefined, - done: undefined, - merged: undefined, + todo: 'list-id', }, }); }); @@ -431,6 +434,14 @@ describe('pm/lifecycle', () => { expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('work-item-1', 'list-progress'); }); + it('moves to custom status keys when moveOnPrepare uses a custom workflow status', async () => { + pmConfig.statuses.story = 'state-story'; + + await manager.prepareForAgent('work-item-1', { moveOnPrepare: 'story' }); + + expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('work-item-1', 'state-story'); + }); + it('does not move work item when moveOnPrepare is not set', async () => { await manager.prepareForAgent('work-item-1', {}); @@ -464,6 +475,15 @@ describe('pm/lifecycle', () => { expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('work-item-1', 'list-review'); }); + it('moves to custom status keys when moveOnSuccess uses a custom workflow status', async () => { + pmConfig.statuses['phased-plan'] = 'state-phased-plan'; + + await manager.handleSuccess('work-item-1', { moveOnSuccess: 'phased-plan' }); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-done'); + expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('work-item-1', 'state-phased-plan'); + }); + it('calls linkPR when prUrl is provided and linkPR hook is true', async () => { await manager.handleSuccess( 'work-item-1', @@ -640,4 +660,192 @@ describe('pm/lifecycle', () => { }); }); }); + + // ========================================================================= + // End-to-end: provider-normalized config + PMLifecycleManager move hooks + // + // These tests guarantee that custom workflow keys (`prd`, `story`, + // `phased-plan`) configured on a real Trello / JIRA project survive + // resolveProjectPMConfig normalization AND remain resolvable when a + // LifecycleHook (`moveOnPrepare` / `moveOnSuccess`) references them. + // ========================================================================= + describe('resolveProjectPMConfig → PMLifecycleManager custom workflow moves', () => { + function makeMockProvider(type: 'trello' | 'jira'): PMProvider { + return { + type, + addLabel: vi.fn().mockResolvedValue(undefined), + removeLabel: vi.fn().mockResolvedValue(undefined), + moveWorkItem: vi.fn().mockResolvedValue(undefined), + addComment: vi.fn().mockResolvedValue(undefined), + updateComment: vi.fn().mockResolvedValue(undefined), + linkPR: vi.fn().mockResolvedValue(undefined), + getWorkItem: vi.fn(), + getWorkItemComments: vi.fn(), + updateWorkItem: vi.fn(), + createWorkItem: vi.fn(), + listWorkItems: vi.fn(), + getChecklists: vi.fn(), + createChecklist: vi.fn(), + addChecklistItem: vi.fn(), + updateChecklistItem: vi.fn(), + getAttachments: vi.fn(), + addAttachment: vi.fn(), + addAttachmentFile: vi.fn(), + getCustomFieldNumber: vi.fn(), + updateCustomFieldNumber: vi.fn(), + getWorkItemUrl: vi.fn(), + getAuthenticatedUser: vi.fn(), + } as unknown as PMProvider; + } + + it('moves a Trello card via moveOnPrepare to a custom story status', async () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'Trello Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'trello' }, + trello: { + boardId: 'board123', + labels: {}, + lists: { + backlog: 'list-backlog', + inProgress: 'list-progress', + story: 'list-story', + 'phased-plan': 'list-phased-plan', + }, + }, + }; + + const pmConfig = resolveProjectPMConfig(project); + expect(pmConfig.statuses.story).toBe('list-story'); + expect(pmConfig.statuses['phased-plan']).toBe('list-phased-plan'); + + const provider = makeMockProvider('trello'); + const manager = new PMLifecycleManager(provider, pmConfig); + + await manager.prepareForAgent('card-1', { moveOnPrepare: 'story' }); + + expect(provider.moveWorkItem).toHaveBeenCalledWith('card-1', 'list-story'); + }); + + it('moves a Trello card via moveOnSuccess to a custom phased-plan status', async () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'Trello Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'trello' }, + trello: { + boardId: 'board123', + labels: {}, + lists: { + inProgress: 'list-progress', + 'phased-plan': 'list-phased-plan', + prd: 'list-prd', + }, + }, + }; + + const pmConfig = resolveProjectPMConfig(project); + + const provider = makeMockProvider('trello'); + const manager = new PMLifecycleManager(provider, pmConfig); + + await manager.handleSuccess('card-1', { moveOnSuccess: 'phased-plan' }); + + expect(provider.moveWorkItem).toHaveBeenCalledWith('card-1', 'list-phased-plan'); + }); + + it('moves a JIRA issue via moveOnPrepare to a custom prd status', async () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'JIRA Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'jira' }, + jira: { + projectKey: 'PROJ', + statuses: { + inProgress: 'In Progress', + prd: 'PRD Review', + story: 'Story Refinement', + 'phased-plan': 'Phased Planning', + }, + }, + }; + + const pmConfig = resolveProjectPMConfig(project); + expect(pmConfig.statuses.prd).toBe('PRD Review'); + + const provider = makeMockProvider('jira'); + const manager = new PMLifecycleManager(provider, pmConfig); + + await manager.prepareForAgent('PROJ-1', { moveOnPrepare: 'prd' }); + + expect(provider.moveWorkItem).toHaveBeenCalledWith('PROJ-1', 'PRD Review'); + }); + + it('moves a JIRA issue via moveOnSuccess to a custom story status', async () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'JIRA Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'jira' }, + jira: { + projectKey: 'PROJ', + statuses: { + inProgress: 'In Progress', + story: 'Story Refinement', + }, + }, + }; + + const pmConfig = resolveProjectPMConfig(project); + + const provider = makeMockProvider('jira'); + const manager = new PMLifecycleManager(provider, pmConfig); + + await manager.handleSuccess('PROJ-1', { moveOnSuccess: 'story' }); + + expect(provider.moveWorkItem).toHaveBeenCalledWith('PROJ-1', 'Story Refinement'); + }); + + it('skips the move when the custom status key is not configured on the project', async () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'Trello Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'trello' }, + trello: { + boardId: 'board123', + labels: {}, + lists: { inProgress: 'list-progress' }, // no `story` + }, + }; + + const pmConfig = resolveProjectPMConfig(project); + expect(pmConfig.statuses.story).toBeUndefined(); + + const provider = makeMockProvider('trello'); + const manager = new PMLifecycleManager(provider, pmConfig); + + await manager.prepareForAgent('card-1', { moveOnPrepare: 'story' }); + + // Provider's moveWorkItem must not be called when the destination is undefined. + expect(provider.moveWorkItem).not.toHaveBeenCalled(); + }); + }); }); diff --git a/tests/unit/pm/linear-integration.test.ts b/tests/unit/pm/linear-integration.test.ts index f4699fdf2..d6f2e92ea 100644 --- a/tests/unit/pm/linear-integration.test.ts +++ b/tests/unit/pm/linear-integration.test.ts @@ -2,8 +2,8 @@ * LinearIntegration.resolveLifecycleConfig — unit tests. * * Verifies the normalized ProjectPMConfig produced from a project's Linear - * config passes through all 8 CASCADE stages Linear operators can map - * (backlog, splitting, planning, todo, inProgress, inReview, done, merged). + * config passes through every configured Linear status mapping, including + * custom workflow status keys. */ import { describe, expect, it } from 'vitest'; @@ -53,6 +53,21 @@ describe('LinearIntegration.resolveLifecycleConfig', () => { expect(cfg.statuses.merged).toBe('s-mg'); }); + it('preserves custom workflow status keys for lifecycle moves', () => { + const project = makeProject({ + prd: 's-prd', + story: 's-story', + 'phased-plan': 's-phased-plan', + inProgress: 's-ip', + }); + const cfg = integration.resolveLifecycleConfig(project); + + expect(cfg.statuses.prd).toBe('s-prd'); + expect(cfg.statuses.story).toBe('s-story'); + expect(cfg.statuses['phased-plan']).toBe('s-phased-plan'); + expect(cfg.statuses.inProgress).toBe('s-ip'); + }); + it('preserves undefined for keys not provided in the project config', () => { const project = makeProject({ inProgress: 's-ip' }); const cfg = integration.resolveLifecycleConfig(project); @@ -66,10 +81,10 @@ describe('LinearIntegration.resolveLifecycleConfig', () => { expect(cfg.statuses.merged).toBeUndefined(); }); - it('does not surface debug from Linear config (Linear has no debug slot)', () => { + it('preserves arbitrary configured Linear status keys', () => { const project = makeProject({ debug: 's-dbg', inProgress: 's-ip' }); const cfg = integration.resolveLifecycleConfig(project); - expect(cfg.statuses.debug).toBeUndefined(); + expect(cfg.statuses.debug).toBe('s-dbg'); expect(cfg.statuses.inProgress).toBe('s-ip'); }); }); diff --git a/tests/unit/pm/linear/integration.test.ts b/tests/unit/pm/linear/integration.test.ts index bca193d40..14d84566e 100644 --- a/tests/unit/pm/linear/integration.test.ts +++ b/tests/unit/pm/linear/integration.test.ts @@ -232,6 +232,23 @@ describe('LinearIntegration', () => { expect(config.statuses.done).toBe('state-done'); }); + it('preserves custom workflow status mappings for lifecycle moves', () => { + mockGetLinearConfig.mockReturnValue({ + teamId: 'team-abc', + statuses: { + prd: 'state-prd', + story: 'state-story', + 'phased-plan': 'state-phased-plan', + }, + }); + const project = makeProject(); + const config = integration.resolveLifecycleConfig(project); + + expect(config.statuses.prd).toBe('state-prd'); + expect(config.statuses.story).toBe('state-story'); + expect(config.statuses['phased-plan']).toBe('state-phased-plan'); + }); + it('returns undefined labels when labels config is missing (not name-string defaults)', () => { // Linear requires UUIDs for addLabel — name-string defaults like 'cascade-processing' // would cause resolveLabelId() to silently return null. When no label is configured, diff --git a/tests/unit/pm/trello/integration.test.ts b/tests/unit/pm/trello/integration.test.ts index a28791846..babe0f248 100644 --- a/tests/unit/pm/trello/integration.test.ts +++ b/tests/unit/pm/trello/integration.test.ts @@ -39,28 +39,31 @@ vi.mock('../../../../src/router/reactions.js', () => ({ sendAcknowledgeReaction: (...args: unknown[]) => mockSendAcknowledgeReaction(...args), })); +const mockGetTrelloConfig = vi.fn(); vi.mock('../../../../src/pm/config.js', () => ({ - getTrelloConfig: vi.fn().mockReturnValue({ - labels: { - processing: 'label-processing', - processed: 'label-processed', - error: 'label-error', - readyToProcess: 'label-ready', - auto: 'label-auto', - }, - lists: { - backlog: 'list-backlog', - inProgress: 'list-in-progress', - inReview: 'list-in-review', - done: 'list-done', - merged: 'list-merged', - }, - }), + getTrelloConfig: (...args: unknown[]) => mockGetTrelloConfig(...args), })); import { TrelloIntegration } from '../../../../src/pm/trello/integration.js'; import type { ProjectConfig } from '../../../../src/types/index.js'; +const DEFAULT_TRELLO_CONFIG = { + labels: { + processing: 'label-processing', + processed: 'label-processed', + error: 'label-error', + readyToProcess: 'label-ready', + auto: 'label-auto', + }, + lists: { + backlog: 'list-backlog', + inProgress: 'list-in-progress', + inReview: 'list-in-review', + done: 'list-done', + merged: 'list-merged', + }, +}; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -92,6 +95,7 @@ describe('TrelloIntegration', () => { beforeEach(() => { integration = new TrelloIntegration(); + mockGetTrelloConfig.mockReturnValue(DEFAULT_TRELLO_CONFIG); }); it('has type "trello"', () => { @@ -240,6 +244,49 @@ describe('TrelloIntegration', () => { expect(config.statuses.done).toBe('list-done'); expect(config.statuses.merged).toBe('list-merged'); }); + + it('preserves custom status keys like prd, story, and phased-plan from trello.lists', () => { + mockGetTrelloConfig.mockReturnValue({ + labels: DEFAULT_TRELLO_CONFIG.labels, + lists: { + ...DEFAULT_TRELLO_CONFIG.lists, + prd: 'list-prd', + story: 'list-story', + 'phased-plan': 'list-phased-plan', + }, + }); + + const project = makeProject(); + const config = integration.resolveLifecycleConfig(project); + + // Built-in statuses still resolve correctly (regression safety) + expect(config.statuses.backlog).toBe('list-backlog'); + expect(config.statuses.inProgress).toBe('list-in-progress'); + expect(config.statuses.done).toBe('list-done'); + // Custom statuses survive normalization so lifecycle hooks like + // moveOnPrepare/moveOnSuccess can resolve them. + expect(config.statuses.prd).toBe('list-prd'); + expect(config.statuses.story).toBe('list-story'); + expect(config.statuses['phased-plan']).toBe('list-phased-plan'); + }); + + it('returns an empty statuses object when trello.lists is missing', () => { + mockGetTrelloConfig.mockReturnValue({ labels: {} }); + const project = makeProject(); + const config = integration.resolveLifecycleConfig(project); + + expect(config.statuses).toEqual({}); + }); + + it('returns an empty statuses object when trello config itself is missing', () => { + mockGetTrelloConfig.mockReturnValue(undefined); + const project = makeProject(); + const config = integration.resolveLifecycleConfig(project); + + expect(config.statuses).toEqual({}); + expect(config.labels.processing).toBeUndefined(); + expect(config.labels.auto).toBeUndefined(); + }); }); // ========================================================================= diff --git a/tests/unit/pm/workflow-statusDefinitions.test.ts b/tests/unit/pm/workflow-statusDefinitions.test.ts new file mode 100644 index 000000000..087f46805 --- /dev/null +++ b/tests/unit/pm/workflow-statusDefinitions.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockGetCustomWorkflowStatusDefinition, mockListCustomWorkflowStatusDefinitions } = + vi.hoisted(() => ({ + mockGetCustomWorkflowStatusDefinition: vi.fn(), + mockListCustomWorkflowStatusDefinitions: vi.fn(), + })); + +vi.mock('../../../src/db/repositories/workflowStatusDefinitionsRepository.js', () => ({ + getCustomWorkflowStatusDefinition: mockGetCustomWorkflowStatusDefinition, + listCustomWorkflowStatusDefinitions: mockListCustomWorkflowStatusDefinitions, +})); + +import { + BUILTIN_WORKFLOW_STATUS_KEYS, + getBuiltinWorkflowStatusDefinition, + listWorkflowStatusDefinitions, + resolveWorkflowStatusDefinition, +} from '../../../src/workflow/statusDefinitions.js'; + +describe('workflow status definitions', () => { + beforeEach(() => { + vi.resetAllMocks(); + mockListCustomWorkflowStatusDefinitions.mockResolvedValue([]); + mockGetCustomWorkflowStatusDefinition.mockResolvedValue(null); + }); + + it('exposes builtin workflow status keys and definitions', () => { + expect(BUILTIN_WORKFLOW_STATUS_KEYS.has('todo')).toBe(true); + expect(BUILTIN_WORKFLOW_STATUS_KEYS.has('friction')).toBe(true); + expect(getBuiltinWorkflowStatusDefinition('todo')).toMatchObject({ + key: 'todo', + agentType: 'implementation', + isBuiltin: true, + }); + expect(getBuiltinWorkflowStatusDefinition('friction')).toMatchObject({ + key: 'friction', + agentType: null, + isBuiltin: true, + }); + expect(getBuiltinWorkflowStatusDefinition('unknown')).toBeUndefined(); + }); + + it('lists builtin statuses followed by non-conflicting custom statuses', async () => { + mockListCustomWorkflowStatusDefinitions.mockResolvedValue([ + { + id: 1, + key: 'todo', + label: 'Todo override', + agentType: 'custom-todo', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }, + { + id: 2, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1010, + createdAt: null, + updatedAt: null, + }, + ]); + + const result = await listWorkflowStatusDefinitions(); + + expect(result.some((status) => status.key === 'todo' && status.isBuiltin)).toBe(true); + expect(result).toContainEqual({ + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1010, + isBuiltin: false, + }); + expect(result).not.toContainEqual( + expect.objectContaining({ key: 'todo', label: 'Todo override' }), + ); + }); + + it('resolves builtin statuses without querying custom definitions', async () => { + const result = await resolveWorkflowStatusDefinition('planning'); + + expect(result).toMatchObject({ key: 'planning', isBuiltin: true }); + expect(mockGetCustomWorkflowStatusDefinition).not.toHaveBeenCalled(); + }); + + it('resolves custom statuses', async () => { + mockGetCustomWorkflowStatusDefinition.mockResolvedValue({ + id: 2, + key: 'prd', + label: 'PRD', + agentType: null, + sortOrder: 1010, + createdAt: null, + updatedAt: null, + }); + + await expect(resolveWorkflowStatusDefinition('prd')).resolves.toEqual({ + key: 'prd', + label: 'PRD', + agentType: null, + sortOrder: 1010, + isBuiltin: false, + }); + }); + + it('returns undefined when a status cannot be resolved', async () => { + await expect(resolveWorkflowStatusDefinition('missing')).resolves.toBeUndefined(); + }); +}); diff --git a/tests/unit/router/queue.test.ts b/tests/unit/router/queue.test.ts index b214c4442..ed2b9730e 100644 --- a/tests/unit/router/queue.test.ts +++ b/tests/unit/router/queue.test.ts @@ -6,9 +6,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // inside factory closures. // --------------------------------------------------------------------------- -const { mockQueueInstance } = vi.hoisted(() => { +const { mockQueueHandlers, mockQueueInstance } = vi.hoisted(() => { + const mockQueueHandlers = new Map void>(); const mockQueueInstance = { - on: vi.fn(), + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + mockQueueHandlers.set(event, handler); + }), add: vi.fn().mockResolvedValue({ id: 'test-job-id' }), getDelayed: vi.fn().mockResolvedValue([]), getWaiting: vi.fn().mockResolvedValue([]), @@ -17,7 +20,7 @@ const { mockQueueInstance } = vi.hoisted(() => { getCompletedCount: vi.fn().mockResolvedValue(0), getFailedCount: vi.fn().mockResolvedValue(0), }; - return { mockQueueInstance }; + return { mockQueueHandlers, mockQueueInstance }; }); vi.mock('bullmq', () => ({ @@ -46,7 +49,15 @@ vi.mock('../../../src/sentry.js', () => ({ })); import type { CascadeJob } from '../../../src/router/queue.js'; -import { scheduleCoalescedJob } from '../../../src/router/queue.js'; +import { + addJob, + getPendingCoalescedJobData, + getQueueStats, + hasPendingCoalescedJob, + scheduleCoalescedJob, +} from '../../../src/router/queue.js'; +import { captureException } from '../../../src/sentry.js'; +import { logger } from '../../../src/utils/logging.js'; const sampleJob: CascadeJob = { type: 'jira', @@ -77,6 +88,76 @@ describe('scheduleCoalescedJob', () => { mockQueueInstance.getDelayed.mockResolvedValue([]); mockQueueInstance.getWaiting.mockResolvedValue([]); mockQueueInstance.add.mockResolvedValue({ id: 'mock-id' }); + mockQueueInstance.getWaitingCount.mockResolvedValue(0); + mockQueueInstance.getActiveCount.mockResolvedValue(0); + mockQueueInstance.getCompletedCount.mockResolvedValue(0); + mockQueueInstance.getFailedCount.mockResolvedValue(0); + vi.mocked(logger.info).mockClear(); + vi.mocked(logger.error).mockClear(); + vi.mocked(captureException).mockClear(); + }); + + it('adds an immediate job and returns the BullMQ id', async () => { + mockQueueInstance.add.mockResolvedValueOnce({ id: 'bull-job-1' }); + + await expect(addJob(sampleJob)).resolves.toBe('bull-job-1'); + + expect(mockQueueInstance.add).toHaveBeenCalledWith( + 'jira', + sampleJob, + expect.objectContaining({ jobId: expect.stringMatching(/^jira-\d+-[a-z0-9]{6}$/) }), + ); + expect(logger.info).toHaveBeenCalledWith('Job added to queue', { + id: 'bull-job-1', + type: 'jira', + }); + }); + + it('falls back to generated id when BullMQ does not return one', async () => { + mockQueueInstance.add.mockResolvedValueOnce({}); + + const jobId = await addJob(sampleJob); + + expect(jobId).toMatch(/^jira-\d+-[a-z0-9]{6}$/); + }); + + it('reports whether a pending coalesced job exists', async () => { + mockQueueInstance.getDelayed.mockResolvedValueOnce([makeFakeJob('proj-1:PROJ-42', sampleJob)]); + mockQueueInstance.getWaiting.mockResolvedValueOnce([makeFakeJob('proj-2:PROJ-99', sampleJob)]); + + await expect(hasPendingCoalescedJob('proj-1:PROJ-42')).resolves.toBe(true); + await expect(hasPendingCoalescedJob('missing:key')).resolves.toBe(false); + }); + + it('returns data for the first pending coalesced job', async () => { + mockQueueInstance.getDelayed.mockResolvedValueOnce([makeFakeJob('proj-1:PROJ-42', sampleJob)]); + + await expect(getPendingCoalescedJobData('proj-1:PROJ-42')).resolves.toEqual(sampleJob); + }); + + it('returns queue stats from BullMQ counters', async () => { + mockQueueInstance.getWaitingCount.mockResolvedValueOnce(2); + mockQueueInstance.getActiveCount.mockResolvedValueOnce(3); + mockQueueInstance.getCompletedCount.mockResolvedValueOnce(5); + mockQueueInstance.getFailedCount.mockResolvedValueOnce(7); + + await expect(getQueueStats()).resolves.toEqual({ + waiting: 2, + active: 3, + completed: 5, + failed: 7, + }); + }); + + it('logs and captures queue errors', () => { + const errorHandler = mockQueueHandlers.get('error'); + const err = new Error('redis down'); + + expect(errorHandler).toBeDefined(); + errorHandler?.(err); + + expect(logger.error).toHaveBeenCalledWith('Queue error', { error: 'Error: redis down' }); + expect(captureException).toHaveBeenCalledWith(err, { tags: { source: 'job_queue' } }); }); it('schedules a new delayed job when no prior pending job exists', async () => { diff --git a/tests/unit/router/webhook-processor.test.ts b/tests/unit/router/webhook-processor.test.ts index f405c934c..df716c620 100644 --- a/tests/unit/router/webhook-processor.test.ts +++ b/tests/unit/router/webhook-processor.test.ts @@ -10,6 +10,7 @@ vi.mock('../../../src/utils/logging.js', () => ({ })); vi.mock('../../../src/router/queue.js', () => ({ addJob: vi.fn(), + getPendingCoalescedJobData: vi.fn().mockResolvedValue(undefined), scheduleCoalescedJob: vi.fn().mockResolvedValue({ jobId: 'coalesce:key', superseded: false }), })); vi.mock('../../../src/pm/coalesce-config.js', () => ({ @@ -51,7 +52,11 @@ import type { RouterProjectConfig } from '../../../src/router/config.js'; import { classifyLockState } from '../../../src/router/lock-state-classifier.js'; import type { RouterPlatformAdapter } from '../../../src/router/platform-adapter.js'; import type { CascadeJob } from '../../../src/router/queue.js'; -import { addJob, scheduleCoalescedJob } from '../../../src/router/queue.js'; +import { + addJob, + getPendingCoalescedJobData, + scheduleCoalescedJob, +} from '../../../src/router/queue.js'; import { processRouterWebhook } from '../../../src/router/webhook-processor.js'; import { clearWorkItemEnqueued, @@ -647,6 +652,22 @@ describe('processRouterWebhook', () => { describe('BullMQ delayed-job coalescing', () => { beforeEach(() => { + vi.mocked(isWorkItemLocked).mockReset(); + vi.mocked(checkAgentTypeConcurrency).mockReset(); + vi.mocked(getPendingCoalescedJobData).mockReset(); + vi.mocked(scheduleCoalescedJob).mockReset(); + vi.mocked(isWorkItemLocked).mockResolvedValue({ locked: false }); + vi.mocked(checkAgentTypeConcurrency).mockResolvedValue({ + maxConcurrency: null, + blocked: false, + }); + vi.mocked(markWorkItemEnqueued).mockClear(); + vi.mocked(markAgentTypeEnqueued).mockClear(); + vi.mocked(markRecentlyDispatched).mockClear(); + vi.mocked(clearWorkItemEnqueued).mockClear(); + vi.mocked(clearAgentTypeEnqueued).mockClear(); + vi.mocked(clearRecentlyDispatched).mockClear(); + vi.mocked(getPendingCoalescedJobData).mockResolvedValue(undefined); vi.mocked(scheduleCoalescedJob).mockResolvedValue({ jobId: 'coalesce:p1:PROJ-1', superseded: false, @@ -669,6 +690,7 @@ describe('processRouterWebhook', () => { expect(result.shouldProcess).toBe(true); expect(result.decisionReason).toMatch(/Coalesced dispatch scheduled/); + expect(getPendingCoalescedJobData).toHaveBeenCalledWith('p1:PROJ-1'); expect(scheduleCoalescedJob).toHaveBeenCalledOnce(); // Immediate addJob must NOT be called for coalesced path expect(addJob).not.toHaveBeenCalled(); @@ -774,7 +796,201 @@ describe('processRouterWebhook', () => { expect(markAgentTypeEnqueued).toHaveBeenCalled(); }); - it('regression pin (MNG-422 2026-04-29): an active job for the same coalesceKey does NOT block a new schedule — locks marked + scheduled decision reason', async () => { + it('allows a pending coalesced job to be superseded even while its in-memory lock exists', async () => { + vi.mocked(getPendingCoalescedJobData).mockResolvedValue({ + type: 'jira', + source: 'jira', + payload: {}, + projectId: 'p1', + issueKey: 'PROJ-1', + webhookEvent: 'jira:issue_updated', + receivedAt: new Date().toISOString(), + triggerResult: { + agentType: 'planning', + workItemId: 'PROJ-1', + agentInput: {}, + }, + }); + vi.mocked(scheduleCoalescedJob).mockResolvedValue({ + jobId: 'coalesce:p1:PROJ-1', + superseded: true, + supersededJobData: { + type: 'jira', + source: 'jira', + payload: {}, + projectId: 'p1', + issueKey: 'PROJ-1', + webhookEvent: 'jira:issue_updated', + receivedAt: new Date().toISOString(), + triggerResult: { + agentType: 'planning', + workItemId: 'PROJ-1', + agentInput: {}, + }, + }, + }); + const adapter = makeMockAdapter({ + type: 'jira', + dispatchWithCredentials: vi.fn().mockResolvedValue({ + agentType: 'planning', + agentInput: { workItemId: 'PROJ-1' }, + workItemId: 'PROJ-1', + coalesceKey: 'p1:PROJ-1', + }), + }); + + const result = await processRouterWebhook(adapter, {}, mockTriggerRegistry); + + expect(result.decisionReason).toMatch(/Coalesced dispatch scheduled/); + expect(clearWorkItemEnqueued).toHaveBeenCalledWith('p1', 'PROJ-1', 'planning'); + expect(isWorkItemLocked).toHaveBeenCalledWith('p1', 'PROJ-1', 'planning', { + ignoreInMemoryCount: 1, + }); + expect(scheduleCoalescedJob).toHaveBeenCalledOnce(); + }); + + it('does not skip active same-type locks just because another pending coalesced job exists for the key', async () => { + vi.mocked(getPendingCoalescedJobData).mockResolvedValue({ + type: 'linear', + source: 'linear', + payload: {}, + projectId: 'p1', + workItemId: 'TF-38', + eventType: 'Issue', + receivedAt: new Date().toISOString(), + triggerResult: { + agentType: 'planning', + workItemId: 'TF-38', + agentInput: {}, + }, + }); + vi.mocked(isWorkItemLocked).mockResolvedValueOnce({ + locked: true, + reason: 'same-type: 1 running, 0 enqueued (max 1 per type)', + }); + const adapter = makeMockAdapter({ + type: 'linear', + dispatchWithCredentials: vi.fn().mockResolvedValue({ + agentType: 'implementation', + agentInput: { workItemId: 'TF-38' }, + workItemId: 'TF-38', + coalesceKey: 'ats:TF-38', + }), + }); + + const result = await processRouterWebhook(adapter, {}, mockTriggerRegistry); + + expect(result.decisionReason).toBe( + 'Awaiting worker slot: same-type: 1 running, 0 enqueued (max 1 per type)', + ); + expect(clearWorkItemEnqueued).not.toHaveBeenCalled(); + expect(scheduleCoalescedJob).not.toHaveBeenCalled(); + expect(markWorkItemEnqueued).not.toHaveBeenCalled(); + }); + + it('leaves pending own locks intact when agent-type concurrency blocks the replacement dispatch', async () => { + vi.mocked(getPendingCoalescedJobData).mockResolvedValue({ + type: 'linear', + source: 'linear', + payload: {}, + projectId: 'p1', + workItemId: 'TF-38', + eventType: 'Issue', + receivedAt: new Date().toISOString(), + triggerResult: { + agentType: 'implementation', + workItemId: 'TF-38', + agentInput: {}, + }, + }); + vi.mocked(checkAgentTypeConcurrency).mockResolvedValueOnce({ + maxConcurrency: 1, + blocked: true, + }); + const adapter = makeMockAdapter({ + type: 'linear', + dispatchWithCredentials: vi.fn().mockResolvedValue({ + agentType: 'implementation', + agentInput: { workItemId: 'TF-38' }, + workItemId: 'TF-38', + coalesceKey: 'ats:TF-38', + }), + }); + + const result = await processRouterWebhook(adapter, {}, mockTriggerRegistry); + + expect(result.decisionReason).toBe('Agent type concurrency limit reached'); + expect(clearWorkItemEnqueued).not.toHaveBeenCalled(); + expect(clearAgentTypeEnqueued).not.toHaveBeenCalled(); + expect(clearRecentlyDispatched).not.toHaveBeenCalled(); + expect(markWorkItemEnqueued).not.toHaveBeenCalled(); + expect(markAgentTypeEnqueued).not.toHaveBeenCalled(); + expect(markRecentlyDispatched).not.toHaveBeenCalled(); + expect(scheduleCoalescedJob).not.toHaveBeenCalled(); + }); + + it('does not release pending own locks when replacement scheduling fails', async () => { + vi.mocked(getPendingCoalescedJobData).mockResolvedValue({ + type: 'linear', + source: 'linear', + payload: {}, + projectId: 'p1', + workItemId: 'TF-38', + eventType: 'Issue', + receivedAt: new Date().toISOString(), + triggerResult: { + agentType: 'implementation', + workItemId: 'TF-38', + agentInput: {}, + }, + }); + vi.mocked(scheduleCoalescedJob).mockRejectedValue(new Error('Redis down')); + const adapter = makeMockAdapter({ + type: 'linear', + dispatchWithCredentials: vi.fn().mockResolvedValue({ + agentType: 'implementation', + agentInput: { workItemId: 'TF-38' }, + workItemId: 'TF-38', + coalesceKey: 'ats:TF-38', + }), + }); + + const result = await processRouterWebhook(adapter, {}, mockTriggerRegistry); + + expect(result.decisionReason).toBe('Failed to schedule coalesced job to Redis'); + expect(clearWorkItemEnqueued).not.toHaveBeenCalled(); + expect(clearAgentTypeEnqueued).not.toHaveBeenCalled(); + expect(clearRecentlyDispatched).not.toHaveBeenCalled(); + expect(markWorkItemEnqueued).not.toHaveBeenCalled(); + expect(markAgentTypeEnqueued).not.toHaveBeenCalled(); + expect(markRecentlyDispatched).not.toHaveBeenCalled(); + }); + + it('blocks a late duplicate coalesced dispatch when the same work item and agent type are already locked', async () => { + vi.mocked(isWorkItemLocked).mockResolvedValueOnce({ + locked: true, + reason: 'in-memory same-type: 1 enqueued (max 1 per type)', + }); + const adapter = makeMockAdapter({ + type: 'linear', + dispatchWithCredentials: vi.fn().mockResolvedValue({ + agentType: 'phased-plan', + agentInput: { workItemId: 'TF-38' }, + workItemId: 'TF-38', + coalesceKey: 'ats:TF-38', + }), + }); + + const result = await processRouterWebhook(adapter, {}, mockTriggerRegistry); + + expect(result.decisionReason).toBe( + 'Awaiting worker slot: in-memory same-type: 1 enqueued (max 1 per type)', + ); + expect(scheduleCoalescedJob).not.toHaveBeenCalled(); + expect(markWorkItemEnqueued).not.toHaveBeenCalled(); + }); + + it('regression pin (MNG-422 2026-04-29): same coalesceKey does NOT block a new schedule when same-type locks are clear', async () => { // Before the unique-jobId rewrite, an active job for the same // coalesceKey caused scheduleCoalescedJob to return // `activeExists: true` and the caller dropped the new event entirely. diff --git a/tests/unit/triggers/agent-execution.test.ts b/tests/unit/triggers/agent-execution.test.ts index 50939cec3..3d1b63989 100644 --- a/tests/unit/triggers/agent-execution.test.ts +++ b/tests/unit/triggers/agent-execution.test.ts @@ -35,6 +35,15 @@ vi.mock('../../../src/triggers/shared/integration-validation.js', () => ({ formatValidationErrors: vi.fn().mockReturnValue(''), })); +vi.mock('../../../src/triggers/shared/implementation-freshness-gate.js', () => ({ + evaluateImplementationFreshness: vi.fn().mockResolvedValue({ + kind: 'dispatchable', + message: 'ok', + evidence: {}, + }), + postFreshnessSkipNotice: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../../../src/triggers/shared/trigger-check.js', () => mockTriggerCheckModule); vi.mock('../../../src/pm/context.js', () => ({ @@ -64,6 +73,10 @@ import { handleAgentResultArtifacts } from '../../../src/triggers/shared/agent-r import { checkBudgetExceeded } from '../../../src/triggers/shared/budget.js'; import { triggerDebugAnalysis } from '../../../src/triggers/shared/debug-runner.js'; import { shouldTriggerDebug } from '../../../src/triggers/shared/debug-trigger.js'; +import { + evaluateImplementationFreshness, + postFreshnessSkipNotice, +} from '../../../src/triggers/shared/implementation-freshness-gate.js'; import { checkTriggerEnabled } from '../../../src/triggers/shared/trigger-check.js'; import type { TriggerResult } from '../../../src/triggers/types.js'; import type { AgentResult, CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; @@ -112,6 +125,12 @@ beforeEach(() => { vi.mocked(handleAgentResultArtifacts).mockResolvedValue(undefined); vi.mocked(shouldTriggerDebug).mockResolvedValue(null); vi.mocked(triggerDebugAnalysis).mockResolvedValue(undefined); + vi.mocked(evaluateImplementationFreshness).mockResolvedValue({ + kind: 'dispatchable', + message: 'ok', + evidence: {}, + }); + vi.mocked(postFreshnessSkipNotice).mockResolvedValue(undefined); vi.mocked(runAgent).mockResolvedValue({ success: true, runId: 'run-1', @@ -453,6 +472,112 @@ describe('runAgentExecutionPipeline', () => { }); }); + describe('implementation freshness gate', () => { + it('runs the gate for implementation runs with a workItemId', async () => { + await runAgentExecutionPipeline(mockTriggerResult, mockProject, mockConfig); + + expect(evaluateImplementationFreshness).toHaveBeenCalledWith( + expect.objectContaining({ + agentType: 'implementation', + workItemId: 'card-123', + project: mockProject, + }), + ); + }); + + it('skips the gate for non-implementation agent types', async () => { + const reviewResult: TriggerResult = { + agentType: 'review', + workItemId: 'card-123', + agentInput: {}, + }; + + await runAgentExecutionPipeline(reviewResult, mockProject, mockConfig); + + expect(evaluateImplementationFreshness).not.toHaveBeenCalled(); + expect(runAgent).toHaveBeenCalledWith('review', expect.any(Object)); + }); + + it('skips the gate when there is no workItemId', async () => { + const noWorkItemResult: TriggerResult = { + agentType: 'implementation', + agentInput: {}, + }; + + await runAgentExecutionPipeline(noWorkItemResult, mockProject, mockConfig); + + expect(evaluateImplementationFreshness).not.toHaveBeenCalled(); + expect(runAgent).toHaveBeenCalledWith('implementation', expect.any(Object)); + }); + + it('stops the pipeline before agent startup when the gate blocks', async () => { + vi.mocked(evaluateImplementationFreshness).mockResolvedValueOnce({ + kind: 'already_implemented', + message: 'Implementation not started: already implemented.', + evidence: { completedChecklists: ['Implementation Steps'] }, + }); + + await runAgentExecutionPipeline(mockTriggerResult, mockProject, mockConfig); + + expect(postFreshnessSkipNotice).toHaveBeenCalledWith( + expect.anything(), + 'card-123', + expect.any(Object), + expect.objectContaining({ kind: 'already_implemented' }), + ); + expect(checkBudgetExceeded).not.toHaveBeenCalled(); + expect(runAgent).not.toHaveBeenCalled(); + expect(mockLifecycle.prepareForAgent).not.toHaveBeenCalled(); + }); + + it('does not call lifecycle failure when blocked by an existing PR', async () => { + vi.mocked(evaluateImplementationFreshness).mockResolvedValueOnce({ + kind: 'implementation_pr_exists', + message: 'Implementation not started: existing PR ...', + evidence: { pullRequests: [{ prNumber: 5, prUrl: 'x', state: 'open', merged: false }] }, + }); + + await runAgentExecutionPipeline(mockTriggerResult, mockProject, mockConfig); + + expect(mockLifecycle.handleFailure).not.toHaveBeenCalled(); + expect(mockLifecycle.cleanupProcessing).not.toHaveBeenCalled(); + }); + + it('stops the pipeline on needs_human_reconciliation', async () => { + vi.mocked(evaluateImplementationFreshness).mockResolvedValueOnce({ + kind: 'needs_human_reconciliation', + message: 'Implementation not started: needs human reconciliation.', + evidence: { uncertaintyReason: 'pr_lookup_failed' }, + }); + + await runAgentExecutionPipeline(mockTriggerResult, mockProject, mockConfig); + + expect(postFreshnessSkipNotice).toHaveBeenCalled(); + expect(runAgent).not.toHaveBeenCalled(); + }); + + it('continues normally when the gate returns dispatchable', async () => { + vi.mocked(evaluateImplementationFreshness).mockResolvedValueOnce({ + kind: 'dispatchable', + message: 'ok', + evidence: {}, + }); + + await runAgentExecutionPipeline(mockTriggerResult, mockProject, mockConfig); + + expect(postFreshnessSkipNotice).not.toHaveBeenCalled(); + expect(runAgent).toHaveBeenCalledWith('implementation', expect.any(Object)); + }); + + it('does not crash when the gate itself throws — proceeds with dispatch', async () => { + vi.mocked(evaluateImplementationFreshness).mockRejectedValueOnce(new Error('gate broke')); + + await runAgentExecutionPipeline(mockTriggerResult, mockProject, mockConfig); + + expect(runAgent).toHaveBeenCalledWith('implementation', expect.any(Object)); + }); + }); + describe('splitting agent auto-label propagation', () => { const mockProvider = { type: 'trello' as const, diff --git a/tests/unit/triggers/builtins.test.ts b/tests/unit/triggers/builtins.test.ts index ae77a6f48..034976bd9 100644 --- a/tests/unit/triggers/builtins.test.ts +++ b/tests/unit/triggers/builtins.test.ts @@ -43,6 +43,9 @@ vi.mock('../../../src/triggers/trello/status-changed.js', () => ({ TrelloStatusChangedTodoTrigger: { name: 'trello-status-changed-todo' }, TrelloStatusChangedBacklogTrigger: { name: 'trello-status-changed-backlog' }, TrelloStatusChangedMergedTrigger: { name: 'trello-status-changed-merged' }, + TrelloCustomStatusChangedTrigger: vi + .fn() + .mockImplementation(() => ({ name: 'trello-status-changed-custom' })), })); vi.mock('../../../src/triggers/trello/comment-mention.js', () => ({ TrelloCommentMentionTrigger: vi @@ -91,6 +94,7 @@ vi.mock('../../../src/integrations/pm/registry.js', () => ({ { name: 'trello-status-changed-todo' }, { name: 'trello-status-changed-backlog' }, { name: 'trello-status-changed-merged' }, + { name: 'trello-status-changed-custom' }, { name: 'ready-to-process-label' }, ], }, @@ -139,10 +143,11 @@ describe('registerBuiltInTriggers', () => { registerBuiltInTriggers(registry as unknown as TriggerRegistry); - // Should have registered all 25 built-in triggers (19 + 3 Sentry alerting + 3 Linear triggers). + // Should have registered all 26 built-in triggers (20 + 3 Sentry alerting + 3 Linear triggers). // Sentry triggers: SentryIssueAlertTrigger (event_alert), SentryMetricAlertTrigger // (metric_alert), SentryIssueLifecycleTrigger (issue lifecycle webhook). - expect(registry.register).toHaveBeenCalledTimes(25); + // Trello: +1 for TrelloCustomStatusChangedTrigger added for custom mapped lists. + expect(registry.register).toHaveBeenCalledTimes(26); }); it('registers TrelloCommentMentionTrigger first', () => { @@ -167,6 +172,21 @@ describe('registerBuiltInTriggers', () => { expect(registeredNames).toContain('trello-status-changed-merged'); }); + it('registers TrelloCustomStatusChangedTrigger after built-in status triggers and before ready-label trigger', () => { + const registry = createMockRegistry(); + + registerBuiltInTriggers(registry as unknown as TriggerRegistry); + + const names = registry.handlers.map((h: object) => (h as { name: string }).name); + const customIdx = names.indexOf('trello-status-changed-custom'); + const mergedIdx = names.indexOf('trello-status-changed-merged'); + const readyLabelIdx = names.indexOf('ready-to-process-label'); + + expect(customIdx).toBeGreaterThanOrEqual(0); + expect(mergedIdx).toBeLessThan(customIdx); + expect(customIdx).toBeLessThan(readyLabelIdx); + }); + it('registers GitHub triggers', () => { const registry = createMockRegistry(); diff --git a/tests/unit/triggers/implementation-freshness-gate.test.ts b/tests/unit/triggers/implementation-freshness-gate.test.ts new file mode 100644 index 000000000..6ec9f24d3 --- /dev/null +++ b/tests/unit/triggers/implementation-freshness-gate.test.ts @@ -0,0 +1,720 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockProject } from '../../helpers/factories.js'; +import { createMockPMProvider } from '../../helpers/mockPMProvider.js'; +import { mockGithubClient, mockLogger, mockWithGitHubToken } from '../../helpers/sharedMocks.js'; + +vi.mock('../../../src/utils/logging.js', () => ({ logger: mockLogger })); +vi.mock('../../../src/github/client.js', () => ({ + withGitHubToken: mockWithGitHubToken, + githubClient: mockGithubClient, +})); +vi.mock('../../../src/github/personas.js', () => ({ + getPersonaToken: vi.fn().mockResolvedValue('github-token'), +})); + +vi.mock('../../../src/db/repositories/runsRepository.js', () => ({ + countActiveRuns: vi.fn(), + DEFAULT_STALE_RUN_THRESHOLD_MS: 2 * 60 * 60 * 1000, + getRunsByWorkItem: vi.fn(), +})); + +vi.mock('../../../src/db/repositories/prWorkItemsRepository.js', () => ({ + listPRsForWorkItem: vi.fn(), +})); + +import { listPRsForWorkItem } from '../../../src/db/repositories/prWorkItemsRepository.js'; +import { countActiveRuns, getRunsByWorkItem } from '../../../src/db/repositories/runsRepository.js'; +import { getPersonaToken } from '../../../src/github/personas.js'; +import { + evaluateImplementationFreshness, + postFreshnessSkipNotice, +} from '../../../src/triggers/shared/implementation-freshness-gate.js'; + +const baseProject = createMockProject({ id: 'project-1', repo: 'org/repo' }); + +// Helper to build a typed `agent_runs.findByWorkItem` row used by tests. +type RunRow = Awaited>[number]; +function makeRunRow(overrides: Partial): RunRow { + return { + id: 'run-default', + projectId: 'project-1', + workItemId: 'card-1', + prNumber: null, + agentType: 'implementation', + engine: 'claude-code', + triggerType: null, + status: 'completed', + model: null, + maxIterations: null, + startedAt: new Date(), + completedAt: new Date(), + durationMs: 1000, + llmIterations: 1, + gadgetCalls: 1, + costUsd: '0.10', + success: true, + error: null, + prUrl: null, + outputSummary: null, + jobId: null, + workItemUrl: null, + workItemTitle: null, + prTitle: null, + ...overrides, + } as RunRow; +} + +beforeEach(() => { + vi.mocked(countActiveRuns).mockResolvedValue(0); + vi.mocked(getRunsByWorkItem).mockResolvedValue([]); + vi.mocked(listPRsForWorkItem).mockResolvedValue([]); + vi.mocked(getPersonaToken).mockResolvedValue('github-token'); + mockWithGitHubToken.mockImplementation((_token, fn) => fn()); +}); + +describe('evaluateImplementationFreshness', () => { + describe('agent-type gating', () => { + it('bypasses non-implementation agent types as dispatchable', async () => { + const provider = createMockPMProvider(); + const outcome = await evaluateImplementationFreshness({ + agentType: 'review', + workItemId: 'card-1', + project: baseProject, + provider, + }); + expect(outcome.kind).toBe('dispatchable'); + expect(provider.getChecklists).not.toHaveBeenCalled(); + }); + + it('bypasses respond-to-review as dispatchable', async () => { + const provider = createMockPMProvider(); + const outcome = await evaluateImplementationFreshness({ + agentType: 'respond-to-review', + workItemId: 'card-1', + project: baseProject, + provider, + }); + expect(outcome.kind).toBe('dispatchable'); + }); + + it('bypasses respond-to-ci as dispatchable', async () => { + const provider = createMockPMProvider(); + const outcome = await evaluateImplementationFreshness({ + agentType: 'respond-to-ci', + workItemId: 'card-1', + project: baseProject, + provider, + }); + expect(outcome.kind).toBe('dispatchable'); + }); + + it('bypasses when no workItemId is resolved', async () => { + const provider = createMockPMProvider(); + const outcome = await evaluateImplementationFreshness({ + agentType: 'implementation', + workItemId: undefined, + project: baseProject, + provider, + }); + expect(outcome.kind).toBe('dispatchable'); + expect(provider.getChecklists).not.toHaveBeenCalled(); + }); + }); + + describe('completed checklists', () => { + it('returns already_implemented when "Implementation Steps" is fully complete', async () => { + const provider = createMockPMProvider(); + provider.getChecklists.mockResolvedValue([ + { + id: 'cl-1', + name: 'Implementation Steps', + workItemId: 'card-1', + items: [ + { id: 'i-1', name: 'a', complete: true }, + { id: 'i-2', name: 'b', complete: true }, + ], + }, + ]); + + const outcome = await evaluateImplementationFreshness({ + agentType: 'implementation', + workItemId: 'card-1', + project: baseProject, + provider, + }); + + expect(outcome.kind).toBe('already_implemented'); + expect(outcome.evidence.completedChecklists).toContain('Implementation Steps'); + }); + + it('returns already_implemented when "Acceptance Criteria" is fully complete', async () => { + const provider = createMockPMProvider(); + provider.getChecklists.mockResolvedValue([ + { + id: 'cl-1', + name: 'Acceptance Criteria', + workItemId: 'card-1', + items: [{ id: 'i-1', name: 'a', complete: true }], + }, + ]); + + const outcome = await evaluateImplementationFreshness({ + agentType: 'implementation', + workItemId: 'card-1', + project: baseProject, + provider, + }); + + expect(outcome.kind).toBe('already_implemented'); + expect(outcome.evidence.completedChecklists).toContain('Acceptance Criteria'); + }); + + it('does NOT block on unrelated checklist names', async () => { + const provider = createMockPMProvider(); + provider.getChecklists.mockResolvedValue([ + { + id: 'cl-1', + name: 'Dependencies', + workItemId: 'card-1', + items: [{ id: 'i-1', name: 'a', complete: true }], + }, + { + id: 'cl-2', + name: 'Friction', + workItemId: 'card-1', + items: [{ id: 'i-2', name: 'b', complete: true }], + }, + ]); + + const outcome = await evaluateImplementationFreshness({ + agentType: 'implementation', + workItemId: 'card-1', + project: baseProject, + provider, + }); + + expect(outcome.kind).toBe('dispatchable'); + }); + + it('does NOT block on partially complete terminal checklists', async () => { + const provider = createMockPMProvider(); + provider.getChecklists.mockResolvedValue([ + { + id: 'cl-1', + name: 'Implementation Steps', + workItemId: 'card-1', + items: [ + { id: 'i-1', name: 'a', complete: true }, + { id: 'i-2', name: 'b', complete: false }, + ], + }, + ]); + + const outcome = await evaluateImplementationFreshness({ + agentType: 'implementation', + workItemId: 'card-1', + project: baseProject, + provider, + }); + + expect(outcome.kind).toBe('dispatchable'); + }); + + it('does NOT block on empty terminal checklists', async () => { + const provider = createMockPMProvider(); + provider.getChecklists.mockResolvedValue([ + { + id: 'cl-1', + name: 'Implementation Steps', + workItemId: 'card-1', + items: [], + }, + ]); + + const outcome = await evaluateImplementationFreshness({ + agentType: 'implementation', + workItemId: 'card-1', + project: baseProject, + provider, + }); + + expect(outcome.kind).toBe('dispatchable'); + }); + }); + + describe('active implementation runs', () => { + it('returns active_implementation when an in-flight run exists', async () => { + const provider = createMockPMProvider(); + provider.getChecklists.mockResolvedValue([]); + vi.mocked(countActiveRuns).mockResolvedValue(1); + + const outcome = await evaluateImplementationFreshness({ + agentType: 'implementation', + workItemId: 'card-1', + project: baseProject, + provider, + }); + + expect(outcome.kind).toBe('active_implementation'); + expect(countActiveRuns).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'project-1', + workItemId: 'card-1', + agentType: 'implementation', + }), + ); + }); + + it('does not block when count is zero', async () => { + const provider = createMockPMProvider(); + provider.getChecklists.mockResolvedValue([]); + vi.mocked(countActiveRuns).mockResolvedValue(0); + + const outcome = await evaluateImplementationFreshness({ + agentType: 'implementation', + workItemId: 'card-1', + project: baseProject, + provider, + }); + + expect(outcome.kind).toBe('dispatchable'); + }); + }); + + describe('linked PR verification', () => { + it('returns implementation_pr_exists when an open PR exists', async () => { + const provider = createMockPMProvider(); + provider.getChecklists.mockResolvedValue([]); + vi.mocked(listPRsForWorkItem).mockResolvedValue([ + { + prNumber: 42, + repoFullName: 'org/repo', + prUrl: 'https://github.com/org/repo/pull/42', + prTitle: 'feat: stuff', + workItemId: 'card-1', + workItemUrl: null, + workItemTitle: null, + runCount: 1, + }, + ]); + mockGithubClient.getPR.mockResolvedValue({ + number: 42, + title: 'feat: stuff', + body: null, + state: 'open', + htmlUrl: 'https://github.com/org/repo/pull/42', + headRef: 'feature/x', + headSha: 'sha', + baseRef: 'main', + merged: false, + mergeable: null, + user: { login: 'cascade-bot' }, + }); + + const outcome = await evaluateImplementationFreshness({ + agentType: 'implementation', + workItemId: 'card-1', + project: baseProject, + provider, + }); + + expect(outcome.kind).toBe('implementation_pr_exists'); + expect(outcome.message).toContain('/pull/42'); + expect(getPersonaToken).toHaveBeenCalledWith('project-1', 'implementation'); + expect(mockWithGitHubToken).toHaveBeenCalledWith('github-token', expect.any(Function)); + }); + + it('returns already_implemented when a linked PR is merged', async () => { + const provider = createMockPMProvider(); + provider.getChecklists.mockResolvedValue([]); + vi.mocked(listPRsForWorkItem).mockResolvedValue([ + { + prNumber: 99, + repoFullName: 'org/repo', + prUrl: 'https://github.com/org/repo/pull/99', + prTitle: 'feat: shipped', + workItemId: 'card-1', + workItemUrl: null, + workItemTitle: null, + runCount: 1, + }, + ]); + mockGithubClient.getPR.mockResolvedValue({ + number: 99, + title: 'feat: shipped', + body: null, + state: 'closed', + htmlUrl: 'https://github.com/org/repo/pull/99', + headRef: 'feature/x', + headSha: 'sha', + baseRef: 'main', + merged: true, + mergeable: null, + user: { login: 'cascade-bot' }, + }); + + const outcome = await evaluateImplementationFreshness({ + agentType: 'implementation', + workItemId: 'card-1', + project: baseProject, + provider, + }); + + expect(outcome.kind).toBe('already_implemented'); + }); + + it('does NOT block on closed-unmerged PRs (allows reimplementation)', async () => { + const provider = createMockPMProvider(); + provider.getChecklists.mockResolvedValue([]); + vi.mocked(listPRsForWorkItem).mockResolvedValue([ + { + prNumber: 7, + repoFullName: 'org/repo', + prUrl: 'https://github.com/org/repo/pull/7', + prTitle: 'abandoned', + workItemId: 'card-1', + workItemUrl: null, + workItemTitle: null, + runCount: 1, + }, + ]); + mockGithubClient.getPR.mockResolvedValue({ + number: 7, + title: 'abandoned', + body: null, + state: 'closed', + htmlUrl: 'https://github.com/org/repo/pull/7', + headRef: 'feature/x', + headSha: 'sha', + baseRef: 'main', + merged: false, + mergeable: null, + user: { login: 'cascade-bot' }, + }); + + const outcome = await evaluateImplementationFreshness({ + agentType: 'implementation', + workItemId: 'card-1', + project: baseProject, + provider, + }); + + expect(outcome.kind).toBe('dispatchable'); + }); + + it('allows manual reimplementation when a run-derived PR is closed-unmerged', async () => { + const provider = createMockPMProvider(); + provider.getChecklists.mockResolvedValue([]); + vi.mocked(listPRsForWorkItem).mockResolvedValue([]); + vi.mocked(getRunsByWorkItem).mockResolvedValue([ + makeRunRow({ + id: 'run-success', + success: true, + prUrl: 'https://github.com/org/repo/pull/77', + completedAt: new Date(), + }), + ]); + mockGithubClient.getPR.mockResolvedValue({ + number: 77, + title: 'closed retry target', + body: null, + state: 'closed', + htmlUrl: 'https://github.com/org/repo/pull/77', + headRef: 'feature/x', + headSha: 'sha', + baseRef: 'main', + merged: false, + mergeable: null, + user: { login: 'cascade-bot' }, + }); + + const outcome = await evaluateImplementationFreshness({ + agentType: 'implementation', + workItemId: 'card-1', + project: baseProject, + provider, + }); + + expect(outcome.kind).toBe('dispatchable'); + expect(getPersonaToken).toHaveBeenCalledWith('project-1', 'implementation'); + expect(mockWithGitHubToken).toHaveBeenCalledWith('github-token', expect.any(Function)); + expect(mockGithubClient.getPR).toHaveBeenCalledWith('org', 'repo', 77); + }); + + it('merges PR candidates from recent runs that point to a PR', async () => { + const provider = createMockPMProvider(); + provider.getChecklists.mockResolvedValue([]); + vi.mocked(listPRsForWorkItem).mockResolvedValue([]); + vi.mocked(getRunsByWorkItem).mockResolvedValue([ + makeRunRow({ + id: 'run-success', + success: true, + prUrl: 'https://github.com/org/repo/pull/12', + completedAt: new Date(), + }), + ]); + mockGithubClient.getPR.mockResolvedValue({ + number: 12, + title: 'derived', + body: null, + state: 'open', + htmlUrl: 'https://github.com/org/repo/pull/12', + headRef: 'feature/x', + headSha: 'sha', + baseRef: 'main', + merged: false, + mergeable: null, + user: { login: 'cascade-bot' }, + }); + + const outcome = await evaluateImplementationFreshness({ + agentType: 'implementation', + workItemId: 'card-1', + project: baseProject, + provider, + }); + + expect(outcome.kind).toBe('implementation_pr_exists'); + expect(mockGithubClient.getPR).toHaveBeenCalledWith('org', 'repo', 12); + }); + }); + + describe('fail-closed', () => { + it('returns needs_human_reconciliation on PR lookup failure with terminal checklist evidence', async () => { + const provider = createMockPMProvider(); + provider.getChecklists.mockResolvedValue([ + { + id: 'cl-1', + name: 'Implementation Steps', + workItemId: 'card-1', + // items present but partially complete — provides ownership evidence + // without being a clean terminal hit, so PR uncertainty matters. + items: [ + { id: 'i-1', name: 'a', complete: true }, + { id: 'i-2', name: 'b', complete: false }, + ], + }, + ]); + vi.mocked(countActiveRuns).mockResolvedValue(1); + vi.mocked(listPRsForWorkItem).mockResolvedValue([ + { + prNumber: 5, + repoFullName: 'org/repo', + prUrl: 'https://github.com/org/repo/pull/5', + prTitle: 'feat', + workItemId: 'card-1', + workItemUrl: null, + workItemTitle: null, + runCount: 1, + }, + ]); + mockGithubClient.getPR.mockRejectedValue(new Error('rate-limited')); + + const outcome = await evaluateImplementationFreshness({ + agentType: 'implementation', + workItemId: 'card-1', + project: baseProject, + provider, + }); + + // Active implementation run wins as the immediate cause rather than + // the PR uncertainty — both block dispatch, but tests pin the + // concrete outcome we expect from this combination. + expect(outcome.kind).toBe('active_implementation'); + }); + + it('fails closed when checklist read fails AND another ownership signal exists', async () => { + const provider = createMockPMProvider(); + provider.getChecklists.mockRejectedValue(new Error('PM read failed')); + vi.mocked(countActiveRuns).mockResolvedValue(0); + vi.mocked(listPRsForWorkItem).mockResolvedValue([]); + vi.mocked(getRunsByWorkItem).mockResolvedValue([ + makeRunRow({ + id: 'run-success', + success: true, + prUrl: 'https://github.com/org/repo/pull/3', + completedAt: new Date(), + }), + ]); + mockGithubClient.getPR.mockResolvedValue({ + number: 3, + title: 'old', + body: null, + state: 'closed', + htmlUrl: 'https://github.com/org/repo/pull/3', + headRef: 'x', + headSha: 'y', + baseRef: 'main', + merged: false, + mergeable: null, + user: { login: 'cascade' }, + }); + + const outcome = await evaluateImplementationFreshness({ + agentType: 'implementation', + workItemId: 'card-1', + project: baseProject, + provider, + }); + + expect(outcome.kind).toBe('needs_human_reconciliation'); + expect(outcome.evidence.uncertaintyReason).toBe('checklist_read_failed'); + }); + + it('treats successful implementation runs without PR as needs_human_reconciliation', async () => { + const provider = createMockPMProvider(); + provider.getChecklists.mockResolvedValue([]); + vi.mocked(countActiveRuns).mockResolvedValue(0); + vi.mocked(listPRsForWorkItem).mockResolvedValue([]); + vi.mocked(getRunsByWorkItem).mockResolvedValue([ + makeRunRow({ + id: 'run-weird', + success: true, + prUrl: null, + completedAt: new Date(), + }), + ]); + + const outcome = await evaluateImplementationFreshness({ + agentType: 'implementation', + workItemId: 'card-1', + project: baseProject, + provider, + }); + + expect(outcome.kind).toBe('needs_human_reconciliation'); + expect(outcome.evidence.uncertaintyReason).toBe('successful_implementation_without_pr'); + }); + + it('fails closed when checklist read fails even without other duplicate-work evidence', async () => { + const provider = createMockPMProvider(); + provider.getChecklists.mockRejectedValue(new Error('PM read failed')); + vi.mocked(countActiveRuns).mockResolvedValue(0); + vi.mocked(listPRsForWorkItem).mockResolvedValue([]); + vi.mocked(getRunsByWorkItem).mockResolvedValue([]); + + const outcome = await evaluateImplementationFreshness({ + agentType: 'implementation', + workItemId: 'card-1', + project: baseProject, + provider, + }); + + expect(outcome.kind).toBe('needs_human_reconciliation'); + expect(outcome.evidence.uncertaintyReason).toBe('checklist_read_failed'); + }); + + it('fails closed when a pr_work_items candidate cannot be verified', async () => { + const provider = createMockPMProvider(); + provider.getChecklists.mockResolvedValue([]); + vi.mocked(countActiveRuns).mockResolvedValue(0); + vi.mocked(listPRsForWorkItem).mockResolvedValue([ + { + prNumber: 88, + repoFullName: 'org/repo', + prUrl: 'https://github.com/org/repo/pull/88', + prTitle: 'possibly open', + workItemId: 'card-1', + workItemUrl: null, + workItemTitle: null, + runCount: 1, + }, + ]); + vi.mocked(getRunsByWorkItem).mockResolvedValue([]); + mockGithubClient.getPR.mockRejectedValue(new Error('No GitHub client in scope')); + + const outcome = await evaluateImplementationFreshness({ + agentType: 'implementation', + workItemId: 'card-1', + project: baseProject, + provider, + }); + + expect(outcome.kind).toBe('needs_human_reconciliation'); + expect(outcome.evidence.uncertaintyReason).toBe('pr_lookup_failed'); + }); + }); +}); + +describe('postFreshnessSkipNotice', () => { + it('updates the existing ack comment when present', async () => { + const provider = createMockPMProvider(); + await postFreshnessSkipNotice( + provider, + 'card-1', + { ackCommentId: 'comment-123' }, + { + kind: 'already_implemented', + message: 'Implementation not started: already implemented.', + evidence: {}, + }, + ); + + expect(provider.updateComment).toHaveBeenCalledWith( + 'card-1', + 'comment-123', + 'Implementation not started: already implemented.', + ); + expect(provider.addComment).not.toHaveBeenCalled(); + }); + + it('falls back to addComment when updateComment fails', async () => { + const provider = createMockPMProvider(); + provider.updateComment.mockRejectedValueOnce(new Error('comment vanished')); + + await postFreshnessSkipNotice( + provider, + 'card-1', + { ackCommentId: 'comment-deleted' }, + { + kind: 'implementation_pr_exists', + message: 'Implementation not started: existing PR ...', + evidence: {}, + }, + ); + + expect(provider.addComment).toHaveBeenCalledWith( + 'card-1', + 'Implementation not started: existing PR ...', + ); + }); + + it('posts a fresh comment when no ack comment id is available', async () => { + const provider = createMockPMProvider(); + await postFreshnessSkipNotice( + provider, + 'card-1', + {}, + { + kind: 'needs_human_reconciliation', + message: 'Implementation not started: needs human reconciliation.', + evidence: {}, + }, + ); + + expect(provider.updateComment).not.toHaveBeenCalled(); + expect(provider.addComment).toHaveBeenCalledWith( + 'card-1', + 'Implementation not started: needs human reconciliation.', + ); + }); + + it('does not throw when both updateComment and addComment fail', async () => { + const provider = createMockPMProvider(); + provider.updateComment.mockRejectedValueOnce(new Error('boom')); + provider.addComment.mockRejectedValueOnce(new Error('also boom')); + + await expect( + postFreshnessSkipNotice( + provider, + 'card-1', + { ackCommentId: 'c' }, + { + kind: 'already_implemented', + message: 'skip', + evidence: {}, + }, + ), + ).resolves.toBeUndefined(); + }); +}); diff --git a/tests/unit/triggers/jira-label-added.test.ts b/tests/unit/triggers/jira-label-added.test.ts index 36fd3f041..2a9b44310 100644 --- a/tests/unit/triggers/jira-label-added.test.ts +++ b/tests/unit/triggers/jira-label-added.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mockAcknowledgmentsModule, mockConfigProvider, @@ -9,6 +9,15 @@ import { mockTriggerCheckModule, } from '../../helpers/sharedMocks.js'; +const { mockGetCustomWorkflowStatusDefinition } = vi.hoisted(() => ({ + mockGetCustomWorkflowStatusDefinition: vi.fn(), +})); + +vi.mock('../../../src/db/repositories/workflowStatusDefinitionsRepository.js', () => ({ + getCustomWorkflowStatusDefinition: mockGetCustomWorkflowStatusDefinition, + listCustomWorkflowStatusDefinitions: vi.fn().mockResolvedValue([]), +})); + vi.mock('../../../src/triggers/config-resolver.js', () => mockConfigResolverModule); vi.mock('../../../src/triggers/shared/trigger-check.js', () => mockTriggerCheckModule); @@ -99,6 +108,11 @@ function buildCtx(overrides: { } describe('JiraReadyToProcessLabelTrigger', () => { + beforeEach(() => { + mockGetCustomWorkflowStatusDefinition.mockReset(); + mockGetCustomWorkflowStatusDefinition.mockResolvedValue(null); + }); + describe('matches()', () => { it('matches when cascade-ready label is added', () => { expect(trigger.matches(buildCtx({}))).toBe(true); @@ -298,4 +312,147 @@ describe('JiraReadyToProcessLabelTrigger', () => { expect(result).toBeNull(); }); }); + + describe('custom workflow status mapping', () => { + const customProject = { + ...baseProject, + jira: { + ...baseJiraConfig, + statuses: { + ...baseJiraConfig.statuses, + prd: 'PRD Review', + ux: 'UX Mocks', + }, + }, + } as TriggerContext['project']; + + it('dispatches a custom agent when the issue is in a custom status', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + const result = await trigger.handle( + buildCtx({ project: customProject, statusName: 'PRD Review' }), + ); + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('prd'); + expect(result?.workItemId).toBe('TEST-42'); + expect(result?.workItemUrl).toBe('https://test.atlassian.net/browse/TEST-42'); + expect(result?.workItemTitle).toBe('Test issue'); + expect(result?.agentInput.triggerEvent).toBe('pm:label-added'); + }); + + it('matches custom status names case-insensitively', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + const result = await trigger.handle( + buildCtx({ project: customProject, statusName: 'prd review' }), + ); + expect(result?.agentType).toBe('prd'); + }); + + it('returns null when a custom status has no dispatch agent configured', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'ux') { + return { + id: 2, + key: 'ux', + label: 'UX', + agentType: null, + sortOrder: 2000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + const result = await trigger.handle( + buildCtx({ project: customProject, statusName: 'UX Mocks' }), + ); + expect(result).toBeNull(); + }); + + it('returns null when a custom status is missing from workflow definitions', async () => { + mockGetCustomWorkflowStatusDefinition.mockResolvedValue(null); + + const result = await trigger.handle( + buildCtx({ project: customProject, statusName: 'PRD Review' }), + ); + expect(result).toBeNull(); + }); + + it('checks trigger enablement for the resolved custom agent', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + await trigger.handle(buildCtx({ project: customProject, statusName: 'PRD Review' })); + + expect(checkTriggerEnabled).toHaveBeenCalledWith( + 'test-project', + 'prd', + 'pm:label-added', + 'jira-ready-to-process-label-added', + ); + }); + + it('returns null when trigger is disabled for the resolved custom agent', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + vi.mocked(checkTriggerEnabled).mockResolvedValueOnce(false); + + const result = await trigger.handle( + buildCtx({ project: customProject, statusName: 'PRD Review' }), + ); + expect(result).toBeNull(); + }); + }); }); diff --git a/tests/unit/triggers/jira-status-changed.test.ts b/tests/unit/triggers/jira-status-changed.test.ts index 12a39197a..d6c20266b 100644 --- a/tests/unit/triggers/jira-status-changed.test.ts +++ b/tests/unit/triggers/jira-status-changed.test.ts @@ -5,6 +5,15 @@ import { mockTriggerCheckModule, } from '../../helpers/sharedMocks.js'; +const { mockGetCustomWorkflowStatusDefinition } = vi.hoisted(() => ({ + mockGetCustomWorkflowStatusDefinition: vi.fn(), +})); + +vi.mock('../../../src/db/repositories/workflowStatusDefinitionsRepository.js', () => ({ + getCustomWorkflowStatusDefinition: mockGetCustomWorkflowStatusDefinition, + listCustomWorkflowStatusDefinitions: vi.fn().mockResolvedValue([]), +})); + vi.mock('../../../src/utils/logging.js', () => ({ logger: mockLogger })); vi.mock('../../../src/triggers/config-resolver.js', () => mockConfigResolverModule); @@ -90,6 +99,7 @@ describe('JiraStatusChangedTrigger', () => { beforeEach(() => { vi.resetAllMocks(); mockTriggerConfig(true); + mockGetCustomWorkflowStatusDefinition.mockResolvedValue(null); trigger = new JiraStatusChangedTrigger(); }); @@ -367,4 +377,177 @@ describe('JiraStatusChangedTrigger', () => { expect(result).not.toHaveProperty('coalesceRole'); }); }); + + describe('custom workflow status mapping', () => { + const customProject = { + id: 'test-project', + name: 'Test Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + jira: { + projectKey: 'PROJ', + baseUrl: 'https://myorg.atlassian.net', + statuses: { + backlog: 'Backlog', + splitting: 'Splitting', + planning: 'Planning', + todo: 'To Do', + done: 'Done', + prd: 'PRD Review', + ux: 'UX Mocks', + }, + }, + } as TriggerContext['project']; + + function buildCustomCtx(statusName: string): TriggerContext { + return { + project: customProject, + source: 'jira', + payload: { + webhookEvent: 'jira:issue_updated', + issue: { + key: 'PROJ-42', + fields: { summary: 'Test Issue' }, + }, + changelog: { + items: [{ field: 'status', fromString: 'Backlog', toString: statusName }], + }, + }, + }; + } + + it('dispatches a custom agent when a custom status is configured and matches case-insensitively', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + const result = await trigger.handle(buildCustomCtx('prd review')); + + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('prd'); + expect(result?.workItemId).toBe('PROJ-42'); + expect(result?.workItemUrl).toBe('https://myorg.atlassian.net/browse/PROJ-42'); + expect(result?.workItemTitle).toBe('Test Issue'); + expect(result?.agentInput.triggerEvent).toBe('pm:status-changed'); + }); + + it('returns null when a custom status has no dispatch agent configured', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'ux') { + return { + id: 2, + key: 'ux', + label: 'UX', + agentType: null, + sortOrder: 2000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + const result = await trigger.handle(buildCustomCtx('UX Mocks')); + expect(result).toBeNull(); + }); + + it('returns null when a custom status is missing from the workflow definitions table', async () => { + mockGetCustomWorkflowStatusDefinition.mockResolvedValue(null); + + const result = await trigger.handle(buildCustomCtx('PRD Review')); + expect(result).toBeNull(); + }); + + it('calls checkTriggerEnabledWithParams with the custom agent type', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + await trigger.handle(buildCustomCtx('PRD Review')); + + expect(checkTriggerEnabledWithParams).toHaveBeenCalledWith( + 'test-project', + 'prd', + 'pm:status-changed', + 'jira-status-changed', + ); + }); + + it('returns null when the trigger is disabled for the resolved custom agent', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + mockTriggerConfig(false); + + const result = await trigger.handle(buildCustomCtx('PRD Review')); + expect(result).toBeNull(); + }); + + it('dispatches a custom agent on create when onCreate is enabled', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + mockTriggerConfig(true, { onCreate: true, onMove: true }); + + const ctx: TriggerContext = { + project: customProject, + source: 'jira', + payload: { + webhookEvent: 'jira:issue_created', + issue: { + key: 'PROJ-42', + fields: { summary: 'Test Issue', status: { name: 'PRD Review' } }, + }, + }, + }; + + const result = await trigger.handle(ctx); + expect(result?.agentType).toBe('prd'); + }); + }); }); diff --git a/tests/unit/triggers/label-added.test.ts b/tests/unit/triggers/label-added.test.ts index a8e07b814..bce55cca8 100644 --- a/tests/unit/triggers/label-added.test.ts +++ b/tests/unit/triggers/label-added.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mockAcknowledgmentsModule, mockConfigProvider, @@ -9,6 +9,15 @@ import { mockTriggerCheckModule, } from '../../helpers/sharedMocks.js'; +const { mockGetCustomWorkflowStatusDefinition } = vi.hoisted(() => ({ + mockGetCustomWorkflowStatusDefinition: vi.fn(), +})); + +vi.mock('../../../src/db/repositories/workflowStatusDefinitionsRepository.js', () => ({ + getCustomWorkflowStatusDefinition: mockGetCustomWorkflowStatusDefinition, + listCustomWorkflowStatusDefinitions: vi.fn().mockResolvedValue([]), +})); + vi.mock('../../../src/triggers/config-resolver.js', () => mockConfigResolverModule); vi.mock('../../../src/triggers/shared/trigger-check.js', () => mockTriggerCheckModule); @@ -37,6 +46,11 @@ describe('ReadyToProcessLabelTrigger', () => { const trigger = new ReadyToProcessLabelTrigger(); const mockGetCard = vi.mocked(trelloClient.getCard); + beforeEach(() => { + mockGetCustomWorkflowStatusDefinition.mockReset(); + mockGetCustomWorkflowStatusDefinition.mockResolvedValue(null); + }); + const mockProject = createMockProject({ trello: { boardId: 'board123', @@ -329,4 +343,225 @@ describe('ReadyToProcessLabelTrigger', () => { expect(result).toBeNull(); }); }); + + describe('custom workflow status mapping', () => { + const customProject = createMockProject({ + trello: { + boardId: 'board123', + lists: { + splitting: 'splitting-list-id', + planning: 'planning-list-id', + todo: 'todo-list-id', + prd: 'prd-list-id', + ux: 'ux-list-id', + }, + labels: { + readyToProcess: 'ready-label-id', + }, + }, + }); + + it('dispatches a custom agent when the card is in a list mapped to a custom workflow status', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + mockGetCard.mockResolvedValue( + createTrelloCard({ + id: 'card-custom', + name: 'Custom Card', + url: 'https://trello.com/c/abc/custom-card', + shortUrl: 'https://trello.com/c/abc', + idList: 'prd-list-id', + }), + ); + + const ctx: TriggerContext = { + project: customProject, + source: 'trello', + payload: createTrelloActionPayload({ + action: { + id: 'action1', + idMemberCreator: 'member1', + type: 'addLabelToCard', + date: '2024-01-01', + data: { + card: { id: 'card-custom', name: 'Custom Card', idShort: 99, shortLink: 'abc' }, + label: { id: 'ready-label-id', name: 'Ready', color: 'green' }, + }, + }, + }), + }; + + const result = await trigger.handle(ctx); + + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('prd'); + expect(result?.workItemId).toBe('card-custom'); + expect(result?.workItemUrl).toBe('https://trello.com/c/abc'); + expect(result?.workItemTitle).toBe('Custom Card'); + expect(result?.agentInput.triggerEvent).toBe('pm:label-added'); + }); + + it('returns null when a custom mapped status has no dispatch agent configured', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'ux') { + return { + id: 2, + key: 'ux', + label: 'UX', + agentType: null, + sortOrder: 2000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + mockGetCard.mockResolvedValue(createTrelloCard({ id: 'card-ux', idList: 'ux-list-id' })); + + const ctx: TriggerContext = { + project: customProject, + source: 'trello', + payload: createTrelloActionPayload({ + action: { + id: 'action1', + idMemberCreator: 'member1', + type: 'addLabelToCard', + date: '2024-01-01', + data: { + card: { id: 'card-ux', name: 'UX Card', idShort: 100, shortLink: 'uxx' }, + label: { id: 'ready-label-id', name: 'Ready', color: 'green' }, + }, + }, + }), + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when a custom mapped status is missing from workflow definitions', async () => { + mockGetCustomWorkflowStatusDefinition.mockResolvedValue(null); + + mockGetCard.mockResolvedValue(createTrelloCard({ id: 'card-prd', idList: 'prd-list-id' })); + + const ctx: TriggerContext = { + project: customProject, + source: 'trello', + payload: createTrelloActionPayload({ + action: { + id: 'action1', + idMemberCreator: 'member1', + type: 'addLabelToCard', + date: '2024-01-01', + data: { + card: { id: 'card-prd', name: 'PRD Card', idShort: 101, shortLink: 'prdx' }, + label: { id: 'ready-label-id', name: 'Ready', color: 'green' }, + }, + }, + }), + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('checks trigger enablement for the resolved custom agent', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + mockGetCard.mockResolvedValue(createTrelloCard({ id: 'card-custom', idList: 'prd-list-id' })); + + const ctx: TriggerContext = { + project: customProject, + source: 'trello', + payload: createTrelloActionPayload({ + action: { + id: 'action1', + idMemberCreator: 'member1', + type: 'addLabelToCard', + date: '2024-01-01', + data: { + card: { id: 'card-custom', name: 'Custom Card', idShort: 99, shortLink: 'abc' }, + label: { id: 'ready-label-id', name: 'Ready', color: 'green' }, + }, + }, + }), + }; + + await trigger.handle(ctx); + + expect(checkTriggerEnabled).toHaveBeenCalledWith( + 'test', + 'prd', + 'pm:label-added', + 'ready-to-process-label-added', + ); + }); + + it('returns null when the trigger is disabled for the resolved custom agent', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + vi.mocked(checkTriggerEnabled).mockResolvedValueOnce(false); + + mockGetCard.mockResolvedValue(createTrelloCard({ id: 'card-custom', idList: 'prd-list-id' })); + + const ctx: TriggerContext = { + project: customProject, + source: 'trello', + payload: createTrelloActionPayload({ + action: { + id: 'action1', + idMemberCreator: 'member1', + type: 'addLabelToCard', + date: '2024-01-01', + data: { + card: { id: 'card-custom', name: 'Custom Card', idShort: 99, shortLink: 'abc' }, + label: { id: 'ready-label-id', name: 'Ready', color: 'green' }, + }, + }, + }), + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + }); }); 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/manual-runner.test.ts b/tests/unit/triggers/manual-runner.test.ts index 7e2d04f32..2cae1577d 100644 --- a/tests/unit/triggers/manual-runner.test.ts +++ b/tests/unit/triggers/manual-runner.test.ts @@ -1,17 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('../../../src/agents/registry.js', () => ({ - runAgent: vi.fn(), +vi.mock('../../../src/agents/definitions/loader.js', () => ({ + isPMFocusedAgent: vi.fn().mockResolvedValue(false), })); vi.mock('../../../src/db/repositories/runsRepository.js', () => ({ getRunById: vi.fn(), })); -vi.mock('../../../src/db/repositories/prWorkItemsRepository.js', () => ({ - createWorkItem: vi.fn(), -})); - // Default: agent is enabled (has a config row) vi.mock('../../../src/db/repositories/agentConfigsRepository.js', () => ({ isAgentEnabledForProject: vi.fn().mockResolvedValue(true), @@ -52,12 +48,16 @@ vi.mock('../../../src/utils/lifecycle.js', () => ({ startWatchdog: vi.fn(), })); -import { runAgent } from '../../../src/agents/registry.js'; +vi.mock('../../../src/triggers/shared/agent-execution.js', () => ({ + runAgentExecutionPipeline: vi.fn().mockResolvedValue(undefined), +})); + +import { isPMFocusedAgent } from '../../../src/agents/definitions/loader.js'; import { isAgentEnabledForProject } from '../../../src/db/repositories/agentConfigsRepository.js'; -import { createWorkItem } from '../../../src/db/repositories/prWorkItemsRepository.js'; import { getRunById } from '../../../src/db/repositories/runsRepository.js'; import { withPMCredentials } from '../../../src/pm/context.js'; import { createPMProvider, withPMProvider } from '../../../src/pm/index.js'; +import { runAgentExecutionPipeline } from '../../../src/triggers/shared/agent-execution.js'; import { clearTriggerTracking, isTriggerRunning, @@ -65,7 +65,6 @@ import { triggerRetryRun, } from '../../../src/triggers/shared/manual-runner.js'; import type { CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; -import { logger } from '../../../src/utils/logging.js'; const mockProject: ProjectConfig = { id: 'test-project', @@ -85,6 +84,8 @@ const mockConfig = {} as CascadeConfig; describe('triggerManualRun', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(runAgentExecutionPipeline).mockResolvedValue(undefined); + vi.mocked(isPMFocusedAgent).mockResolvedValue(false); clearTriggerTracking(); }); @@ -105,9 +106,8 @@ describe('triggerManualRun', () => { }); it('throws when trigger is already running for same project+agent+card', async () => { - vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); // Never resolves + vi.mocked(runAgentExecutionPipeline).mockImplementation(() => new Promise(() => {})); - // Start first trigger (don't await — runAgent never resolves) const firstRun = triggerManualRun( { projectId: 'test-project', @@ -138,17 +138,11 @@ describe('triggerManualRun', () => { void firstRun; }); - it('calls runAgent with correct input including triggerType: manual', async () => { - vi.mocked(runAgent).mockResolvedValue({ - success: true, - output: 'Done', - runId: 'run-1', - }); - + it('runs through the shared agent execution pipeline with manual trigger input', async () => { await triggerManualRun( { projectId: 'test-project', - agentType: 'implementation', + agentType: 'plan-implement', workItemId: 'card-1', workItemUrl: 'https://linear.app/org/issue/TEAM-123/example', workItemTitle: 'Implement example', @@ -158,118 +152,88 @@ describe('triggerManualRun', () => { mockConfig, ); - expect(runAgent).toHaveBeenCalledWith( - 'implementation', + expect(runAgentExecutionPipeline).toHaveBeenCalledWith( expect.objectContaining({ + agentType: 'plan-implement', workItemId: 'card-1', workItemUrl: 'https://linear.app/org/issue/TEAM-123/example', workItemTitle: 'Implement example', - modelOverride: 'claude-3-5-sonnet-20241022', - triggerType: 'manual', - project: mockProject, - config: mockConfig, + agentInput: expect.objectContaining({ + workItemId: 'card-1', + workItemUrl: 'https://linear.app/org/issue/TEAM-123/example', + workItemTitle: 'Implement example', + modelOverride: 'claude-3-5-sonnet-20241022', + triggerType: 'manual', + }), }), - ); - - expect(createWorkItem).toHaveBeenCalledWith('test-project', 'card-1', { - workItemUrl: 'https://linear.app/org/issue/TEAM-123/example', - workItemTitle: 'Implement example', - }); - }); - - it('does not create a work item link when manual metadata is omitted', async () => { - vi.mocked(runAgent).mockResolvedValue({ - success: true, - output: 'Done', - runId: 'run-no-metadata', - }); - - await triggerManualRun( - { - projectId: 'test-project', - agentType: 'implementation', - workItemId: 'card-1', - }, mockProject, mockConfig, ); - - expect(createWorkItem).not.toHaveBeenCalled(); }); - it('continues running the agent when work item metadata persistence fails', async () => { - vi.mocked(createWorkItem).mockRejectedValueOnce(new Error('db unavailable')); - vi.mocked(runAgent).mockResolvedValue({ - success: true, - output: 'Done', - runId: 'run-metadata-failed', - }); - + it('passes PR fields through the shared execution pipeline when provided', async () => { await triggerManualRun( { projectId: 'test-project', - agentType: 'implementation', - workItemId: 'card-1', - workItemUrl: 'https://linear.app/org/issue/TEAM-123/example', - workItemTitle: 'Implement example', + agentType: 'review', + prNumber: 42, + prBranch: 'feature/test', + repoFullName: 'owner/repo', + headSha: 'abc123', }, mockProject, mockConfig, ); - expect(logger.warn).toHaveBeenCalledWith('Failed to persist work-item row for manual run', { - projectId: 'test-project', - workItemId: 'card-1', - error: 'Error: db unavailable', - }); - expect(runAgent).toHaveBeenCalledWith( - 'implementation', + expect(runAgentExecutionPipeline).toHaveBeenCalledWith( expect.objectContaining({ - workItemId: 'card-1', - triggerType: 'manual', + agentType: 'review', + prNumber: 42, + agentInput: expect.objectContaining({ + prNumber: 42, + prBranch: 'feature/test', + repoFullName: 'owner/repo', + headSha: 'abc123', + triggerType: 'manual', + }), }), + mockProject, + mockConfig, + { + skipPrepareForAgent: true, + skipHandleFailure: true, + handleSuccessOnlyForAgentType: 'implementation', + logLabel: 'GitHub manual agent', + }, ); }); - it('calls runAgent with PR fields when provided', async () => { - vi.mocked(runAgent).mockResolvedValue({ - success: true, - output: 'Done', - runId: 'run-2', - }); + it('keeps PM lifecycle defaults for PR-based manual runs owned by PM-focused agents', async () => { + vi.mocked(isPMFocusedAgent).mockResolvedValueOnce(true); await triggerManualRun( { projectId: 'test-project', - agentType: 'review', + agentType: 'backlog-manager', + workItemId: 'card-1', prNumber: 42, - prBranch: 'feature/test', - repoFullName: 'owner/repo', - headSha: 'abc123', }, mockProject, mockConfig, ); - expect(runAgent).toHaveBeenCalledWith( - 'review', + expect(runAgentExecutionPipeline).toHaveBeenCalledWith( expect.objectContaining({ + agentType: 'backlog-manager', + workItemId: 'card-1', prNumber: 42, - prBranch: 'feature/test', - repoFullName: 'owner/repo', - headSha: 'abc123', - triggerType: 'manual', }), + mockProject, + mockConfig, ); }); it('wraps runAgent with PM credential and provider context', async () => { - vi.mocked(runAgent).mockResolvedValue({ - success: true, - output: 'Done', - runId: 'run-pm', - }); - await triggerManualRun( { projectId: 'test-project', agentType: 'review', prNumber: 42 }, mockProject, @@ -296,12 +260,6 @@ describe('triggerManualRun', () => { const agentType = 'implementation'; const workItemId = 'card-complete'; - vi.mocked(runAgent).mockResolvedValue({ - success: true, - output: 'Done', - runId: 'run-complete', - }); - await triggerManualRun({ projectId, agentType, workItemId }, mockProject, mockConfig); // After awaiting triggerManualRun, trigger should already be complete @@ -314,7 +272,7 @@ describe('triggerManualRun', () => { const agentType = 'implementation'; const workItemId = 'card-fail'; - vi.mocked(runAgent).mockRejectedValue(new Error('Agent error')); + vi.mocked(runAgentExecutionPipeline).mockRejectedValue(new Error('Agent error')); await triggerManualRun({ projectId, agentType, workItemId }, mockProject, mockConfig); @@ -326,6 +284,9 @@ describe('triggerManualRun', () => { describe('triggerRetryRun', () => { beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(runAgentExecutionPipeline).mockResolvedValue(undefined); + vi.mocked(isPMFocusedAgent).mockResolvedValue(false); clearTriggerTracking(); }); @@ -359,21 +320,20 @@ describe('triggerRetryRun', () => { model: 'claude-sonnet-4-5-20250929', } as ReturnType extends Promise ? NonNullable : never); - vi.mocked(runAgent).mockResolvedValue({ - success: true, - output: 'Retried', - runId: 'run-2', - }); - await triggerRetryRun('run-1', mockProject, mockConfig); - expect(runAgent).toHaveBeenCalledWith( - 'implementation', + expect(runAgentExecutionPipeline).toHaveBeenCalledWith( expect.objectContaining({ + agentType: 'implementation', workItemId: 'card-1', - modelOverride: 'claude-sonnet-4-5-20250929', - triggerType: 'manual', + agentInput: expect.objectContaining({ + workItemId: 'card-1', + modelOverride: 'claude-sonnet-4-5-20250929', + triggerType: 'manual', + }), }), + mockProject, + mockConfig, ); }); @@ -387,19 +347,24 @@ describe('triggerRetryRun', () => { model: 'claude-sonnet-4-5-20250929', } as ReturnType extends Promise ? NonNullable : never); - vi.mocked(runAgent).mockResolvedValue({ - success: true, - output: 'Retried', - runId: 'run-3', - }); - await triggerRetryRun('run-1', mockProject, mockConfig, 'claude-3-5-sonnet-20241022'); - expect(runAgent).toHaveBeenCalledWith( - 'review', + expect(runAgentExecutionPipeline).toHaveBeenCalledWith( expect.objectContaining({ - modelOverride: 'claude-3-5-sonnet-20241022', + agentType: 'review', + prNumber: 10, + agentInput: expect.objectContaining({ + modelOverride: 'claude-3-5-sonnet-20241022', + }), }), + mockProject, + mockConfig, + { + skipPrepareForAgent: true, + skipHandleFailure: true, + handleSuccessOnlyForAgentType: 'implementation', + logLabel: 'GitHub manual agent', + }, ); }); }); diff --git a/tests/unit/triggers/shared/pm-label.test.ts b/tests/unit/triggers/shared/pm-label.test.ts index 2b80fbcf7..140ac29a1 100644 --- a/tests/unit/triggers/shared/pm-label.test.ts +++ b/tests/unit/triggers/shared/pm-label.test.ts @@ -1,12 +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 { buildPMLabelDispatchResult, resolvePMLabelAgentByList, resolvePMLabelAgentByStatusId, + resolvePMLabelAgentByStatusIdFromWorkflowDefinitions, resolvePMLabelAgentByStatusName, + resolvePMLabelAgentByStatusNameFromWorkflowDefinitions, } from '../../../../src/triggers/shared/pm-label.js'; describe('PM label helpers', () => { + beforeEach(() => { + mockGetCustomWorkflowStatusDefinition.mockReset(); + mockGetCustomWorkflowStatusDefinition.mockResolvedValue(null); + }); + it('resolves Trello current lists to agent types', () => { const lists = { splitting: 'list-splitting', @@ -42,6 +59,60 @@ describe('PM label helpers', () => { ).toEqual({ agentType: 'implementation', cascadeStatus: 'todo' }); }); + it('resolves JIRA status names through workflow definitions case-insensitively', async () => { + await expect( + resolvePMLabelAgentByStatusNameFromWorkflowDefinitions({ + statusName: 'to do', + configuredStatuses: { + todo: 'To Do', + }, + }), + ).resolves.toEqual({ agentType: 'implementation', cascadeStatus: 'todo' }); + }); + + it('resolves custom JIRA status names through workflow definitions', async () => { + mockGetCustomWorkflowStatusDefinition.mockResolvedValue({ + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }); + + await expect( + resolvePMLabelAgentByStatusNameFromWorkflowDefinitions({ + statusName: 'PRD Review', + configuredStatuses: { + prd: 'PRD Review', + }, + }), + ).resolves.toEqual({ agentType: 'prd', cascadeStatus: 'prd' }); + }); + + it('returns undefined when JIRA workflow status has no dispatch agent', async () => { + await expect( + resolvePMLabelAgentByStatusNameFromWorkflowDefinitions({ + statusName: 'Done', + configuredStatuses: { + done: 'Done', + }, + }), + ).resolves.toBeUndefined(); + }); + + it('resolves Linear state IDs through workflow definitions', async () => { + await expect( + resolvePMLabelAgentByStatusIdFromWorkflowDefinitions({ + statusId: 'state-todo', + configuredStatuses: { + todo: 'state-todo', + }, + }), + ).resolves.toEqual({ agentType: 'implementation', cascadeStatus: 'todo' }); + }); + it('builds canonical label-added dispatch results', () => { expect( buildPMLabelDispatchResult({ diff --git a/tests/unit/triggers/shared/pm-status.test.ts b/tests/unit/triggers/shared/pm-status.test.ts index 3d9c1f497..e834369d5 100644 --- a/tests/unit/triggers/shared/pm-status.test.ts +++ b/tests/unit/triggers/shared/pm-status.test.ts @@ -1,13 +1,30 @@ -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, + resolvePMStatusAgentByNameFromWorkflowDefinitions, 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 +60,93 @@ 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('resolves custom workflow status names to custom agents case-insensitively', async () => { + mockGetCustomWorkflowStatusDefinition.mockResolvedValue({ + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }); + + await expect( + resolvePMStatusAgentByNameFromWorkflowDefinitions({ + statusName: 'prd review', + configuredStatuses: { + prd: 'PRD Review', + }, + }), + ).resolves.toEqual({ agentType: 'prd', cascadeStatus: 'prd' }); + }); + + it('resolves built-in status names through workflow definitions case-insensitively', async () => { + await expect( + resolvePMStatusAgentByNameFromWorkflowDefinitions({ + statusName: 'to do', + configuredStatuses: { + todo: 'To Do', + }, + }), + ).resolves.toEqual({ agentType: 'implementation', cascadeStatus: 'todo' }); + }); + + it('ignores workflow statuses with no dispatch agent when resolving by name', async () => { + await expect( + resolvePMStatusAgentByNameFromWorkflowDefinitions({ + statusName: 'Done', + configuredStatuses: { + done: 'Done', + }, + }), + ).resolves.toBeUndefined(); + }); + + it('returns undefined when name does not match any configured status', async () => { + await expect( + resolvePMStatusAgentByNameFromWorkflowDefinitions({ + statusName: 'Unknown', + configuredStatuses: { + todo: 'To Do', + planning: 'Planning', + }, + }), + ).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/triggers/trello-custom-status-changed.test.ts b/tests/unit/triggers/trello-custom-status-changed.test.ts new file mode 100644 index 000000000..e0e214846 --- /dev/null +++ b/tests/unit/triggers/trello-custom-status-changed.test.ts @@ -0,0 +1,467 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + mockAcknowledgmentsModule, + mockConfigProvider, + mockConfigResolverModule, + mockJiraClientModule, + mockLogger, + mockReactionsModule, + mockTrelloClientModule, + mockTriggerCheckModule, +} from '../../helpers/sharedMocks.js'; + +const { mockGetCustomWorkflowStatusDefinition } = vi.hoisted(() => ({ + mockGetCustomWorkflowStatusDefinition: vi.fn(), +})); + +vi.mock('../../../src/db/repositories/workflowStatusDefinitionsRepository.js', () => ({ + getCustomWorkflowStatusDefinition: mockGetCustomWorkflowStatusDefinition, + listCustomWorkflowStatusDefinitions: vi.fn().mockResolvedValue([]), +})); + +vi.mock('../../../src/utils/logging.js', () => ({ logger: mockLogger })); + +vi.mock('../../../src/triggers/config-resolver.js', () => mockConfigResolverModule); +vi.mock('../../../src/triggers/shared/trigger-check.js', () => mockTriggerCheckModule); + +// Spec 017 / plan 2: the capacity gate is now fail-closed when no +// PM-provider AsyncLocalStorage scope is in effect (the case in these +// unit tests). Mock as passthrough so trigger-logic assertions still run. +vi.mock('../../../src/triggers/shared/pipeline-capacity-gate.js', () => ({ + shouldBlockForPipelineCapacity: vi.fn().mockResolvedValue(false), +})); + +// Mocks required for PM integration registration (pm/index.js side-effect) +vi.mock('../../../src/config/provider.js', () => mockConfigProvider); +vi.mock('../../../src/trello/client.js', () => mockTrelloClientModule); +vi.mock('../../../src/jira/client.js', () => mockJiraClientModule); +vi.mock('../../../src/router/acknowledgments.js', () => mockAcknowledgmentsModule); +vi.mock('../../../src/router/reactions.js', () => mockReactionsModule); + +// Register PM integrations in the registry +import '../../../src/pm/index.js'; + +import { checkTriggerEnabledWithParams } from '../../../src/triggers/shared/trigger-check.js'; +import { TrelloCustomStatusChangedTrigger } from '../../../src/triggers/trello/status-changed.js'; +import type { TriggerContext } from '../../../src/triggers/types.js'; +import { createMockProject, createTrelloActionPayload } from '../../helpers/factories.js'; + +/** Default mock: enabled, onCreate=true onMove=true (matches Trello's backfilled state). */ +function mockTriggerConfig( + enabled: boolean, + parameters: Record = { onCreate: true, onMove: true }, +) { + vi.mocked(checkTriggerEnabledWithParams).mockResolvedValue({ enabled, parameters }); +} + +// Project with both built-in and custom mapped lists. The custom 'prd' and +// 'ux' keys map to non-built-in workflow status definitions. +const customProject = createMockProject({ + trello: { + boardId: 'board123', + lists: { + splitting: 'splitting-list-id', + planning: 'planning-list-id', + todo: 'todo-list-id', + backlog: 'backlog-list-id', + merged: 'merged-list-id', + prd: 'prd-list-id', + ux: 'ux-list-id', + }, + labels: {}, + }, +}); + +function movePayload(targetListId: string, fromListId = 'other-list-id') { + return createTrelloActionPayload({ + action: { + id: 'action1', + idMemberCreator: 'member1', + type: 'updateCard', + date: '2024-01-01', + data: { + card: { id: 'card789', name: 'Custom Card', idShort: 1, shortLink: 'xyz' }, + listBefore: { id: fromListId, name: 'From' }, + listAfter: { id: targetListId, name: 'To' }, + }, + }, + }); +} + +function createPayload(targetListId: string) { + return createTrelloActionPayload({ + action: { + id: 'action1', + idMemberCreator: 'member1', + type: 'createCard', + date: '2024-01-01', + data: { + card: { id: 'card789', name: 'Custom Card', idShort: 1, shortLink: 'xyz' }, + list: { id: targetListId, name: 'To' }, + }, + }, + }); +} + +describe('TrelloCustomStatusChangedTrigger', () => { + const trigger = new TrelloCustomStatusChangedTrigger(); + + beforeEach(() => { + mockGetCustomWorkflowStatusDefinition.mockReset(); + mockGetCustomWorkflowStatusDefinition.mockResolvedValue(null); + mockTriggerConfig(true); + }); + + describe('matches', () => { + it('matches when card moved to a list mapped under a custom workflow status key', () => { + const ctx: TriggerContext = { + project: customProject, + source: 'trello', + payload: movePayload('prd-list-id'), + }; + expect(trigger.matches(ctx)).toBe(true); + }); + + it('matches when card created directly in a custom-mapped list', () => { + const ctx: TriggerContext = { + project: customProject, + source: 'trello', + payload: createPayload('ux-list-id'), + }; + expect(trigger.matches(ctx)).toBe(true); + }); + + it('does not match destination lists mapped under built-in status keys (handled by built-in triggers)', () => { + for (const builtInListId of [ + 'splitting-list-id', + 'planning-list-id', + 'todo-list-id', + 'backlog-list-id', + 'merged-list-id', + ]) { + const ctx: TriggerContext = { + project: customProject, + source: 'trello', + payload: movePayload(builtInListId), + }; + expect(trigger.matches(ctx)).toBe(false); + } + }); + + it('does not match when destination list is not in the project config', () => { + const ctx: TriggerContext = { + project: customProject, + source: 'trello', + payload: movePayload('some-other-list-id'), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match when the move is a no-op (same list before and after)', () => { + const ctx: TriggerContext = { + project: customProject, + source: 'trello', + payload: movePayload('prd-list-id', 'prd-list-id'), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match non-trello source', () => { + const ctx: TriggerContext = { + project: customProject, + source: 'github', + payload: {}, + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match unrelated trello action types (e.g. addLabelToCard)', () => { + const ctx: TriggerContext = { + project: customProject, + source: 'trello', + payload: createTrelloActionPayload({ + action: { + id: 'action1', + idMemberCreator: 'member1', + type: 'addLabelToCard', + date: '2024-01-01', + data: { + card: { id: 'card789', name: 'Custom Card', idShort: 1, shortLink: 'xyz' }, + label: { id: 'label-1', name: 'Label', color: 'blue' }, + }, + }, + }), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match when trello config has no lists', () => { + const noListsProject = createMockProject({ + trello: { boardId: 'board123', lists: {}, labels: {} }, + }); + const ctx: TriggerContext = { + project: noListsProject, + source: 'trello', + payload: movePayload('prd-list-id'), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + }); + + describe('handle — custom mapped status dispatch', () => { + it('dispatches a custom agent when the destination list maps to a custom workflow status', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + const ctx: TriggerContext = { + project: customProject, + source: 'trello', + payload: movePayload('prd-list-id'), + }; + + const result = await trigger.handle(ctx); + + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('prd'); + expect(result?.workItemId).toBe('card789'); + expect(result?.agentInput.workItemId).toBe('card789'); + expect(result?.workItemUrl).toBe('https://trello.com/c/xyz'); + expect(result?.workItemTitle).toBe('Custom Card'); + expect(result?.agentInput.triggerEvent).toBe('pm:status-changed'); + expect(result?.coalesceKey).toBe('test:card789'); + }); + + it('dispatches a custom agent on create when onCreate is enabled', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + mockTriggerConfig(true, { onCreate: true, onMove: true }); + + const ctx: TriggerContext = { + project: customProject, + source: 'trello', + payload: createPayload('prd-list-id'), + }; + + const result = await trigger.handle(ctx); + expect(result?.agentType).toBe('prd'); + }); + + it('calls checkTriggerEnabledWithParams with the resolved custom agent type', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + const ctx: TriggerContext = { + project: customProject, + source: 'trello', + payload: movePayload('prd-list-id'), + }; + + await trigger.handle(ctx); + + expect(checkTriggerEnabledWithParams).toHaveBeenCalledWith( + 'test', + 'prd', + 'pm:status-changed', + 'trello-status-changed-custom', + ); + }); + }); + + describe('handle — no-agent / no-match behavior', () => { + it('returns null when the custom status has no dispatch agent configured', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'ux') { + return { + id: 2, + key: 'ux', + label: 'UX', + agentType: null, + sortOrder: 2000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + const ctx: TriggerContext = { + project: customProject, + source: 'trello', + payload: movePayload('ux-list-id'), + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when the custom status is missing from workflow definitions table', async () => { + mockGetCustomWorkflowStatusDefinition.mockResolvedValue(null); + + const ctx: TriggerContext = { + project: customProject, + source: 'trello', + payload: movePayload('prd-list-id'), + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when the trigger is disabled for the resolved custom agent', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + mockTriggerConfig(false); + + const ctx: TriggerContext = { + project: customProject, + source: 'trello', + payload: movePayload('prd-list-id'), + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when card ID is missing', async () => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + + const ctx: TriggerContext = { + project: customProject, + source: 'trello', + payload: createTrelloActionPayload({ + action: { + id: 'action1', + idMemberCreator: 'member1', + type: 'updateCard', + date: '2024-01-01', + data: { + listBefore: { id: 'other-list-id', name: 'From' }, + listAfter: { id: 'prd-list-id', name: 'To' }, + }, + }, + }), + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + }); + + describe('handle — onCreate / onMove gating', () => { + beforeEach(() => { + mockGetCustomWorkflowStatusDefinition.mockImplementation(async (key: string) => { + if (key === 'prd') { + return { + id: 1, + key: 'prd', + label: 'PRD', + agentType: 'prd', + sortOrder: 1000, + createdAt: null, + updatedAt: null, + }; + } + return null; + }); + }); + + it('does NOT fire on create when onCreate=false', async () => { + mockTriggerConfig(true, { onCreate: false, onMove: true }); + const ctx: TriggerContext = { + project: customProject, + source: 'trello', + payload: createPayload('prd-list-id'), + }; + expect(await trigger.handle(ctx)).toBeNull(); + }); + + it('does NOT fire on move when onMove=false', async () => { + mockTriggerConfig(true, { onCreate: true, onMove: false }); + const ctx: TriggerContext = { + project: customProject, + source: 'trello', + payload: movePayload('prd-list-id'), + }; + expect(await trigger.handle(ctx)).toBeNull(); + }); + + it('fires only on create when onCreate=true and onMove=false', async () => { + mockTriggerConfig(true, { onCreate: true, onMove: false }); + const createCtx: TriggerContext = { + project: customProject, + source: 'trello', + payload: createPayload('prd-list-id'), + }; + expect((await trigger.handle(createCtx))?.agentType).toBe('prd'); + + mockTriggerConfig(true, { onCreate: true, onMove: false }); + const moveCtx: TriggerContext = { + project: customProject, + source: 'trello', + payload: movePayload('prd-list-id'), + }; + expect(await trigger.handle(moveCtx)).toBeNull(); + }); + }); +}); 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..c833666e7 --- /dev/null +++ b/tests/unit/web/global-definitions-route.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { AGENT_DEFINITIONS_TABS } from '../../../web/src/routes/global/definitions-tabs.js'; +import { getStatusDispatchAgentTypes } from '../../../web/src/routes/global/definitions-utils.js'; + +describe('global definitions route', () => { + it('exposes the workflow statuses tab in the tab bar', () => { + expect(AGENT_DEFINITIONS_TABS).toEqual(['definitions', 'partials', 'workflow-statuses']); + }); + + it('lists only agents that declare status-changed dispatch for workflow status mappings', () => { + const result = getStatusDispatchAgentTypes([ + { + agentType: 'debug', + definition: { + triggers: [], + }, + }, + { + agentType: 'story', + definition: { + triggers: [ + { + event: 'pm:status-changed', + }, + ], + }, + }, + { + agentType: 'respond-to-pr-comment', + definition: { + triggers: [ + { + event: 'scm:pr-comment-mention', + }, + ], + }, + }, + ]); + + expect(result).toEqual(['story']); + }); +}); diff --git a/tests/unit/web/pm-wizard-hooks.test.ts b/tests/unit/web/pm-wizard-hooks.test.ts index f85e66ffe..0d4f8013c 100644 --- a/tests/unit/web/pm-wizard-hooks.test.ts +++ b/tests/unit/web/pm-wizard-hooks.test.ts @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { jiraProviderWizard } from '../../../web/src/components/projects/pm-providers/jira/wizard.js'; import { linearProviderWizard } from '../../../web/src/components/projects/pm-providers/linear/wizard.js'; +import { buildMissingStatusTriggerConfigs } from '../../../web/src/components/projects/pm-providers/save-trigger-configs.js'; import { trelloProviderWizard } from '../../../web/src/components/projects/pm-providers/trello/wizard.js'; import { buildCurrentUserDiscoveryRequest, @@ -149,6 +150,73 @@ 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', isBuiltin: false }, + { key: 'done', agentType: null, isBuiltin: true }, + { key: 'story', agentType: 'story', isBuiltin: false }, + ], + 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', isBuiltin: false }], + existingConfigs: [{ agentType: 'prd', triggerEvent: 'pm:status-changed' }], + }); + + expect(result).toEqual([]); + }); + + it('auto-enables only allowlisted built-in status trigger configs', () => { + const result = buildMissingStatusTriggerConfigs({ + statusMappings: { + backlog: 'state-backlog', + planning: 'state-planning', + todo: 'state-todo', + inReview: 'state-review', + }, + workflowStatuses: [ + { key: 'backlog', agentType: 'backlog-manager', isBuiltin: true }, + { key: 'planning', agentType: 'planning', isBuiltin: true }, + { key: 'todo', agentType: 'implementation', isBuiltin: true }, + { key: 'inReview', agentType: null, isBuiltin: true }, + ], + existingConfigs: [], + }); + + expect(result).toEqual([ + { + agentType: 'planning', + triggerEvent: 'pm:status-changed', + enabled: true, + }, + { + agentType: 'implementation', + triggerEvent: 'pm:status-changed', + enabled: true, + }, + ]); + }); +}); + // ============================================================================ // Metadata-driven verification // ============================================================================ @@ -649,6 +717,204 @@ describe('linearProviderWizard.buildIntegrationConfig', () => { }); }); +// ============================================================================ +// trelloProviderWizard.buildSaveTriggerConfigs +// ============================================================================ + +describe('trelloProviderWizard.buildSaveTriggerConfigs', () => { + function seed(overrides: Partial = {}): WizardState { + return { + ...createInitialState(), + provider: 'trello', + trelloBoardId: 'board-1', + trelloListMappings: { todo: 'list-todo' }, + ...overrides, + }; + } + + it('creates a status-changed trigger for a mapped custom status with a dispatch agent', () => { + const configs = trelloProviderWizard.buildSaveTriggerConfigs?.({ + state: seed({ + trelloListMappings: { + todo: 'list-todo', + prd: 'list-prd', + }, + }), + workflowStatuses: [ + { key: 'todo', agentType: 'implementation', isBuiltin: true }, + { key: 'prd', agentType: 'prd', isBuiltin: false }, + ], + existingConfigs: [], + }); + + expect(configs).toEqual([ + { agentType: 'implementation', triggerEvent: 'pm:status-changed', enabled: true }, + { agentType: 'prd', triggerEvent: 'pm:status-changed', enabled: true }, + ]); + }); + + it('does not overwrite existing trigger configs', () => { + const configs = trelloProviderWizard.buildSaveTriggerConfigs?.({ + state: seed({ + trelloListMappings: { + todo: 'list-todo', + prd: 'list-prd', + }, + }), + workflowStatuses: [ + { key: 'todo', agentType: 'implementation', isBuiltin: true }, + { key: 'prd', agentType: 'prd', isBuiltin: false }, + ], + existingConfigs: [{ agentType: 'prd', triggerEvent: 'pm:status-changed' }], + }); + + expect(configs).toEqual([ + { agentType: 'implementation', triggerEvent: 'pm:status-changed', enabled: true }, + ]); + }); + + it('preserves built-in splitting/planning/todo defaults when mapped', () => { + const configs = trelloProviderWizard.buildSaveTriggerConfigs?.({ + state: seed({ + trelloListMappings: { + splitting: 'list-splitting', + planning: 'list-planning', + todo: 'list-todo', + }, + }), + workflowStatuses: [ + { key: 'splitting', agentType: 'splitting', isBuiltin: true }, + { key: 'planning', agentType: 'planning', isBuiltin: true }, + { key: 'todo', agentType: 'implementation', isBuiltin: true }, + ], + existingConfigs: [], + }); + + expect(configs).toEqual([ + { agentType: 'splitting', triggerEvent: 'pm:status-changed', enabled: true }, + { agentType: 'planning', triggerEvent: 'pm:status-changed', enabled: true }, + { agentType: 'implementation', triggerEvent: 'pm:status-changed', enabled: true }, + ]); + }); + + it('does not create a config for a status with no agentType', () => { + const configs = trelloProviderWizard.buildSaveTriggerConfigs?.({ + state: seed({ + trelloListMappings: { + alerts: 'list-alerts', + friction: 'list-friction', + }, + }), + workflowStatuses: [ + { key: 'alerts', agentType: null, isBuiltin: true }, + { key: 'friction', agentType: null, isBuiltin: true }, + ], + existingConfigs: [], + }); + + expect(configs).toEqual([]); + }); +}); + +// ============================================================================ +// jiraProviderWizard.buildSaveTriggerConfigs +// ============================================================================ + +describe('jiraProviderWizard.buildSaveTriggerConfigs', () => { + function seed(overrides: Partial = {}): WizardState { + return { + ...createInitialState(), + provider: 'jira', + jiraProjectKey: 'PROJ', + jiraStatusMappings: { todo: 'To Do' }, + ...overrides, + }; + } + + it('creates a status-changed trigger for a mapped custom status with a dispatch agent', () => { + const configs = jiraProviderWizard.buildSaveTriggerConfigs?.({ + state: seed({ + jiraStatusMappings: { + todo: 'To Do', + prd: 'PRD', + }, + }), + workflowStatuses: [ + { key: 'todo', agentType: 'implementation', isBuiltin: true }, + { key: 'prd', agentType: 'prd', isBuiltin: false }, + ], + existingConfigs: [], + }); + + expect(configs).toEqual([ + { agentType: 'implementation', triggerEvent: 'pm:status-changed', enabled: true }, + { agentType: 'prd', triggerEvent: 'pm:status-changed', enabled: true }, + ]); + }); + + it('does not overwrite existing trigger configs', () => { + const configs = jiraProviderWizard.buildSaveTriggerConfigs?.({ + state: seed({ + jiraStatusMappings: { + todo: 'To Do', + prd: 'PRD', + }, + }), + workflowStatuses: [ + { key: 'todo', agentType: 'implementation', isBuiltin: true }, + { key: 'prd', agentType: 'prd', isBuiltin: false }, + ], + existingConfigs: [{ agentType: 'prd', triggerEvent: 'pm:status-changed' }], + }); + + expect(configs).toEqual([ + { agentType: 'implementation', triggerEvent: 'pm:status-changed', enabled: true }, + ]); + }); + + it('preserves built-in splitting/planning/todo defaults when mapped', () => { + const configs = jiraProviderWizard.buildSaveTriggerConfigs?.({ + state: seed({ + jiraStatusMappings: { + splitting: 'Splitting', + planning: 'Planning', + todo: 'To Do', + }, + }), + workflowStatuses: [ + { key: 'splitting', agentType: 'splitting', isBuiltin: true }, + { key: 'planning', agentType: 'planning', isBuiltin: true }, + { key: 'todo', agentType: 'implementation', isBuiltin: true }, + ], + existingConfigs: [], + }); + + expect(configs).toEqual([ + { agentType: 'splitting', triggerEvent: 'pm:status-changed', enabled: true }, + { agentType: 'planning', triggerEvent: 'pm:status-changed', enabled: true }, + { agentType: 'implementation', triggerEvent: 'pm:status-changed', enabled: true }, + ]); + }); + + it('does not create a config for a status with no agentType', () => { + const configs = jiraProviderWizard.buildSaveTriggerConfigs?.({ + state: seed({ + jiraStatusMappings: { + alerts: 'Alerts', + friction: 'Friction', + }, + }), + workflowStatuses: [ + { key: 'alerts', agentType: null, isBuiltin: true }, + { key: 'friction', agentType: null, isBuiltin: true }, + ], + existingConfigs: [], + }); + + expect(configs).toEqual([]); + }); +}); + // ============================================================================ // runPerLabelCreations // ============================================================================ diff --git a/tests/unit/web/stats-filters.test.ts b/tests/unit/web/stats-filters.test.ts new file mode 100644 index 000000000..ae00ea6be --- /dev/null +++ b/tests/unit/web/stats-filters.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { getStatsAgentTypeOptions } from '../../../web/src/components/projects/stats-filters.js'; + +describe('getStatsAgentTypeOptions', () => { + it('uses dynamically supplied custom agent types', () => { + expect(getStatsAgentTypeOptions(['prd', 'plan-implement'])).toEqual(['prd', 'plan-implement']); + }); + + it('falls back to built-in agent types when dynamic data is unavailable', () => { + const options = getStatsAgentTypeOptions(); + + expect(options).toContain('implementation'); + expect(options).toContain('planning'); + }); + + it('keeps the selected custom value even if it is not in the loaded options', () => { + expect(getStatsAgentTypeOptions(['implementation'], 'prd')).toEqual(['implementation', 'prd']); + }); +}); diff --git a/web/src/components/projects/pm-providers/jira/wizard.ts b/web/src/components/projects/pm-providers/jira/wizard.ts index 92ffb4a38..d52f247fa 100644 --- a/web/src/components/projects/pm-providers/jira/wizard.ts +++ b/web/src/components/projects/pm-providers/jira/wizard.ts @@ -20,6 +20,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { ReactElement } from 'react'; import { API_URL } from '@/lib/api.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; +import { buildMissingStatusTriggerConfigs } from '../save-trigger-configs.js'; import { ContainerPickStep } from '../steps/container-pick.js'; import { CredentialsStep } from '../steps/credentials.js'; import { CustomFieldMappingStep } from '../steps/custom-field-mapping.js'; @@ -130,6 +131,7 @@ interface JiraProviderHooks { readonly createError: string | undefined; readonly deleteJiraWebhook: (callbackBaseUrl: string) => void; readonly deleteLoading: boolean; + readonly workflowStatuses: ReadonlyArray<{ readonly key: string; readonly label: string }>; } function asJiraHooks(providerHooks: Record | undefined): JiraProviderHooks { @@ -180,7 +182,7 @@ function JiraStatusMappingAdapter({ return StatusMappingStep({ step: { kind: 'status-mapping', id: 'jira-statuses' }, providerId: 'jira', - cascadeStatuses: JIRA_STATUS_SLOTS, + cascadeStatuses: h.workflowStatuses.length > 0 ? h.workflowStatuses : JIRA_STATUS_SLOTS, providerStates: h.providerStates, mappings: state.jiraStatusMappings, onMappingChange: (key, value) => dispatch({ type: 'SET_JIRA_STATUS_MAPPING', key, value }), @@ -306,6 +308,13 @@ export const jiraProviderWizard: ProviderWizardDefinition = { ...(state.jiraCostFieldId ? { customFields: { cost: state.jiraCostFieldId } } : {}), }), + buildSaveTriggerConfigs: ({ state, workflowStatuses, existingConfigs }) => + buildMissingStatusTriggerConfigs({ + statusMappings: state.jiraStatusMappings, + workflowStatuses, + existingConfigs, + }), + buildEditState: (initialConfig, configuredKeys) => { const statuses = initialConfig.statuses as Record | undefined; const issueTypes = initialConfig.issueTypes as Record | undefined; @@ -359,6 +368,12 @@ export const jiraProviderWizard: ProviderWizardDefinition = { const webhooksQuery = useQuery(trpc.webhooks.list.queryOptions({ projectId: projectId ?? '' })); const activeJiraWebhooks = normalizeJiraActiveWebhooks(webhooksQuery.data); + // Load workflow status definitions so the status-mapping step can + // render rows for custom statuses (e.g. `prd`) alongside the + // built-in CASCADE stages. Falls back to `JIRA_STATUS_SLOTS` while + // loading or when the query has no data. + const workflowStatusesQuery = useQuery(trpc.workflowStatuses.list.queryOptions()); + const createWebhookMutation = useMutation({ mutationFn: () => trpcClient.webhooks.create.mutate({ @@ -422,6 +437,11 @@ export const jiraProviderWizard: ProviderWizardDefinition = { : undefined, deleteJiraWebhook: (baseUrl: string) => deleteWebhookMutation.mutate(baseUrl), deleteLoading: deleteWebhookMutation.isPending, + workflowStatuses: + workflowStatusesQuery.data?.map((status) => ({ + key: status.key, + label: status.label, + })) ?? JIRA_STATUS_SLOTS, } satisfies JiraProviderHooks & Record; }, }; diff --git a/web/src/components/projects/pm-providers/linear/wizard.ts b/web/src/components/projects/pm-providers/linear/wizard.ts index 92983781c..3ae35f6bc 100644 --- a/web/src/components/projects/pm-providers/linear/wizard.ts +++ b/web/src/components/projects/pm-providers/linear/wizard.ts @@ -24,6 +24,7 @@ import { type ReactElement, useState } from 'react'; import { API_URL } from '@/lib/api.js'; import { trpc } from '@/lib/trpc.js'; import type { ProjectCredentialMeta } from '../../project-secret-field.js'; +import { buildMissingStatusTriggerConfigs } from '../save-trigger-configs.js'; import { ContainerPickStep } from '../steps/container-pick.js'; import { CredentialsStep } from '../steps/credentials.js'; import { LabelMappingStep } from '../steps/label-mapping.js'; @@ -135,6 +136,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 +181,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 }), @@ -281,6 +283,13 @@ export const linearProviderWizard: ProviderWizardDefinition = { ...(Object.keys(state.linearLabels).length > 0 ? { labels: state.linearLabels } : {}), }), + buildSaveTriggerConfigs: ({ state, workflowStatuses, existingConfigs }) => + buildMissingStatusTriggerConfigs({ + statusMappings: state.linearStatusMappings, + workflowStatuses, + existingConfigs, + }), + buildEditState: (initialConfig, configuredKeys) => { const statuses = initialConfig.statuses as Record | undefined; const labels = initialConfig.labels as Record | undefined; @@ -311,6 +320,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 +380,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-providers/save-trigger-configs.ts b/web/src/components/projects/pm-providers/save-trigger-configs.ts new file mode 100644 index 000000000..39bbdc5b5 --- /dev/null +++ b/web/src/components/projects/pm-providers/save-trigger-configs.ts @@ -0,0 +1,47 @@ +interface StatusTriggerInput { + readonly key: string; + readonly agentType: string | null; + readonly isBuiltin: boolean; +} + +interface ExistingTriggerInput { + readonly agentType: string; + readonly triggerEvent: string; +} + +const AUTO_ENABLED_BUILTIN_STATUS_KEYS = new Set(['splitting', 'planning', 'todo']); + +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 (status.isBuiltin && !AUTO_ENABLED_BUILTIN_STATUS_KEYS.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, + }, + ]; + }); +} diff --git a/web/src/components/projects/pm-providers/trello/wizard.ts b/web/src/components/projects/pm-providers/trello/wizard.ts index d7c4a0c3b..0469e67df 100644 --- a/web/src/components/projects/pm-providers/trello/wizard.ts +++ b/web/src/components/projects/pm-providers/trello/wizard.ts @@ -22,6 +22,7 @@ import { useState } from 'react'; import { API_URL } from '@/lib/api.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; import type { WizardState } from '../../pm-wizard-state.js'; +import { buildMissingStatusTriggerConfigs } from '../save-trigger-configs.js'; import { ContainerPickStep } from '../steps/container-pick.js'; import { CustomFieldMappingStep } from '../steps/custom-field-mapping.js'; import { LabelMappingStep } from '../steps/label-mapping.js'; @@ -155,6 +156,7 @@ interface TrelloProviderHooks { readonly createError: string | undefined; readonly deleteTrelloWebhook: (callbackBaseUrl: string) => void; readonly deleteLoading: boolean; + readonly workflowStatuses: ReadonlyArray<{ readonly key: string; readonly label: string }>; } function asTrelloHooks(providerHooks: Record | undefined): TrelloProviderHooks { @@ -190,7 +192,7 @@ function TrelloStatusMappingAdapter({ return StatusMappingStep({ step: { kind: 'status-mapping', id: 'trello-statuses' }, providerId: 'trello', - cascadeStatuses: TRELLO_LIST_SLOTS, + cascadeStatuses: h.workflowStatuses.length > 0 ? h.workflowStatuses : TRELLO_LIST_SLOTS, providerStates: h.providerStates, mappings: state.trelloListMappings, onMappingChange: (key, value) => dispatch({ type: 'SET_TRELLO_LIST_MAPPING', key, value }), @@ -299,6 +301,13 @@ export const trelloProviderWizard: ProviderWizardDefinition = { ...(state.trelloCostFieldId ? { customFields: { cost: state.trelloCostFieldId } } : {}), }), + buildSaveTriggerConfigs: ({ state, workflowStatuses, existingConfigs }) => + buildMissingStatusTriggerConfigs({ + statusMappings: state.trelloListMappings, + workflowStatuses, + existingConfigs, + }), + buildEditState: (initialConfig, configuredKeys) => { const editState = { provider: 'trello', @@ -378,6 +387,12 @@ export const trelloProviderWizard: ProviderWizardDefinition = { const webhooksQuery = useQuery(trpc.webhooks.list.queryOptions({ projectId: projectId ?? '' })); const activeTrelloWebhooks = normalizeTrelloActiveWebhooks(webhooksQuery.data); + // Load workflow status definitions so the status-mapping step can + // render rows for custom statuses (e.g. `prd`) alongside the + // built-in CASCADE stages. Falls back to `TRELLO_LIST_SLOTS` while + // loading or when the query has no data. + const workflowStatusesQuery = useQuery(trpc.workflowStatuses.list.queryOptions()); + const createWebhookMutation = useMutation({ mutationFn: () => trpcClient.webhooks.create.mutate({ @@ -439,6 +454,11 @@ export const trelloProviderWizard: ProviderWizardDefinition = { : undefined, deleteTrelloWebhook: (baseUrl: string) => deleteWebhookMutation.mutate(baseUrl), deleteLoading: deleteWebhookMutation.isPending, + workflowStatuses: + workflowStatusesQuery.data?.map((status) => ({ + key: status.key, + label: status.label, + })) ?? TRELLO_LIST_SLOTS, } satisfies TrelloProviderHooks & Record; }, }; diff --git a/web/src/components/projects/pm-providers/types.ts b/web/src/components/projects/pm-providers/types.ts index 9a5483da1..65e7cb0f5 100644 --- a/web/src/components/projects/pm-providers/types.ts +++ b/web/src/components/projects/pm-providers/types.ts @@ -88,6 +88,25 @@ export interface ProviderCredentialPersistenceMapping { readonly label: string; } +export interface ProviderSaveTriggerConfig { + readonly agentType: string; + readonly triggerEvent: string; + readonly enabled: boolean; +} + +export interface ProviderSaveTriggerContext { + readonly state: WizardState; + readonly workflowStatuses: ReadonlyArray<{ + readonly key: string; + readonly agentType: string | null; + readonly isBuiltin: boolean; + }>; + readonly existingConfigs: ReadonlyArray<{ + readonly agentType: string; + readonly triggerEvent: string; + }>; +} + export interface ProviderWizardDefinition { /** Must match the backend manifest id (e.g. 'trello', 'linear'). */ readonly id: string; @@ -110,6 +129,13 @@ export interface ProviderWizardDefinition { * save API. Mirrors the existing `buildXxxIntegrationConfig` functions. */ readonly buildIntegrationConfig: (state: WizardState) => Record; + /** + * Optional provider-owned trigger config side effects to run after saving + * integration config and credentials. + */ + readonly buildSaveTriggerConfigs?: ( + context: ProviderSaveTriggerContext, + ) => readonly ProviderSaveTriggerConfig[]; /** * Hydrates provider-owned edit-mode wizard state from a saved integration * config plus the project credential keys currently configured on the server. diff --git a/web/src/components/projects/pm-wizard-hooks.ts b/web/src/components/projects/pm-wizard-hooks.ts index 27061fa2c..fcb2fa426 100644 --- a/web/src/components/projects/pm-wizard-hooks.ts +++ b/web/src/components/projects/pm-wizard-hooks.ts @@ -387,16 +387,22 @@ 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) { - await trpcClient.agentTriggerConfigs.bulkUpsert.mutate({ - projectId, - configs: [ - { agentType: 'implementation', triggerEvent: 'pm:status-changed', enabled: true }, - { agentType: 'splitting', triggerEvent: 'pm:status-changed', enabled: true }, - { agentType: 'planning', triggerEvent: 'pm:status-changed', enabled: true }, - ], + if (manifestDef.buildSaveTriggerConfigs) { + const [workflowStatuses, existingConfigs] = await Promise.all([ + trpcClient.workflowStatuses.list.query(), + trpcClient.agentTriggerConfigs.listByProject.query({ projectId }), + ]); + const configs = manifestDef.buildSaveTriggerConfigs({ + state, + workflowStatuses, + existingConfigs, }); + if (configs.length > 0) { + await trpcClient.agentTriggerConfigs.bulkUpsert.mutate({ + projectId, + configs: [...configs], + }); + } } // If the user switched provider mid-edit, clean up the old provider's credentials. diff --git a/web/src/components/projects/stats-filters.tsx b/web/src/components/projects/stats-filters.tsx index ca152d7d8..b15e8c19e 100644 --- a/web/src/components/projects/stats-filters.tsx +++ b/web/src/components/projects/stats-filters.tsx @@ -1,4 +1,4 @@ -const agentTypes = [ +const defaultAgentTypes = [ 'splitting', 'planning', 'implementation', @@ -12,6 +12,13 @@ const agentTypes = [ 'resolve-conflicts', ]; +export function getStatsAgentTypeOptions(agentTypes?: string[], selectedAgentType?: string) { + const source = agentTypes && agentTypes.length > 0 ? agentTypes : defaultAgentTypes; + return [...new Set([...source, ...(selectedAgentType ? [selectedAgentType] : [])])].filter( + Boolean, + ); +} + const statuses = ['completed', 'failed', 'timed_out']; const timeRanges = [ @@ -29,13 +36,16 @@ export interface StatsFilters { interface StatsFiltersProps { filters: StatsFilters; + agentTypes?: string[]; onFilterChange: (filters: StatsFilters) => void; } const selectClass = 'h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring dark:bg-card dark:text-foreground [&_option]:bg-card sm:w-auto'; -export function StatsFiltersBar({ filters, onFilterChange }: StatsFiltersProps) { +export function StatsFiltersBar({ filters, agentTypes, onFilterChange }: StatsFiltersProps) { + const agentTypeOptions = getStatsAgentTypeOptions(agentTypes, filters.agentType); + return (
+ 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', diff --git a/web/src/routes/projects/$projectId.stats.tsx b/web/src/routes/projects/$projectId.stats.tsx index f4a603fec..ce46687f2 100644 --- a/web/src/routes/projects/$projectId.stats.tsx +++ b/web/src/routes/projects/$projectId.stats.tsx @@ -37,6 +37,7 @@ function ProjectStatsPage() { status: filters.status || undefined, }), ); + const agentTypesQuery = useQuery(trpc.agentDefinitions.knownTypes.queryOptions()); return (
@@ -44,7 +45,11 @@ function ProjectStatsPage() {

Stats

- + {statsQuery.isLoading && (
Loading stats...