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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ cascade projects trigger-set <project-id> --agent <type> --event <event> --enabl

Some triggers take params (e.g. `review` + `scm:check-suite-success` accepts `{"authorMode":"own"|"external"}`). Legacy configs on `project_integrations.triggers` are auto-migrated on merge to `dev`/`main`.

**External webhook trigger** — `internal:external-webhook` lets any third-party system dispatch an agent via `POST {router}/external/webhook/:projectId/:agentType` with `Authorization: Bearer <password>`. The password is the `EXTERNAL_WEBHOOK_PASSWORD_<AGENT_TYPE>` project credential (org inheritance applies; server enforces ≥16 chars); authentication **fails closed** — unknown project, unset password, and wrong token all return an identical generic 401 (anti-enumeration; distinct decision reasons only in `webhook_logs`), and failed attempts are IP-rate-limited via the shared sliding-window limiter. The POST body (≤64 KiB, read with an incremental cap) reaches the agent as its work request — `appendExternalTriggerRequest` appends it to the rendered task prompt centrally, gated on `triggerType === 'external-webhook'`. Dispatch rides the existing manual-run dashboard-job path (`createQueuedRun` + `submitDashboardJob`), so runs appear as trigger `external-webhook`. **Security invariant:** `EXTERNAL_WEBHOOK_PASSWORD_*` keys are inbound-auth verifiers and are excluded from worker container env injection (`src/router/worker-env.ts`) — an agent must never see them. Enable per agent in the project's Agents → Triggers tab, which displays the URL and password field (`src/router/external-webhook.ts`, helpers at `src/triggers/shared/external-webhook.ts`).

**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.
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/03-trigger-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ Triggers use category-prefixed events from `src/triggers/shared/events.ts`. `TRI
- PM: `pm:status-changed`, `pm:label-added`, `pm:comment-mention`
- SCM: `scm:check-suite-success`, `scm:check-suite-failure`, `scm:pr-review-submitted`, `scm:review-requested`, `scm:pr-opened`, `scm:pr-comment-mention`, `scm:pr-merged`, `scm:pr-ready-to-merge`, `scm:pr-conflict-detected`
- Alerting: `alerting:issue-alert`, `alerting:metric-alert`, `alerting:issue-lifecycle`
- Internal: `internal:auto-chain`
- Internal: `internal:auto-chain`, `internal:external-webhook`

New handlers should import `TRIGGER_EVENTS` instead of adding raw string literals. The static guard in `tests/unit/triggers/trigger-event-consistency.test.ts` fails when a handler gates on one event string and emits a different `agentInput.triggerEvent`.

Expand Down
7 changes: 7 additions & 0 deletions src/agents/definitions/alerting.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ triggers:
defaultEnabled: true
providers: [sentry]
contextPipeline: [alertingIssue, directoryListing, contextFiles]
- event: internal:external-webhook
label: External Webhook
description: >-
Dispatch this agent from an external system via an authenticated POST to
the project's per-agent webhook URL. The request body reaches the agent
as trigger context.
defaultEnabled: false

strategies: {}

Expand Down
7 changes: 7 additions & 0 deletions src/agents/definitions/backlog-manager.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ triggers:
description: When splitting completes on a card with the auto label, immediately chain to backlog manager
defaultEnabled: false
contextPipeline: [pipelineSnapshot]
- event: internal:external-webhook
label: External Webhook
description: >-
Dispatch this agent from an external system via an authenticated POST to
the project's per-agent webhook URL. The request body reaches the agent
as trigger context.
defaultEnabled: false

# Required context — runs for EVERY invocation regardless of trigger source.
# Manual `cascade runs trigger --agent-type backlog-manager` would otherwise
Expand Down
7 changes: 7 additions & 0 deletions src/agents/definitions/implementation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ triggers:
options: [todo]
defaultValue: todo
contextPipeline: [directoryListing, contextFiles, workItem, prepopulateTodos]
- event: internal:external-webhook
label: External Webhook
description: >-
Dispatch this agent from an external system via an authenticated POST to
the project's per-agent webhook URL. The request body reaches the agent
as trigger context.
defaultEnabled: false

strategies: {}

Expand Down
7 changes: 7 additions & 0 deletions src/agents/definitions/planning.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ triggers:
options: [planning]
defaultValue: planning
contextPipeline: [directoryListing, contextFiles, workItem]
- event: internal:external-webhook
label: External Webhook
description: >-
Dispatch this agent from an external system via an authenticated POST to
the project's per-agent webhook URL. The request body reaches the agent
as trigger context.
defaultEnabled: false
strategies: {}

hooks:
Expand Down
6 changes: 5 additions & 1 deletion src/agents/definitions/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '../capabilities/resolver.js';
import type { ContextInjection, ToolManifest } from '../contracts/index.js';
import {
appendExternalTriggerRequest,
buildTaskPromptContext,
renderInlineTaskPrompt,
validateTemplate,
Expand Down Expand Up @@ -255,7 +256,10 @@ function buildProfileFromDefinition(def: AgentDefinition, agentType: string): Ag
return injections;
},
buildTaskPrompt: (input) =>
renderInlineTaskPrompt(taskPromptTemplate, buildTaskPromptContext(input)),
appendExternalTriggerRequest(
renderInlineTaskPrompt(taskPromptTemplate, buildTaskPromptContext(input)),
input,
),
capabilities: def.capabilities,
getLlmistGadgets: (integrationChecker?: IntegrationChecker) => {
// Resolve effective capabilities based on integration availability
Expand Down
7 changes: 7 additions & 0 deletions src/agents/definitions/resolve-conflicts.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ triggers:
defaultEnabled: false
providers: [github, gitlab]
contextPipeline: [prContext, directoryListing, contextFiles, workItem]
- event: internal:external-webhook
label: External Webhook
description: >-
Dispatch this agent from an external system via an authenticated POST to
the project's per-agent webhook URL. The request body reaches the agent
as trigger context.
defaultEnabled: false

strategies: {}

Expand Down
7 changes: 7 additions & 0 deletions src/agents/definitions/review.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ triggers:
options: [own, external, all]
defaultValue: own
contextPipeline: [prContext, contextFiles, workItem]
- event: internal:external-webhook
label: External Webhook
description: >-
Dispatch this agent from an external system via an authenticated POST to
the project's per-agent webhook URL. The request body reaches the agent
as trigger context.
defaultEnabled: false
strategies: {}

prompts:
Expand Down
7 changes: 7 additions & 0 deletions src/agents/definitions/splitting.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ triggers:
options: [splitting]
defaultValue: splitting
contextPipeline: [directoryListing, contextFiles, workItem]
- event: internal:external-webhook
label: External Webhook
description: >-
Dispatch this agent from an external system via an authenticated POST to
the project's per-agent webhook URL. The request body reaches the agent
as trigger context.
defaultEnabled: false

strategies: {}

Expand Down
31 changes: 31 additions & 0 deletions src/agents/prompts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,37 @@ export function buildTaskPromptContext(input: TaskPromptInput): TaskPromptContex
};
}

/**
* Append the external webhook request payload to a rendered task prompt.
*
* The external webhook trigger's core promise is that the POST body reaches
* the agent as its work request — but only the respond-to-* task prompts
* render the commentBody template variable. Rather than editing every agent's
* task prompt, this appends a standard section centrally, gated on the
* external-webhook trigger type so no other dispatch path is affected.
*/
export function appendExternalTriggerRequest(
renderedTaskPrompt: string,
input: { triggerType?: string; triggerCommentBody?: string },
): string {
if (input.triggerType !== 'external-webhook' || !input.triggerCommentBody) {
return renderedTaskPrompt;
}
return [
renderedTaskPrompt,
'',
'## External trigger request',
'',
'This run was dispatched by an external system via webhook. The request',
'payload below describes what this run should accomplish — treat it as',
'the work request:',
'',
'<external-request>',
input.triggerCommentBody,
'</external-request>',
].join('\n');
}

/**
* Render an inline task prompt template with Eta variable interpolation.
* Used for task prompts stored directly in agent definitions (prompts.taskPrompt).
Expand Down
7 changes: 7 additions & 0 deletions src/api/routers/_shared/triggerTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,5 +234,12 @@ export const TRIGGER_REGISTRY: Record<TriggerCategory, KnownTriggerEvent[]> = {
description: 'Orchestration trigger for chaining agents after completion',
contextPipeline: [],
},
{
event: 'internal:external-webhook',
label: 'External Webhook',
description:
'Dispatch from an external system via an authenticated POST to the per-agent webhook URL',
contextPipeline: [],
},
],
};
20 changes: 20 additions & 0 deletions src/api/routers/_shared/webhookPasswordPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { TRPCError } from '@trpc/server';
import { isExternalWebhookPasswordKey } from '../../../triggers/shared/external-webhook.js';

export const EXTERNAL_WEBHOOK_PASSWORD_MIN_LENGTH = 16;

/**
* External webhook passwords authenticate an unauthenticated internet-facing
* endpoint that dispatches agents — a guessable password is remote agent
* execution. Enforce a minimum length at every write path (project + org
* credential mutations). Other credential keys are unaffected.
*/
export function assertWebhookPasswordStrength(envVarKey: string, value: string): void {
if (!isExternalWebhookPasswordKey(envVarKey)) return;
if (value.length < EXTERNAL_WEBHOOK_PASSWORD_MIN_LENGTH) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Webhook passwords must be at least ${EXTERNAL_WEBHOOK_PASSWORD_MIN_LENGTH} characters`,
});
}
}
2 changes: 2 additions & 0 deletions src/api/routers/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { captureException } from '../../sentry.js';
import { adminProcedure, protectedProcedure, router, superAdminProcedure } from '../trpc.js';
import { maskCredentialValue } from './_shared/maskCredential.js';
import { assertOrgAdmin, resolveActorRole } from './_shared/orgRole.js';
import { assertWebhookPasswordStrength } from './_shared/webhookPasswordPolicy.js';

export const organizationRouter = router({
get: protectedProcedure.query(async ({ ctx }) => {
Expand Down Expand Up @@ -99,6 +100,7 @@ export const organizationRouter = router({
)
.mutation(async ({ ctx, input }) => {
assertOrgAdmin(await resolveActorRole(ctx));
assertWebhookPasswordStrength(input.envVarKey, input.value);
await writeOrgCredential(
ctx.effectiveOrgId,
input.envVarKey,
Expand Down
2 changes: 2 additions & 0 deletions src/api/routers/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { captureException } from '../../sentry.js';
import { logger } from '../../utils/logging.js';
import { protectedProcedure, publicProcedure, router, superAdminProcedure } from '../trpc.js';
import { maskCredentialValue } from './_shared/maskCredential.js';
import { assertWebhookPasswordStrength } from './_shared/webhookPasswordPolicy.js';

/**
* The current worker-image/dockerfile state read alongside the ownership check.
Expand Down Expand Up @@ -921,6 +922,7 @@ export const projectsRouter = router({
)
.mutation(async ({ ctx, input }) => {
await verifyProjectOwnership(input.projectId, ctx.effectiveOrgId);
assertWebhookPasswordStrength(input.envVarKey, input.value);
await writeProjectCredential(
input.projectId,
input.envVarKey,
Expand Down
7 changes: 7 additions & 0 deletions src/queue/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ export interface ManualRunJob {
triggerCommentUrl?: string;
triggerCommentPath?: string;
triggerCommentAuthor?: string;
/**
* Trigger provenance for non-dashboard callers of the manual-run path
* (e.g. the router's external webhook endpoint). Defaults to 'manual'.
*/
triggerType?: 'manual' | 'external-webhook';
/** Canonical trigger event (e.g. 'internal:external-webhook') for observability. */
triggerEvent?: string;
/**
* MNG-1695: id of the `status='queued'` run row pre-created at tRPC trigger
* time. The worker activates it (queued → running) instead of inserting a new
Expand Down
Loading
Loading