Skip to content

feat(webhooks): add dispatch listing and replay#44

Open
devcool20 wants to merge 7 commits into
inthhq:mainfrom
devcool20:fix/6-dispatch-replay
Open

feat(webhooks): add dispatch listing and replay#44
devcool20 wants to merge 7 commits into
inthhq:mainfrom
devcool20:fix/6-dispatch-replay

Conversation

@devcool20

@devcool20 devcool20 commented May 19, 2026

Copy link
Copy Markdown

Summary

Closes #6.

This PR adds the first scoped recovery flow for outbound webhook dispatches:

  • Adds GET /webhooks/dispatches to list recorded outbound webhook delivery attempts.
  • Adds POST /webhooks/dispatches/:id/replay to replay one failed webhook dispatch.
  • Adds CLI coverage through:
    • dsar webhooks list --status failed
    • dsar webhooks replay <dispatch-id>
  • Documents the new API and CLI surfaces.
  • Updates OpenAPI snapshots and route parity coverage.

Scope

This PR intentionally keeps the feature narrow and safe:

  • Replay is limited to one dispatch at a time.
  • Only failed webhook delivery attempts are replayable.
  • Non-webhook attempts are rejected.
  • Operator/service authorization is required.
  • Replays support x-idempotency-key so retrying the same replay request does not send the webhook twice.

Maintainer Update

Follow-up commits on this PR complete the remaining issue #6 scope:

  • POST /webhooks/dispatches/replay bulk replay with filter body (status, endpoint_id, created_after/created_before, limit, capped at 100 per call), idempotent per dispatch, continuing past per-dispatch delivery failures
  • dsar webhooks replay-all CLI command
  • dsar webhooks tail polling command (mirrors dsar audit tail)
  • changeset added (dsar: minor)

Out of scope (tracked separately): the async retry worker (#5) and replaying non-webhook email attempts.

Screenshots

Sequence Diagram

Untitled-2025-11-23-1715

Commands showing the desired behaviour

Screenshot 2026-05-19 174118 Screenshot 2026-05-19 174156

Testing

Ran:

bun x ultracite check
bun --cwd packages/backend vitest run test/runtime.test.ts test/openapi.test.ts test/services/notifications.service.test.ts test/lifecycle/idempotency.test.ts
bun --cwd packages/cli vitest run test/config.test.ts test/e2e/parity-guard.e2e.test.ts test/e2e/commands.e2e.test.ts



@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@KayleeWilliams, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 49 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7c4a3281-b775-4ca6-81f0-7ef5d4aff761

📥 Commits

Reviewing files that changed from the base of the PR and between 239ea85 and 50b07af.

⛔ Files ignored due to path filters (1)
  • packages/backend/test/__snapshots__/openapi.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (20)
  • .changeset/webhook-dispatch-replay.md
  • docs/reference/api/webhooks.mdx
  • docs/reference/developer/cli.mdx
  • packages/backend/src/http-api/groups/webhooks.ts
  • packages/backend/src/routes/webhooks.ts
  • packages/backend/src/routes/webhooks/dispatches.ts
  • packages/backend/src/services/notifications/service.ts
  • packages/backend/src/testing/minimal-persistence.ts
  • packages/backend/test/e2e/tenant-isolation.test.ts
  • packages/backend/test/runtime.test.ts
  • packages/backend/test/services/notifications.service.test.ts
  • packages/cli/src/commands/factory.ts
  • packages/cli/src/commands/helpers.ts
  • packages/cli/src/commands/webhooks-tail.ts
  • packages/cli/src/commands/webhooks.ts
  • packages/cli/src/parity/route-map.ts
  • packages/cli/src/types.ts
  • packages/cli/test/e2e/commands.e2e.test.ts
  • packages/cli/test/e2e/parity-guard.e2e.test.ts
  • packages/cli/test/webhooks-tail.test.ts
📝 Walkthrough

Walkthrough

This PR implements outbound webhook dispatch management, adding the ability to list delivery attempts with filters and replay failed dispatches. The implementation spans persistence queries, service-layer replay logic, HTTP endpoints, CLI commands, and comprehensive test coverage.

Changes

Webhook Dispatch Management

Layer / File(s) Summary
Persistence: Dispatch Query Contracts & SQL
packages/internals/persistence/src/types/domain.ts, packages/internals/persistence/src/services/persistence.ts, packages/internals/persistence/src/index.ts
New ListNotificationDeliveryAttemptsInput filter type and extended NotificationDeliveryAttemptsRepository with getById and list methods; SQL implementation filters by channel, status, destination, and date bounds.
Notification Service: Webhook Replay
packages/backend/src/services/notifications/service.ts
Extended deliverWithRetries to accept optional startAttempt for consistent replay numbering; introduced replayWebhookDispatch effect that validates webhook config, computes next attempt number, and re-dispatches with retry logic.
HTTP API: Dispatch Schemas & Endpoint Wiring
packages/backend/src/http-api/groups/webhooks.ts, packages/backend/src/routes/webhooks.ts
Defined Effect schemas for dispatch records and paginated/replay response payloads; registered GET /webhooks/dispatches and POST /webhooks/dispatches/:id/replay endpoints with query/path parameter coercion.
Route Handlers: List & Replay
packages/backend/src/routes/webhooks/dispatches.ts
GET handler lists webhook attempts with tenant scoping, status/date/endpoint filtering, and enriched event data; POST handler validates failed-webhook eligibility, checks idempotency via audit events, triggers replay, and records audit metadata.
CLI: Argument Parsing & Commands
packages/cli/src/config.ts, packages/cli/src/commands/helpers.ts, packages/cli/src/commands/webhooks.ts, packages/cli/src/parity/route-map.ts
Extended flag parsing to support --key=value format; added query builders for dispatch filters; registered CLI commands and route-to-CLI parity mappings.
Test Fixtures: In-Memory Persistence
packages/backend/src/testing/minimal-persistence.ts, packages/backend/test/e2e/fixtures.ts, packages/backend/test/e2e/tenant-isolation.test.ts, packages/backend/test/lifecycle/idempotency.test.ts, packages/backend/test/services/notifications.service.test.ts
Updated in-memory persistence implementations to provide getById and list with optional channel/status/destination/date filtering across all test harnesses.
Integration Tests: Runtime & CLI E2E
packages/backend/test/runtime.test.ts, packages/backend/test/openapi.test.ts, packages/cli/test/config.test.ts, packages/cli/test/e2e/commands.e2e.test.ts
Added notification adapter test helper; seeds failed webhook data and tests dispatch listing/filtering; validates replay (202 success, idempotency, admin-only); tests CLI --flag=value parsing and E2E command execution.
Documentation: API & CLI References
docs/reference/api/index.mdx, docs/reference/api/webhooks.mdx, docs/reference/developer/cli.mdx
Updated API overview to mention dispatch inspection/replay; documents GET/POST endpoints with filters, response schemas, and replay constraints; adds CLI commands to developer guide.

Sequence Diagram

sequenceDiagram
  participant Client
  participant WebhookAPI as Webhook API
  participant Persistence as PersistenceLayer
  participant Notification as NotificationService
  participant Audit as AuditLog
  Client->>WebhookAPI: POST /webhooks/dispatches/:id/replay (x-idempotency-key)
  WebhookAPI->>Persistence: getById(dispatchId)
  Persistence-->>WebhookAPI: delivery attempt record
  WebhookAPI->>Persistence: loadNotificationEvent(attempt.eventId)
  WebhookAPI->>Audit: query events for idempotency marker
  Audit-->>WebhookAPI: already_replayed? / none
  alt not already replayed
    WebhookAPI->>Audit: append(replay-requested, idempotency)
    WebhookAPI->>Notification: replayWebhookDispatch(event, idempotencyKey, tenant)
    Notification->>Persistence: list prior webhook attempts
    Notification-->>Notification: compute startAttempt
    Notification->>Notification: deliverWithRetries(startAttempt)
    Notification-->>WebhookAPI: replay result
    WebhookAPI->>Audit: append(replayed, beforeAfter)
  end
  WebhookAPI-->>Client: 202 { status: "replayed" | "already_replayed" }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Poem

🐰 I hopped through logs at break of day,
Replayed a webhook that once went astray,
With idempotent keys and audit bright,
One retry at a time — the bunny's delight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(webhooks): add dispatch listing and replay' clearly and concisely summarizes the main changes in the PR: adding two new webhook dispatch features (listing and replay).
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The description matches the changeset, covering webhook dispatch listing, replay, CLI/docs updates, OpenAPI coverage, and the intended scope.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Replay idempotency leans on an audit-event lookup that runs before the webhook send, while the dedupe-marker audit event is appended after. Any audit-append failure or concurrent replay with the same key leaves a window where the webhook is delivered twice. Operator-triggered, so blast radius is bounded, but worth tightening before this lands.

TL;DR — Adds operator-facing list + single-dispatch replay for outbound webhook deliveries, with x-idempotency-key opt-in and authz scoped to operator/service principals. Implementation is clean and well-tested; the main rough edges are around replay idempotency robustness and a couple of fragile string-based checks.

Key changes

  • GET /webhooks/dispatches and POST /webhooks/dispatches/:id/replay — new operator-only routes for inspecting and replaying outbound webhook delivery attempts.
  • replayWebhookDispatch service — webhook-only replay that resumes deliverWithRetries from nextWebhookAttemptNumber(...) so the new attempt-number continues the dispatch chain.
  • Audit-based replay dedupe — replay scans audit_events for a prior webhook_dispatch_replayed entry matching dispatchId + caller-supplied idempotency key.
  • Persistence repo additionsnotificationDeliveryAttempts.getById / .list plus ListNotificationDeliveryAttemptsInput; SQL list orders by created_at DESC, id DESC.
  • CLI surfacedsar webhooks list / webhooks replay <id> via the parity map, plus --flag=value parsing in parseCliInput.

Summary | 24 files | 1 commit | base: mainfix/6-dispatch-replay


Replay idempotency and ordering

Before: No way to replay a failed outbound webhook dispatch.
After: Operators can replay a single failed dispatch with x-idempotency-key providing best-effort dedupe via the audit log.

The replay handler dedupes by scanning audit_events for a prior webhook_dispatch_replayed entry, then calls replayWebhookDispatch, then appends the audit event. That ordering plus the lack of any guard between check and append leaves two related windows:

  • if the send succeeds and the audit append fails, the webhook is delivered but no dedupe marker is written — the next replay re-sends.
  • two concurrent replay requests with the same idempotency key both clear the dedupe check and both send.

Single-operator usage makes this unlikely in practice, but the contract advertised by x-idempotency-key ("safe to retry") leans on stronger guarantees than the current flow provides.

packages/backend/src/routes/webhooks/dispatches.ts · packages/backend/src/services/notifications/service.ts


Listing and pagination

Before: Outbound webhook dispatches were not introspectable from the API/CLI.
After: GET /webhooks/dispatches returns a paginated, filterable view of attempts with a replayable flag.

Filtering on status / channel / destination / time window happens in SQL, but the route then loads the full filtered set into memory and applies slice(offset, offset + limit); total is the unbounded count. Fine at current scale, but doesn't follow the keyset-pagination shape used by requests.listBySubject / auditEvents.list, so it will need revisiting if dispatch volume grows.

packages/backend/src/routes/webhooks/dispatches.ts · packages/internals/persistence/src/services/persistence.ts


CLI --flag=value parsing

Before: parseFlags only handled space-separated flag values.
After: --flag=value is split inline before lookahead, matching the CLI examples in the new docs.

Small, contained change; the new test in packages/cli/test/config.test.ts covers the happy path.

packages/cli/src/config.ts

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread packages/backend/src/routes/webhooks/dispatches.ts Outdated
Comment thread packages/backend/src/routes/webhooks/dispatches.ts Outdated
Comment thread packages/backend/src/services/notifications/service.ts Outdated
Comment thread packages/backend/src/routes/webhooks/dispatches.ts Outdated
Comment thread packages/backend/src/routes/webhooks/dispatches.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/reference/api/webhooks.mdx`:
- Around line 90-96: The top-level introduction still reads “public inbound
endpoints only”; update that introductory paragraph to mention both public
inbound endpoints and protected outbound management endpoints (e.g., the GET
/webhooks/dispatches section). Edit the file-level scope text so it clearly
states that the page documents public inbound webhook endpoints and protected
outbound/management endpoints for delivery/dispatch records, and ensure any
existing phrase “public inbound endpoints only” is replaced with a balanced
description referencing both endpoint types.
- Line 135: The endpoint path string "POST /webhooks/dispatches/:id/replay" uses
colon-style path parameters while the rest of the docs use contract-style
`{id}`; update the route declaration for the replay endpoint (the "POST
/webhooks/dispatches/:id/replay" heading) to use "POST
/webhooks/dispatches/{id}/replay" so it matches the `{id}` convention used
elsewhere in the webhooks docs.

In `@packages/backend/src/routes/webhooks/dispatches.ts`:
- Around line 210-238: The current code calls
services.repos.persistence.notificationDeliveryAttempts.list and then does
in-memory slicing (attempts.slice) which causes scalability issues; update the
persistence call to support server-side pagination (e.g., accept limit and
offset or a cursor and return items plus total) and change this handler to call
notificationDeliveryAttempts.list with those pagination params instead of
slicing, then map the returned page through Effect.forEach and toDispatchSummary
(keeping the tenant pipe withTenant(tenantId)); ensure the repository returns a
{ items, total } shaped response and update the handler to use that shape
(remove attempts.slice and the page local) so large result sets aren’t loaded
into memory.
- Around line 290-314: The current idempotency check calls
services.repos.persistence.auditEvents.listByRequestId(event.requestId) and
scans all audit events, which can be large; change the query to pass a
ListAuditEventsInput with an action filter (e.g., the audit action used for
replay events) so only replay-related audit events are returned, then run the
same isPriorReplay check against that smaller result set. Update the call site
referencing listByRequestId and withTenant (and keep
replayIdempotencyKey/isPriorReplay logic unchanged) so the database only returns
events whose action matches the replay/audit action instead of all events for
the request.

In `@packages/backend/src/testing/minimal-persistence.ts`:
- Around line 599-601: The current filter treats an empty input?.status array as
a filter that excludes all rows; change the condition used in the list()
filtering so that status filtering only applies when input?.status is a
non-empty array (e.g., compute a clearly named boolean like hasStatusFilter from
Array.isArray(input?.status) && input.status.length > 0 and use it instead of
Array.isArray(input?.status) alone), and replace the inline magic condition with
that named boolean to keep behavior consistent with other in-memory fixtures and
clearer per the coding guidelines.

In `@packages/cli/src/commands/helpers.ts`:
- Around line 182-191: The helper handling route-specific request payloads
doesn't map the replay idempotency header; update the route dispatch mapping in
packages/cli/src/commands/helpers.ts to handle route.id ===
"webhooks_dispatches_replay" by returning an object that includes a headers
property mapping "x-idempotency-key" to the CLI flag (e.g.
input.flags["idempotency-key"]) so CLI calls for webhooks_dispatches_replay send
the idempotency header; locate the existing webhooks_dispatches_list branch and
add a new branch for webhooks_dispatches_replay that mirrors how other route
params are returned but includes headers: { "x-idempotency-key":
input.flags["idempotency-key"] }.

In `@packages/internals/persistence/src/services/persistence.ts`:
- Around line 1023-1064: The list handler (the list property in the notification
delivery attempts service) currently issues an unbounded SELECT; add a LIMIT to
the SQL and enforce a sane max: accept an optional input.limit from
ListNotificationDeliveryAttemptsInput, coerce/validate it (e.g., default to 100
and cap to MAX_PAGE_SIZE like 1000), then append a sql`LIMIT ${limit}` clause to
the query built in listNotificationDeliveryAttempts; ensure you reference
requireTenantId and mapNotificationDeliveryAttemptRecordEffect unchanged and
apply the validated/capped limit value so large result sets are bounded.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4c29f623-b73c-4ec4-a14f-4175e43254aa

📥 Commits

Reviewing files that changed from the base of the PR and between e0c300b and 2e660fd.

⛔ Files ignored due to path filters (1)
  • packages/backend/test/__snapshots__/openapi.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (23)
  • docs/reference/api/index.mdx
  • docs/reference/api/webhooks.mdx
  • docs/reference/developer/cli.mdx
  • packages/backend/src/http-api/groups/webhooks.ts
  • packages/backend/src/routes/webhooks.ts
  • packages/backend/src/routes/webhooks/dispatches.ts
  • packages/backend/src/services/notifications/service.ts
  • packages/backend/src/testing/minimal-persistence.ts
  • packages/backend/test/e2e/fixtures.ts
  • packages/backend/test/e2e/tenant-isolation.test.ts
  • packages/backend/test/lifecycle/idempotency.test.ts
  • packages/backend/test/openapi.test.ts
  • packages/backend/test/runtime.test.ts
  • packages/backend/test/services/notifications.service.test.ts
  • packages/cli/src/commands/helpers.ts
  • packages/cli/src/commands/webhooks.ts
  • packages/cli/src/config.ts
  • packages/cli/src/parity/route-map.ts
  • packages/cli/test/config.test.ts
  • packages/cli/test/e2e/commands.e2e.test.ts
  • packages/internals/persistence/src/index.ts
  • packages/internals/persistence/src/services/persistence.ts
  • packages/internals/persistence/src/types/domain.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (4)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Use explicit types for function parameters and return values when they enhance clarity
Prefer unknown over any when the type is genuinely unknown
Use const assertions (as const) for immutable values and literal types
Leverage TypeScript's type narrowing instead of type assertions

Files:

  • packages/cli/src/commands/webhooks.ts
  • packages/backend/test/services/notifications.service.test.ts
  • packages/cli/test/config.test.ts
  • packages/cli/test/e2e/commands.e2e.test.ts
  • packages/cli/src/commands/helpers.ts
  • packages/backend/test/openapi.test.ts
  • packages/backend/src/routes/webhooks.ts
  • packages/cli/src/parity/route-map.ts
  • packages/internals/persistence/src/index.ts
  • packages/backend/test/e2e/tenant-isolation.test.ts
  • packages/backend/test/lifecycle/idempotency.test.ts
  • packages/cli/src/config.ts
  • packages/backend/test/e2e/fixtures.ts
  • packages/backend/src/routes/webhooks/dispatches.ts
  • packages/internals/persistence/src/types/domain.ts
  • packages/backend/test/runtime.test.ts
  • packages/backend/src/testing/minimal-persistence.ts
  • packages/backend/src/http-api/groups/webhooks.ts
  • packages/internals/persistence/src/services/persistence.ts
  • packages/backend/src/services/notifications/service.ts
**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{js,jsx,ts,tsx}: Use meaningful variable names instead of magic numbers - extract constants with descriptive names
Use arrow functions for callbacks and short functions
Prefer for...of loops over .forEach() and indexed for loops
Use optional chaining (?.) and nullish coalescing (??) for safer property access
Prefer template literals over string concatenation
Use destructuring for object and array assignments
Use const by default, let only when reassignment is needed, never var
Always await promises in async functions - don't forget to use the return value
Use async/await syntax instead of promise chains for better readability
Handle errors appropriately in async code with try-catch blocks
Don't use async functions as Promise executors
Remove console.log, debugger, and alert statements from production code
Throw Error objects with descriptive messages, not strings or other values
Use try-catch blocks meaningfully - don't catch errors just to rethrow them
Prefer early returns over nested conditionals for error cases
Extract complex conditions into well-named boolean variables
Use early returns to reduce nesting
Prefer simple conditionals over nested ternary operators
Don't use eval() or assign directly to document.cookie
Avoid spread syntax in accumulators within loops
Use top-level regex literals instead of creating them in loops
Prefer specific imports over namespace imports
Use descriptive names for functions, variables, and types instead of relying on Oxlint + Oxfmt to catch naming issues
Add comments for complex logic, but prefer self-documenting code

Files:

  • packages/cli/src/commands/webhooks.ts
  • packages/backend/test/services/notifications.service.test.ts
  • packages/cli/test/config.test.ts
  • packages/cli/test/e2e/commands.e2e.test.ts
  • packages/cli/src/commands/helpers.ts
  • packages/backend/test/openapi.test.ts
  • packages/backend/src/routes/webhooks.ts
  • packages/cli/src/parity/route-map.ts
  • packages/internals/persistence/src/index.ts
  • packages/backend/test/e2e/tenant-isolation.test.ts
  • packages/backend/test/lifecycle/idempotency.test.ts
  • packages/cli/src/config.ts
  • packages/backend/test/e2e/fixtures.ts
  • packages/backend/src/routes/webhooks/dispatches.ts
  • packages/internals/persistence/src/types/domain.ts
  • packages/backend/test/runtime.test.ts
  • packages/backend/src/testing/minimal-persistence.ts
  • packages/backend/src/http-api/groups/webhooks.ts
  • packages/internals/persistence/src/services/persistence.ts
  • packages/backend/src/services/notifications/service.ts
**/*.{test,spec}.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{test,spec}.{js,jsx,ts,tsx}: Write assertions inside it() or test() blocks
Avoid done callbacks in async tests - use async/await instead
Don't use .only or .skip in committed code
Keep test suites reasonably flat - avoid excessive describe nesting

Files:

  • packages/backend/test/services/notifications.service.test.ts
  • packages/cli/test/config.test.ts
  • packages/cli/test/e2e/commands.e2e.test.ts
  • packages/backend/test/openapi.test.ts
  • packages/backend/test/e2e/tenant-isolation.test.ts
  • packages/backend/test/lifecycle/idempotency.test.ts
  • packages/backend/test/runtime.test.ts
**/index.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Avoid barrel files (index files that re-export everything)

Files:

  • packages/internals/persistence/src/index.ts
🔇 Additional comments (25)
docs/reference/api/index.mdx (1)

16-16: LGTM!

docs/reference/developer/cli.mdx (1)

55-55: LGTM!

Also applies to: 87-93

packages/internals/persistence/src/types/domain.ts (2)

736-752: LGTM!


1464-1493: LGTM!

packages/internals/persistence/src/services/persistence.ts (1)

998-1022: LGTM!

packages/internals/persistence/src/index.ts (1)

36-36: LGTM!

packages/backend/src/services/notifications/service.ts (4)

4-7: LGTM!

Also applies to: 222-225


257-257: LGTM!

Also applies to: 281-281, 287-288


290-300: LGTM!


309-377: LGTM!

packages/backend/src/http-api/groups/webhooks.ts (2)

113-143: LGTM!


30-56: No action needed—Schema.Literals with array syntax is correct.

The code correctly uses Schema.Literals(["pending", "delivered", "failed", "skipped"]) for defining a union of literal types. This is the proper Effect Schema API for literal unions.

packages/backend/src/routes/webhooks.ts (1)

2-5: LGTM!

Also applies to: 18-19

packages/backend/src/routes/webhooks/dispatches.ts (8)

34-51: LGTM!


53-82: LGTM!


84-100: LGTM!


102-129: LGTM!


131-167: LGTM!


169-209: LGTM!

Also applies to: 239-244


246-289: LGTM!


315-351: LGTM!

packages/cli/src/config.ts (1)

27-33: LGTM!

packages/cli/src/commands/webhooks.ts (1)

11-12: LGTM!

packages/cli/src/parity/route-map.ts (1)

100-113: LGTM!

packages/cli/test/e2e/commands.e2e.test.ts (1)

149-160: LGTM!

Comment thread docs/reference/api/webhooks.mdx
Comment thread docs/reference/api/webhooks.mdx Outdated
Comment thread packages/backend/src/routes/webhooks/dispatches.ts
Comment thread packages/backend/src/routes/webhooks/dispatches.ts Outdated
Comment thread packages/backend/src/testing/minimal-persistence.ts Outdated
Comment thread packages/cli/src/commands/helpers.ts
Comment thread packages/internals/persistence/src/services/persistence.ts

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues. Reviewed the following changes:

  • Reordered replay to append webhook_dispatch_replay_requested before the send, with a deterministic id (webhook-dispatch-replay:<tenant>:<dispatchId>:<encoded-caller-key>) so the audit-events unique-id constraint becomes the atomic dedupe primitive; concurrent claims fall through a recoverReplayRequestAppend branch that re-queries and short-circuits to already_replayed.
  • Promoted x-idempotency-key to a required header (400 otherwise) and dropped the synthesized-timestamp fallback; CLI surfaces the same requirement via --idempotency-key in headersForRoute.
  • Replaced the key !== "outbound-resend" magic-string adapter selection with a channels-based supportsNotificationChannel check; NotificationAdapterContract gained an optional channels field and outbound-resend declares channels: ["email"].
  • Pushed listing pagination into SQL via LIMIT/OFFSET on notificationDeliveryAttempts.list and a new .count query for the total; memory persistence fixtures mirror the bounded slicing.
  • Added validation for limit, offset, status, created_after, created_before query params (with IsoTimestampSchema normalization) so listing returns 400 on malformed input instead of leaking through to SQL.
  • Tightened isMissingWebhookDispatchError from message.includes(dispatchId) to exact Missing ${dispatchId} equality, and the e2e fixture now raises PersistenceEntityNotFoundError so the tag check is the primary path.
  • Updated docs ({id} path convention, scope wording, required-header note), OpenAPI snapshot, and tests covering the new validation, 404, audit-trail, and CLI behaviors.

Pullfrog  | View workflow run | Using Claude Opus𝕏

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/backend/src/services/notifications/service.ts`:
- Around line 351-352: input.event.eventType is being force-cast to
NotificationEventDraft["eventType"] which bypasses validation; instead add a
type guard (e.g., isNotificationEventType) that checks whether
input.event.eventType is one of the NotificationEventType union members, call
that guard before building dispatchInput, and if it returns false throw or
return an error (fail-fast) so invalid persisted strings cannot be passed into
dispatchInput/eventType; update the code around dispatchInput and any callers to
use the validated value (not a cast) and reference input.event.eventType,
NotificationEventType, NotificationEventDraft, and dispatchInput when locating
where to apply the guard.

In `@packages/backend/src/testing/minimal-persistence.ts`:
- Around line 362-369: The duplicate-audit-id check is non-atomic because it
uses Ref.get then Ref.set on auditEventsRef; replace that pair with a single
Ref.modify transaction on auditEventsRef to perform the check-and-append
atomically: inside Ref.modify read the current array, if any event.id ===
record.id return a failing Effect (new Error(`Duplicate audit event
${String(record.id)}`)) while leaving the state unchanged, otherwise return the
appended array and the record; ensure the function still returns the same Effect
type as before and remove the separate Ref.get/Ref.set usage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1b8224e2-4d95-4fd9-8748-3a1738d1bf4e

📥 Commits

Reviewing files that changed from the base of the PR and between 2e660fd and 239ea85.

⛔ Files ignored due to path filters (1)
  • packages/backend/test/__snapshots__/openapi.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (16)
  • docs/reference/api/webhooks.mdx
  • docs/reference/developer/cli.mdx
  • packages/backend/src/adapters/contract.ts
  • packages/backend/src/audit/service.ts
  • packages/backend/src/http-api/groups/webhooks.ts
  • packages/backend/src/routes/webhooks/dispatches.ts
  • packages/backend/src/services/notifications/service.ts
  • packages/backend/src/testing/minimal-persistence.ts
  • packages/backend/test/e2e/fixtures.ts
  • packages/backend/test/runtime.test.ts
  • packages/backend/test/services/notifications.service.test.ts
  • packages/cli/src/commands/helpers.ts
  • packages/cli/test/e2e/commands.e2e.test.ts
  • packages/internals/persistence/src/services/persistence.ts
  • packages/internals/persistence/src/types/domain.ts
  • packages/outbound-resend/src/adapter.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (3)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Use explicit types for function parameters and return values when they enhance clarity
Prefer unknown over any when the type is genuinely unknown
Use const assertions (as const) for immutable values and literal types
Leverage TypeScript's type narrowing instead of type assertions

Files:

  • packages/cli/src/commands/helpers.ts
  • packages/backend/test/services/notifications.service.test.ts
  • packages/outbound-resend/src/adapter.ts
  • packages/cli/test/e2e/commands.e2e.test.ts
  • packages/backend/src/audit/service.ts
  • packages/backend/src/http-api/groups/webhooks.ts
  • packages/backend/src/adapters/contract.ts
  • packages/internals/persistence/src/types/domain.ts
  • packages/backend/src/routes/webhooks/dispatches.ts
  • packages/internals/persistence/src/services/persistence.ts
  • packages/backend/src/services/notifications/service.ts
  • packages/backend/test/e2e/fixtures.ts
  • packages/backend/test/runtime.test.ts
  • packages/backend/src/testing/minimal-persistence.ts
**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{js,jsx,ts,tsx}: Use meaningful variable names instead of magic numbers - extract constants with descriptive names
Use arrow functions for callbacks and short functions
Prefer for...of loops over .forEach() and indexed for loops
Use optional chaining (?.) and nullish coalescing (??) for safer property access
Prefer template literals over string concatenation
Use destructuring for object and array assignments
Use const by default, let only when reassignment is needed, never var
Always await promises in async functions - don't forget to use the return value
Use async/await syntax instead of promise chains for better readability
Handle errors appropriately in async code with try-catch blocks
Don't use async functions as Promise executors
Remove console.log, debugger, and alert statements from production code
Throw Error objects with descriptive messages, not strings or other values
Use try-catch blocks meaningfully - don't catch errors just to rethrow them
Prefer early returns over nested conditionals for error cases
Extract complex conditions into well-named boolean variables
Use early returns to reduce nesting
Prefer simple conditionals over nested ternary operators
Don't use eval() or assign directly to document.cookie
Avoid spread syntax in accumulators within loops
Use top-level regex literals instead of creating them in loops
Prefer specific imports over namespace imports
Use descriptive names for functions, variables, and types instead of relying on Oxlint + Oxfmt to catch naming issues
Add comments for complex logic, but prefer self-documenting code

Files:

  • packages/cli/src/commands/helpers.ts
  • packages/backend/test/services/notifications.service.test.ts
  • packages/outbound-resend/src/adapter.ts
  • packages/cli/test/e2e/commands.e2e.test.ts
  • packages/backend/src/audit/service.ts
  • packages/backend/src/http-api/groups/webhooks.ts
  • packages/backend/src/adapters/contract.ts
  • packages/internals/persistence/src/types/domain.ts
  • packages/backend/src/routes/webhooks/dispatches.ts
  • packages/internals/persistence/src/services/persistence.ts
  • packages/backend/src/services/notifications/service.ts
  • packages/backend/test/e2e/fixtures.ts
  • packages/backend/test/runtime.test.ts
  • packages/backend/src/testing/minimal-persistence.ts
**/*.{test,spec}.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{test,spec}.{js,jsx,ts,tsx}: Write assertions inside it() or test() blocks
Avoid done callbacks in async tests - use async/await instead
Don't use .only or .skip in committed code
Keep test suites reasonably flat - avoid excessive describe nesting

Files:

  • packages/backend/test/services/notifications.service.test.ts
  • packages/cli/test/e2e/commands.e2e.test.ts
  • packages/backend/test/runtime.test.ts
🔇 Additional comments (14)
docs/reference/developer/cli.mdx (1)

55-55: LGTM!

Also applies to: 87-94

packages/cli/src/commands/helpers.ts (1)

182-191: LGTM!

Also applies to: 245-253

packages/backend/test/services/notifications.service.test.ts (1)

133-156: LGTM!

Also applies to: 379-382

packages/outbound-resend/src/adapter.ts (1)

254-254: LGTM!

packages/cli/test/e2e/commands.e2e.test.ts (1)

149-166: LGTM!

packages/backend/src/audit/service.ts (1)

58-59: LGTM!

Also applies to: 150-150

packages/backend/src/http-api/groups/webhooks.ts (1)

30-57: LGTM!

Also applies to: 112-143

packages/backend/src/adapters/contract.ts (1)

140-155: LGTM!

docs/reference/api/webhooks.mdx (1)

3-11: LGTM!

Also applies to: 91-164

packages/internals/persistence/src/types/domain.ts (1)

736-756: LGTM!

Also applies to: 1468-1507

packages/backend/src/routes/webhooks/dispatches.ts (1)

33-498: LGTM!

packages/internals/persistence/src/services/persistence.ts (1)

998-1095: LGTM!

packages/backend/test/e2e/fixtures.ts (1)

87-111: LGTM!

Also applies to: 178-180, 429-463

packages/backend/test/runtime.test.ts (1)

151-168: LGTM!

Also applies to: 520-787

Comment thread packages/backend/src/services/notifications/service.ts Outdated
Comment thread packages/backend/src/testing/minimal-persistence.ts Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues. Reviewed the following changes:

  • Replaced the as NotificationEventDraft["eventType"] cast in replayWebhookDispatch with parseNotificationEventType, a runtime guard against a NOTIFICATION_EVENT_TYPES const list (as const satisfies readonly NotificationEventType[]); persisted events with unsupported event types now reject with a 400 carrying a specific message instead of being smuggled into toDispatchInput.
  • Added toReplayDispatchValidationError so the new RequestValidationError from parseNotificationEventType is passed through unchanged rather than rewrapped as the generic "Failed to generate or dispatch notification event" error.
  • Made the in-memory auditEvents.append duplicate-id check atomic by collapsing the prior Ref.get + some() + Ref.set pair into a single Ref.modify transaction in testing/minimal-persistence.ts.
  • Updated the notifications-service test fixture event type from the non-existent acknowledgement_due to acknowledgement_sent so it survives the new guard, and added a service test asserting replay rejects unsupported event types without sending.

Pullfrog  | View workflow run | Using Claude Opus𝕏

@devcool20 devcool20 left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed all the Remaining issues, the code is clean now.

Comment thread packages/backend/src/routes/webhooks/dispatches.ts Outdated
@KayleeWilliams

Copy link
Copy Markdown
Collaborator

Maintainer review + update: strong original implementation — parameterized SQL only, tenant scoping via withTenant, operator/service authz, deterministic audit-marker idempotency, and no dependency/workflow changes. Since we only merge full fixes, I pushed a follow-up commit completing the remaining issue #6 scope:

  • POST /webhooks/dispatches/replay bulk replay with filter body (status, endpoint_id, created_after, created_before, limit), capped at 100 per call, requiring x-idempotency-key. Per-dispatch idempotency keys derive from the caller key + dispatch id (same marker scheme as single replay), and per-dispatch delivery failures are reported in the results rather than failing the batch. Registered before the /:id/replay route so replay is never treated as a dispatch id (covered by tests).
  • dsar webhooks replay-all building the filter body from flags, and dsar webhooks tail mirroring dsar audit tail (cursor, dedupe, --interval/--limit/--once).
  • Single-replay path refactored to share replayOneWebhookDispatch with the bulk path, OpenAPI snapshot, docs, tenant-isolation + runtime + CLI tests, and a dsar minor changeset.

Verified: full backend (106), cli (99), and persistence (23) suites pass; ultracite, tsdoc, error-docs, docs-lint, alpha-warning all clean. The async retry worker remains tracked in #5. Touches the same CLI parity files as #43 — whichever merges second needs a trivial rebase.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The new dsar webhooks tail command can silently and permanently drop dispatches whenever a poll's matching backlog or burst exceeds --limit (default 200). The rest of the scope expansion — bulk replay, replay-all, docs, OpenAPI, tests — is sound; all five threads from the initial review are addressed and retired.

Reviewed changes — the "Maintainer Update" scope expansion since the prior pullfrog review (b72421a): bulk webhook-dispatch replay plus two new CLI commands.

  • Add bulk replay POST /webhooks/dispatches/replay — filter body (status must be failed, endpoint_id, created_after/created_before, limit 1–100, empty body → 100 cap), idempotent per dispatch, continuing past per-dispatch delivery failures. Extracts a shared replayOneWebhookDispatch reused by the single-dispatch route.
  • Add dsar webhooks replay-all CLI — wired through the parity map (webhooks_dispatches_replay_bulk), builds the bulk body from flags and requires --idempotency-key.
  • Add dsar webhooks tail CLI — polling loop mirroring dsar audit tail (--once/--max-polls/--interval/--limit, SIGINT-aware).
  • Test + fixture updatesfixtures.ts makeMemoryPersistence gained count, filtering, sort and pagination; new bulk runtime tests, tenant-isolation probes, dsar minor changeset, and updated API/CLI docs + OpenAPI snapshot.

ℹ️ Bulk replay inherits single-replay fail-stop semantics at batch scale

The bulk endpoint reuses replayOneWebhookDispatch, which appends the webhook_dispatch_replay_requested marker before attempting the send. When a send fails for a dispatch — e.g. the webhook endpoint is temporarily misconfigured, or the persisted eventType is no longer supported — that dispatch is reported failed in results, but the marker is already committed.

  • A retry of the same bulk request with the same x-idempotency-key returns already_replayed for that dispatch and never re-sends it.
  • Recovery requires issuing a fresh idempotency key.

This is the documented single-replay fail-stop behavior; the new surface simply applies it to up to 100 dispatches per call, so a single bulk request against a briefly-unhealthy endpoint can leave many dispatches durably marked already_replayed without delivery.

Technical details
# Bulk replay fail-stop poisoning at scale

## Affected sites
- `packages/backend/src/routes/webhooks/dispatches.ts``replayOneWebhookDispatch` appends the `webhook_dispatch_replay_requested` marker (deterministic id) before `replayWebhookDispatch`; the dedupe check keys off the *requested* marker, not a completed send.
- `packages/backend/src/routes/webhooks/dispatches.ts``bulkReplayWebhookDispatchesRoute` catches per-dispatch failures into `status: "failed"` but does not roll back the committed marker.

## Required outcome
- Confirm this is the intended contract for the bulk surface (it matches the documented single-replay behavior).

## Open questions for the human
- For bulk recovery specifically, is "retry with a new idempotency key after any per-dispatch failure" an acceptable operator workflow, or should a `failed` result leave the dispatch replayable under the same key? No code change requested — flagging for an explicit decision on the bulk surface.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread packages/cli/src/commands/webhooks-tail.ts Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes — the single follow-up commit 2ad354c ("Drain all dispatch pages per webhook tail poll"), which addresses the prior review's ⚠️ finding that dsar webhooks tail silently dropped the oldest rows of any backlog/burst larger than --limit.

  • dsar webhooks tail now drains every page per pollpollOnce walks incrementing offset pages (via the extracted fetchDispatchPage) until a short page or POLL_DRAIN_PAGE_CAP (50), collecting the full match set before advancing the created_after watermark. This is the same drain-with-cap shape the sibling dsar audit tail already uses.
  • Cross-page duplicate handling — mid-drain inserts can repeat rows across pages; runWebhookTailLoop dedupes by dispatch id via the ascending sort + monotonic watermark + seenAtWatermark set, so no dispatch is emitted twice within a poll.
  • New burst testtest/webhooks-tail.test.ts asserts that with --limit 2 a 3-item burst walks offsets 0 and 2 and emits all three dispatches in creation order.

The drain, sort, and dedup logic all trace correctly, and the page cap mirrors the established audit tail bound (identical POLL_DRAIN_PAGE_CAP = 50), so the residual truncation only at >10k rows per poll is a deliberate, consistent design choice rather than a regression. Both prior pullfrog inline threads (the tail finding and the earlier audit-ordering thread) are already resolved.

Pullfrog  | View workflow run | Using Claude Opus𝕏

@KayleeWilliams

Copy link
Copy Markdown
Collaborator

Merge-sequencing note: #43 introduces per-command --help with a CommandDefinition.flagHelp field. When this PR is rebased after #43 lands (or vice versa), the webhook commands here should declare flagHelp so their command-specific flags are discoverable from the CLI:

  • webhooks list: --status, --endpoint-id, --created-after/--since, --created-before/--until, --limit, --offset
  • webhooks replay: --idempotency-key (required)
  • webhooks replay-all: --status, --endpoint-id, --created-after, --created-before, --limit, --idempotency-key (required)
  • webhooks tail: --status, --endpoint-id, --interval, --limit, --once

I'll handle it during the rebase of whichever PR merges second.

@KayleeWilliams

Copy link
Copy Markdown
Collaborator

Done in 50b07af rather than deferring: CommandDefinition.flagHelp and RouteParityDefinition.flagHelp are declared here with the flag documentation for webhooks list, webhooks replay, webhooks replay-all, and webhooks tail (factory passes route-map flagHelp through to the generated commands, covered by tests). The field is identical in shape to the one #43 introduces; #43's per-command --help renders it, so whichever PR merges second only needs a help-snapshot refresh (bun --cwd packages/cli vitest run test/help.test.ts -u) during the rebase — no code changes left to remember.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — one minor suggestion inline.

Reviewed changes — the single follow-up commit 50b07af ("Declare command flag help for webhook dispatch commands"), which adds preformatted per-command flag help to the four webhook CLI commands.

  • Add optional flagHelp to command metadataCommandDefinition and RouteParityDefinition in packages/cli/src/types.ts gain flagHelp?: readonly string[], populated on webhooks list / replay / replay-all (route-map.ts) and webhooks tail (webhooks-tail.ts).
  • Wire route flagHelp into route commandsmakeRouteCommand in factory.ts spreads route.flagHelp onto the generated command definition.
  • Add declaration testwebhooks-tail.test.ts asserts each webhook command declares its expected flags.

ℹ️ Declared flagHelp is never rendered

The new flagHelp metadata is written on every webhook command but no code path reads it. renderHelp in packages/cli/src/runtime.ts prints only usage + description, and isHelpRequest short-circuits any --help to that global list before command resolution — so dsar webhooks list --help shows the global help, not the per-command flags.

The result is inert metadata whose doc comment advertises behavior the CLI doesn't have. The added test only asserts the field is declared, so it passes without exercising any rendering.

Technical details
# Declared `flagHelp` is never rendered

## Affected sites
- `packages/cli/src/types.ts:97-101` / `128-132` — doc comment states the lines are "rendered by `--help`".
- `packages/cli/src/runtime.ts:62-82``renderHelp` only emits `command.usage` + `command.description`; never reads `flagHelp`.
- `packages/cli/src/runtime.ts:84-85,124-127``isHelpRequest` matches any `--help`/`-h` in argv and returns the global help before a command is resolved, so there is no per-command help path.
- `packages/cli/test/webhooks-tail.test.ts` (flag-help block) — asserts declaration only, not rendering.

## Required outcome
- Either render `flagHelp` for the resolved command (per-command `--help`), or drop the field and the "rendered by `--help`" claim so the codebase carries no dead metadata.

## Open questions for the human
- Is per-command `--help` intended to land in a follow-up commit? If so, this is a harmless forward-declaration and can be left as-is with a note; if not, the metadata + doc comment are misleading today.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread packages/cli/src/types.ts
*/
export interface CommandDefinition {
/**
* Preformatted command-specific flag lines rendered by `--help`, e.g.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment claims these lines are "rendered by --help", but nothing consumes flagHelp. renderHelp (runtime.ts:62) prints only usage + description, and isHelpRequest short-circuits any --help to the global list before the command is resolved, so there is no per-command help path. Either wire flagHelp into the help renderer or drop the field and this claim.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[webhooks] Replay endpoint and CLI command

2 participants