feat(routines): add safe webhook setup and in-routine run management (#13637)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Routines turn scheduled work and external events into tasks for an
assigned agent.
> - Webhook setup was disabled, and actor authentication rejected valid
webhook bearer keys.
> - Operators need to connect and test a sending app before events can
start work.
> - This pull request adds a guided setup with durable connection tests
that cannot dispatch a task.
> - It keeps trigger management, execution tasks, and activity within
the routine.
> - The benefit is a webhook that can be configured, verified, and
operated from one place.

## Linked Issues or Issue Description

Fixes #11937.

Related: #13216 adds provider-specific Sentry support. This PR addresses
general routine setup and ingress. #6841 addresses legacy secret
bindings; this PR retains the existing secret service.

**Current behavior**

Webhook creation is disabled. Bearer deliveries can fail in agent
authentication before the routine checks its key. Setup has no safe
connection test. Runs and Activity send the operator away from the
routine.

**Proposed behavior**

Choose a schedule or a webhook. Follow the setup steps, copy credentials
or complete agent instructions, and test delivery without creating work.
Finish setup to allow future events to start tasks. Edit or remove
compact trigger cards, undo removal, and inspect tasks and activity
inside the routine.

**Reason and benefit**

An operator can verify credentials and delivery before enabling
automatic work. Durable setup state survives refreshes and restarts.
Retry receipts prevent an old test event from starting work after
activation.

## What Changed

- Add a production trigger wizard using reusable Slack setup navigation
and footer components.
- Add schedule and webhook choices, one-time credentials, agent
instructions, and live connection feedback.
- Persist pending setup, test delivery receipts, connection status, and
reversible trigger removal.
- Keep setup checks free of routine runs, tasks, and agent wakeups.
Preserve delivery idempotency after activation.
- Add compact trigger cards, inline editing, key rotation, pause
controls, removal, and Undo.
- Keep Runs and Activity in the routine. Use the shared task list and
compact activity rows.
- Permit only exact public delivery POSTs through actor authentication.
Retain webhook authentication, JSON-object validation, and log
redaction.
- Add production-backed Storybook states and focused server, database,
and UI coverage.
- Document signing modes, setup checks, retries, rotation, HTTPS
ingress, and navigation.

## Verification

- Full workspace typecheck, build, and token gates passed on the rebased
branch. Storybook also builds.
- Focused routine, middleware, logging, shared wizard, and UI coverage
passes on the rebased branch: 195 tests across 14 files. The migration
passed on a fresh PostgreSQL database and on two repeated applications.
- Browser testing used the real app, database, and a deterministic
process worker through Tailscale HTTPS and the current Cloud proxy code.
- Verified rejected keys, safe setup deliveries, persisted state after
restart, activation, retry deduplication, key rotation, schedule
editing, removal, and Undo.
- Fresh bearer and GitHub-signed deliveries created tasks that the
worker checked out and completed. Runs and Activity stayed within the
routine.
- Current Cloud ingress tests passed. Public delivery POSTs passed
through without a browser session; management routes remained gated.
- All 54 current-head PR checks pass, including general and serialized
tests, all eight browser E2E shards, typecheck, build, runner checks,
security checks, and the canary dry run. Two optional Storybook jobs are
skipped by workflow conditions.
- Greptile is 5/5 on commit `7ea63a61e`, with no unresolved review
threads. The stale connection-status finding is fixed and covered by a
regression test.
- No production deployment was performed.

## Risks

- Migration 0281 adds three trigger columns and a test-receipt table. It
is additive and safe to reapply. Apply it before running the new server.
Existing triggers remain live by default.
- Requests without delivery IDs are new events after activation. Senders
must reuse an event's delivery ID for retries.
- Completed webhooks keep normal dispatch behavior. Their management
connection check can start work; the UI states this.
- Removing a trigger archives it. Undo restores the URL and credentials.
Permanent deletion remains available through the existing API.
- Public ingress must remain restricted to the delivery POST route. The
tenant verifies credentials. Cloud sleeping-stack behavior is unchanged.
- Shared setup components also serve Slack. Existing setup contracts and
navigation tests cover that integration.
- Senders must use application/json with an object. Other media types
receive 415.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository tools, shell
execution, and browser testing. The exact deployment model ID and
context-window size are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-19 10:49:05 -05:00
1 parent 20d26117c9
commit f589660ec0
57 files changed
+51027 -835

No files matched your search

+4
View File
@@ -424,3 +424,7 @@ Independently addressable examples live under `Connections/In-task connections`
- `AnnouncementCard`: image, eyebrow, headline, description, navigation links and dismissal; accepts an announcement and `onDismiss`.
- `AnnouncementWell`: one app-shell placement that owns eligibility, dismissal sync, modal deferral and toast priority. Use only once in Layout.
- Preview variants live in `/design-guide` and Storybook under `Announcements/AnnouncementCard`.
## Shared setup wizard (2026-09-19)
`ui/src/components/SetupWizard.tsx` extracts the Slack setup navigation into reusable numbered steps, a portal sidebar, an optional section-menu takeover outlet, and a single-row footer. `SetupWizardSidebarProvider` owns the portal target and takeover lifecycle. Chat exports retain their existing names and defaults for compatibility. The Design Guide demonstrates the components. The production routine trigger wizard and its Storybook previews share the sidebar takeover, navigation, and footer. `routine-triggers/TriggerWizard.tsx`, `TriggerCard.tsx`, and `WebhookFields.tsx` provide the shared trigger setup, compact editable cards, copyable credentials, and agent instructions.
+84 -2
View File
@@ -126,7 +126,11 @@ Three trigger kinds:
}
```
Signing modes: `bearer` (default), `hmac_sha256`. Replay window range: 30–86400 seconds (default 300).
Signing modes: `bearer` (default), `hmac_sha256`, `github_hmac`, and `none`.
The replay window applies to `hmac_sha256` only: 30–86400 seconds (default 300).
Creating a webhook returns `trigger` and one-time `secretMaterial` containing
`webhookUrl` and `webhookSecret`. Save the secret before closing the dialog.
Routine details retain the URL; rotate the secret if its value is lost.
**API** — fires only when called explicitly via [Manual Run](#manual-run):
@@ -184,7 +188,68 @@ Fires a run immediately, bypassing the schedule. Concurrency policy still applie
POST /api/routine-triggers/public/{publicId}/fire
```
Fires a webhook trigger from an external system. Requires a valid `Authorization` or `X-Paperclip-Signature` + `X-Paperclip-Timestamp` header pair matching the trigger's signing mode.
Fires a webhook trigger from an external system without a Paperclip login. Send
`Content-Type: application/json` and a JSON object. The trigger authenticates the
request using its own secret; an agent or board API key is not a substitute.
| Mode | Headers and signature |
|------|-----------------------|
| `bearer` | `Authorization: Bearer <webhookSecret>` |
| `hmac_sha256` | `X-Paperclip-Timestamp` (Unix seconds or milliseconds) and `X-Paperclip-Signature: sha256=<hex>`; HMAC-SHA256 over the timestamp string, a dot, and the exact body bytes |
| `github_hmac` | `X-Hub-Signature-256: sha256=<hex>`; HMAC-SHA256 over the exact body bytes, without a timestamp. `X-Paperclip-Signature` is also accepted. Configure GitHub to send JSON. |
| `none` | No signature. Anyone with the generated URL can fire the trigger; keep it private. |
For a bearer trigger:
```sh
curl --fail-with-body "$WEBHOOK_URL" \
-H "Authorization: Bearer $WEBHOOK_SECRET" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: deployment-123' \
--data-binary '{"event":"deployment","variables":{"environment":"staging"}}'
```
For a timestamped HMAC trigger, sign and send the same bytes:
```js
import { createHmac } from "node:crypto";
const body = JSON.stringify({ variables: { environment: "staging" } });
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(`${timestamp}.`).update(body).digest("hex");
const response = await fetch(process.env.WEBHOOK_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Paperclip-Timestamp": timestamp,
"X-Paperclip-Signature": `sha256=${signature}`,
},
body,
});
console.log(response.status, await response.json());
```
`202` returns the routine run, including its status and linked task. The task
runs asynchronously, so acceptance does not mean the agent has finished.
Concurrency policy can coalesce or skip a delivery while work is active.
Payload fields or a nested `variables` object supply declared routine variables;
nested values take precedence. The full payload is retained in run history.
For bearer, GitHub HMAC, and unsigned triggers, send a stable `Idempotency-Key`
when retrying a delivery to receive the original run without creating another
task. Timestamped HMAC rejects an identical signed delivery with `409`, even
inside the replay window; stale timestamps and invalid secrets/signatures return
`401`. Disabled triggers and paused/archived routines return `409`. Missing
required variables return `422`; non-JSON media types return `415` and invalid
JSON objects return `400`. Rotating a secret immediately invalidates the old one.
Cloud installations use their canonical public origin for generated URLs.
Self-hosted installations should set `PAPERCLIP_PUBLIC_URL` to their HTTPS
origin. The reverse proxy must forward this POST endpoint and its authorization,
signature, timestamp, and idempotency headers without requiring a browser login.
For local HTTPS testing, proxy an isolated test instance through Tailscale Serve;
use Funnel only if the sender is outside the tailnet. Existing routine and agent
execution controls still apply, including the isolated-worktree execution gate.
## List Runs
@@ -216,3 +281,20 @@ active -> paused -> active
```
Archived routines do not fire and cannot be reactivated.
## Routine detail navigation
The routine detail page keeps **Runs** and **Activity** in the routine sidebar. Runs lists the execution issues for that routine using the shared issue list, including issue status, priority, assignee, and search controls. Activity shows the routine, trigger, and run event timeline without leaving the routine page. The overview links to these same local tabs.
## Webhook setup and connection checks
Create a webhook trigger with `setupPending: true` to configure it safely. While setup is pending, authenticated deliveries return `202` with `{ "status": "test_received", "test": true, "routineStarted": false, "linkedIssueId": null }`. They never create a routine run, task, or agent wakeup. This state survives refreshes and server restarts, and connection checks also work while the routine is paused. Invalid authentication still returns `401`.
Routine detail exposes `setupPending` and `lastWebhookDelivery` (`status`, `receivedAt`, and `test`) so the wizard can show live connection feedback. The secret is only returned at creation or rotation; it is never stored in browser draft storage or included in routine detail.
Finish setup with `PATCH /api/routine-triggers/{id}` and `{ "setupPending": false }`. Future deliveries use normal routine dispatch and still respect the routine's enabled state. Test events are not dispatched on activation. Retries with the same `Idempotency-Key`, GitHub `X-GitHub-Delivery`, or timestamp-HMAC replay key remain test receipts after activation. Send a unique delivery ID per event so a sender's retries can be recognized. Requests without a delivery ID are new events after activation.
For compatibility, API-created triggers without `setupPending: true` are immediately live. Completed triggers cannot be returned to setup mode. Checking a previously enabled webhook observes real deliveries and can start the routine; the management UI explains this difference.
Trigger cards support removal with Undo. `PATCH` with `{ "archived": true }` excludes a trigger from routine detail and scheduling, and rejects its webhook deliveries. Setting `archived` back to `false` restores the same URL and credentials. `DELETE` remains the permanent deletion API.
@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS "routine_webhook_test_receipts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"trigger_id" uuid NOT NULL,
"delivery_key_hash" text NOT NULL,
"received_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "routine_triggers" ADD COLUMN IF NOT EXISTS "setup_pending" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "routine_triggers" ADD COLUMN IF NOT EXISTS "archived" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "routine_triggers" ADD COLUMN IF NOT EXISTS "last_webhook_delivery" jsonb;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "routine_webhook_test_receipts" ADD CONSTRAINT "routine_webhook_test_receipts_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
DO $$ BEGIN ALTER TABLE "routine_webhook_test_receipts" ADD CONSTRAINT "routine_webhook_test_receipts_trigger_id_routine_triggers_id_fk" FOREIGN KEY ("trigger_id") REFERENCES "public"."routine_triggers"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "routine_webhook_test_receipts_delivery_uq" ON "routine_webhook_test_receipts" USING btree ("trigger_id","delivery_key_hash");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -1954,6 +1954,13 @@
"when": 1789743991329,
"tag": "0280_unique_genesis",
"breakpoints": true
},
{
"idx": 281,
"version": "7",
"when": 1789827314772,
"tag": "0281_true_boom_boom",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -63,7 +63,7 @@ export {
chatSdkState,
} from "./chat_channels.js";
export { issueRelations } from "./issue_relations.js";
export { routines, routineRevisions, routineTriggers, routineRuns } from "./routines.js";
export { routines, routineRevisions, routineTriggers, routineWebhookTestReceipts, routineRuns } from "./routines.js";
export { pipelines, pipelineStages, pipelineTransitions } from "./pipelines.js";
export {
cases,
+15 -1
View File
@@ -18,7 +18,7 @@ import { projects } from "./projects.js";
import { goals } from "./goals.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { folders } from "./folders.js";
import type { RoutineEnvConfig, RoutineRevisionSnapshotV1, RoutineVariable } from "@paperclipai/shared";
import type { RoutineEnvConfig, RoutineRevisionSnapshotV1, RoutineVariable, RoutineWebhookDelivery } from "@paperclipai/shared";
export const routines = pgTable(
"routines",
@@ -112,6 +112,9 @@ export const routineTriggers = pgTable(
kind: text("kind").notNull(),
label: text("label"),
enabled: boolean("enabled").notNull().default(true),
setupPending: boolean("setup_pending").notNull().default(false),
archived: boolean("archived").notNull().default(false),
lastWebhookDelivery: jsonb("last_webhook_delivery").$type<RoutineWebhookDelivery>(),
cronExpression: text("cron_expression"),
timezone: text("timezone"),
nextRunAt: timestamp("next_run_at", { withTimezone: true }),
@@ -174,3 +177,14 @@ export const routineRuns = pgTable(
idempotencyIdx: index("routine_runs_trigger_idempotency_idx").on(table.triggerId, table.idempotencyKey),
}),
);
// Content-free receipts prevent sender retries of setup tests from starting work after activation.
export const routineWebhookTestReceipts = pgTable("routine_webhook_test_receipts", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
triggerId: uuid("trigger_id").notNull().references(() => routineTriggers.id, { onDelete: "cascade" }),
deliveryKeyHash: text("delivery_key_hash").notNull(),
receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
deliveryUq: uniqueIndex("routine_webhook_test_receipts_delivery_uq").on(table.triggerId, table.deliveryKeyHash),
}));
+1
View File
@@ -1559,6 +1559,7 @@ export type {
RoutineRevisionSnapshot,
RoutineRevision,
RoutineTrigger,
RoutineWebhookDelivery,
RoutineRun,
RoutineTriggerSecretMaterial,
RoutineDetail,
+1
View File
@@ -867,6 +867,7 @@ export type {
RoutineRevisionSnapshot,
RoutineRevision,
RoutineTrigger,
RoutineWebhookDelivery,
RoutineRun,
RoutineTriggerSecretMaterial,
RoutineDetail,
+11
View File
@@ -138,6 +138,7 @@ export interface RoutineRevisionSnapshotRoutineV1 {
}
export interface RoutineRevisionSnapshotTriggerV1 {
setupPending?: boolean;
id: string;
kind: RoutineTriggerKind;
label: string | null;
@@ -173,6 +174,12 @@ export interface RoutineRevision {
createdAt: Date;
}
export interface RoutineWebhookDelivery {
status: "received" | "rejected";
receivedAt: string;
test: boolean;
}
export interface RoutineTrigger {
id: string;
companyId: string;
@@ -185,6 +192,10 @@ export interface RoutineTrigger {
nextRunAt: Date | null;
lastFiredAt: Date | null;
publicId: string | null;
webhookUrl?: string | null;
setupPending?: boolean;
archived?: boolean;
lastWebhookDelivery?: RoutineWebhookDelivery | null;
secretId: string | null;
signingMode: string | null;
replayWindowSec: number | null;
@@ -110,6 +110,7 @@ export const routineRevisionSnapshotRoutineV1Schema = z.object({
export const routineRevisionSnapshotTriggerV1Schema = z.object({
id: z.string().guid(),
setupPending: z.boolean().optional(),
kind: z.enum(ROUTINE_TRIGGER_KINDS),
label: z.string().nullable(),
enabled: z.boolean(),
@@ -143,6 +144,7 @@ export const createRoutineTriggerSchema = z.discriminatedUnion("kind", [
}),
baseTriggerSchema.extend({
kind: z.literal("webhook"),
setupPending: z.boolean().optional(),
signingMode: z.enum(ROUTINE_TRIGGER_SIGNING_MODES).optional().default("bearer"),
replayWindowSec: z.number().int().min(30).max(86_400).optional().default(300),
}),
@@ -154,6 +156,8 @@ export const createRoutineTriggerSchema = z.discriminatedUnion("kind", [
export type CreateRoutineTrigger = z.infer<typeof createRoutineTriggerSchema>;
export const updateRoutineTriggerSchema = z.object({
setupPending: z.literal(false).optional(),
archived: z.boolean().optional(),
label: z.string().trim().max(120).optional().nullable(),
enabled: z.boolean().optional(),
cronExpression: z.string().trim().min(1).optional().nullable(),
@@ -102,6 +102,9 @@ function createApp(db: any, deploymentMode: "authenticated" | "local_trusted" =
app.get("/actor", (req, res) => {
res.json(req.actor);
});
app.post("/api/routine-triggers/public/:publicId/fire", (req, res) => {
res.json({ reachedWebhook: true, actorType: req.actor.type });
});
app.post("/mcp/gateways/:gatewayPublicId", (req, res) => {
res.json({ reachedGatewayProtocol: true, actorType: req.actor.type });
});
@@ -206,6 +209,29 @@ describe("agent auth middleware", () => {
expect(commentWrites).toBe(0);
});
it.each(["authenticated", "local_trusted"] as const)(
"leaves webhook authentication to the trigger in %s mode",
async (deploymentMode) => {
const { db } = createDbState({ agent: { id: randomUUID(), companyId: randomUUID() } });
const res = await request(createApp(db, deploymentMode))
.post(`/api/routine-triggers/public/${"a".repeat(24)}/fire`)
.set("Authorization", "Bearer routine-secret")
.send({ event: "created" });
expect(res.status).toBe(200);
expect(res.body).toEqual({ reachedWebhook: true, actorType: "none" });
},
);
it.each([
`/api/routine-triggers/public/${"a".repeat(24)}/rotate-secret`,
"/api/routine-triggers/public/not-a-public-id/fire",
`/api/routine-triggers/public/${"a".repeat(24)}/fire/extra`,
])("does not bypass actor authentication for %s", async (path) => {
const { db } = createDbState({ agent: { id: randomUUID(), companyId: randomUUID() } });
const res = await request(createApp(db)).post(path).set("Authorization", "Bearer routine-secret");
expect(res.status).toBe(401);
});
it("leaves public MCP gateway bearers for the gateway protocol to validate", async () => {
const { db } = createDbState({ agent: { id: randomUUID(), companyId: randomUUID() } });
const publicId = `gw_${"a".repeat(32)}`;
+13 -8
View File
@@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest";
import {
isPrivateChatWebhookHttpRequest,
isPrivateWebhookHttpRequest,
isSecretSensitiveHttpRequest,
shouldSilenceHttpSuccessLog,
} from "../middleware/http-log-policy.js";
describe("isPrivateChatWebhookHttpRequest", () => {
describe("isPrivateWebhookHttpRequest", () => {
it("protects the native webhook namespace, including rejected methods and query data", () => {
for (const provider of [
"slack",
@@ -17,15 +17,18 @@ describe("isPrivateChatWebhookHttpRequest", () => {
for (const method of ["POST", "GET", "PUT", "DELETE"]) {
for (const suffix of ["", "/", "?ignored=private"]) {
const path = `/api/chat-webhooks/endpoint-1/${provider}${suffix}`;
expect(isPrivateChatWebhookHttpRequest(method, path)).toBe(true);
expect(isPrivateWebhookHttpRequest(method, path)).toBe(true);
expect(isSecretSensitiveHttpRequest(method, path)).toBe(true);
}
}
}
expect(
isPrivateChatWebhookHttpRequest("POST", "/API/CHAT-WEBHOOKS/id/SLACK"),
isPrivateWebhookHttpRequest("POST", "/API/CHAT-WEBHOOKS/id/SLACK"),
).toBe(true);
for (const path of [
"/api/routine-triggers/public",
"/api/routine-triggers/public/private-id/fire",
"https://host.invalid/api/routine-triggers/public/../private-id",
"/api/chat-webhooks",
"/api/chat-webhooks/id",
"/api/chat-webhooks/id/slack/history",
@@ -35,22 +38,24 @@ describe("isPrivateChatWebhookHttpRequest", () => {
"http://host.invalid/api/chat-webhooks/../private-component",
"HTTPS://user:private@host.invalid/API/CHAT-WEBHOOKS/id/SLACK/",
])
expect(isPrivateChatWebhookHttpRequest("POST", path)).toBe(true);
expect(isPrivateWebhookHttpRequest("POST", path)).toBe(true);
});
it("does not change adjacent non-webhook routes", () => {
for (const path of [
"/api/routine-triggers/id/rotate-secret",
"/api/routine-triggers/public-extra/id/fire",
"/api/chat-webhooks-extra/id/slack",
"https://host.invalid/api/chat-webhooks-extra/id/slack",
"/api/other/chat-webhooks/id/slack",
"/api/chat-endpoints/id/test",
"/chat-webhooks/id/slack",
])
expect(isPrivateChatWebhookHttpRequest("POST", path)).toBe(false);
expect(isPrivateWebhookHttpRequest("POST", path)).toBe(false);
expect(
isPrivateChatWebhookHttpRequest(undefined, "/api/chat-webhooks/id/slack"),
isPrivateWebhookHttpRequest(undefined, "/api/chat-webhooks/id/slack"),
).toBe(false);
expect(isPrivateChatWebhookHttpRequest("POST", undefined)).toBe(false);
expect(isPrivateWebhookHttpRequest("POST", undefined)).toBe(false);
});
});
@@ -13,6 +13,9 @@ import { createHttpLogger } from "../middleware/logger.js";
describe("HTTP logger redaction", () => {
it.each([
{ method: "POST", path: "/api/routine-triggers/public/private-url-canary/fire" },
{ method: "PUT", path: "/api/routine-triggers/public/private-url-canary/fire" },
{
method: "POST",
path: "http://provider.invalid/api/chat-webhooks/../private-url-canary",
@@ -95,7 +98,7 @@ describe("HTTP logger redaction", () => {
const log = JSON.parse(output.trim());
expect(log.req).toMatchObject({
method,
url: "/api/chat-webhooks/:publicId/:provider",
url: path.includes("routine-triggers") ? "/api/routine-triggers/public/:publicId/fire" : "/api/chat-webhooks/:publicId/:provider",
});
expect(log.reqBody).toBe("[REDACTED]");
expect(log.err.message).toBe("Chat webhook request failed");
+158 -1
View File
@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto";
import { createHmac, randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import express from "express";
import request from "supertest";
@@ -9,6 +9,10 @@ import {
agents,
companies,
companyMemberships,
companySecrets,
companySecretVersions,
companySecretBindings,
secretAccessEvents,
createDb,
documentAnnotationAnchorSnapshots,
documentAnnotationComments,
@@ -101,7 +105,12 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
}, 20_000);
afterEach(async () => {
vi.unstubAllEnvs();
await db.delete(activityLog);
await db.delete(secretAccessEvents);
await db.delete(companySecretBindings);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
await db.delete(documentAnnotationAnchorSnapshots);
await db.delete(documentAnnotationComments);
await db.delete(documentAnnotationThreads);
@@ -229,6 +238,154 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
return { companyId, agentId, projectId, userId };
}
it.each(["bearer", "hmac_sha256", "github_hmac", "none"] as const)(
"authenticates %s HTTP deliveries, persists payloads, and enforces trigger lifecycle",
async (signingMode) => {
vi.stubEnv("PAPERCLIP_API_URL", "http://localhost:3100");
vi.stubEnv("PAPERCLIP_IN_WORKTREE", "false");
const { companyId, agentId, projectId, userId } = await seedFixture();
const board = await createApp({ type: "board", source: "local_implicit", userId, isInstanceAdmin: true });
const created = await request(board).post(`/api/companies/${companyId}/routines`).send({
projectId, assigneeAgentId: agentId, title: "Webhook {{event}}",
description: "Handle {{event}}", variables: [{ name: "event", type: "text", required: true }],
concurrencyPolicy: "always_enqueue",
});
expect(created.status, JSON.stringify(created.body)).toBe(201);
const routineId = created.body.id;
const configured = await request(board).post(`/api/routines/${routineId}/triggers`).send({ kind: "webhook", signingMode });
expect(configured.status, JSON.stringify(configured.body)).toBe(201);
const { trigger, secretMaterial } = configured.body;
const detail = await request(board).get(`/api/routines/${routineId}`);
expect(detail.body.triggers[0].webhookUrl).toBe(secretMaterial.webhookUrl);
const path = new URL(secretMaterial.webhookUrl).pathname;
const [{ actorMiddleware }, { boardMutationGuard }, { routineRoutes }, { errorHandler }] = await Promise.all([
import("../middleware/auth.js"), import("../middleware/board-mutation-guard.js"),
import("../routes/routines.js"), import("../middleware/error-handler.js"),
]);
const ingress = express();
ingress.use(express.json({ verify: (req, _res, buf) => { (req as any).rawBody = buf; } }));
ingress.use(actorMiddleware(db, {
deploymentMode: "authenticated",
// A webhook must not acquire an ambient board session or need CSRF headers.
resolveSession: async () => { throw new Error("Webhook must not resolve a browser session"); },
}));
ingress.use("/api", boardMutationGuard(), routineRoutes(db));
ingress.use(errorHandler);
const raw = '{ "event": "deploy", "detail": { "text": "café" } }';
const timestamp = String(Math.floor(Date.now() / 1000));
function delivery(secret = secretMaterial.webhookSecret, body = raw, ts = timestamp) {
const req = request(ingress).post(path).set("Content-Type", "application/json");
if (signingMode === "bearer") req.set("Authorization", `Bearer ${secret}`);
if (signingMode === "hmac_sha256") {
req.set("X-Paperclip-Timestamp", ts).set("X-Paperclip-Signature",
`sha256=${createHmac("sha256", secret).update(`${ts}.`).update(raw).digest("hex")}`);
}
if (signingMode === "github_hmac") req.set("X-Hub-Signature-256",
`sha256=${createHmac("sha256", secret).update(raw).digest("hex")}`);
return req.send(body);
}
if (signingMode !== "none") expect((await delivery("wrong-secret")).status).toBe(401);
if (signingMode === "hmac_sha256" || signingMode === "github_hmac") {
expect((await delivery(undefined, raw.replace("deploy", "tampered"))).status).toBe(401);
}
if (signingMode === "hmac_sha256") {
expect((await delivery(undefined, raw, "1")).status).toBe(401);
const malformed = await request(ingress).post(path).set("Content-Type", "application/json")
.set("X-Paperclip-Timestamp", timestamp).set("X-Paperclip-Signature", "é".repeat(64)).send(raw);
expect(malformed.status).toBe(401);
}
expect((await request(ingress).post(path).type("text").send(raw)).status).toBe(415);
expect((await request(ingress).post(path).send([])).status).toBe(400);
const accepted = await delivery();
expect(accepted.status, JSON.stringify(accepted.body)).toBe(202);
expect(accepted.body).toMatchObject({ source: "webhook", status: "issue_created" });
const [issue] = await db.select().from(issues).where(eq(issues.id, accepted.body.linkedIssueId));
expect(issue).toMatchObject({ companyId, assigneeAgentId: agentId, title: "Webhook deploy", description: "Handle deploy" });
const history = await request(board).get(`/api/routines/${routineId}/runs`);
expect(history.body[0].triggerPayload).toMatchObject({ event: "deploy", detail: { text: "café" } });
await db.update(issues).set({ status: "done", executionRunId: null }).where(eq(issues.id, issue.id));
if (signingMode === "hmac_sha256") {
expect((await delivery()).status).toBe(409);
} else {
const first = await delivery().set("Idempotency-Key", "delivery-2");
const retry = await delivery().set("Idempotency-Key", "delivery-2");
expect(retry.status).toBe(202);
expect(retry.body.id).toBe(first.body.id);
await db.update(issues).set({ status: "done", executionRunId: null }).where(eq(issues.id, first.body.linkedIssueId));
}
if (signingMode !== "none") {
const rotated = await request(board).post(`/api/routine-triggers/${trigger.id}/rotate-secret`).send({});
expect(rotated.status).toBe(200);
expect(rotated.body.trigger.lastWebhookDelivery).toBeNull();
expect((await delivery()).status).toBe(401);
expect((await delivery(rotated.body.secretMaterial.webhookSecret)).status).toBe(202);
}
await request(board).patch(`/api/routine-triggers/${trigger.id}`).send({ enabled: false });
expect((await delivery()).status).toBe(409);
await request(board).patch(`/api/routine-triggers/${trigger.id}`).send({ enabled: true });
await request(board).patch(`/api/routines/${routineId}`).send({ status: "paused" });
expect((await delivery()).status).toBe(409);
},
);
it("keeps setup deliveries out of runs, persists test receipts, and restores removed triggers", async () => {
vi.stubEnv("PAPERCLIP_API_URL", "http://localhost:3100");
vi.stubEnv("PAPERCLIP_IN_WORKTREE", "false");
const { companyId, agentId, projectId, userId } = await seedFixture();
const board = await createApp({ type: "board", source: "local_implicit", userId, isInstanceAdmin: true });
const created = await request(board).post(`/api/companies/${companyId}/routines`).send({
projectId, assigneeAgentId: agentId, title: "Verify deployment", description: "Inspect deployment", concurrencyPolicy: "always_enqueue",
});
expect(created.status).toBe(201);
const routineId = created.body.id;
const configured = await request(board).post(`/api/routines/${routineId}/triggers`).send({ kind: "webhook", signingMode: "bearer", setupPending: true });
expect(configured.status).toBe(201);
const { trigger, secretMaterial } = configured.body;
const path = new URL(secretMaterial.webhookUrl).pathname;
const publicApp = await createApp({ type: "none" });
const deliver = (key: string, secret = secretMaterial.webhookSecret) => request(publicApp).post(path).set("Authorization", `Bearer ${secret}`).set("Idempotency-Key", key).send({ event: "deployment.completed" });
await request(board).patch(`/api/routines/${routineId}`).send({ status: "paused" });
expect((await deliver("bad", "wrong")).status).toBe(401);
let detail = await request(board).get(`/api/routines/${routineId}`);
expect(detail.body.triggers[0]).toMatchObject({ setupPending: true, lastWebhookDelivery: { status: "rejected", test: true } });
const tested = await deliver("setup-event");
expect(tested.status, JSON.stringify(tested.body)).toBe(202);
expect(tested.body).toMatchObject({ status: "test_received", routineStarted: false, linkedIssueId: null });
expect(await db.select().from(routineRuns)).toHaveLength(0);
expect(await db.select().from(issues)).toHaveLength(0);
expect(await db.select().from(heartbeatRuns)).toHaveLength(0);
// Read through a fresh app instance to prove the state is database-backed.
const reopened = await createApp({ type: "board", source: "local_implicit", userId, isInstanceAdmin: true });
detail = await request(reopened).get(`/api/routines/${routineId}`);
expect(detail.body.triggers[0]).toMatchObject({ setupPending: true, lastWebhookDelivery: { status: "received", test: true } });
expect(JSON.stringify(detail.body)).not.toContain(secretMaterial.webhookSecret);
expect((await request(board).patch(`/api/routine-triggers/${trigger.id}`).send({ setupPending: false })).status).toBe(200);
await request(board).patch(`/api/routines/${routineId}`).send({ status: "active" });
expect((await deliver("setup-event")).body.status).toBe("test_received");
expect(await db.select().from(routineRuns)).toHaveLength(0);
const live = await deliver("live-event");
expect(live.status, JSON.stringify(live.body)).toBe(202);
expect(live.body.status).toBe("issue_created");
expect(await db.select().from(issues)).toHaveLength(1);
expect((await deliver("live-event")).body.id).toBe(live.body.id);
expect(await db.select().from(activityLog).where(eq(activityLog.action, "routine.run_triggered"))).toHaveLength(1);
expect(await db.select().from(issues)).toHaveLength(1);
expect((await request(board).patch(`/api/routine-triggers/${trigger.id}`).send({ setupPending: true })).status).toBe(400);
expect((await request(board).patch(`/api/routine-triggers/${trigger.id}`).send({ archived: true })).status).toBe(200);
expect((await deliver("removed-event")).status).toBe(404);
expect((await request(board).get(`/api/routines/${routineId}`)).body.triggers).toHaveLength(0);
expect((await request(board).patch(`/api/routine-triggers/${trigger.id}`).send({ archived: false })).status).toBe(200);
detail = await request(board).get(`/api/routines/${routineId}`);
expect(detail.body.triggers[0].webhookUrl).toBe(secretMaterial.webhookUrl);
expect((await deliver("restored-event")).status).toBe(202);
const schedule = await request(board).post(`/api/routines/${routineId}/triggers`).send({ kind: "schedule", cronExpression: "0 9 * * 1-5", timezone: "America/Chicago" });
expect(schedule.status).toBe(201);
await request(board).patch(`/api/routine-triggers/${schedule.body.trigger.id}`).send({ archived: true });
expect((await request(board).get(`/api/routines/${routineId}`)).body.triggers).toHaveLength(1);
await request(board).patch(`/api/routine-triggers/${schedule.body.trigger.id}`).send({ archived: false });
expect((await request(board).get(`/api/routines/${routineId}`)).body.triggers).toHaveLength(2);
});
it("supports creating, scheduling, and manually running a routine through the API", async () => {
const { companyId, agentId, projectId, userId } = await seedFixture();
const app = await createApp({
@@ -2465,6 +2465,21 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
expect(newRuns).toMatchObject([{ status: "issue_created" }]);
});
it("excludes removed schedules from dispatch and resumes them after Undo", async () => {
const { routine, svc, wakeups } = await seedFixture({ runtimeEnv: {} });
const { trigger } = await svc.createTrigger(routine.id, { kind: "schedule", cronExpression: "0 9 * * *", timezone: "UTC" }, {});
await svc.updateTrigger(trigger.id, { archived: true }, {});
const due = new Date("2026-09-19T09:00:00Z");
await db.update(routineTriggers).set({ nextRunAt: due }).where(eq(routineTriggers.id, trigger.id));
expect(await svc.tickScheduledTriggers(due)).toEqual({ triggered: 0 });
expect(wakeups).toHaveLength(0);
expect(await db.select().from(routineRuns)).toHaveLength(0);
await svc.updateTrigger(trigger.id, { archived: false }, {});
await db.update(routineTriggers).set({ nextRunAt: due }).where(eq(routineTriggers.id, trigger.id));
expect(await svc.tickScheduledTriggers(due)).toEqual({ triggered: 1 });
expect(wakeups).toHaveLength(1);
});
it("coalesces multiple missed sub-hourly ticks into one catch-up run", async () => {
const { routine, svc } = await seedFixture();
await db.update(routines).set({
+10
View File
@@ -212,6 +212,8 @@ interface ActorMiddlewareOptions {
resolveSession?: (req: Request) => Promise<BetterAuthSessionResult | null>;
}
const publicRoutineWebhookPath = /^\/api\/routine-triggers\/public\/[a-f0-9]{24}\/fire\/?$/i;
const publicMcpGatewayProtocolPath = /^\/mcp\/gateways\/gw_[a-f0-9]{32}\/?$/i;
export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHandler {
@@ -229,6 +231,14 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
}
: { type: "none", source: "none" };
// Routine ingress authenticates its own bearer/signature. Never interpret
// webhook credentials as agent keys or attach an ambient browser session.
if (req.method === "POST" && publicRoutineWebhookPath.test(req.path)) {
req.actor = { type: "none", source: "none" };
next();
return;
}
const runIdHeader = req.header("x-paperclip-run-id");
const authHeader = req.header("authorization");
+4 -4
View File
@@ -43,7 +43,7 @@ const SECRET_SENSITIVE_HTTP_PATHS = [
const SECRET_SENSITIVE_HTTP_METHODS = new Set(["POST", "PUT", "PATCH"]);
/** Provider payloads are private even when a method/signature is rejected. */
export function isPrivateChatWebhookHttpRequest(
export function isPrivateWebhookHttpRequest(
method: string | undefined,
url: string | undefined,
): boolean {
@@ -53,7 +53,7 @@ export function isPrivateChatWebhookHttpRequest(
// Do not let URL dot-segment normalization erase an explicitly supplied
// ingress namespace on a malformed absolute-form callback.
const rawPath = pathname.replace(/^https?:\/\/[^/]*/i, "");
if (/^\/api\/chat-webhooks(?:\/|$)/i.test(rawPath)) return true;
if (/^\/api\/(?:chat-webhooks|routine-triggers\/public)(?:\/|$)/i.test(rawPath)) return true;
try {
pathname = new URL(url).pathname;
} catch {
@@ -62,7 +62,7 @@ export function isPrivateChatWebhookHttpRequest(
}
// This namespace is reserved for provider ingress, including malformed or
// unknown callback paths. Rejecting a route must not make its payload public.
return /^\/api\/chat-webhooks(?:\/|$)/i.test(pathname);
return /^\/api\/(?:chat-webhooks|routine-triggers\/public)(?:\/|$)/i.test(pathname);
}
/**
@@ -76,7 +76,7 @@ export function isSecretSensitiveHttpRequest(
method: string | undefined,
url: string | undefined,
): boolean {
if (isPrivateChatWebhookHttpRequest(method, url)) return true;
if (isPrivateWebhookHttpRequest(method, url)) return true;
if (!method || !url) return false;
if (!SECRET_SENSITIVE_HTTP_METHODS.has(method.toUpperCase())) return false;
const pathname = normalizePath(url);
+10 -4
View File
@@ -3,7 +3,7 @@ import type { Logger } from "pino";
import { pinoHttp } from "pino-http";
import { HTTP_LOG_REDACT_PATHS } from "./http-log-redaction.js";
import {
isPrivateChatWebhookHttpRequest,
isPrivateWebhookHttpRequest,
isSecretSensitiveHttpRequest,
shouldSilenceHttpSuccessLog,
} from "./http-log-policy.js";
@@ -56,19 +56,25 @@ function isPrivateWebhook(req: {
originalUrl?: unknown;
url?: unknown;
}) {
return isPrivateChatWebhookHttpRequest(
return isPrivateWebhookHttpRequest(
req.method,
requestClassificationUrl(req),
);
}
function privateWebhookLogUrl(url: unknown) {
return typeof url === "string" && /\/routine-triggers\/public(?:\/|$)/i.test(url)
? "/api/routine-triggers/public/:publicId/fire"
: "/api/chat-webhooks/:publicId/:provider";
}
function requestLogUrl(req: {
method?: string;
originalUrl?: unknown;
url?: unknown;
}) {
return isPrivateWebhook(req)
? "/api/chat-webhooks/:publicId/:provider"
? privateWebhookLogUrl(requestClassificationUrl(req))
: stripSecretBearingUrlParts(typeof req.url === "string" ? req.url : "");
}
@@ -89,7 +95,7 @@ export function createHttpLogger(baseLogger: Logger) {
return {
id: req.id,
method: req.method,
url: "/api/chat-webhooks/:publicId/:provider",
url: privateWebhookLogUrl(req.url),
};
}
return {
+12 -6
View File
@@ -15,7 +15,7 @@ import { trackRoutineCreated } from "@paperclipai/shared/telemetry";
import { validate, validateIssueMutationBody } from "../middleware/validate.js";
import { accessService, documentAnnotationService, logActivity, routineService } from "../services/index.js";
import { assertCompanyAccess, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js";
import { forbidden, unauthorized } from "../errors.js";
import { badRequest, forbidden, unauthorized, unsupportedMediaType } from "../errors.js";
import { getTelemetryClient } from "../telemetry.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
@@ -522,10 +522,10 @@ export function routineRoutes(
agentId: actor.agentId,
runId: actor.runId,
agentApiKeyId: actor.agentApiKeyId,
action: "routine.trigger_updated",
entityType: "routine_trigger",
entityId: trigger.id,
details: { routineId: routine.id, kind: updated?.trigger.kind ?? trigger.kind },
action: req.body.archived === true ? "routine.trigger_removed" : req.body.archived === false ? "routine.trigger_restored" : req.body.setupPending === false ? "routine.trigger_setup_finished" : "routine.trigger_updated",
entityType: "routine",
entityId: routine.id,
details: { triggerId: trigger.id, kind: updated?.trigger.kind ?? trigger.kind },
});
if (updated) {
await logRoutineRevisionCreated(req, {
@@ -654,12 +654,18 @@ export function routineRoutes(
});
router.post("/routine-triggers/public/:publicId/fire", async (req, res) => {
if (!req.is("application/json")) {
throw unsupportedMediaType("Send the webhook payload with Content-Type: application/json");
}
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
throw badRequest("Webhook payload must be a JSON object");
}
const result = await svc.firePublicTrigger(req.params.publicId as string, {
authorizationHeader: req.header("authorization"),
signatureHeader: req.header("x-paperclip-signature"),
hubSignatureHeader: req.header("x-hub-signature-256"),
timestampHeader: req.header("x-paperclip-timestamp"),
idempotencyKey: req.header("idempotency-key"),
idempotencyKey: req.header("idempotency-key") ?? req.header("x-github-delivery"),
rawBody: (req as { rawBody?: Buffer }).rawBody ?? null,
payload: typeof req.body === "object" && req.body !== null ? req.body as Record<string, unknown> : null,
});
+147 -75
View File
@@ -25,6 +25,7 @@ import {
routineDocuments,
routines,
routineTriggers,
routineWebhookTestReceipts,
} from "@paperclipai/db";
import type {
CreateRoutine,
@@ -575,6 +576,7 @@ function routineRevisionSnapshotRoutine(routine: RoutineRow): RoutineRevisionSna
function routineRevisionSnapshotTrigger(trigger: RoutineTriggerRow): RoutineRevisionSnapshotV1["triggers"][number] {
return {
id: trigger.id,
setupPending: trigger.setupPending,
kind: trigger.kind as RoutineRevisionSnapshotV1["triggers"][number]["kind"],
label: trigger.label,
enabled: trigger.enabled,
@@ -593,7 +595,7 @@ async function buildRoutineRevisionSnapshot(
const triggers = await executor
.select()
.from(routineTriggers)
.where(and(eq(routineTriggers.companyId, routine.companyId), eq(routineTriggers.routineId, routine.id)))
.where(and(eq(routineTriggers.companyId, routine.companyId), eq(routineTriggers.routineId, routine.id), eq(routineTriggers.archived, false)))
.orderBy(asc(routineTriggers.createdAt), asc(routineTriggers.id));
return {
@@ -1054,7 +1056,7 @@ export function routineService(
const rows = await db
.select()
.from(routineTriggers)
.where(and(eq(routineTriggers.companyId, companyId), inArray(routineTriggers.routineId, routineIds)))
.where(and(eq(routineTriggers.companyId, companyId), inArray(routineTriggers.routineId, routineIds), eq(routineTriggers.archived, false)))
.orderBy(asc(routineTriggers.createdAt), asc(routineTriggers.id));
const map = new Map<string, RoutineTrigger[]>();
for (const row of rows) {
@@ -1409,6 +1411,14 @@ export function routineService(
sql`select id from ${routines} where ${routines.id} = ${input.routine.id} and ${routines.companyId} = ${input.routine.companyId} for update`,
);
if (input.trigger && (input.source === "webhook" || input.source === "schedule")) {
const currentTrigger = await txDb.select().from(routineTriggers).where(eq(routineTriggers.id, input.trigger.id)).then((rows) => rows[0]);
const currentRoutine = await txDb.select({ status: routines.status }).from(routines).where(eq(routines.id, input.routine.id)).then((rows) => rows[0]);
if (!currentTrigger || currentTrigger.archived || !currentTrigger.enabled || currentTrigger.setupPending || currentRoutine?.status !== "active") {
throw conflict("Routine trigger is not active");
}
}
if (input.idempotencyKey) {
const existing = await txDb
.select()
@@ -1773,12 +1783,21 @@ export function routineService(
title,
description,
});
let reusedExistingRun = false;
const run = await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
await tx.execute(
sql`select id from ${routines} where ${routines.id} = ${input.routine.id} and ${routines.companyId} = ${input.routine.companyId} for update`,
);
if (input.trigger && (input.source === "webhook" || input.source === "schedule")) {
const currentTrigger = await txDb.select().from(routineTriggers).where(eq(routineTriggers.id, input.trigger.id)).then((rows) => rows[0]);
const currentRoutine = await txDb.select({ status: routines.status }).from(routines).where(eq(routines.id, input.routine.id)).then((rows) => rows[0]);
if (!currentTrigger || currentTrigger.archived || !currentTrigger.enabled || currentTrigger.setupPending || currentRoutine?.status !== "active") {
throw conflict("Routine trigger is not active");
}
}
if (input.idempotencyKey) {
const existing = await txDb
.select()
@@ -1799,6 +1818,7 @@ export function routineService(
if (input.rejectIdempotencyReplay) {
throw conflict("Webhook replay detected");
}
reusedExistingRun = true;
return existing;
}
}
@@ -1992,7 +2012,7 @@ export function routineService(
}
});
if (input.source === "schedule" || input.source === "webhook") {
if (!reusedExistingRun && (input.source === "schedule" || input.source === "webhook")) {
const actorId = input.source === "schedule" ? "routine-scheduler" : "routine-webhook";
try {
await logActivity(db, {
@@ -2080,7 +2100,7 @@ export function routineService(
: null,
row.parentIssueId ? issueSvc.getById(row.parentIssueId) : null,
getRoutineDescriptionDocument(row.id),
db.select().from(routineTriggers).where(eq(routineTriggers.routineId, row.id)).orderBy(asc(routineTriggers.createdAt)),
db.select().from(routineTriggers).where(and(eq(routineTriggers.routineId, row.id), eq(routineTriggers.archived, false))).orderBy(asc(routineTriggers.createdAt)),
db
.select({
id: routineRuns.id,
@@ -2163,7 +2183,10 @@ export function routineService(
assignee,
parentIssue,
descriptionDocument,
triggers: triggers as RoutineTrigger[],
triggers: triggers.map((trigger) => ({
...trigger,
webhookUrl: trigger.kind === "webhook" && trigger.publicId ? routineWebhookUrl(trigger.publicId) : null,
})) as RoutineTrigger[],
recentRuns,
activeIssue,
};
@@ -2280,6 +2303,7 @@ export function routineService(
eq(routineTriggers.routineId, existing.id),
eq(routineTriggers.kind, "schedule"),
eq(routineTriggers.enabled, true),
eq(routineTriggers.archived, false),
),
)
.limit(1)
@@ -2461,6 +2485,7 @@ export function routineService(
kind: input.kind,
label: input.label ?? null,
enabled: input.enabled ?? true,
setupPending: input.kind === "webhook" && input.setupPending === true,
cronExpression: input.kind === "schedule" ? input.cronExpression : null,
timezone: input.kind === "schedule" ? (input.timezone || "UTC") : null,
nextRunAt,
@@ -2531,6 +2556,8 @@ export function routineService(
.set({
label: patch.label === undefined ? existing.label : patch.label,
enabled: patch.enabled ?? existing.enabled,
...(patch.setupPending === false ? { setupPending: false } : {}),
...(patch.archived !== undefined ? { archived: patch.archived } : {}),
cronExpression,
timezone,
nextRunAt,
@@ -2607,6 +2634,7 @@ export function routineService(
.update(routineTriggers)
.set({
lastRotatedAt: new Date(),
lastWebhookDelivery: null,
updatedByAgentId: actor.agentId ?? null,
updatedByUserId: actor.userId ?? null,
updatedAt: new Date(),
@@ -2775,6 +2803,8 @@ export function routineService(
enabled: triggerSnapshot.enabled,
cronExpression: triggerSnapshot.kind === "schedule" ? triggerSnapshot.cronExpression : null,
timezone: triggerSnapshot.kind === "schedule" ? triggerSnapshot.timezone : null,
setupPending: triggerSnapshot.setupPending ?? current?.setupPending ?? false,
archived: false,
publicId: triggerSnapshot.kind === "webhook" ? (current?.publicId ?? webhookSecret?.publicId ?? triggerSnapshot.publicId) : null,
secretId: triggerSnapshot.kind === "webhook" ? (current?.secretId ?? webhookSecret?.secretId ?? null) : null,
signingMode: triggerSnapshot.kind === "webhook" ? triggerSnapshot.signingMode : null,
@@ -2881,78 +2911,118 @@ export function routineService(
rawBody?: Buffer | null;
payload?: Record<string, unknown> | null;
}) => {
const trigger = await db
.select()
.from(routineTriggers)
.where(and(eq(routineTriggers.publicId, publicId), eq(routineTriggers.kind, "webhook")))
.then((rows) => rows[0] ?? null);
if (!trigger) throw notFound("Routine trigger not found");
const routine = await getRoutineById(trigger.routineId);
if (!routine) throw notFound("Routine not found");
if (!trigger.enabled || routine.status !== "active") throw conflict("Routine trigger is not active");
let hmacReplayKey: string | null = null;
if (trigger.signingMode === "none") {
// No authentication — the publicId in the URL acts as a shared secret.
} else if (trigger.signingMode === "github_hmac") {
const secretValue = await resolveTriggerSecret(trigger, routine.companyId);
const rawBody = input.rawBody ?? Buffer.from(JSON.stringify(input.payload ?? {}));
// Accept X-Hub-Signature-256 (GitHub/Sentry) or fall back to the
// generic X-Paperclip-Signature header so operators can use github_hmac
// mode with either header convention.
const providedSignature = (input.hubSignatureHeader ?? input.signatureHeader)?.trim() ?? "";
if (!providedSignature) throw unauthorized();
const expectedHmac = crypto
.createHmac("sha256", secretValue)
.update(rawBody)
.digest("hex");
const normalizedSignature = providedSignature.replace(/^sha256=/, "");
const normalizedBuf = Buffer.from(normalizedSignature);
const expectedBuf = Buffer.from(expectedHmac);
const valid =
normalizedBuf.length === expectedBuf.length &&
crypto.timingSafeEqual(normalizedBuf, expectedBuf);
if (!valid) throw unauthorized();
} else if (trigger.signingMode === "bearer") {
const secretValue = await resolveTriggerSecret(trigger, routine.companyId);
const expected = `Bearer ${secretValue}`;
const provided = input.authorizationHeader?.trim() ?? "";
const expectedBuf = Buffer.from(expected);
const providedBuf = Buffer.alloc(expectedBuf.length);
providedBuf.write(provided.slice(0, expectedBuf.length));
const valid =
provided.length === expected.length &&
crypto.timingSafeEqual(providedBuf, expectedBuf);
if (!valid) {
throw unauthorized();
// Serialize setup completion with authentication and receipt persistence. A
// delivery is classified exactly once, before any dispatch can occur.
const accepted = await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const found = await txDb.select().from(routineTriggers)
.where(and(eq(routineTriggers.publicId, publicId), eq(routineTriggers.kind, "webhook")))
.then((rows) => rows[0] ?? null);
if (!found) throw notFound("Routine trigger not found");
await tx.execute(sql`select id from ${routines} where ${routines.id} = ${found.routineId} for update`);
const trigger = await txDb.select().from(routineTriggers).where(eq(routineTriggers.id, found.id)).then((rows) => rows[0]);
if (!trigger || trigger.archived) throw notFound("Routine trigger not found");
const routine = await txDb.select().from(routines).where(eq(routines.id, trigger.routineId)).then((rows) => rows[0]);
if (!routine) throw notFound("Routine not found");
if (routine.status === "archived") throw conflict("Routine is archived");
if (!trigger.enabled || (!trigger.setupPending && routine.status !== "active")) throw conflict("Routine trigger is not active");
const recordDelivery = async (status: "received" | "rejected") => {
const receivedAt = new Date().toISOString();
await txDb.update(routineTriggers).set({ lastWebhookDelivery: { status, receivedAt, test: trigger.setupPending } }).where(eq(routineTriggers.id, trigger.id));
await logActivity(txDb, {
companyId: routine.companyId, actorType: "system", actorId: "routine-webhook",
action: trigger.setupPending ? `routine.webhook_test_${status}` : `routine.webhook_${status}`,
entityType: "routine", entityId: routine.id,
details: { triggerId: trigger.id, test: trigger.setupPending, result: status === "received" ? "passed" : "rejected" },
});
};
let hmacReplayKey: string | null = null;
try {
if (trigger.signingMode === "none") {
// No authentication — the publicId in the URL acts as a shared secret.
} else if (trigger.signingMode === "github_hmac") {
const secretValue = await resolveTriggerSecret(trigger, routine.companyId);
const rawBody = input.rawBody ?? Buffer.from(JSON.stringify(input.payload ?? {}));
// Accept X-Hub-Signature-256 (GitHub/Sentry) or fall back to the
// generic X-Paperclip-Signature header so operators can use github_hmac
// mode with either header convention.
const providedSignature = (input.hubSignatureHeader ?? input.signatureHeader)?.trim() ?? "";
if (!providedSignature) throw unauthorized();
const expectedHmac = crypto
.createHmac("sha256", secretValue)
.update(rawBody)
.digest("hex");
const normalizedSignature = providedSignature.replace(/^sha256=/, "");
const normalizedBuf = Buffer.from(normalizedSignature);
const expectedBuf = Buffer.from(expectedHmac);
const valid =
normalizedBuf.length === expectedBuf.length &&
crypto.timingSafeEqual(normalizedBuf, expectedBuf);
if (!valid) throw unauthorized();
} else if (trigger.signingMode === "bearer") {
const secretValue = await resolveTriggerSecret(trigger, routine.companyId);
const expected = `Bearer ${secretValue}`;
const provided = input.authorizationHeader?.trim() ?? "";
const expectedBuf = Buffer.from(expected);
const providedBuf = Buffer.alloc(expectedBuf.length);
providedBuf.write(provided.slice(0, expectedBuf.length));
const valid =
provided.length === expected.length &&
crypto.timingSafeEqual(providedBuf, expectedBuf);
if (!valid) {
throw unauthorized();
}
} else {
const secretValue = await resolveTriggerSecret(trigger, routine.companyId);
const rawBody = input.rawBody ?? Buffer.from(JSON.stringify(input.payload ?? {}));
const providedSignature = input.signatureHeader?.trim() ?? "";
const providedTimestamp = input.timestampHeader?.trim() ?? "";
if (!providedSignature || !providedTimestamp) throw unauthorized();
const tsMillis = normalizeWebhookTimestampMs(providedTimestamp);
if (tsMillis == null) throw unauthorized();
const replayWindowSec = trigger.replayWindowSec ?? 300;
if (Math.abs(Date.now() - tsMillis) > replayWindowSec * 1000) {
throw unauthorized();
}
const expectedHmac = crypto
.createHmac("sha256", secretValue)
.update(`${providedTimestamp}.`)
.update(rawBody)
.digest("hex");
const normalizedSignature = providedSignature.replace(/^sha256=/, "");
const providedBuf = Buffer.from(normalizedSignature);
const expectedBuf = Buffer.from(expectedHmac);
const valid =
providedBuf.length === expectedBuf.length &&
crypto.timingSafeEqual(providedBuf, expectedBuf);
if (!valid) throw unauthorized();
hmacReplayKey = `webhook-hmac:${crypto
.createHash("sha256")
.update(`${trigger.id}:${providedTimestamp}:${expectedHmac}`)
.digest("hex")}`;
}
} catch (error) {
await recordDelivery("rejected");
return { routine, trigger, hmacReplayKey, testReceived: false, error };
}
} else {
const secretValue = await resolveTriggerSecret(trigger, routine.companyId);
const rawBody = input.rawBody ?? Buffer.from(JSON.stringify(input.payload ?? {}));
const providedSignature = input.signatureHeader?.trim() ?? "";
const providedTimestamp = input.timestampHeader?.trim() ?? "";
if (!providedSignature || !providedTimestamp) throw unauthorized();
const tsMillis = normalizeWebhookTimestampMs(providedTimestamp);
if (tsMillis == null) throw unauthorized();
const replayWindowSec = trigger.replayWindowSec ?? 300;
if (Math.abs(Date.now() - tsMillis) > replayWindowSec * 1000) {
throw unauthorized();
const deliveryKey = hmacReplayKey ?? input.idempotencyKey;
const deliveryKeyHash = deliveryKey ? crypto.createHash("sha256").update(deliveryKey).digest("hex") : null;
if (trigger.setupPending) {
if (deliveryKeyHash) await txDb.insert(routineWebhookTestReceipts).values({ companyId: routine.companyId, triggerId: trigger.id, deliveryKeyHash }).onConflictDoNothing();
await recordDelivery("received");
return { routine, trigger, hmacReplayKey, testReceived: true };
}
const expectedHmac = crypto
.createHmac("sha256", secretValue)
.update(`${providedTimestamp}.`)
.update(rawBody)
.digest("hex");
const normalizedSignature = providedSignature.replace(/^sha256=/, "");
const valid =
normalizedSignature.length === expectedHmac.length &&
crypto.timingSafeEqual(Buffer.from(normalizedSignature), Buffer.from(expectedHmac));
if (!valid) throw unauthorized();
hmacReplayKey = `webhook-hmac:${crypto
.createHash("sha256")
.update(`${trigger.id}:${providedTimestamp}:${expectedHmac}`)
.digest("hex")}`;
}
if (deliveryKeyHash) {
const receipt = await txDb.select({ id: routineWebhookTestReceipts.id }).from(routineWebhookTestReceipts)
.where(and(eq(routineWebhookTestReceipts.triggerId, trigger.id), eq(routineWebhookTestReceipts.deliveryKeyHash, deliveryKeyHash))).limit(1);
if (receipt.length) return { routine, trigger, hmacReplayKey, testReceived: true };
}
await recordDelivery("received");
return { routine, trigger, hmacReplayKey, testReceived: false };
});
if ("error" in accepted) throw accepted.error;
if (accepted.testReceived) return { status: "test_received" as const, test: true, routineStarted: false, linkedIssueId: null };
const { routine, trigger, hmacReplayKey } = accepted;
const eligibility = await getAutomaticRoutineDispatchEligibility(routine);
if (!eligibility.eligible) {
@@ -3073,6 +3143,7 @@ export function routineService(
and(
eq(routineTriggers.kind, "schedule"),
eq(routineTriggers.enabled, true),
eq(routineTriggers.archived, false),
eq(routines.status, "active"),
isNotNull(routineTriggers.nextRunAt),
lte(routineTriggers.nextRunAt, now),
@@ -3119,6 +3190,7 @@ export function routineService(
and(
eq(routineTriggers.id, row.trigger.id),
eq(routineTriggers.enabled, true),
eq(routineTriggers.archived, false),
eq(routineTriggers.nextRunAt, row.trigger.nextRunAt),
),
)
@@ -135,7 +135,7 @@ export function BreadcrumbBar() {
) : (
<BreadcrumbLink asChild>
{crumb.leading || crumb.identifier ? (
<Link to={crumb.href} className="flex items-center gap-1.5">
<Link to={crumb.href} onClick={crumb.onClick} className="flex items-center gap-1.5">
{crumb.leading && (
<span className="flex shrink-0 items-center">{crumb.leading}</span>
)}
@@ -143,7 +143,7 @@ export function BreadcrumbBar() {
<span className="truncate">{crumb.label}</span>
</Link>
) : (
<Link to={crumb.href}>{crumb.label}</Link>
<Link to={crumb.href} onClick={crumb.onClick}>{crumb.label}</Link>
)}
</BreadcrumbLink>
)}
+2 -2
View File
@@ -152,7 +152,7 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
<BreadcrumbLink asChild>
{crumb.leading || crumb.identifier ? (
<Link
to={crumb.href}
to={crumb.href} onClick={crumb.onClick}
className={cn(
"flex min-w-0 items-baseline gap-1.5",
i === 0 && "font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground",
@@ -167,7 +167,7 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
</Link>
) : (
<Link
to={crumb.href}
to={crumb.href} onClick={crumb.onClick}
className={cn(
"min-w-0 truncate",
i === 0 && "font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground",
+2 -1
View File
@@ -1,3 +1,4 @@
import { SetupWizardSidebarOutlet } from "./SetupWizard";
import { ChatSetupSidebarProvider } from "@/context/ChatSetupSidebarContext";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
@@ -206,7 +207,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
const secondarySidebar = shellRoute.builtInContextualSurface === "agent" && agentId ? (
<AgentContextualSidebar agentRef={agentId} />
) : streamlinedUiEnabled && shellRoute.builtInContextualSurface === "routine" && routineId ? (
<RoutineContextualSidebar routineId={routineId} />
<SetupWizardSidebarOutlet><RoutineContextualSidebar routineId={routineId} /></SetupWizardSidebarOutlet>
) : streamlinedUiEnabled && shellRoute.builtInContextualSurface === "skills" ? (
<SkillsContextualSidebar />
) : sharedSecondarySidebar;
+33 -13
View File
@@ -1,7 +1,6 @@
import { useState } from "react";
import { ChevronRight } from "lucide-react";
import type { ActivityEvent } from "@paperclipai/shared";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
export type RoutineActivityEvent = Pick<ActivityEvent, "id" | "action" | "details" | "createdAt">;
@@ -14,11 +13,17 @@ function formatTime(value: string | Date): string {
}
}
function summarizeDetails(details: Record<string, unknown> | null | undefined): string {
function summarizeEvent(event: RoutineActivityEvent): string {
const details = event.details;
if (event.action === "routine.webhook_test_received") return "Connection working · No run or task created";
if (event.action === "routine.webhook_test_rejected") return "Update the key in your app and resend";
if (event.action === "routine.webhook_received") return "Authentication passed";
if (event.action === "routine.webhook_rejected") return "Check the key in your sending app";
if (!details) return "";
const entries = Object.entries(details).slice(0, 3);
return entries
.map(([key, value]) => `${key.replaceAll("_", " ")}: ${formatDetailValue(value)}`)
if (typeof details.changeSummary === "string") return details.changeSummary;
if (event.action === "routine.run_triggered") return `${details.source === "webhook" ? "Webhook" : details.source === "schedule" ? "Schedule" : "Manual"} · ${details.status === "issue_created" ? "Task created" : String(details.status ?? "").replaceAll("_", " ")}`;
return Object.entries(details).filter(([key]) => !/id$/i.test(key)).slice(0, 3)
.map(([key, value]) => `${key.replace(/([a-z])([A-Z])/g, "$1 $2").replaceAll("_", " ").toLowerCase()}: ${formatDetailValue(value)}`)
.join(" · ");
}
@@ -34,6 +39,20 @@ function formatDetailValue(value: unknown): string {
}
}
const actionLabels: Record<string, string> = {
"routine.webhook_test_received": "Connection test passed",
"routine.webhook_test_rejected": "Connection test rejected",
"routine.webhook_received": "Webhook event received",
"routine.webhook_rejected": "Webhook authentication failed",
"routine.created": "Routine created", "routine.updated": "Routine updated",
"routine.trigger_created": "Trigger added", "routine.trigger_updated": "Trigger updated",
"routine.trigger_deleted": "Trigger removed", "routine.trigger_removed": "Trigger removed", "routine.trigger_restored": "Trigger restored", "routine.trigger_setup_finished": "Webhook setup finished", "routine.trigger_secret_rotated": "Webhook key replaced",
"routine.run_triggered": "Routine started", "routine.run_created": "Run created",
};
function actionLabel(action: string) {
return actionLabels[action] ?? action.replace(/^routine[._]/, "").replaceAll("_", " ").replaceAll(".", " ").replace(/^./, (char) => char.toUpperCase());
}
/** Activity log row with an expandable JSON payload (§3.7). */
export function RoutineActivityRow({ event }: { event: RoutineActivityEvent }) {
const [expanded, setExpanded] = useState(false);
@@ -44,20 +63,21 @@ export function RoutineActivityRow({ event }: { event: RoutineActivityEvent }) {
<button
type="button"
disabled={!hasPayload}
aria-expanded={hasPayload ? expanded : undefined}
onClick={() => setExpanded((value) => !value)}
className={cn(
"flex w-full items-center gap-3 px-1 py-2 text-left text-xs",
"flex min-w-0 w-full items-center gap-2 rounded-md px-2 py-2 text-left text-xs whitespace-nowrap",
hasPayload ? "hover:bg-accent/30" : "cursor-default",
)}
>
<span className="w-12 shrink-0 font-mono text-muted-foreground/70">
<span className="w-16 shrink-0 whitespace-nowrap font-mono tabular-nums text-muted-foreground">
{formatTime(event.createdAt)}
</span>
<Badge variant="outline" className="shrink-0 font-mono">
{event.action}
</Badge>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{summarizeDetails(event.details)}
<span title={event.action} className="min-w-0 max-w-1/2 shrink-0 truncate font-medium text-foreground">
{actionLabel(event.action)}
</span>
<span title={summarizeEvent(event)} className="min-w-0 flex-1 truncate text-muted-foreground">
{summarizeEvent(event)}
</span>
{hasPayload ? (
<ChevronRight
@@ -69,7 +89,7 @@ export function RoutineActivityRow({ event }: { event: RoutineActivityEvent }) {
) : null}
</button>
{expanded && hasPayload ? (
<pre className="mx-1 mb-2 overflow-x-auto rounded-md bg-neutral-950 p-3 font-mono text-xs text-neutral-200">
<pre className="mx-2 mb-2 overflow-x-auto rounded-md bg-muted p-3 font-mono text-xs text-foreground">
{JSON.stringify(event.details, null, 2)}
</pre>
) : null}
@@ -8,9 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
RoutineContextualSidebar,
resolveRoutineDetailDestination,
routineActivityAuditHref,
routineDetailHref,
routineRunsAuditHref,
} from "./RoutineContextualSidebar";
vi.mock("@/lib/router", () => ({
@@ -68,29 +66,29 @@ describe("routine contextual navigation", () => {
container.remove();
});
it("builds stable detail and scoped Audit destinations", () => {
it("builds stable routine detail destinations", () => {
expect(routineDetailHref("routine-1")).toBe("/routines/routine-1/overview");
expect(routineDetailHref("routine-1", "triggers")).toBe("/routines/routine-1/triggers");
expect(routineRunsAuditHref("routine-1")).toBe(
"/activity/runs?entityType=routine&entityId=routine-1",
expect(routineDetailHref("routine-1", "runs")).toBe(
"/routines/routine-1/runs",
);
expect(routineActivityAuditHref("routine-1")).toBe(
"/activity?entityType=routine&entityId=routine-1",
expect(routineDetailHref("routine-1", "activity")).toBe(
"/routines/routine-1/activity",
);
});
it("defaults and redirects legacy operation routes without remembering a prior section", () => {
it("defaults and resolves legacy operation routes locally without remembering a prior section", () => {
expect(resolveRoutineDetailDestination({ routineId: "routine-1" }))
.toBe("/routines/routine-1/overview");
expect(resolveRoutineDetailDestination({ routineId: "routine-1", section: "unknown" }))
.toBe("/routines/routine-1/overview");
expect(resolveRoutineDetailDestination({ routineId: "routine-1", section: "runs" }))
.toBe("/activity/runs?entityType=routine&entityId=routine-1");
.toBe("/routines/routine-1/runs");
expect(resolveRoutineDetailDestination({ routineId: "routine-1", legacyTab: "activity" }))
.toBe("/activity?entityType=routine&entityId=routine-1");
.toBe("/routines/routine-1/activity");
});
it("renders the routine contextual sidebar with configuration and Audit links", () => {
it("renders the routine contextual sidebar with configuration and operation links", () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
flushSync(() => root.render(
<QueryClientProvider client={queryClient}>
@@ -106,10 +104,10 @@ describe("routine contextual navigation", () => {
expect(frame?.classList).toContain("bg-background");
expect(frame?.classList).toContain("border-r");
expect(container.querySelector('a[href="/routines/routine-1/overview"]')?.textContent).toBe("Overview");
expect(container.querySelector('a[href="/routines/routine-1/triggers"]')?.textContent).toBe("Schedule");
expect(container.querySelector('a[href="/activity/runs?entityType=routine&entityId=routine-1"]'))
expect(container.querySelector('a[href="/routines/routine-1/triggers"]')?.textContent).toBe("Triggers");
expect(container.querySelector('a[href="/routines/routine-1/runs"]'))
.not.toBeNull();
expect(container.querySelector('a[href="/activity?entityType=routine&entityId=routine-1"]'))
expect(container.querySelector('a[href="/routines/routine-1/activity"]'))
.not.toBeNull();
expect(container.textContent).not.toContain("History");
});
+7 -26
View File
@@ -13,10 +13,6 @@ import {
import { routinesApi } from "@/api/routines";
import { queryKeys } from "@/lib/queryKeys";
import { useParams } from "@/lib/router";
import {
auditSectionHref,
routineAuditHref,
} from "@/pages/audit/audit-navigation";
import { ContextualSidebarFrame } from "./ContextualSidebarFrame";
import { SidebarNavItem } from "./SidebarNavItem";
@@ -27,6 +23,8 @@ export const ROUTINE_DETAIL_VIEWS = [
"delivery",
"secrets",
"history",
"runs",
"activity",
] as const;
export type RoutineDetailView = (typeof ROUTINE_DETAIL_VIEWS)[number];
@@ -39,7 +37,7 @@ export type RoutineContextualNavItem = {
export const ROUTINE_CONTEXTUAL_NAV_ITEMS: readonly RoutineContextualNavItem[] = [
{ view: "overview", label: "Overview", icon: LayoutDashboard },
{ view: "triggers", label: "Schedule", icon: CalendarClock },
{ view: "triggers", label: "Triggers", icon: CalendarClock },
{ view: "variables", label: "Variables", icon: Braces },
{ view: "delivery", label: "Delivery", icon: Send },
{ view: "secrets", label: "Secrets", icon: KeyRound },
@@ -53,30 +51,13 @@ export function routineDetailHref(routineId: string, view: RoutineDetailView = "
return `/routines/${routineId}/${view}`;
}
export function routineRunsAuditHref(routineId: string) {
return auditSectionHref("runs", {
entityType: "routine",
entityId: routineId,
});
}
export function routineActivityAuditHref(routineId: string) {
return routineAuditHref(routineId);
}
/**
* Canonical landing/legacy resolver used by the page and available to the
* shell. Detail configuration stays local; immutable operational history
* lives in scoped Audit.
*/
/** Keep routine configuration and operations in the routine detail shell. */
export function resolveRoutineDetailDestination(input: {
routineId: string;
section?: string | null;
legacyTab?: string | null;
}) {
const requested = input.legacyTab ?? input.section;
if (requested === "runs") return routineRunsAuditHref(input.routineId);
if (requested === "activity") return routineActivityAuditHref(input.routineId);
if (isRoutineDetailView(requested)) return routineDetailHref(input.routineId, requested);
return routineDetailHref(input.routineId, "overview");
}
@@ -120,16 +101,16 @@ export function RoutineContextualSidebar({
</div>
<p className="px-4 pb-1 pt-5 text-(length:--text-nano) font-mono font-medium uppercase tracking-widest text-muted-foreground/60">
Audit
Operate
</p>
<div className="flex flex-col gap-0.5">
<SidebarNavItem
to={routineRunsAuditHref(routineId)}
to={routineDetailHref(routineId, "runs")}
label="Runs"
icon={Play}
/>
<SidebarNavItem
to={routineActivityAuditHref(routineId)}
to={routineDetailHref(routineId, "activity")}
label="Activity"
icon={Activity}
/>
+8 -2
View File
@@ -166,6 +166,12 @@ describe("RoutineOverview", () => {
});
});
it("describes webhook-only routines as externally triggered", () => {
expect(summarizeRoutineSchedule([{ ...routine.triggers[0]!, kind: "webhook", enabled: true }])).toEqual({
label: "1 active webhook", detail: "Runs on incoming requests", nextRunAt: null,
});
});
it("adapts compact run tasks to the canonical task presentation", () => {
const issue = routineRunIssue(run.linkedIssue!, run, "company-1", "project-1");
expect(issue).toMatchObject({
@@ -195,9 +201,9 @@ describe("RoutineOverview", () => {
expect(container.textContent).not.toContain("PRIVATE_TOKEN");
expect(container.textContent).not.toContain("secret-1");
expect(issueRowRender).toHaveBeenCalledWith(expect.objectContaining({ presentation: "task" }));
expect(container.querySelector('a[href="/activity/runs?entityType=routine&entityId=routine-1"]'))
expect(container.querySelector('a[href="/routines/routine-1/runs"]'))
.not.toBeNull();
expect(container.querySelector('a[href="/activity?entityType=routine&entityId=routine-1"]'))
expect(container.querySelector('a[href="/routines/routine-1/activity"]'))
.not.toBeNull();
});
});
+15 -8
View File
@@ -14,9 +14,7 @@ import { Button } from "@/components/ui/button";
import { createIssueDetailLocationState } from "@/lib/issueDetailBreadcrumb";
import { Link } from "@/lib/router";
import {
routineActivityAuditHref,
routineDetailHref,
routineRunsAuditHref,
} from "./RoutineContextualSidebar";
import { useRoutineDetail } from "./routine-sections/context";
@@ -41,6 +39,14 @@ export function summarizeRoutineSchedule(triggers: RoutineTrigger[]): RoutineSch
.sort((left, right) => left.getTime() - right.getTime())[0] ?? null;
if (schedules.length === 0) {
const webhooks = triggers.filter((trigger) => trigger.kind === "webhook" && trigger.enabled);
if (webhooks.length > 0) {
return {
label: `${webhooks.length} active webhook${webhooks.length === 1 ? "" : "s"}`,
detail: "Runs on incoming requests",
nextRunAt: null,
};
}
return { label: "No active schedule", detail: "Manual runs only", nextRunAt: null };
}
@@ -134,6 +140,7 @@ function OverviewFact({
export function RoutineOverview() {
const { routine, routineRuns, currentAssignee, hasLiveRun } = useRoutineDetail();
const schedule = summarizeRoutineSchedule(routine.triggers);
const hasWebhook = routine.triggers.some((trigger) => trigger.kind === "webhook" && trigger.enabled);
const sortedRuns = [...(routineRuns ?? [])].sort(
(left, right) => new Date(right.triggeredAt).getTime() - new Date(left.triggeredAt).getTime(),
);
@@ -161,21 +168,21 @@ export function RoutineOverview() {
/>
<OverviewFact
icon={CalendarClock}
label="Schedule"
label="Triggers"
value={schedule.label}
detail={<span className="font-mono">{schedule.detail}</span>}
/>
<OverviewFact
icon={Clock3}
label="Next run"
value={schedule.nextRunAt ? formatRoutineTimestamp(schedule.nextRunAt) : "Not scheduled"}
detail={schedule.nextRunAt ? "Scheduled" : "Add or enable a schedule"}
value={schedule.nextRunAt ? formatRoutineTimestamp(schedule.nextRunAt) : hasWebhook ? "On webhook delivery" : "Not scheduled"}
detail={schedule.nextRunAt ? "Scheduled" : hasWebhook ? "Waiting for an incoming request" : "Add or enable a schedule"}
/>
<OverviewFact
icon={Play}
label="Last run"
value={lastRun ? <StatusBadge status={lastRun.status} /> : "No runs yet"}
detail={lastRun ? formatRoutineTimestamp(lastRun.triggeredAt) : "Run manually or wait for the schedule"}
detail={lastRun ? formatRoutineTimestamp(lastRun.triggeredAt) : "Run manually or wait for a trigger"}
/>
</div>
@@ -209,7 +216,7 @@ export function RoutineOverview() {
<div className="flex items-center justify-between gap-3">
<h2 id="routine-recent-runs-heading" className="text-sm font-semibold">Recent runs</h2>
<Button variant="ghost" size="sm" asChild>
<Link to={routineRunsAuditHref(routine.id)}>View all runs</Link>
<Link to={routineDetailHref(routine.id, "runs")}>View all runs</Link>
</Button>
</div>
{recentRuns.length === 0 ? (
@@ -241,7 +248,7 @@ export function RoutineOverview() {
</div>
)}
<Button variant="link" size="sm" className="w-fit px-0" asChild>
<Link to={routineActivityAuditHref(routine.id)}>View routine activity</Link>
<Link to={routineDetailHref(routine.id, "activity")}>View routine activity</Link>
</Button>
</section>
</div>
+17 -6
View File
@@ -17,7 +17,7 @@ import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch";
import { describeCron } from "../lib/cron-readable";
const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"];
const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["github_hmac", "none"]);
const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["bearer", "github_hmac", "none"]);
function getLocalTimezone(): string {
try {
@@ -63,9 +63,10 @@ export function RoutineTriggerCard({
const KindIcon =
trigger.kind === "schedule" ? Clock3 : trigger.kind === "webhook" ? Webhook : Zap;
const humanCron = trigger.kind === "schedule" ? describeCron(draft.cronExpression) : null;
const lastResultOk =
trigger.lastResult != null &&
/succeed|success|ok|200|delivered/i.test(String(trigger.lastResult));
const lastResultFailed = /fail|error/i.test(trigger.lastResult ?? "");
const lastResultLabel = trigger.lastResult?.startsWith("Created execution issue ")
? "Task created"
: trigger.lastResult;
return (
<form
@@ -87,8 +88,8 @@ export function RoutineTriggerCard({
</div>
<div className="flex shrink-0 items-center gap-2">
{trigger.lastResult ? (
<Badge variant={lastResultOk ? "secondary" : "destructive"}>
{String(trigger.lastResult)}
<Badge variant={lastResultFailed ? "destructive" : "secondary"}>
{lastResultLabel}
</Badge>
) : null}
<span className="text-xs text-muted-foreground">
@@ -101,6 +102,16 @@ export function RoutineTriggerCard({
</div>
</div>
{trigger.kind === "webhook" && trigger.webhookUrl && (
<div className="space-y-1.5">
<Label htmlFor={`webhook-url-${trigger.id}`} className="text-xs">Webhook URL</Label>
<Input id={`webhook-url-${trigger.id}`} value={trigger.webhookUrl} readOnly onFocus={(event) => event.target.select()} />
<p className="text-xs text-muted-foreground">
Send a POST request with Content-Type: application/json. Keep this URL private when signing is disabled.
</p>
</div>
)}
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Label</Label>
+45
View File
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SetupWizardNavigation, SetupWizardSidebarOutlet } from "./SetupWizard";
import { SetupWizardSidebarProvider } from "@/context/SetupWizardSidebarContext";
const setSidebarOpen = vi.hoisted(() => vi.fn());
vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: true, setSidebarOpen }) }));
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
describe("SetupWizard sidebar takeover", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); vi.clearAllMocks(); });
afterEach(() => { act(() => root.unmount()); container.remove(); });
it("replaces section navigation, portals the steps, and restores the menu on exit", () => {
const select = vi.fn();
const render = (wizard: boolean) => act(() => root.render(<SetupWizardSidebarProvider>
<SetupWizardSidebarOutlet><nav aria-label="Routine navigation">Overview · Triggers</nav></SetupWizardSidebarOutlet>
{wizard && <SetupWizardNavigation takeover ariaLabel="Trigger setup progress" labels={["Choose", "Connect", "Check"]} step={1} availableStep={1} onSelect={select} />}
</SetupWizardSidebarProvider>));
render(false);
expect(container.querySelector('[aria-label="Routine navigation"]')).not.toBeNull();
render(true);
expect(container.querySelector('[aria-label="Routine navigation"]')).toBeNull();
expect(container.querySelector('aside nav[aria-label="Trigger setup progress"]')).not.toBeNull();
const buttons = container.querySelectorAll<HTMLButtonElement>("nav button");
expect(buttons[1].getAttribute("aria-current")).toBe("step");
expect(buttons[2].disabled).toBe(true);
act(() => buttons[0].click());
expect(select).toHaveBeenCalledWith(0);
expect(setSidebarOpen).toHaveBeenCalledWith(false);
render(false);
expect(container.querySelector('[aria-label="Routine navigation"]')).not.toBeNull();
expect(container.querySelector('[aria-label="Trigger setup progress"]')).toBeNull();
});
it("keeps steps accessible when the shell has no sidebar outlet", () => {
act(() => root.render(<SetupWizardSidebarProvider>
<SetupWizardNavigation takeover ariaLabel="Trigger setup progress" labels={["Choose", "Connect", "Check"]} step={1} availableStep={1} onSelect={vi.fn()} />
</SetupWizardSidebarProvider>));
expect(container.querySelector('nav[aria-label="Trigger setup progress"]')).not.toBeNull();
expect(container.querySelector('[aria-current="step"]')?.textContent).toContain("Connect");
});
});
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { createPortal } from "react-dom";
import { useSetupWizardSidebar } from "@/context/SetupWizardSidebarContext";
import { useSidebar } from "@/context/SidebarContext";
import { cn } from "@/lib/utils";
export function SetupWizardSidebar() {
const sidebar = useSetupWizardSidebar();
return (
<aside className="flex h-full min-h-0 w-full flex-col border-r border-border bg-background">
<div ref={sidebar?.setTarget} className="overflow-y-auto px-4 py-6" />
</aside>
);
}
export function SetupWizardNavigation({
labels,
ariaLabel = "Setup progress",
takeover = false,
inline = false,
step,
availableStep,
disabled = false,
onSelect,
}: {
labels: string[];
ariaLabel?: string;
takeover?: boolean;
inline?: boolean;
step: number;
availableStep: number;
disabled?: boolean;
onSelect: (step: number) => void;
}) {
const sidebar = useSetupWizardSidebar();
const { isMobile, setSidebarOpen } = useSidebar();
const setActive = sidebar?.setActive;
useEffect(() => {
if (!takeover || !setActive) return;
setActive(true);
return () => setActive(false);
}, [takeover, setActive]);
const navigation = (
<nav aria-label={ariaLabel}>
<ol className="text-sm">
{labels.map((label, index) => (
<li key={label}>
{index > 0 && (
<div aria-hidden="true" className="flex w-8 justify-center py-1">
<span className="h-4 border-l border-border" />
</div>
)}
<button
type="button"
aria-current={index === step ? "step" : undefined}
disabled={disabled || index > availableStep}
onClick={() => {
onSelect(index);
if (isMobile) setSidebarOpen(false);
}}
className="flex w-full items-center gap-3 rounded-md text-left outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:hover:bg-transparent"
>
<span className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full border font-medium",
index === step ? "border-primary bg-primary text-primary-foreground" :
index <= availableStep ? "border-foreground text-foreground" : "border-border text-muted-foreground",
)}>
{index + 1}
</span>
<span className={index === step ? "font-medium text-foreground" : "text-muted-foreground"}>
{label}
</span>
</button>
</li>
))}
</ol>
</nav>
);
// Standalone renders (including component previews) retain usable navigation.
if (!sidebar || inline) return navigation;
return sidebar.target ? createPortal(navigation, sidebar.target) : navigation;
}
/** Lets a form temporarily replace the section menu, without replacing global navigation. */
export function SetupWizardSidebarOutlet({ children }: { children: ReactNode }) {
const sidebar = useSetupWizardSidebar();
return sidebar?.active ? <SetupWizardSidebar /> : children;
}
/** Each step owns one footer row; secondary actions stay with the primary action. */
export function SetupWizardFooter({ onSaveExit, children }: { onSaveExit: () => void; children: ReactNode }) {
return <div className="flex flex-wrap items-center justify-between gap-3 pt-2">
<Button variant="ghost" className="text-muted-foreground" onClick={onSaveExit}>Save &amp; exit</Button>
<div className="ml-auto flex flex-wrap items-center gap-2">{children}</div>
</div>;
}
+5 -67
View File
@@ -1,69 +1,7 @@
import { createPortal } from "react-dom";
import { useChatSetupSidebar } from "@/context/ChatSetupSidebarContext";
import { useSidebar } from "@/context/SidebarContext";
import { cn } from "@/lib/utils";
export function ChatSetupSidebar() {
const sidebar = useChatSetupSidebar();
return (
<aside className="flex h-full min-h-0 w-full flex-col border-r border-border bg-background">
<div ref={sidebar?.setTarget} className="overflow-y-auto px-4 py-6" />
</aside>
);
}
export function ChatSetupNavigation({
labels = ["Choose agent", "Connect provider", "Try it"],
step,
availableStep,
disabled = false,
onSelect,
}: {
labels?: string[];
step: number;
availableStep: number;
disabled?: boolean;
onSelect: (step: number) => void;
import { SetupWizardNavigation, SetupWizardSidebar } from "../SetupWizard";
export { SetupWizardSidebar as ChatSetupSidebar };
export function ChatSetupNavigation(props: {
labels?: string[]; step: number; availableStep: number; disabled?: boolean; onSelect: (step: number) => void;
}) {
const sidebar = useChatSetupSidebar();
const { isMobile, setSidebarOpen } = useSidebar();
const navigation = (
<nav aria-label="Connection setup progress">
<ol className="text-sm">
{labels.map((label, index) => (
<li key={label}>
{index > 0 && (
<div aria-hidden="true" className="flex w-8 justify-center py-1">
<span className="h-4 border-l border-border" />
</div>
)}
<button
type="button"
aria-current={index === step ? "step" : undefined}
disabled={disabled || index > availableStep}
onClick={() => {
onSelect(index);
if (isMobile) setSidebarOpen(false);
}}
className="flex w-full items-center gap-3 rounded-md text-left outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:hover:bg-transparent"
>
<span className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full border font-medium",
index === step ? "border-primary bg-primary text-primary-foreground" :
index <= availableStep ? "border-foreground text-foreground" : "border-border text-muted-foreground",
)}>
{index + 1}
</span>
<span className={index === step ? "font-medium text-foreground" : "text-muted-foreground"}>
{label}
</span>
</button>
</li>
))}
</ol>
</nav>
);
// Standalone renders (including component previews) retain usable navigation.
if (!sidebar) return navigation;
return sidebar.target ? createPortal(navigation, sidebar.target) : null;
return <SetupWizardNavigation {...props} labels={props.labels ?? ["Choose agent", "Connect provider", "Try it"]} ariaLabel="Connection setup progress" />;
}
@@ -98,12 +98,12 @@ const activityGateScopeOptions = [
const triggerKinds = ["schedule", "webhook"];
const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"];
const signingModeDescriptions: Record<string, string> = {
bearer: "Expect a shared bearer token in the Authorization header.",
hmac_sha256: "Expect an HMAC SHA-256 signature over the request using the shared secret.",
bearer: "Send Authorization: Bearer <secret> with each request.",
hmac_sha256: "Send X-Paperclip-Timestamp and X-Paperclip-Signature: sha256=<hex>, signing timestamp + a dot + the exact JSON body.",
github_hmac: "Accept GitHub-style X-Hub-Signature-256 header (HMAC over raw body, no timestamp).",
none: "No authentication — the webhook URL itself acts as a shared secret.",
};
const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["github_hmac", "none"]);
const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["bearer", "github_hmac", "none"]);
export function OverviewSection({
defaultDescriptionAnnotationsOpen = false,
@@ -417,7 +417,7 @@ function SummaryCard({
export function TriggersSection() {
const ctx = useRoutineDetail();
const { routine, newTrigger, setNewTrigger, createTrigger, updateTrigger, deleteTrigger, rotateTrigger } = ctx;
const { routine, newTrigger, setNewTrigger, createTrigger, updateTrigger, deleteTrigger, rotateTrigger, secretMessage, copySecretValue, setSecretMessage } = ctx;
const [addOpen, setAddOpen] = useState(false);
const [newScheduleEditorValid, setNewScheduleEditorValid] = useState(true);
const newScheduleValidation = useMemo(
@@ -477,9 +477,8 @@ export function TriggersSection() {
</SelectTrigger>
<SelectContent>
{triggerKinds.map((kind) => (
<SelectItem key={kind} value={kind} disabled={kind === "webhook"}>
<SelectItem key={kind} value={kind}>
{kind}
{kind === "webhook" ? " — COMING SOON" : ""}
</SelectItem>
))}
</SelectContent>
@@ -558,12 +557,42 @@ export function TriggersSection() {
</div>
) : null}
{secretMessage ? (
<div className="space-y-3 rounded-lg border border-border bg-muted/20 p-4 text-sm">
<div>
<p className="font-medium">{secretMessage.title}</p>
<p className="text-xs text-muted-foreground">
Save this now. Paperclip will not show the secret value again.
</p>
</div>
<div className="space-y-3">
{secretMessage.entries.map((entry, index) => (
<div key={`${entry.webhookUrl}-${index}`} className="space-y-2">
<div className="flex items-center gap-2">
<Input aria-label="New webhook URL" value={entry.webhookUrl} readOnly className="flex-1" />
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook URL", entry.webhookUrl)}>
URL
</Button>
</div>
<div className="flex items-center gap-2">
<Input aria-label="New webhook secret" value={entry.webhookSecret} readOnly className="flex-1" />
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook secret", entry.webhookSecret)}>
Secret
</Button>
</div>
</div>
))}
</div>
<Button variant="outline" size="sm" onClick={() => setSecretMessage(null)}>Done</Button>
</div>
) : null}
{/* Existing triggers */}
{routine.triggers.length === 0 ? (
<EmptyState
icon={Clock3}
message="No triggers yet."
action="Add a schedule"
action="Add a trigger"
onAction={() => setAddOpen(true)}
/>
) : (
@@ -623,7 +652,7 @@ export function VariablesSection() {
export function SecretsSection() {
const ctx = useRoutineDetail();
const { editDraft, setEditDraft, availableSecrets, createSecret, secretMessage, copySecretValue } = ctx;
const { editDraft, setEditDraft, availableSecrets, createSecret } = ctx;
// Project/company-scoped secrets that already see real usage, surfaced as
// quick-bind chips (§3.4). Ranked by reference count then recency.
@@ -647,34 +676,6 @@ export function SecretsSection() {
project and agent env. <span className="font-mono">PAPERCLIP_*</span> names are reserved.
</div>
{secretMessage ? (
<div className="space-y-3 rounded-lg border border-blue-500/30 bg-blue-500/5 p-4 text-sm">
<div>
<p className="font-medium">{secretMessage.title}</p>
<p className="text-xs text-muted-foreground">
Save this now. Paperclip will not show the secret value again.
</p>
</div>
<div className="space-y-3">
{secretMessage.entries.map((entry, index) => (
<div key={`${entry.webhookUrl}-${index}`} className="space-y-2">
<div className="flex items-center gap-2">
<Input value={entry.webhookUrl} readOnly className="flex-1" />
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook URL", entry.webhookUrl)}>
URL
</Button>
</div>
<div className="flex items-center gap-2">
<Input value={entry.webhookSecret} readOnly className="flex-1" />
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook secret", entry.webhookSecret)}>
Secret
</Button>
</div>
</div>
))}
</div>
</div>
) : null}
<EnvironmentVariablesEditor
value={(editDraft.env ?? {}) as Record<string, EnvBinding>}
@@ -1,145 +1,146 @@
// @vitest-environment jsdom
import { useState } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RoutineDetail, RoutineTrigger } from "@paperclipai/shared";
import { BreadcrumbProvider } from "@/context/BreadcrumbContext";
import { queryKeys } from "@/lib/queryKeys";
import { TriggersSection } from "./editable-sections";
import {
RoutineDetailContext,
createDefaultNewTrigger,
type NewTriggerDraft,
type RoutineDetailContextValue,
} from "./context";
import { RoutineDetailContext, type RoutineDetailContextValue, type SecretMessage } from "./context";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const api = vi.hoisted(() => ({ get: vi.fn(), createTrigger: vi.fn(), updateTrigger: vi.fn() }));
vi.mock("@/api/routines", () => ({ routinesApi: api }));
vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: false, setSidebarOpen: vi.fn() }) }));
vi.mock("../MarkdownEditor", () => ({ MarkdownEditor: () => null }));
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
vi.mock("../MarkdownEditor", () => ({
MarkdownEditor: () => null,
}));
function act(callback: () => void) {
flushSync(callback);
let container: HTMLDivElement;
let root: Root;
let client: QueryClient;
let routine: RoutineDetail;
function button(label: string) {
const found = [...container.querySelectorAll<HTMLButtonElement>("button")].find((item) => item.textContent?.trim() === label);
if (!found) throw new Error(`Button not found: ${label}`);
return found;
}
function buttonByText(container: HTMLElement, label: string): HTMLButtonElement {
const button = [...container.querySelectorAll("button")].find(
(candidate) => candidate.textContent?.trim() === label,
);
if (!button) throw new Error(`Button not found: ${label}`);
return button as HTMLButtonElement;
async function click(label: string) { await act(async () => button(label).click()); }
async function choose(label: string) {
const option = [...container.querySelectorAll("label")].find((item) => item.textContent?.includes(label));
expect(option).toBeTruthy();
await act(async () => option!.click());
}
function typeCron(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
function Harness({ secretMessage, contextRoutine }: { secretMessage?: SecretMessage; contextRoutine?: RoutineDetail }) {
const { data } = useQuery({ queryKey: queryKeys.routines.detail(routine.id), queryFn: api.get });
const value = { routine: contextRoutine ?? data ?? routine, routineId: routine.id, companyId: routine.companyId, secretMessage, setSecretMessage: vi.fn() } as unknown as RoutineDetailContextValue;
return <RoutineDetailContext.Provider value={value}><TriggersSection /></RoutineDetailContext.Provider>;
}
function Harness({ createMutate }: { createMutate: ReturnType<typeof vi.fn> }) {
const [newTrigger, setNewTrigger] = useState<NewTriggerDraft>({
...createDefaultNewTrigger(),
cronExpression: "0 8-18/2 * * 1-5",
});
const value = {
routine: {
id: "routine-1",
triggers: [],
},
newTrigger,
setNewTrigger,
createTrigger: {
isPending: false,
mutate: createMutate,
},
updateTrigger: { mutate: vi.fn() },
deleteTrigger: { mutate: vi.fn() },
rotateTrigger: { mutate: vi.fn() },
} as unknown as RoutineDetailContextValue;
return (
<RoutineDetailContext.Provider value={value}>
<TriggersSection />
</RoutineDetailContext.Provider>
);
async function render(secretMessage?: SecretMessage, contextRoutine?: RoutineDetail) {
await act(async () => root.render(<MemoryRouter initialEntries={["/routines/routine-1/triggers"]}>
<QueryClientProvider client={client}><BreadcrumbProvider><Harness secretMessage={secretMessage} contextRoutine={contextRoutine} /></BreadcrumbProvider></QueryClientProvider>
</MemoryRouter>));
}
describe("TriggersSection", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
beforeEach(() => {
vi.clearAllMocks();
sessionStorage.clear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
routine = { id: "routine-1", companyId: "company-1", title: "Verify deployment", status: "active", triggers: [] } as unknown as RoutineDetail;
api.get.mockImplementation(async () => routine);
api.createTrigger.mockImplementation(async (_id, input) => {
const trigger = { id: "trigger-1", enabled: true, webhookUrl: "https://paperclip.example/api/routine-triggers/public/0123456789abcdef01234567/fire", ...input } as RoutineTrigger;
routine = { ...routine, triggers: [...routine.triggers, trigger] };
return { trigger, secretMaterial: { webhookUrl: trigger.webhookUrl, webhookSecret: "one-time-secret" } };
});
api.updateTrigger.mockImplementation(async (id, patch) => {
routine = { ...routine, triggers: routine.triggers.map((trigger) => trigger.id === id ? { ...trigger, ...patch } : trigger) };
return routine.triggers.find((trigger) => trigger.id === id);
});
});
afterEach(async () => {
await act(async () => root.unmount());
client.clear();
container.remove();
});
describe("TriggersSection", () => {
it("offers schedule and webhook choices before creating a trigger", async () => {
await render();
await click("Add trigger");
expect(button("Continue").disabled).toBe(true);
expect(container.textContent).toContain("On a schedule");
await choose("When another app sends a webhook");
expect(button("Continue").disabled).toBe(false);
expect(api.createTrigger).not.toHaveBeenCalled();
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
it("creates a pending webhook, shows credentials, and activates only when setup finishes", async () => {
await render();
await click("Add trigger");
await choose("When another app sends a webhook");
await click("Continue");
expect(api.createTrigger).toHaveBeenCalledWith("routine-1", { kind: "webhook", signingMode: "bearer", setupPending: true });
expect(container.textContent).toContain("Bearer one-time-secret");
expect(button("Copy for your agent")).toBeTruthy();
expect(JSON.stringify(Object.values(sessionStorage))).not.toContain("one-time-secret");
await click("Check connection");
expect(container.textContent).toContain("This won’t start the routine");
expect(api.updateTrigger).not.toHaveBeenCalled();
await click("Finish without checking");
expect(api.updateTrigger).toHaveBeenCalledWith("trigger-1", { setupPending: false });
expect(button("Add trigger")).toBeTruthy();
expect(container.textContent).not.toContain("one-time-secret");
});
it("closes the add-trigger composer and resets the draft after a successful create", () => {
const createMutate = vi.fn((_variables, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.();
});
const root = createRoot(container);
it("shows polled connection results even when routine context is stale", async () => {
routine.triggers = [{ id: "trigger-1", kind: "webhook", enabled: true, setupPending: true, signingMode: "bearer", webhookUrl: "https://paperclip.example/webhook" }] as RoutineTrigger[];
await render(undefined, routine);
await click("Resume setup");
await click("Check connection");
expect(container.textContent).toContain("Waiting to verify");
for (const status of ["rejected", "received"] as const) {
routine = { ...routine, triggers: [{ ...routine.triggers[0], lastWebhookDelivery: { status, test: true, receivedAt: new Date().toISOString() } }] };
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.routines.detail(routine.id) }); });
await vi.waitFor(() => expect(container.textContent).toContain(status === "received" ? "Authentication passed. No routine run or task was created." : "Go back to Connect your app, update the key"));
}
expect(button("Finish setup")).toBeTruthy();
});
act(() => {
root.render(<Harness createMutate={createMutate} />);
});
act(() => {
buttonByText(container, "New trigger").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.querySelector('input[aria-label="Cron expression"]')).not.toBeNull();
act(() => {
buttonByText(container, "Add trigger").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(createMutate).toHaveBeenCalledTimes(1);
expect(container.querySelector('input[aria-label="Cron expression"]')).toBeNull();
expect(buttonByText(container, "New trigger")).not.toBeNull();
act(() => {
buttonByText(container, "New trigger").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.querySelector('input[aria-label="Cron expression"]')).toBeNull();
expect(container.textContent).toContain("Every day");
it("shows restored one-time credentials on the triggers screen", async () => {
await render({ title: "Webhook key restored", entries: [{ webhookUrl: "https://paperclip.example/webhook", webhookSecret: "restored-secret" }] });
expect(container.textContent).toContain("restored-secret");
expect(container.querySelector('[aria-label="Copy Secret key"]')).not.toBeNull();
expect(button("Done")).toBeTruthy();
});
act(() => root.unmount());
it("creates a schedule on confirmation and starts a fresh wizard afterward", async () => {
await render();
await click("Add trigger");
await choose("On a schedule");
await click("Continue");
await click("Review schedule");
expect(api.createTrigger).not.toHaveBeenCalled();
await click("Add schedule");
expect(api.createTrigger).toHaveBeenCalledWith("routine-1", expect.objectContaining({ kind: "schedule", cronExpression: "0 9 * * 1-5" }));
await click("Add trigger");
expect(button("Continue").disabled).toBe(true);
});
it("disables add trigger while the custom cron draft is invalid locally", () => {
const createMutate = vi.fn();
const root = createRoot(container);
act(() => {
root.render(<Harness createMutate={createMutate} />);
});
act(() => {
buttonByText(container, "New trigger").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const input = container.querySelector<HTMLInputElement>('input[aria-label="Cron expression"]');
it("blocks saving an invalid custom cron in the inline schedule editor", async () => {
routine.triggers = [{ id: "schedule-1", kind: "schedule", enabled: true, cronExpression: "0 8-18/2 * * 1-5", timezone: "UTC" }] as RoutineTrigger[];
await render();
await click("Edit schedule");
const input = container.querySelector<HTMLInputElement>('input[aria-label="Cron expression"]')!;
expect(input).not.toBeNull();
expect(buttonByText(container, "Add trigger").disabled).toBe(false);
act(() => {
typeCron(input!, "0 8-18/2 *");
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "0 8-18/2 *");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(input?.value).toBe("0 8-18/2 *");
expect(container.textContent).toContain("Use exactly 5 fields");
expect(buttonByText(container, "Add trigger").disabled).toBe(true);
act(() => {
buttonByText(container, "Add trigger").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(createMutate).not.toHaveBeenCalled();
act(() => root.unmount());
expect(button("Save schedule").disabled).toBe(true);
expect(api.updateTrigger).not.toHaveBeenCalled();
});
});
@@ -1,12 +1,11 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import {
ArrowRight,
Braces,
Clock3,
Edit3,
Play,
Plus,
X,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
@@ -29,11 +28,9 @@ import { EmptyState } from "../EmptyState";
import { InlineEntitySelector } from "../InlineEntitySelector";
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "../IssueDocumentAnnotations";
import { MarkdownEditor } from "../MarkdownEditor";
import { ScheduleEditor, getScheduleCronValidation } from "../ScheduleEditor";
import { RoutineVariablesEditor, RoutineVariablesHint } from "../RoutineVariablesEditor";
import { RoutineTriggerCard } from "../RoutineTriggerCard";
import { EnvironmentVariablesEditor } from "../environment-variables-editor";
import { createDefaultNewTrigger, useRoutineDetail } from "./context";
import { useRoutineDetail } from "./context";
import type { EnvBinding, RoutineDetail as RoutineDetailType } from "@paperclipai/shared";
const concurrencyPolicyOptions = [
@@ -94,16 +91,6 @@ const activityGateScopeOptions = [
},
];
const triggerKinds = ["schedule", "webhook"];
const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"];
const signingModeDescriptions: Record<string, string> = {
bearer: "Expect a shared bearer token in the Authorization header.",
hmac_sha256: "Expect an HMAC SHA-256 signature over the request using the shared secret.",
github_hmac: "Accept GitHub-style X-Hub-Signature-256 header (HMAC over raw body, no timestamp).",
none: "No authentication — the webhook URL itself acts as a shared secret.",
};
const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["github_hmac", "none"]);
export function OverviewSection({
defaultDescriptionAnnotationsOpen = false,
}: {
@@ -405,173 +392,7 @@ function SummaryCard({
);
}
export function TriggersSection() {
const ctx = useRoutineDetail();
const { routine, newTrigger, setNewTrigger, createTrigger, updateTrigger, deleteTrigger, rotateTrigger } = ctx;
const [addOpen, setAddOpen] = useState(false);
const [newScheduleEditorValid, setNewScheduleEditorValid] = useState(true);
const newScheduleValidation = useMemo(
() => newTrigger.kind === "schedule" ? getScheduleCronValidation(newTrigger.cronExpression) : null,
[newTrigger.cronExpression, newTrigger.kind],
);
const addDisabled =
createTrigger.isPending ||
(newScheduleValidation ? !newScheduleValidation.valid || !newScheduleEditorValid : false);
useEffect(() => {
if (newTrigger.kind !== "schedule") setNewScheduleEditorValid(true);
}, [newTrigger.kind]);
return (
<div className="space-y-4">
{/* Add-trigger drawer header (§3.2) */}
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-muted-foreground">
{routine.triggers.length === 0
? "No triggers yet"
: `${routine.triggers.length} trigger${routine.triggers.length === 1 ? "" : "s"}`}
</p>
<Button
size="sm"
variant={addOpen ? "secondary" : "default"}
onClick={() => setAddOpen((open) => !open)}
aria-expanded={addOpen}
>
{addOpen ? (
<>
<X className="mr-1.5 h-3.5 w-3.5" />
Cancel
</>
) : (
<>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New trigger
</>
)}
</Button>
</div>
{/* Add trigger form — expand-on-click drawer */}
{addOpen ? (
<div className="space-y-3 rounded-lg border border-border p-4">
<p className="text-sm font-medium">Add trigger</p>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Kind</Label>
<Select
value={newTrigger.kind}
onValueChange={(kind) => setNewTrigger((current) => ({ ...current, kind }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{triggerKinds.map((kind) => (
<SelectItem key={kind} value={kind} disabled={kind === "webhook"}>
{kind}
{kind === "webhook" ? " — COMING SOON" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{newTrigger.kind === "schedule" && (
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs">Schedule</Label>
<ScheduleEditor
value={newTrigger.cronExpression}
onChange={(cronExpression) =>
setNewTrigger((current) => ({ ...current, cronExpression }))
}
onValidityChange={setNewScheduleEditorValid}
/>
</div>
)}
{newTrigger.kind === "webhook" && (
<>
<div className="space-y-1.5">
<Label className="text-xs">Signing mode</Label>
<Select
value={newTrigger.signingMode}
onValueChange={(signingMode) =>
setNewTrigger((current) => ({ ...current, signingMode }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{signingModes.map((mode) => (
<SelectItem key={mode} value={mode}>
{mode}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{signingModeDescriptions[newTrigger.signingMode]}
</p>
</div>
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(newTrigger.signingMode) && (
<div className="space-y-1.5">
<Label className="text-xs">Replay window (seconds)</Label>
<Input
value={newTrigger.replayWindowSec}
onChange={(event) =>
setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))
}
/>
</div>
)}
</>
)}
</div>
<div className="flex items-center justify-end gap-2">
<Button size="sm" variant="ghost" onClick={() => setAddOpen(false)}>
Cancel
</Button>
<Button
size="sm"
onClick={() =>
createTrigger.mutate(undefined, {
onSuccess: () => {
setNewTrigger(createDefaultNewTrigger());
setAddOpen(false);
},
})
}
disabled={addDisabled}
>
{createTrigger.isPending ? "Adding..." : "Add trigger"}
</Button>
</div>
</div>
) : null}
{/* Existing triggers */}
{routine.triggers.length === 0 ? (
<EmptyState
icon={Clock3}
message="No triggers yet."
action="Add a schedule"
onAction={() => setAddOpen(true)}
/>
) : (
<div className="space-y-3">
{routine.triggers.map((trigger) => (
<RoutineTriggerCard
key={trigger.id}
trigger={trigger}
onSave={(id, patch) => updateTrigger.mutate({ id, patch })}
onRotate={(id) => rotateTrigger.mutate(id)}
onDelete={(id) => deleteTrigger.mutate(id)}
/>
))}
</div>
)}
</div>
);
}
export { RoutineTriggers as TriggersSection } from "../routine-triggers/RoutineTriggers";
export function VariablesSection() {
const ctx = useRoutineDetail();
@@ -613,7 +434,7 @@ export function VariablesSection() {
export function SecretsSection() {
const ctx = useRoutineDetail();
const { editDraft, setEditDraft, availableSecrets, createSecret, secretMessage, copySecretValue } = ctx;
const { editDraft, setEditDraft, availableSecrets, createSecret } = ctx;
// Project/company-scoped secrets that already see real usage, surfaced as
// quick-bind chips (§3.4). Ranked by reference count then recency.
@@ -637,34 +458,6 @@ export function SecretsSection() {
project and agent env. <span className="font-mono">PAPERCLIP_*</span> names are reserved.
</div>
{secretMessage ? (
<div className="space-y-3 rounded-lg border border-blue-500/30 bg-blue-500/5 p-4 text-sm">
<div>
<p className="font-medium">{secretMessage.title}</p>
<p className="text-xs text-muted-foreground">
Save this now. Paperclip will not show the secret value again.
</p>
</div>
<div className="space-y-3">
{secretMessage.entries.map((entry, index) => (
<div key={`${entry.webhookUrl}-${index}`} className="space-y-2">
<div className="flex items-center gap-2">
<Input value={entry.webhookUrl} readOnly className="flex-1" />
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook URL", entry.webhookUrl)}>
URL
</Button>
</div>
<div className="flex items-center gap-2">
<Input value={entry.webhookSecret} readOnly className="flex-1" />
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook secret", entry.webhookSecret)}>
Secret
</Button>
</div>
</div>
))}
</div>
</div>
) : null}
<EnvironmentVariablesEditor
value={(editDraft.env ?? {}) as Record<string, EnvBinding>}
@@ -0,0 +1,70 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RunsSection, ActivitySection } from "./operate-sections";
import { RoutineDetailContext, type RoutineDetailContextValue } from "./context";
const { list, update, renderList, toast } = vi.hoisted(() => ({
list: vi.fn(), update: vi.fn(), renderList: vi.fn(), toast: vi.fn(),
}));
vi.mock("@/api/issues", () => ({ issuesApi: { list, update } }));
vi.mock("@/context/ToastContext", () => ({ useToastActions: () => ({ pushToast: toast }) }));
vi.mock("../IssuesList", () => ({ IssuesList: (props: unknown) => { renderList(props); return <div>Issue list</div>; } }));
vi.mock("../RoutineHistoryTab", () => ({ RoutineHistoryTab: () => null }));
let root: Root;
let container: HTMLDivElement;
let queryClient: QueryClient;
const context = {
companyId: "company-1", routine: { id: "routine-1" }, agents: [], projects: [], activity: [],
} as unknown as RoutineDetailContextValue;
function render(section = <RunsSection />) {
flushSync(() => root.render(
<QueryClientProvider client={queryClient}>
<RoutineDetailContext.Provider value={context}>{section}</RoutineDetailContext.Provider>
</QueryClientProvider>,
));
}
beforeEach(() => {
vi.clearAllMocks();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
afterEach(() => {
flushSync(() => root.unmount());
queryClient.clear();
container.remove();
});
describe("routine operations", () => {
it("loads this routine's execution issues and keeps list search scoped to the same routine", async () => {
const issues = [{ id: "execution-1", title: "Check deployment", status: "done" }];
list.mockResolvedValue(issues);
render();
await vi.waitFor(() => expect(renderList.mock.lastCall?.[0].issues).toEqual(issues));
expect(list).toHaveBeenCalledWith("company-1", { originKind: "routine_execution", originId: "routine-1" });
expect(renderList.mock.lastCall?.[0].searchFilters).toEqual({ originKind: "routine_execution", originId: "routine-1" });
expect(renderList.mock.lastCall?.[0].issueLinkState.issueDetailBreadcrumb.href).toBe("/routines/routine-1/runs");
});
it("surfaces issue loading and update failures", async () => {
list.mockRejectedValue(new Error("Could not load execution issues"));
render();
await vi.waitFor(() => expect(renderList.mock.lastCall?.[0].error?.message).toBe("Could not load execution issues"));
update.mockRejectedValue(new Error("Update denied"));
renderList.mock.lastCall?.[0].onUpdateIssue("execution-1", { status: "done" });
await vi.waitFor(() => expect(toast).toHaveBeenCalledWith(expect.objectContaining({ body: "Update denied", tone: "error" })));
});
it("distinguishes activity loading and failures from an empty timeline", () => {
render(<ActivitySection isLoading />);
expect(container.textContent).toContain("Loading activity");
expect(container.textContent).not.toContain("No activity yet");
render(<ActivitySection error={new Error("Activity unavailable")} />);
expect(container.querySelector('[role="alert"]')?.textContent).toBe("Activity unavailable");
});
});
@@ -1,203 +1,56 @@
import { useMemo, useState } from "react";
import { Activity as ActivityIcon, Play, SlidersHorizontal } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { timeAgo } from "../../lib/timeAgo";
import { runRowSubtitle, dedupedTriggerLabel } from "../../lib/routine-run-display";
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Activity as ActivityIcon } from "lucide-react";
import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
import { routineDetailHref } from "../RoutineContextualSidebar";
import { createIssueDetailLocationState } from "@/lib/issueDetailBreadcrumb";
import { useToastActions } from "@/context/ToastContext";
import { IssuesList } from "../IssuesList";
import { EmptyState } from "../EmptyState";
import { EntityRow } from "../EntityRow";
import { FilterBar, type FilterValue } from "../FilterBar";
import { LiveRunWidget } from "../LiveRunWidget";
import { RoutineHistoryTab } from "../RoutineHistoryTab";
import { RoutineActivityRow } from "../RoutineActivityRow";
import { useRoutineDetail } from "./context";
const DATE_WINDOW_OPTIONS: { value: string; label: string; ms: number | null }[] = [
{ value: "any", label: "Any time", ms: null },
{ value: "24h", label: "Last 24h", ms: 24 * 60 * 60 * 1000 },
{ value: "7d", label: "Last 7d", ms: 7 * 24 * 60 * 60 * 1000 },
{ value: "30d", label: "Last 30d", ms: 30 * 24 * 60 * 60 * 1000 },
];
export function RunsSection() {
const ctx = useRoutineDetail();
const { routine, routineRuns, hasLiveRun, activeIssueId, onOpenRunDialog } = ctx;
const runs = useMemo(() => routineRuns ?? [], [routineRuns]);
const [sourceFilter, setSourceFilter] = useState("any");
const [statusFilter, setStatusFilter] = useState("any");
const [dateFilter, setDateFilter] = useState("any");
const sourceOptions = useMemo(
() => [...new Set(runs.map((run) => run.source))].sort(),
[runs],
);
const statusOptions = useMemo(
() => [...new Set(runs.map((run) => run.status))].sort(),
[runs],
);
const filtered = useMemo(() => {
const windowMs = DATE_WINDOW_OPTIONS.find((option) => option.value === dateFilter)?.ms ?? null;
const cutoff = windowMs == null ? null : Date.now() - windowMs;
return runs.filter((run) => {
if (sourceFilter !== "any" && run.source !== sourceFilter) return false;
if (statusFilter !== "any" && run.status !== statusFilter) return false;
if (cutoff != null && new Date(run.triggeredAt).getTime() < cutoff) return false;
return true;
});
}, [runs, sourceFilter, statusFilter, dateFilter]);
const activeFilters = useMemo<FilterValue[]>(() => {
const list: FilterValue[] = [];
if (sourceFilter !== "any") list.push({ key: "source", label: "Source", value: sourceFilter });
if (statusFilter !== "any") {
list.push({ key: "status", label: "Status", value: statusFilter.replaceAll("_", " ") });
}
if (dateFilter !== "any") {
const label = DATE_WINDOW_OPTIONS.find((option) => option.value === dateFilter)?.label ?? dateFilter;
list.push({ key: "date", label: "Date", value: label });
}
return list;
}, [sourceFilter, statusFilter, dateFilter]);
function clearFilters() {
setSourceFilter("any");
setStatusFilter("any");
setDateFilter("any");
}
function removeFilter(key: string) {
if (key === "source") setSourceFilter("any");
if (key === "status") setStatusFilter("any");
if (key === "date") setDateFilter("any");
}
const { routine, companyId, agents, projects, hasLiveRun, activeIssueId } = useRoutineDetail();
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const filters = { originKind: "routine_execution", originId: routine.id };
const issueQueryKey = [...queryKeys.issues.list(companyId), "routine", routine.id];
const { data: issues, isLoading, error } = useQuery({
queryKey: issueQueryKey,
queryFn: () => issuesApi.list(companyId, filters),
});
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => issuesApi.update(id, data),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routine.id) });
void queryClient.invalidateQueries({ queryKey: queryKeys.routines.runs(routine.id) });
},
onError: (updateError) => pushToast({ title: "Failed to update task", body: updateError.message, tone: "error" }),
});
return (
<div className="space-y-4">
{hasLiveRun && activeIssueId ? (
<LiveRunWidget issueId={activeIssueId} companyId={routine.companyId} />
) : null}
{runs.length === 0 ? (
<EmptyState
icon={Play}
message="No runs yet. Trigger a run from the header or wait for the schedule."
action="Run now"
onAction={onOpenRunDialog}
/>
) : (
<>
{/* Filter chips row (§3.6) */}
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Select value={sourceFilter} onValueChange={setSourceFilter}>
<SelectTrigger size="sm" className="h-8 w-auto gap-1.5 text-xs" aria-label="Filter by source">
<span className="text-muted-foreground">Source:</span>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">any</SelectItem>
{sourceOptions.map((source) => (
<SelectItem key={source} value={source}>
{source}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger size="sm" className="h-8 w-auto gap-1.5 text-xs" aria-label="Filter by status">
<span className="text-muted-foreground">Status:</span>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">any</SelectItem>
{statusOptions.map((status) => (
<SelectItem key={status} value={status}>
{status.replaceAll("_", " ")}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={dateFilter} onValueChange={setDateFilter}>
<SelectTrigger size="sm" className="h-8 w-auto gap-1.5 text-xs" aria-label="Filter by date">
<span className="text-muted-foreground">Date:</span>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_WINDOW_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<FilterBar filters={activeFilters} onRemove={removeFilter} onClear={clearFilters} />
</div>
{filtered.length === 0 ? (
<EmptyState
icon={SlidersHorizontal}
message="No runs match these filters."
action="Clear filters"
onAction={clearFilters}
/>
) : (
<div className="rounded-lg border border-border">
{filtered.map((run) => {
const label = dedupedTriggerLabel(run.trigger);
const title = run.linkedIssue?.title ?? label ?? "Run";
return (
<EntityRow
key={run.id}
leading={
<>
<Badge variant="outline" className="shrink-0">
{run.source}
</Badge>
<Badge
variant={run.status === "failed" ? "destructive" : "secondary"}
className="shrink-0"
>
{run.status.replaceAll("_", " ")}
</Badge>
</>
}
identifier={
run.linkedIssue
? run.linkedIssue.identifier ?? run.linkedIssue.id.slice(0, 8)
: undefined
}
title={title}
subtitle={runRowSubtitle(run, routine.variables)}
reserveSubtitleSpace
trailing={
<span className="text-xs text-muted-foreground">{timeAgo(run.triggeredAt)}</span>
}
to={
run.linkedIssue
? `/issues/${run.linkedIssue.identifier ?? run.linkedIssue.id}`
: undefined
}
/>
);
})}
</div>
)}
</>
)}
</div>
<IssuesList
issues={issues ?? []}
isLoading={isLoading}
error={error}
agents={agents}
projects={projects}
liveIssueIds={new Set(hasLiveRun && activeIssueId ? [activeIssueId] : [])}
viewStateKey={`paperclip:routine-runs:${companyId}:${routine.id}`}
searchFilters={filters}
issueLinkState={createIssueDetailLocationState("Runs", routineDetailHref(routine.id, "runs"))}
rowPresentation="task"
toolbarPresentation="collection"
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
/>
);
}
export function ActivitySection() {
export function ActivitySection({ isLoading = false, error }: { isLoading?: boolean; error?: Error | null } = {}) {
const ctx = useRoutineDetail();
const { activity } = ctx;
const events = activity ?? [];
@@ -222,6 +75,9 @@ export function ActivitySection() {
return Array.from(byDay.entries());
}, [events]);
if (isLoading) return <p className="text-sm text-muted-foreground">Loading activity…</p>;
if (error) return <p role="alert" className="text-sm text-destructive">{error.message}</p>;
if (events.length === 0) {
return <EmptyState icon={ActivityIcon} message="No activity yet." />;
}
@@ -0,0 +1,650 @@
import { useCallback, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { CalendarClock, Plus, Webhook } from "lucide-react";
import type { RoutineTrigger } from "@paperclipai/shared";
import { useSearchParams } from "@/lib/router";
import { routinesApi } from "@/api/routines";
import { queryKeys } from "@/lib/queryKeys";
import { describeCron } from "@/lib/cron-readable";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { ScheduleEditor } from "@/components/ScheduleEditor";
import { useRoutineDetail } from "@/components/routine-sections/context";
import { RoutineTriggerCard } from "./TriggerCard";
import {
RoutineTriggerWizard,
defaultTriggerDraft,
webhookAgentInstructions,
type TriggerDraft,
} from "./TriggerWizard";
import { AgentInstructions, CopyField } from "./WebhookFields";
function readDraft(key: string): TriggerDraft | null {
try {
const draft = JSON.parse(sessionStorage.getItem(key) ?? "null");
return draft && ["choose", "schedule", "webhook"].includes(draft.kind)
? { ...defaultTriggerDraft, ...draft }
: null;
} catch {
return null;
}
}
function saveDraft(key: string, draft: TriggerDraft) {
// Only setup choices are persisted. The one-time key stays in component memory.
try {
sessionStorage.setItem(key, JSON.stringify(draft));
} catch {
throw new Error(
"Couldn’t save your draft in this browser. Keep this page open and try again.",
);
}
}
function scheduleCron(draft: TriggerDraft) {
const [hour, minute] = draft.time.split(":").map(Number);
const day = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
].indexOf(draft.weekday);
return `${minute} ${hour} * * ${draft.frequency === "daily" ? "*" : draft.frequency === "weekly" ? day : "1-5"}`;
}
export function RoutineTriggers() {
const ctx = useRoutineDetail();
const [params, setParams] = useSearchParams();
const setupId = params.get("triggerSetup");
const client = useQueryClient();
const { data, error: statusError } = useQuery({
queryKey: queryKeys.routines.detail(ctx.routineId),
queryFn: () => routinesApi.get(ctx.routineId),
refetchInterval: 3000,
});
const routine = data ?? ctx.routine;
const [expanded, setExpanded] = useState<string | null>(null);
const [removed, setRemoved] = useState<RoutineTrigger[]>([]);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
await client.invalidateQueries({ queryKey: ["routines"] });
}, [client]);
const change = async (action: () => Promise<void>) => {
if (busy) return;
setBusy(true);
setError("");
try {
await action();
await refresh();
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: "Couldn’t save the trigger. Please try again.",
);
} finally {
setBusy(false);
}
};
const startSetup = (id: string) => setParams({ triggerSetup: id });
const closeSetup = useCallback(() => setParams({}), [setParams]);
if (setupId) {
const trigger = routine.triggers.find((item) => item.id === setupId);
if (setupId !== "new" && !trigger)
return (
<p role="alert">
This trigger is no longer available.{" "}
<Button variant="link" onClick={closeSetup}>
Back to triggers
</Button>
</p>
);
if (trigger && !trigger.setupPending)
return (
<p>
This trigger is ready.{" "}
<Button variant="link" onClick={closeSetup}>
Back to triggers
</Button>
</p>
);
return (
<div className="space-y-4">
{statusError && (
<p role="alert" className="text-sm text-destructive">
Connection status is unavailable. Retrying…
</p>
)}
<TriggerSetup
key={routine.id}
routineId={routine.id}
companyId={routine.companyId}
routineTitle={routine.title}
trigger={trigger}
onExit={closeSetup}
onRefresh={refresh}
/>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{routine.triggers.length}{" "}
{routine.triggers.length === 1 ? "trigger" : "triggers"}
</p>
<Button size="sm" onClick={() => startSetup("new")}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Add trigger
</Button>
</div>
{(error || statusError) && (
<p role="alert" className="text-sm text-destructive">
{error || "Connection status is unavailable. Retrying…"}
</p>
)}
{removed.map((trigger) => (
<div
key={trigger.id}
role="status"
className="flex items-center gap-3 rounded-md bg-muted/40 px-4 py-3 text-sm"
>
<span className="flex-1">
{trigger.kind === "schedule"
? "Schedule"
: trigger.kind === "api"
? "API trigger"
: "Webhook"}{" "}
removed.
</span>
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() =>
void change(async () => {
await routinesApi.updateTrigger(trigger.id, {
archived: false,
});
setRemoved((items) =>
items.filter((item) => item.id !== trigger.id),
);
})
}
>
Undo
</Button>
</div>
))}
{ctx.secretMessage && (
<div className="space-y-3 rounded-md border p-4">
<p className="text-sm font-medium">{ctx.secretMessage.title}</p>
{ctx.secretMessage.entries.map((entry) => (
<div key={entry.webhookUrl} className="space-y-3">
<CopyField label="Webhook URL" value={entry.webhookUrl} />
<CopyField label="Secret key" value={entry.webhookSecret} />
</div>
))}
<Button variant="outline" onClick={() => ctx.setSecretMessage(null)}>
Done
</Button>
</div>
)}
{routine.triggers.length === 0 && (
<p className="py-6 text-sm text-muted-foreground">
Run this routine on a schedule or when another app sends a webhook.
</p>
)}
<fieldset disabled={busy} className="min-w-0 space-y-3">
{routine.triggers.map((trigger) => (
<RoutineTriggerCard
key={trigger.id}
kind={
trigger.kind === "schedule"
? "schedule"
: trigger.kind === "api"
? "api"
: "webhook"
}
icon={
trigger.kind === "schedule" ? (
<CalendarClock className="h-4 w-4" />
) : (
<Webhook className="h-4 w-4" />
)
}
title={
trigger.kind === "schedule"
? (describeCron(trigger.cronExpression) ?? "Schedule")
: trigger.kind === "api"
? "API trigger"
: trigger.signingMode === "github_hmac"
? "GitHub webhook"
: "Webhook"
}
summary={
trigger.setupPending
? "Setup unfinished · Events only test the connection"
: !trigger.enabled
? "Paused"
: trigger.kind === "schedule"
? (trigger.timezone ?? "UTC")
: trigger.kind === "api"
? "Run through the API"
: trigger.lastWebhookDelivery?.status === "rejected"
? "Authentication failed · Check the key in your app"
: trigger.lastWebhookDelivery?.status === "received" &&
!trigger.lastWebhookDelivery.test
? "Receiving events"
: "Ready · Waiting for an event"
}
expanded={expanded === trigger.id}
editLabel={trigger.setupPending ? "Resume setup" : undefined}
onEdit={() =>
trigger.setupPending
? startSetup(trigger.id)
: setExpanded((id) => (id === trigger.id ? null : trigger.id))
}
onRemove={() =>
void change(async () => {
await routinesApi.updateTrigger(trigger.id, { archived: true });
setRemoved((items) => [...items, trigger]);
})
}
>
{trigger.setupPending ? (
<Button onClick={() => startSetup(trigger.id)}>
Resume setup
</Button>
) : trigger.kind === "schedule" ? (
<ScheduleSettings
trigger={trigger}
onSave={async (patch) => {
await routinesApi.updateTrigger(trigger.id, patch);
await refresh();
setExpanded(null);
}}
onCancel={() => setExpanded(null)}
/>
) : trigger.kind === "api" ? (
<p className="text-sm text-muted-foreground">
This trigger starts the routine through the API.
</p>
) : (
<WebhookSettings
trigger={trigger}
routineTitle={routine.title}
onRefresh={refresh}
/>
)}
{!trigger.setupPending && (
<Button
variant="ghost"
size="sm"
onClick={() =>
void change(async () => {
await routinesApi.updateTrigger(trigger.id, {
enabled: !trigger.enabled,
});
})
}
>
{trigger.enabled ? "Pause trigger" : "Enable trigger"}
</Button>
)}
</RoutineTriggerCard>
))}
</fieldset>
</div>
);
}
function TriggerSetup({
routineId,
companyId,
routineTitle,
trigger,
onExit,
onRefresh,
}: {
routineId: string;
companyId: string;
routineTitle: string;
trigger?: RoutineTrigger;
onExit: () => void;
onRefresh: () => Promise<void>;
}) {
const [, setParams] = useSearchParams();
const storagePrefix = `routine-trigger-draft:${companyId}:${routineId}:`;
const createdRef = useRef<RoutineTrigger | undefined>(trigger);
const [created, setCreated] = useState(trigger);
const [secret, setSecret] = useState("");
const currentTrigger = trigger ?? created;
const [initialDraft] = useState<TriggerDraft>(() => {
const saved = readDraft(storagePrefix + (trigger?.id ?? "new"));
if (trigger)
return {
...defaultTriggerDraft,
...saved,
kind: "webhook",
sender: trigger.signingMode === "github_hmac" ? "github" : "custom",
created: true,
step: saved?.step ?? 1,
availableStep: Math.max(1, saved?.availableStep ?? 1),
};
return (
saved ?? {
...defaultTriggerDraft,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
created: false,
}
);
});
const createWebhook = useCallback(
async (draft: TriggerDraft) => {
if (createdRef.current) return;
const response = await routinesApi.createTrigger(routineId, {
kind: "webhook",
signingMode: draft.sender === "github" ? "github_hmac" : "bearer",
setupPending: true,
});
createdRef.current = response.trigger;
setCreated({
...response.trigger,
webhookUrl:
response.secretMaterial?.webhookUrl ?? response.trigger.webhookUrl,
});
setSecret(response.secretMaterial?.webhookSecret ?? "");
saveDraft(storagePrefix + response.trigger.id, {
...draft,
created: true,
step: 1,
availableStep: 1,
});
sessionStorage.removeItem(storagePrefix + "new");
await onRefresh();
setParams({ triggerSetup: response.trigger.id }, { replace: true });
},
[routineId, onRefresh, storagePrefix, setParams],
);
const saveExit = useCallback(
(draft: TriggerDraft) => {
saveDraft(storagePrefix + (createdRef.current?.id ?? "new"), draft);
onExit();
},
[onExit, storagePrefix],
);
const finish = useCallback(
async (draft: TriggerDraft) => {
if (draft.kind === "schedule")
await routinesApi.createTrigger(routineId, {
kind: "schedule",
cronExpression: scheduleCron(draft),
timezone: draft.timezone,
});
else {
if (!createdRef.current)
throw new Error("Create the webhook before finishing setup.");
await routinesApi.updateTrigger(createdRef.current.id, {
setupPending: false,
});
}
try {
sessionStorage.removeItem(
storagePrefix + (createdRef.current?.id ?? "new"),
);
} catch {
// The server mutation succeeded; draft cleanup must not cause a retry.
}
await onRefresh();
onExit();
},
[routineId, onRefresh, onExit, storagePrefix],
);
const { routine: currentRoutine } = useRoutineDetail();
return (
<RoutineTriggerWizard
initialDraft={initialDraft}
routineTitle={routineTitle}
routineId={routineId}
routineActive={currentRoutine.status === "active"}
webhookUrl={currentTrigger?.webhookUrl ?? ""}
webhookSecret={secret}
onCreateWebhook={createWebhook}
onSaveExit={saveExit}
onFinish={finish}
onRotateKey={async () => {
if (!createdRef.current) return;
const response = await routinesApi.rotateTriggerSecret(
createdRef.current.id,
);
setSecret(response.secretMaterial.webhookSecret);
await onRefresh();
}}
checkResult={currentTrigger?.lastWebhookDelivery?.status ?? "waiting"}
/>
);
}
function ScheduleSettings({
trigger,
onSave,
onCancel,
}: {
trigger: RoutineTrigger;
onSave: (patch: Record<string, unknown>) => Promise<void>;
onCancel: () => void;
}) {
const [cronExpression, setCron] = useState(
trigger.cronExpression ?? "0 9 * * *",
);
const [timezone, setTimezone] = useState(trigger.timezone ?? "UTC");
const [valid, setValid] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
return (
<fieldset disabled={busy} className="min-w-0 space-y-4">
<ScheduleEditor
value={cronExpression}
onChange={setCron}
onValidityChange={setValid}
/>
<Label>
Time zone
<Input
value={timezone}
onChange={(event) => setTimezone(event.target.value)}
/>
</Label>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<div className="flex gap-2">
<Button
disabled={!valid}
onClick={async () => {
setBusy(true);
setError("");
try {
await onSave({ cronExpression, timezone });
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: "Couldn’t save schedule.",
);
} finally {
setBusy(false);
}
}}
>
Save schedule
</Button>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
</div>
</fieldset>
);
}
function WebhookSettings({
trigger,
routineTitle,
onRefresh,
}: {
trigger: RoutineTrigger;
routineTitle: string;
onRefresh: () => Promise<void>;
}) {
const [secret, setSecret] = useState("");
const [replace, setReplace] = useState(false);
const [checkBaseline, setCheckBaseline] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const github = trigger.signingMode === "github_hmac";
const delivery = trigger.lastWebhookDelivery;
const checked =
checkBaseline !== null && delivery && delivery.receivedAt !== checkBaseline;
return (
<div className="space-y-4">
{secret && (github || trigger.signingMode === "bearer") && (
<AgentInstructions
value={webhookAgentInstructions(
github ? "github" : "custom",
routineTitle,
trigger.webhookUrl ?? "",
secret,
false,
)}
/>
)}
<CopyField label="Webhook URL" value={trigger.webhookUrl ?? ""} />
{trigger.signingMode !== "none" && (
<div className="space-y-2">
{secret ? (
<CopyField
label={
trigger.signingMode === "bearer"
? "Authorization header value"
: "Secret"
}
value={
trigger.signingMode === "bearer" ? `Bearer ${secret}` : secret
}
/>
) : (
<p className="text-sm text-muted-foreground">
The secret key is hidden.
</p>
)}
<Button variant="outline" size="sm" onClick={() => setReplace(true)}>
Replace key
</Button>
{secret && (
<Button variant="ghost" size="sm" onClick={() => setSecret("")}>
Hide key
</Button>
)}
</div>
)}
{trigger.signingMode === "none" && (
<p className="text-sm text-muted-foreground">
This webhook uses its URL as the shared secret.
</p>
)}
{trigger.signingMode === "hmac_sha256" && (
<p className="text-sm text-muted-foreground">
Sign the timestamp, a period, and the exact JSON body with
HMAC-SHA256. Send X-Paperclip-Timestamp and X-Paperclip-Signature:
sha256=&lt;signature&gt;.
</p>
)}
<Button
variant="outline"
onClick={() =>
setCheckBaseline(trigger.lastWebhookDelivery?.receivedAt ?? "")
}
>
Check connection
</Button>
<Dialog open={replace} onOpenChange={setReplace}>
<DialogContent>
<DialogHeader>
<DialogTitle>Replace the webhook key?</DialogTitle>
<DialogDescription>
The old key will stop working. Update your sending app with the
new key.
</DialogDescription>
</DialogHeader>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<Button
disabled={busy}
onClick={async () => {
setBusy(true);
setError("");
try {
const response = await routinesApi.rotateTriggerSecret(
trigger.id,
);
setSecret(response.secretMaterial.webhookSecret);
setReplace(false);
await onRefresh();
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: "Couldn’t replace the key.",
);
} finally {
setBusy(false);
}
}}
>
Replace key
</Button>
</DialogContent>
</Dialog>
<Dialog
open={checkBaseline !== null}
onOpenChange={(open) => {
if (!open) setCheckBaseline(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Check connection</DialogTitle>
<DialogDescription>
This webhook is already enabled. Events sent now can start the
routine. Send an event from your app to check delivery.
</DialogDescription>
</DialogHeader>
<p role="status" className="text-sm">
{checked
? delivery.status === "received"
? "Event received · Authentication passed"
: "Event arrived, but authentication failed. Check the key in your app."
: "Waiting for an event from your app…"}
</p>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,66 @@
import { useId, type ReactNode } from "react";
import { Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
export function RoutineTriggerCard({
kind,
icon,
title,
summary,
expanded,
onEdit,
onRemove,
editLabel,
children,
}: {
kind: "schedule" | "webhook" | "api";
icon: ReactNode;
title: string;
summary: string;
expanded: boolean;
onEdit: () => void;
editLabel?: string;
onRemove: () => void;
children: ReactNode;
}) {
const editorId = useId();
return (
<section
aria-label={`${kind === "schedule" ? "Schedule" : kind === "api" ? "API" : "Webhook"} trigger`}
className="rounded-md border border-border"
>
<div className="flex flex-wrap items-center gap-3 p-4">
<span className="shrink-0 text-muted-foreground">{icon}</span>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-medium">{title}</h3>
<p className="text-xs text-muted-foreground">{summary}</p>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
aria-expanded={expanded}
aria-controls={editorId}
onClick={onEdit}
>
{editLabel ?? (expanded ? "Close" : `Edit ${kind}`)}
</Button>
<Button
variant="ghost"
size="icon"
aria-label={`Remove ${kind}`}
title={`Remove ${kind}`}
onClick={onRemove}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{expanded && (
<div id={editorId} className="space-y-5 border-t border-border p-4">
{children}
</div>
)}
</section>
);
}
@@ -0,0 +1,571 @@
import { useCallback, useEffect, useState } from "react";
import {
CalendarClock,
Check,
CheckCircle2,
AlertCircle,
Globe,
GitBranch,
Radio,
Webhook,
} from "lucide-react";
import {
SetupWizardNavigation,
SetupWizardFooter,
} from "@/components/SetupWizard";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { cn } from "@/lib/utils";
import { AgentInstructions, CopyField } from "./WebhookFields";
export type TriggerDraft = {
kind: "choose" | "schedule" | "webhook";
step: number;
availableStep: number;
sender: "custom" | "github";
frequency: string;
time: string;
weekday: string;
timezone: string;
created: boolean;
};
export const defaultTriggerDraft: TriggerDraft = {
kind: "choose",
step: 0,
availableStep: 0,
sender: "custom",
frequency: "weekdays",
time: "09:00",
weekday: "Monday",
timezone: "America/Chicago",
created: false,
};
export function webhookAgentInstructions(
sender: TriggerDraft["sender"],
routineTitle: string,
webhookUrl: string,
webhookSecret: string,
setupPending = true,
) {
const common = [
`Connect the sending app to the Paperclip routine ${JSON.stringify(routineTitle)}.`,
`Webhook URL: ${webhookUrl}`,
"Send an HTTP POST request with a JSON object as the body (not an array or string).",
"Content-Type: application/json",
];
const auth =
sender === "github"
? [
"In GitHub, open your repository → Settings → Webhooks → Add webhook.",
"Use the webhook URL above as Payload URL and select application/json as Content type.",
`Secret: ${webhookSecret}`,
"Paste this value into GitHub’s Secret field. GitHub signs requests with X-Hub-Signature-256; do not use Bearer authentication.",
"Select the events that should start this routine, enable the webhook, and save.",
"To check the connection, open Recent Deliveries and redeliver an event.",
]
: [
`Secret key: ${webhookSecret}`,
`Authorization: Bearer ${webhookSecret}`,
"Set the HTTP header name to Authorization and its value to the complete Bearer value above, including the space after Bearer.",
"In the sending app, add a webhook using this URL, POST method, JSON body, and headers, then save it.",
"Send a unique Idempotency-Key header for each event and reuse it on retries, so retrying a setup test after activation cannot start the routine.",
'Example JSON body: {"event":"deployment.completed","environment":"production"}',
"To check the connection, send a test event from the app or perform the action that triggers a delivery.",
];
return [
...common,
...auth,
"Open Check connection in Paperclip to see whether the event arrived and authentication passed.",
...(setupPending
? [
"During setup, deliveries only test the connection. They do not start the routine or create a task.",
"Finish setup in Paperclip to enable this webhook for future events. Test events are not replayed.",
]
: [
"This webhook is enabled. Deliveries can start the routine and create tasks.",
]),
"Store the key securely; do not put it in source control or logs.",
].join("\n");
}
export function describeSchedule(draft: TriggerDraft) {
return `${draft.frequency === "daily" ? "Every day" : draft.frequency === "weekly" ? `Every ${draft.weekday}` : "Every weekday"} at ${draft.time}`;
}
export function RoutineTriggerWizard({
initialDraft,
onSaveExit,
onFinish,
onCreateWebhook,
onRotateKey,
routineTitle,
routineId,
routineActive = true,
webhookUrl = "",
webhookSecret = "",
checkResult = "waiting",
}: {
initialDraft: TriggerDraft;
routineTitle: string;
routineId: string;
routineActive?: boolean;
webhookUrl?: string;
webhookSecret?: string;
onCreateWebhook?: (draft: TriggerDraft) => Promise<void>;
onRotateKey?: () => Promise<void>;
onSaveExit: (draft: TriggerDraft) => void | Promise<void>;
onFinish: (draft: TriggerDraft) => void | Promise<void>;
checkResult?: "waiting" | "received" | "rejected" | "no_event";
}) {
const [draft, setDraft] = useState(initialDraft);
const [busy, setBusy] = useState(false);
const [saveError, setSaveError] = useState("");
const { setBreadcrumbs } = useBreadcrumbs();
const perform = useCallback(
async (action: () => void | Promise<void>) => {
if (busy) return;
setBusy(true);
setSaveError("");
try {
await action();
} catch (error) {
setSaveError(
error instanceof Error
? error.message
: "Couldn’t save. Please try again.",
);
} finally {
setBusy(false);
}
},
[busy],
);
const saveAndExit = useCallback(() => {
void perform(() => onSaveExit(draft));
}, [draft, onSaveExit, perform]);
useEffect(() => {
setBreadcrumbs([
{
label: routineTitle,
href: `/routines/${routineId}/triggers`,
onClick: (event) => {
if (
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
event.preventDefault();
saveAndExit();
},
},
{ label: "Add trigger" },
]);
}, [saveAndExit, setBreadcrumbs, routineTitle, routineId]);
const schedule = draft.kind === "schedule";
const github = draft.sender === "github";
const labels = schedule
? ["Choose trigger", "Set schedule", "Review schedule"]
: ["Choose trigger", "Connect your app", "Check connection"];
function patch(values: Partial<TriggerDraft>) {
setDraft((current) => ({ ...current, ...values }));
}
function advance() {
void perform(async () => {
if (draft.kind === "webhook" && draft.step === 0 && !draft.created)
await onCreateWebhook?.(draft);
const step = draft.step + 1;
patch({
step,
availableStep: Math.max(draft.availableStep, step),
created:
draft.created || (draft.kind === "webhook" && draft.step === 0),
});
});
}
const title =
draft.step === 0
? "When should this routine run?"
: schedule
? draft.step === 1
? "Set a schedule"
: "Review your schedule"
: draft.step === 1
? `Connect ${github ? "GitHub" : "your app"}`
: "Check your connection";
const subtitle =
draft.step === 0
? `Choose how to start “${routineTitle}”. You can add another trigger later.`
: schedule
? draft.step === 1
? "Choose when Paperclip should start this routine automatically."
: "This schedule starts the routine automatically. You can pause or change it later."
: draft.step === 1
? "Copy these details into the sending app, then save its webhook settings."
: "Test that events arrive and authentication works. This won’t start the routine.";
const selectClass =
"w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
const goBack = (
<Button variant="outline" onClick={() => patch({ step: draft.step - 1 })}>
Back
</Button>
);
return (
<div className="min-w-0 w-full max-w-2xl space-y-6">
<SetupWizardNavigation
takeover
disabled={busy}
ariaLabel="Trigger setup progress"
labels={labels}
step={draft.step}
availableStep={draft.availableStep}
onSelect={(step) => patch({ step })}
/>
<fieldset disabled={busy} className="min-w-0 space-y-6">
<div className="space-y-1">
<h1 className="text-xl font-bold">{title}</h1>
<p className="text-sm text-muted-foreground">{subtitle}</p>
</div>
{draft.step === 0 && (
<fieldset className="space-y-3">
<legend className="sr-only">Trigger type</legend>
{(
[
{
kind: "schedule",
label: "On a schedule",
detail: "Every day, on weekdays, or once a week.",
Icon: CalendarClock,
},
{
kind: "webhook",
label: "When another app sends a webhook",
detail:
"When something happens in GitHub, another app, or a script.",
Icon: Webhook,
},
] as const
).map(({ kind, label, detail, Icon }) => (
<label
key={kind}
className={cn(
"flex cursor-pointer items-start gap-3 rounded-md border p-4 focus-within:ring-2 focus-within:ring-ring",
draft.kind === kind
? "border-primary bg-accent/30"
: "border-border hover:bg-accent/20",
)}
>
<input
type="radio"
name="trigger-kind"
checked={draft.kind === kind}
disabled={draft.created && kind !== draft.kind}
onChange={() =>
patch({
kind,
availableStep:
kind === draft.kind ? draft.availableStep : 0,
})
}
className="sr-only"
/>
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1">
<span className="block text-sm font-medium">{label}</span>
<span className="block text-xs text-muted-foreground">
{detail}
</span>
</span>
{draft.kind === kind && <Check className="h-4 w-4" />}
</label>
))}
</fieldset>
)}
{schedule && draft.step === 1 && (
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="repeat">Repeat</Label>
<select
id="repeat"
className={selectClass}
value={draft.frequency}
onChange={(event) => patch({ frequency: event.target.value })}
>
<option value="daily">Every day</option>
<option value="weekdays">Weekdays (Monday–Friday)</option>
<option value="weekly">Every week</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="run-time">Time</Label>
<Input
id="run-time"
type="time"
value={draft.time}
onChange={(event) => patch({ time: event.target.value })}
/>
</div>
</div>
{draft.frequency === "weekly" && (
<div className="space-y-2">
<Label htmlFor="run-day">Day</Label>
<select
id="run-day"
className={selectClass}
value={draft.weekday}
onChange={(event) => patch({ weekday: event.target.value })}
>
{[
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
].map((day) => (
<option key={day}>{day}</option>
))}
</select>
</div>
)}
<div className="space-y-2">
<Label htmlFor="timezone">Time zone</Label>
<select
id="timezone"
className={selectClass}
value={draft.timezone}
onChange={(event) => patch({ timezone: event.target.value })}
>
{Array.from(
new Set([
draft.timezone,
"America/Chicago",
"America/New_York",
"America/Los_Angeles",
"Europe/London",
"UTC",
]),
).map((zone) => (
<option key={zone}>{zone}</option>
))}
</select>
<p className="text-xs text-muted-foreground">
The time follows this zone, including daylight saving changes.
</p>
</div>
</div>
)}
{schedule && draft.step === 2 && (
<div className="space-y-5">
<div className="flex items-start gap-3 rounded-md bg-muted/40 p-4">
<CalendarClock className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">{describeSchedule(draft)}</p>
<p className="text-xs text-muted-foreground">
{draft.timezone}
</p>
</div>
</div>
<p className="text-sm text-muted-foreground">
Each scheduled run creates a task for the routine’s assigned
agent. Any existing webhook triggers will continue to work.
</p>
</div>
)}
{draft.step === 0 && draft.kind === "webhook" && (
<fieldset className="space-y-2">
<legend className="mb-2 text-sm font-medium">
What’s sending the webhook?
</legend>
<div className="grid gap-2 sm:grid-cols-2">
{(
[
{
sender: "custom",
label: "Another app or script",
Icon: Globe,
},
{ sender: "github", label: "GitHub", Icon: GitBranch },
] as const
).map(({ sender, label, Icon }) => (
<label
key={sender}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-md border p-3 focus-within:ring-2 focus-within:ring-ring",
draft.sender === sender
? "border-primary bg-accent/30"
: "border-border",
draft.created && "cursor-default",
)}
>
<input
className="sr-only"
type="radio"
name="sender"
checked={draft.sender === sender}
disabled={draft.created}
onChange={() => patch({ sender })}
/>
<Icon className="h-4 w-4" />
<span className="flex-1 text-sm">{label}</span>
{draft.sender === sender && <Check className="h-4 w-4" />}
</label>
))}
</div>
</fieldset>
)}
{!schedule && draft.step === 1 && (
<div className="space-y-5">
{webhookSecret && (
<AgentInstructions
value={webhookAgentInstructions(
draft.sender,
routineTitle,
webhookUrl,
webhookSecret,
)}
/>
)}
<CopyField
label={github ? "Payload URL" : "Webhook URL"}
value={webhookUrl}
/>
{webhookSecret ? (
<CopyField
label={github ? "Secret" : "Authorization header value"}
value={github ? webhookSecret : `Bearer ${webhookSecret}`}
/>
) : (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
The key is hidden after leaving setup. If you haven’t saved it
in your app, generate a replacement.
</p>
<Button
variant="outline"
onClick={() => void perform(() => onRotateKey?.())}
>
Generate new key
</Button>
</div>
)}
</div>
)}
{!schedule && draft.step === 2 && (
<div className="space-y-5">
<div className="space-y-1 rounded-md border border-border p-4">
<p className="text-sm font-medium">Connection test only</p>
<p className="text-sm text-muted-foreground">
Events received during setup won’t start the routine or create
tasks.
</p>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">
Send an event from {github ? "GitHub" : "your app"}
</p>
<p className="text-sm text-muted-foreground">
{github
? "Open this webhook in your repository settings. Under Recent Deliveries, choose Redeliver on an event."
: "Look for “Send test” in your app’s webhook settings. If it doesn’t have one, do the action that should trigger the webhook—for example, complete a deployment."}
</p>
<p className="text-xs text-muted-foreground">
Keep this page open to see the test result.
</p>
</div>
<div
role="status"
className="flex items-start gap-3 rounded-md bg-muted/40 p-4"
>
{checkResult === "received" ? (
<CheckCircle2 className="h-5 w-5 shrink-0 text-(--status-task-done)" />
) : checkResult === "rejected" ? (
<AlertCircle className="h-5 w-5 shrink-0 text-destructive" />
) : (
<Radio className="h-5 w-5 shrink-0 text-muted-foreground" />
)}
<div className="space-y-1">
<p className="text-sm font-medium">
{checkResult === "received"
? "Test event received · Connection working"
: checkResult === "rejected"
? "Event arrived, but the key was rejected"
: checkResult === "no_event"
? "No event received yet"
: "Waiting for an event from your app…"}
</p>
<p className="text-xs text-muted-foreground">
{checkResult === "received"
? "Authentication passed. No routine run or task was created."
: checkResult === "rejected"
? "Go back to Connect your app, update the key in your sending app, and resend. No task was created."
: "Waiting to verify delivery and authentication. The routine is not running."}
</p>
</div>
</div>
<details>
<summary className="cursor-pointer text-xs text-muted-foreground">
Troubleshoot delivery
</summary>
<div className="space-y-3 pt-3">
<p className="text-xs text-muted-foreground">
Check that the webhook is enabled in your sending app and that
its URL matches. Scripts must send POST with a JSON body.
</p>
<CopyField label="Webhook URL" value={webhookUrl} />
</div>
</details>
</div>
)}
{!schedule && draft.step === 2 && (
<p className="text-xs text-muted-foreground">
{routineActive
? "Finish setup to enable this webhook. Future events will start the routine; this test event won’t be replayed."
: "Finish setup to save this webhook. The routine is paused; enable its automatic triggers when you’re ready. This test event won’t be replayed."}
</p>
)}
{schedule && draft.step === 2 && !routineActive && (
<p className="text-sm text-muted-foreground">
The routine is paused. Enable its automatic triggers when you’re
ready to use this schedule.
</p>
)}
{saveError && (
<p role="alert" className="text-sm text-destructive">
{saveError}
</p>
)}
<SetupWizardFooter onSaveExit={saveAndExit}>
{draft.step > 0 && goBack}
{draft.step === 0 ? (
<Button disabled={draft.kind === "choose"} onClick={advance}>
Continue
</Button>
) : schedule ? (
draft.step === 1 ? (
<Button disabled={!draft.time} onClick={advance}>
Review schedule
</Button>
) : (
<Button onClick={() => void perform(() => onFinish(draft))}>
Add schedule
</Button>
)
) : draft.step === 1 ? (
<Button onClick={advance}>Check connection</Button>
) : (
<Button onClick={() => void perform(() => onFinish(draft))}>
{checkResult === "received"
? "Finish setup"
: "Finish without checking"}
</Button>
)}
</SetupWizardFooter>
</fieldset>
</div>
);
}
@@ -0,0 +1,118 @@
import { useState } from "react";
import { Check, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { copyTextToClipboard } from "@/lib/clipboard";
export function CopyField({
label,
value,
help,
}: {
label: string;
value: string;
help?: string;
}) {
const [copied, setCopied] = useState(false);
const [error, setError] = useState(false);
return (
<div className="space-y-1.5">
<Label className="text-xs">{label}</Label>
<div className="flex min-w-0 items-center gap-2 rounded-md border border-border bg-background px-3 py-2">
<code title={value} className="min-w-0 flex-1 truncate text-xs">
{value}
</code>
<Button
variant="ghost"
size="sm"
aria-label={`Copy ${label}`}
onClick={async () => {
try {
await copyTextToClipboard(value);
setCopied(true);
setError(false);
} catch {
setError(true);
}
}}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{copied ? "Copied" : "Copy"}
</Button>
</div>
{help && <p className="text-xs text-muted-foreground">{help}</p>}
{error && (
<div className="space-y-2">
<p role="alert" className="text-xs text-destructive">
Copy failed. Select and copy the text below.
</p>
<textarea
readOnly
aria-label={`${label} text`}
value={value}
rows={5}
className="w-full rounded-md border border-input bg-background p-3 font-mono text-xs"
/>
</div>
)}
</div>
);
}
export function AgentInstructions({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
const [error, setError] = useState(false);
return (
<section
aria-label="Agent instructions"
className="space-y-3 rounded-md bg-muted/40 p-4"
>
<div className="space-y-1">
<h2 className="text-sm font-medium">Agent instructions</h2>
<p className="text-sm text-muted-foreground">
Give your agent everything it needs to connect this webhook: the URL,
authentication key, and step-by-step instructions.
</p>
</div>
<Button
variant="outline"
size="sm"
aria-label="Copy for your agent"
onClick={async () => {
try {
await copyTextToClipboard(value);
setCopied(true);
setError(false);
} catch {
setError(true);
}
}}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{copied ? "Copied instructions" : "Copy for your agent"}
</Button>
{error && (
<div className="space-y-2">
<p role="alert" className="text-xs text-destructive">
Copy failed. Select and copy the instructions below.
</p>
<textarea
readOnly
aria-label="Agent instructions text"
value={value}
rows={5}
className="w-full rounded-md border border-input bg-background p-3 text-sm"
/>
</div>
)}
</section>
);
}
+4 -1
View File
@@ -1,8 +1,10 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode, type MouseEventHandler } from "react";
export interface Breadcrumb {
label: string;
href?: string;
/** Optional handler for preserving local work before navigating. */
onClick?: MouseEventHandler<HTMLAnchorElement>;
/**
* Optional task identifier (e.g. "PAP-1204") rendered in gray monospace
* between the leading glyph and the label.
@@ -51,6 +53,7 @@ function breadcrumbsEqual(left: Breadcrumb[], right: Breadcrumb[]) {
for (let index = 0; index < left.length; index += 1) {
if (
left[index]?.label !== right[index]?.label
|| left[index]?.onClick !== right[index]?.onClick
|| left[index]?.href !== right[index]?.href
|| left[index]?.identifier !== right[index]?.identifier
|| left[index]?.leadingKey !== right[index]?.leadingKey
+1 -17
View File
@@ -1,17 +1 @@
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
const ChatSetupSidebarContext = createContext<{
target: HTMLDivElement | null;
setTarget: (target: HTMLDivElement | null) => void;
} | null>(null);
/** Keeps setup navigation owned by the form while rendering it in the shell. */
export function ChatSetupSidebarProvider({ children }: { children: ReactNode }) {
const [target, setTarget] = useState<HTMLDivElement | null>(null);
const value = useMemo(() => ({ target, setTarget }), [target]);
return <ChatSetupSidebarContext.Provider value={value}>{children}</ChatSetupSidebarContext.Provider>;
}
export function useChatSetupSidebar() {
return useContext(ChatSetupSidebarContext);
}
export { SetupWizardSidebarProvider as ChatSetupSidebarProvider, useSetupWizardSidebar as useChatSetupSidebar } from "./SetupWizardSidebarContext";
@@ -0,0 +1,20 @@
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
const SetupWizardSidebarContext = createContext<{
active: boolean;
setActive: (active: boolean) => void;
target: HTMLDivElement | null;
setTarget: (target: HTMLDivElement | null) => void;
} | null>(null);
/** Keeps setup navigation owned by the form while rendering it in the shell. */
export function SetupWizardSidebarProvider({ children }: { children: ReactNode }) {
const [active, setActive] = useState(false);
const [target, setTarget] = useState<HTMLDivElement | null>(null);
const value = useMemo(() => ({ target, setTarget, active, setActive }), [target, active]);
return <SetupWizardSidebarContext.Provider value={value}>{children}</SetupWizardSidebarContext.Provider>;
}
export function useSetupWizardSidebar() {
return useContext(SetupWizardSidebarContext);
}
+9
View File
@@ -1,3 +1,4 @@
import { SetupWizardNavigation, SetupWizardFooter } from "../components/SetupWizard";
import { AgentChatPicker } from "@/components/AgentChatPicker";
import { TaskChatProjectCreatedCard } from "@/components/task-chat/TaskChatProjectCreatedCard";
import { AnnouncementCard } from "@/components/AnnouncementCard";
@@ -496,6 +497,7 @@ function AgentChatPickerExample() {
}
export function DesignGuide() {
const [wizardStep, setWizardStep] = useState(0);
const [status, setStatus] = useState("todo");
const [priority, setPriority] = useState("medium");
const [selectValue, setSelectValue] = useState("in_progress");
@@ -1690,6 +1692,13 @@ export function DesignGuide() {
{/* NAVIGATION PATTERNS */}
{/* ============================================================ */}
<Section title="Navigation Patterns">
<SubSection title="Setup wizard">
<p className="text-sm text-muted-foreground">Shared by connection setup and trigger previews. Setup navigation takes over the section sidebar; each step owns a single footer.</p>
<div className="max-w-sm space-y-6">
<SetupWizardNavigation inline labels={["Choose trigger", "Configure", "Review"]} step={wizardStep} availableStep={2} onSelect={setWizardStep} />
<SetupWizardFooter onSaveExit={() => setWizardStep(0)}><Button onClick={() => setWizardStep((wizardStep + 1) % 3)}>Continue</Button></SetupWizardFooter>
</div>
</SubSection>
<SubSection title="Agent chat picker">
<AgentChatPickerExample />
</SubSection>
+2 -1
View File
@@ -627,6 +627,7 @@ export function RoutineDetail() {
const onHistoryRestoreSecretMaterials = useCallback((response: RestoreRoutineRevisionResponse) => {
if (response.secretMaterials.length > 0) {
navigateToSection("triggers");
setSecretMessage({
title:
response.secretMaterials.length === 1
@@ -638,7 +639,7 @@ export function RoutineDetail() {
})),
});
}
}, []);
}, [navigateToSection]);
const onHistoryRestored = useCallback(
(response: RestoreRoutineRevisionResponse) => {
+13 -7
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Navigate, useNavigate, useParams } from "@/lib/router";
import { Navigate, useNavigate, useParams, useSearchParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, History, Pencil, Repeat, Sparkles, X } from "lucide-react";
import { ApiError } from "../api/client";
@@ -86,7 +86,7 @@ export function buildRoutineProjectOptions(
const SECTION_TITLES: Record<RoutineSectionKey, string> = {
overview: "Overview",
triggers: "Schedule",
triggers: "Triggers",
variables: "Variables",
secrets: "Secrets",
delivery: "Delivery",
@@ -121,6 +121,8 @@ function buildRoutineMutationPayload(input: RoutineEditDraft) {
export function RoutineDetail() {
const { routineId, section: sectionParam } = useParams<{ routineId: string; section?: string }>();
const [searchParams] = useSearchParams();
const triggerSetup = sectionParam === "triggers" && searchParams.has("triggerSetup");
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
@@ -198,7 +200,7 @@ export function RoutineDetail() {
}),
[routine?.triggers, routineRuns],
);
const { data: activity } = useQuery({
const { data: activity, isLoading: activityLoading, error: activityError } = useQuery({
queryKey: [
...queryKeys.routines.activity(selectedCompanyId!, routineId!),
relatedActivityIds.triggerIds.join(","),
@@ -325,14 +327,14 @@ export function RoutineDetail() {
useEffect(() => {
if (!routine) return;
setBreadcrumbs([{ label: "Routines", href: "/routines" }, { label: routine.title }]);
if (!triggerSetup) setBreadcrumbs([{ label: "Routines", href: "/routines" }, { label: routine.title }]);
if (!routineDefaults) return;
const changedRoutine = hydratedRoutineIdRef.current !== routine.id;
if (changedRoutine || !isEditDirty) {
setEditDraft(routineDefaults);
hydratedRoutineIdRef.current = routine.id;
}
}, [routine, routineDefaults, isEditDirty, setBreadcrumbs]);
}, [routine, routineDefaults, isEditDirty, setBreadcrumbs, triggerSetup]);
useEffect(() => {
autoResizeTextarea(titleInputRef.current);
@@ -413,6 +415,7 @@ export function RoutineDetail() {
setRunVariablesOpen(false);
navigateToSection("runs");
await Promise.all([
queryClient.invalidateQueries({ queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine", routineId!] }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.runs(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
@@ -602,6 +605,7 @@ export function RoutineDetail() {
const onHistoryRestoreSecretMaterials = useCallback((response: RestoreRoutineRevisionResponse) => {
if (response.secretMaterials.length > 0) {
navigateToSection("triggers");
setSecretMessage({
title:
response.secretMaterials.length === 1
@@ -613,7 +617,7 @@ export function RoutineDetail() {
})),
});
}
}, []);
}, [navigateToSection]);
const onHistoryRestored = useCallback(
(response: RestoreRoutineRevisionResponse) => {
@@ -760,6 +764,8 @@ export function RoutineDetail() {
navigateToSection,
};
if (triggerSetup) return <RoutineDetailContext.Provider value={contextValue}><TriggersSection /></RoutineDetailContext.Provider>;
const isEditableSection = EDITABLE_SECTIONS.includes(section);
return (
@@ -903,7 +909,7 @@ export function RoutineDetail() {
{section === "secrets" && <SecretsSection />}
{section === "delivery" && <DeliverySection />}
{section === "runs" && <RunsSection />}
{section === "activity" && <ActivitySection />}
{section === "activity" && <ActivitySection isLoading={activityLoading} error={activityError} />}
{section === "history" && <HistorySection />}
{isEditableSection && (section !== "overview" || overviewEditing) ? (
+3 -5
View File
@@ -1,3 +1,4 @@
import { SetupWizardFooter } from "@/components/SetupWizard";
import { ChatSetupNavigation } from "@/components/chat/ChatSetupNavigation";
import { SlackIdentityStep } from "./SlackIdentityStep";
import { PhotonConnectStep } from "./PhotonConnectStep";
@@ -476,10 +477,7 @@ function ChatSdkEndpointSetup() {
placeholder="Choose an active agent"
emptyMessage="No active agents are available."
/>}
<div className="flex items-center justify-between gap-3">
<Button variant="ghost" className="text-muted-foreground" onClick={() => navigate("/apps")}>
Save &amp; exit
</Button>
<SetupWizardFooter onSaveExit={() => navigate("/apps")}>
<Button
disabled={!agentId || createEndpoint.isPending}
onClick={() => endpoint ? setViewedStep(1) : createEndpoint.mutate()}
@@ -489,7 +487,7 @@ function ChatSdkEndpointSetup() {
)}
Continue
</Button>
</div>
</SetupWizardFooter>
</>
) : null}
{endpoint && (
@@ -74,6 +74,7 @@ describe("RoutineAuditActivity", () => {
triggerIds: ["trigger-1", "trigger-2"],
runIds: ["run-1", "run-2"],
});
expect(container.textContent).toContain("routine.run.completed");
expect(container.textContent).toContain("Run completed");
expect(container.textContent).not.toContain("routine.run.completed");
});
});
@@ -0,0 +1 @@
export { RoutineTriggerCard } from "@/components/routine-triggers/TriggerCard";
@@ -0,0 +1,11 @@
import { RoutineTriggerWizard as ProductionWizard, webhookAgentInstructions as instructions } from "@/components/routine-triggers/TriggerWizard";
import type { ComponentProps } from "react";
export { defaultTriggerDraft, describeSchedule, type TriggerDraft } from "@/components/routine-triggers/TriggerWizard";
export const demoWebhookUrl = "https://acme.paperclip.example/api/routine-triggers/public/0123456789abcdef01234567/fire";
const demoKey = "demo_webhook_key_for_storybook_only";
export function webhookAgentInstructions(sender: "custom" | "github") {
return instructions(sender, "Verify a deployment", demoWebhookUrl, demoKey);
}
export function RoutineTriggerWizard(props: Pick<ComponentProps<typeof ProductionWizard>, "initialDraft" | "onSaveExit" | "onFinish" | "checkResult">) {
return <ProductionWizard {...props} routineTitle="Verify a deployment" routineId="routine-webhook-story" webhookUrl={demoWebhookUrl} webhookSecret={demoKey} />;
}
@@ -0,0 +1,37 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, expect, it, vi } from "vitest";
import { AgentInstructions } from "./routineWebhookFields";
import { webhookAgentInstructions, demoWebhookUrl } from "./routineTriggerWizard";
const container = document.createElement("div");
document.body.append(container);
let root: ReturnType<typeof createRoot>;
afterEach(async () => {
if (root) await act(async () => root.unmount());
vi.unstubAllGlobals();
});
it.each(["custom", "github"] as const)("copies complete %s instructions from the agent instructions card", async (sender) => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", { clipboard: { writeText } });
const value = webhookAgentInstructions(sender);
root = createRoot(container);
await act(async () => root.render(<AgentInstructions value={value} />));
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
expect(writeText).toHaveBeenCalledExactlyOnceWith(value);
expect(value).toContain(demoWebhookUrl);
expect(value).toContain("HTTP POST");
expect(value).toContain("JSON object");
expect(value).toContain("Content-Type: application/json");
expect(value).toContain("demo_webhook_key_for_storybook_only");
expect(value).toContain("They do not start the routine or create a task.");
expect(value).toContain("Test events are not replayed.");
if (sender === "custom") expect(value).toContain("Authorization: Bearer demo_webhook_key_for_storybook_only");
else {
expect(value).toContain("X-Hub-Signature-256");
expect(value).not.toContain("Authorization: Bearer");
}
expect(container.querySelector("button")!.textContent).toContain("Copied");
});
@@ -0,0 +1 @@
export { CopyField, AgentInstructions } from "@/components/routine-triggers/WebhookFields";
+205
View File
@@ -0,0 +1,205 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { ActivityEvent, Issue, RoutineDetail as RoutineDetailData, RoutineRunSummary, RoutineTrigger } from "@paperclipai/shared";
import { PluginLauncherProvider } from "@/plugins/launchers";
import { routinesApi } from "@/api/routines";
import { issuesApi } from "@/api/issues";
import { foldersApi } from "@/api/folders";
import { Layout } from "@/components/Layout";
import { RoutineDetail } from "@/pages/RoutineDetail";
import { Routines } from "@/pages/Routines";
import { Route, Routes, useNavigate } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
import { storybookAgents, storybookIssues } from "./paperclipData";
const now = new Date("2026-09-18T15:00:00Z");
const routineId = "routine-webhook-story";
const companyId = "company-storybook";
const webhookUrl = "https://acme.paperclip.example/api/routine-triggers/public/0123456789abcdef01234567/fire";
const demoSecret = "storybook-demo-secret-not-a-real-credential";
const actorFields = {
createdByAgentId: null, createdByUserId: null,
updatedByAgentId: null, updatedByUserId: null,
createdAt: now, updatedAt: now,
};
function webhook(signingMode: string, index = 1): RoutineTrigger {
return {
...actorFields, id: `webhook-${index}`, companyId, routineId,
kind: "webhook", label: "Deployment completed", enabled: true,
cronExpression: null, timezone: null, nextRunAt: null,
lastFiredAt: now, publicId: "0123456789abcdef01234567", webhookUrl,
secretId: "storybook-webhook-secret", signingMode,
replayWindowSec: signingMode === "hmac_sha256" ? 300 : null,
lastRotatedAt: null, lastResult: "issue_created",
lastWebhookDelivery: { status: "received", receivedAt: now.toISOString(), test: false },
};
}
const completedRun: RoutineRunSummary = {
id: "storybook-webhook-run", companyId, routineId, triggerId: "webhook-1",
source: "webhook", status: "completed", triggeredAt: now, completedAt: now,
createdAt: now, updatedAt: now, idempotencyKey: "deployment-123",
triggerPayload: { event: "deployment", environment: "production" },
dispatchFingerprint: null, linkedIssueId: "storybook-task", coalescedIntoRunId: null,
failureReason: null, trigger: { id: "webhook-1", kind: "webhook", label: "Deployment completed" },
linkedIssue: {
id: "storybook-task", identifier: "PAP-123", title: "Verify the production deployment",
status: "done", priority: "medium", updatedAt: now,
},
};
const baseRoutine: RoutineDetailData = {
...actorFields, id: routineId, companyId, projectId: null, goalId: null,
parentIssueId: null, responsibleUserId: null,
title: "Verify a deployment",
description: "Check the deployment from the incoming webhook. Verify the service is healthy and report the result.",
assigneeAgentId: storybookAgents[0]?.id ?? null, priority: "medium", status: "active",
concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed",
activityGatePolicy: "always", activityGateScope: "company", variables: [], env: null,
latestRevisionId: "revision-1", latestRevisionNumber: 1,
lastTriggeredAt: now, lastEnqueuedAt: null, managedByPlugin: null,
project: null, assignee: null, parentIssue: null, activeIssue: null,
triggers: [], recentRuns: [completedRun],
};
const executionIssues: Issue[] = [
{ title: "Verify production deployment v2.8.4", status: "in_progress", priority: "high" },
{ title: "Verify staging deployment v2.8.4", status: "in_review", priority: "medium" },
{ title: "Verify production deployment v2.8.3", status: "done", priority: "medium" },
{ title: "Verify rollback health checks", status: "blocked", priority: "high" },
].map((entry, index) => ({
...storybookIssues[0], ...entry,
id: `routine-execution-${index}`, identifier: `PAP-${123 + index}`, issueNumber: 123 + index,
companyId, originKind: "routine_execution", originId: routineId,
originRunId: `routine-run-${index}`, projectId: null, parentId: null,
assigneeAgentId: storybookAgents[0].id,
executionRunId: null, checkoutRunId: null, executionLockedAt: null,
labels: [], labelIds: [], blockedBy: [],
createdAt: new Date(now.getTime() - index * 3_600_000),
updatedAt: new Date(now.getTime() - index * 3_600_000),
lastActivityAt: new Date(now.getTime() - index * 3_600_000),
lastExternalCommentAt: null,
})) as Issue[];
const routineActivity: ActivityEvent[] = [
{ action: "routine.run_triggered", entityType: "routine_run", details: { source: "webhook", status: "issue_created", issueIdentifier: "PAP-123" } },
{ action: "routine.trigger_updated", entityType: "routine_trigger", details: { label: "Deployment completed", signingMode: "bearer" } },
{ action: "routine.updated", entityType: "routine", details: { title: "Verify a deployment", changedFields: ["description"] } },
{ action: "routine.trigger_created", entityType: "routine_trigger", details: { kind: "webhook", label: "Deployment completed" } },
].map((event, index) => ({
...event, id: `routine-activity-${index}`, companyId, entityId: routineId,
actorType: "user", actorId: "user-board", agentId: null, runId: null,
createdAt: new Date(now.getTime() - index * 3_600_000),
}));
type Props = {
preview?: ReactNode;
signingMode: "bearer" | "hmac_sha256" | "github_hmac" | "none";
state: "setup" | "credentials" | "configured" | "failure" | "overview" | "list" | "runs" | "activity";
};
/** The actual application shell and route pages, backed by an in-memory API. */
export function WebhookReview({ signingMode = "bearer", state = "configured", preview }: Props) {
const navigate = useNavigate();
const { setSelectedCompanyId } = useCompany();
const [ready, setReady] = useState(false);
const initialActions = useRef({ navigate, setSelectedCompanyId });
useEffect(() => {
const fresh = state === "setup" || state === "credentials";
let routine: RoutineDetailData = {
...baseRoutine,
lastTriggeredAt: fresh ? null : now,
recentRuns: fresh ? [] : [completedRun],
triggers: fresh ? [] : [{
...webhook(signingMode),
lastWebhookDelivery: { status: state === "failure" ? "rejected" : "received", receivedAt: now.toISOString(), test: false },
lastResult: state === "failure"
? "Failed to create task: no default agent assigned"
: webhook(signingMode).lastResult,
}],
};
const originalApi = { ...routinesApi };
const originalFoldersList = foldersApi.list;
const originalIssuesList = issuesApi.list;
const originalIssueUpdate = issuesApi.update;
const originalCompactList = issuesApi.listCompact;
let issues = executionIssues.map((issue) => ({ ...issue }));
issuesApi.list = async (id, filters) => {
if (id !== companyId || filters?.originId !== routineId) return originalIssuesList(id, filters);
const search = filters.q?.toLowerCase();
return issues.filter((issue) => !search || `${issue.identifier} ${issue.title}`.toLowerCase().includes(search));
};
issuesApi.listCompact = async (id, filters, options) => {
if (id !== companyId || filters?.originId !== routineId) return originalCompactList(id, filters, options);
const rows = await issuesApi.list(id, filters);
return rows.filter((issue) => !filters.status || filters.status.split(",").includes(issue.status))
.map((issue) => ({ ...issue, activeRecoveryAction: null, successfulRunHandoff: null }));
};
issuesApi.update = async (id, patch) => {
const issue = { ...issues.find((item) => item.id === id)!, ...patch };
issues = issues.map((item) => item.id === id ? issue : item);
return { ...issue, changes: {} };
};
routinesApi.get = async () => ({ ...routine, triggers: routine.triggers.filter((trigger) => !trigger.archived) });
routinesApi.list = async () => [{ ...routine, lastRun: routine.recentRuns[0] ?? null }];
routinesApi.listRuns = async () => routine.recentRuns;
routinesApi.activity = async () => fresh ? [] : routineActivity;
routinesApi.listRevisions = async () => [];
routinesApi.update = async (_id, patch) => (routine = { ...routine, ...patch });
routinesApi.createTrigger = async (_id, input) => {
const trigger: RoutineTrigger = {
...webhook(String(input.signingMode ?? "bearer"), routine.triggers.length + 1), ...input,
lastFiredAt: null, lastResult: null,
};
routine = { ...routine, triggers: [...routine.triggers, trigger] };
return { trigger, secretMaterial: trigger.kind === "webhook" ? { webhookUrl, webhookSecret: demoSecret } : null };
};
routinesApi.updateTrigger = async (id, patch) => {
const trigger = { ...routine.triggers.find((item) => item.id === id)!, ...patch };
routine = { ...routine, triggers: routine.triggers.map((item) => item.id === id ? trigger : item) };
return trigger;
};
routinesApi.deleteTrigger = async (id) => {
routine = { ...routine, triggers: routine.triggers.filter((item) => item.id !== id) };
};
routinesApi.rotateTriggerSecret = async (id) => ({
trigger: routine.triggers.find((item) => item.id === id)!,
secretMaterial: { webhookUrl, webhookSecret: `${demoSecret}-rotated` },
});
foldersApi.list = async (_companyId, kind) => ({ kind, folders: [], allCount: 1, unfiledCount: 1 });
const previousFetch = window.fetch;
window.fetch = async (input, init) => {
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin);
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
// Keep unrelated page actions inside the review fixture as well.
if (url.pathname.startsWith("/api/") && method !== "GET") {
return Response.json({ error: "This action is outside the webhook review fixture." }, { status: 422 });
}
return previousFetch(input, init);
};
initialActions.current.setSelectedCompanyId(companyId);
initialActions.current.navigate(state === "list" ? "/PAP/routines" : `/PAP/routines/${routineId}/${["overview", "runs", "activity"].includes(state) ? state : "triggers"}`, { replace: true });
setReady(true);
return () => {
Object.assign(routinesApi, originalApi);
foldersApi.list = originalFoldersList;
issuesApi.list = originalIssuesList;
issuesApi.update = originalIssueUpdate;
issuesApi.listCompact = originalCompactList;
window.fetch = previousFetch;
};
}, [signingMode, state]);
if (!ready) return null;
return (
<PluginLauncherProvider>
<Routes>
<Route path="/:companyPrefix" element={<Layout />}>
<Route path="routines" element={<Routines />} />
<Route path="routines/:routineId/:section?" element={preview ?? <RoutineDetail />} />
</Route>
</Routes>
</PluginLauncherProvider>
);
}
@@ -0,0 +1,268 @@
import { RoutineTriggerCard } from "../fixtures/routineTriggerCard";
import { RoutineTriggerWizard, defaultTriggerDraft, type TriggerDraft } from "../fixtures/routineTriggerWizard";
import { CopyField } from "../fixtures/routineWebhookFields";
import { useCallback, useEffect, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { AlertCircle, ArrowRight, CalendarClock, Check, CheckCircle2, ChevronRight, Copy, GitBranch, Globe, KeyRound, Play, Radio, Webhook } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { RoutineDetail } from "@/pages/RoutineDetail";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { Link, useNavigate, useParams } from "@/lib/router";
import { cn } from "@/lib/utils";
import { WebhookReview } from "../fixtures/routineWebhooks";
// Deliberately Storybook-only: no production components or webhook APIs are changed.
const endpoint = "https://acme.paperclip.example/api/routine-triggers/public/0123456789abcdef01234567/fire";
const secret = "demo_webhook_key_for_storybook_only";
const root = "/routines/routine-webhook-story";
type Stage = "setup" | "credentials" | "waiting" | "received" | "failure";
type Sender = "custom" | "github";
type TriggerKind = "choose" | "schedule" | "webhook";
type CheckResult = "waiting" | "received" | "rejected" | "no_event";
type Props = {
stage: Stage;
sender: Sender;
page: "triggers" | "overview" | "activity";
triggerKind: TriggerKind;
wizardStep: number;
expandedWebhook?: boolean;
scheduleSaved: boolean;
checkResult: CheckResult;
};
function HumanActivity({ received, sample }: { received: boolean; sample: boolean }) {
const events = [
...(received ? [{ time: "Just now", title: sample ? "Test event received" : "Webhook received", summary: sample ? "Connection check passed" : "Created PAP-123", details: { event: "deployment.completed", environment: "production", authentication: "Passed", ...(sample ? { test: true, taskCreated: false } : { task: "PAP-123" }) } }] : []),
{ time: "10:04 AM", title: "Webhook rejected", summary: "Invalid secret", details: { reason: "The sender's key did not match this webhook.", taskCreated: false } },
{ time: "9:42 AM", title: "Instructions updated", summary: "Riley Board", details: { changed: "Deployment health checks" } },
{ time: "9:40 AM", title: "Webhook created", summary: "Deployment completed", details: { sender: "Another app or script", authentication: "Secret key" } },
];
return <div className="space-y-3"><p className="text-xs text-muted-foreground">Today</p>{events.map((event) => <details key={event.title} className="group border-b border-border pb-2">
<summary className="flex cursor-pointer list-none items-center gap-3 rounded-md py-2 text-xs hover:bg-accent/50">
<span className="w-16 shrink-0 whitespace-nowrap font-mono text-muted-foreground">{event.time}</span>
<span className="shrink-0 font-medium">{event.title}</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground" title={event.summary}>{event.summary}</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground group-open:rotate-90" />
</summary>
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs">{JSON.stringify(event.details, null, 2)}</pre>
</details>)}</div>;
}
function WebhookPrototypePage({ stage: initialStage, sender: initialSender, triggerKind: initialKind, scheduleSaved: initialScheduleSaved, checkResult, wizardStep, expandedWebhook = false }: Props) {
const { section = "triggers" } = useParams<{ section?: string }>();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
const draftKey = `storybook:routine-trigger-draft:v2:${window.location.search}`;
const [savedDraft, setSavedDraft] = useState<TriggerDraft | null>(() => {
try { const stored = sessionStorage.getItem(draftKey); return stored ? JSON.parse(stored) as TriggerDraft : null; } catch { return null; }
});
const [wizardDraft, setWizardDraft] = useState<TriggerDraft>({ ...defaultTriggerDraft, kind: initialKind, sender: initialSender, step: wizardStep, availableStep: wizardStep, created: wizardStep >= 1 && initialKind === "webhook" });
const [stage, setStage] = useState(initialStage);
const [scheduleSaved, setScheduleSaved] = useState(initialScheduleSaved);
const [editingTrigger, setEditingTrigger] = useState<"schedule" | "webhook" | null>(expandedWebhook ? "webhook" : null);
const [scheduleRemoved, setScheduleRemoved] = useState(false);
const [webhookRemoved, setWebhookRemoved] = useState(false);
const [scheduleEdit, setScheduleEdit] = useState({ frequency: "weekdays", time: "09:00", weekday: "Monday", timezone: "America/Chicago" });
// Step stories always open at their requested step, even after saving a preview draft.
const [adding, setAdding] = useState(initialStage === "setup" && !initialScheduleSaved);
const [frequency, setFrequency] = useState("weekdays");
const [time, setTime] = useState("09:00");
const [weekday, setWeekday] = useState("Monday");
const [timezone, setTimezone] = useState("America/Chicago");
const [checkState, setCheckState] = useState(checkResult);
useEffect(() => { setCheckState(checkResult); }, [checkResult]);
const [sender, setSender] = useState(initialSender);
const [active, setActive] = useState(true);
const [keyVisible, setKeyVisible] = useState(initialStage === "credentials");
const [mode, setMode] = useState("bearer");
const [dialog, setDialog] = useState<"connection" | "routine" | "rotate" | null>(null);
const sample = false;
const [tested, setTested] = useState(false);
const [payload, setPayload] = useState('{\n "event": "deployment.completed",\n "environment": "production"\n}');
const [payloadError, setPayloadError] = useState("");
const saveAndExit = useCallback((draft: TriggerDraft) => {
sessionStorage.setItem(draftKey, JSON.stringify(draft));
setSavedDraft(draft);
setWizardDraft(draft);
setAdding(false);
}, [draftKey]);
useEffect(() => {
const webhookCard = <RoutineTriggerCard kind="webhook" icon={<Webhook className="h-4 w-4" />} title={github ? "GitHub webhook" : "Webhook"} summary={`${github ? "GitHub" : "Another app or script"} · ${status}`} expanded={editingTrigger === "webhook"} onEdit={() => setEditingTrigger(editingTrigger === "webhook" ? null : "webhook")} onRemove={() => { setWebhookRemoved(true); setEditingTrigger(null); }}>
{connection}
<div className="flex justify-end"><Button variant="outline" onClick={() => setEditingTrigger(null)}>Done</Button></div>
</RoutineTriggerCard>;
if (adding && section === "triggers") return;
if (!["triggers", "overview", "activity"].includes(section)) return;
setBreadcrumbs([
{ label: "Verify a deployment", href: `${root}/overview` },
{ label: section === "activity" ? "Activity" : section === "overview" ? "Overview" : "Triggers" },
]);
}, [adding, section, setBreadcrumbs]);
if (!["triggers", "overview", "activity"].includes(section)) return <RoutineDetail />;
const created = stage !== "setup" && !webhookRemoved;
const received = stage === "received";
const failed = stage === "failure";
const github = sender === "github";
const authenticated = github || mode !== "none";
const status = !active ? "Paused" : failed ? "Needs attention" : received ? (sample ? "Test event received" : "Receiving events") : "Waiting for first event";
const validatePayload = () => {
try {
const parsed: unknown = JSON.parse(payload);
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw new Error();
setPayloadError(""); return true;
} catch { setPayloadError("Enter a valid JSON object, for example {\"event\": \"deployment.completed\"}."); return false; }
};
const showConnection = () => { setCheckState(checkResult); setDialog("connection"); };
const scheduleSummary = `${frequency === "daily" ? "Every day" : frequency === "weekly" ? `Every ${weekday}` : "Every weekday"} at ${time}`;
const editSchedule = () => {
setScheduleEdit({ frequency, time, weekday, timezone });
setEditingTrigger(editingTrigger === "schedule" ? null : "schedule");
};
const selectClass = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
const scheduleCard = <RoutineTriggerCard kind="schedule" icon={<CalendarClock className="h-4 w-4" />} title={scheduleSummary} summary={`${timezone} · ${active ? "Runs automatically on this schedule" : "Paused"}`} expanded={editingTrigger === "schedule"} onEdit={editSchedule} onRemove={() => { setScheduleSaved(false); setScheduleRemoved(true); setEditingTrigger(null); }}>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"><Label htmlFor="edit-repeat">Repeat</Label><select id="edit-repeat" className={selectClass} value={scheduleEdit.frequency} onChange={(event) => setScheduleEdit({ ...scheduleEdit, frequency: event.target.value })}><option value="daily">Every day</option><option value="weekdays">Weekdays (Monday–Friday)</option><option value="weekly">Every week</option></select></div>
<div className="space-y-2"><Label htmlFor="edit-time">Time</Label><Input id="edit-time" type="time" value={scheduleEdit.time} onChange={(event) => setScheduleEdit({ ...scheduleEdit, time: event.target.value })} /></div>
{scheduleEdit.frequency === "weekly" && <div className="space-y-2"><Label htmlFor="edit-day">Day</Label><select id="edit-day" className={selectClass} value={scheduleEdit.weekday} onChange={(event) => setScheduleEdit({ ...scheduleEdit, weekday: event.target.value })}>{["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"].map((day) => <option key={day}>{day}</option>)}</select></div>}
<div className="space-y-2"><Label htmlFor="edit-zone">Time zone</Label><select id="edit-zone" className={selectClass} value={scheduleEdit.timezone} onChange={(event) => setScheduleEdit({ ...scheduleEdit, timezone: event.target.value })}>{["America/Chicago", "America/New_York", "America/Los_Angeles", "Europe/London", "UTC"].map((zone) => <option key={zone}>{zone}</option>)}</select></div>
</div>
<div className="flex justify-end gap-2"><Button variant="ghost" onClick={() => setEditingTrigger(null)}>Cancel</Button><Button disabled={!scheduleEdit.time} onClick={() => { setFrequency(scheduleEdit.frequency); setTime(scheduleEdit.time); setWeekday(scheduleEdit.weekday); setTimezone(scheduleEdit.timezone); setEditingTrigger(null); }}>Save schedule</Button></div>
</RoutineTriggerCard>;
const connectionStatus = <div className={cn("flex items-start gap-3 rounded-md bg-muted/40 p-4", failed && "bg-destructive/5")} role="status">
{failed ? <AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" /> : received ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-(--status-task-done)" /> : <Radio className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />}
<div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-medium">{status}</p>
<p className="text-xs text-muted-foreground">{!active ? "Automatic runs are paused. Resume when you’re ready to receive events." : failed ? "The last request had an invalid secret. Update the key in your sending app, then try again." : received ? (sample ? "Webhook intake passed. Send an event from your app to finish checking the connection." : "Last event 2 minutes ago · Deployment completed") : "This webhook is enabled. New events from your app will start the routine."}</p>
{received && !sample && <Link className="inline-flex items-center gap-1 text-xs underline underline-offset-4" to={`${root}/runs`}>Created PAP-123 · View task in runs <ArrowRight className="h-3 w-3" /></Link>}
</div>
</div>;
const connection = <div className="space-y-5">
{connectionStatus}
<div className="space-y-4"><div className="space-y-1"><h3 className="text-sm font-medium">Connect {github ? "GitHub" : "your app"}</h3><p className="text-xs text-muted-foreground">{github ? "In your repository, open Settings → Webhooks → Add webhook." : "Open your app’s webhook settings and paste these connection details."}</p></div>
<CopyField label={github ? "Payload URL" : "Webhook URL"} value={endpoint} help={github ? "Set Content type to application/json and choose the events to send." : "Send a POST request with a JSON body to this URL."} />
{authenticated && (keyVisible ? <div className="space-y-4 rounded-md border border-border p-4"><div className="flex items-start gap-2"><KeyRound className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" /><div><p className="text-sm font-medium">Save your key before leaving</p><p className="text-xs text-muted-foreground">This key is only shown once. Paste it into {github ? "GitHub’s Secret field" : "your app’s authentication settings"}.</p></div></div>
{github || mode === "hmac" ? <CopyField label="Secret" value={secret} /> : <><CopyField label="Authorization header value" value={`Bearer ${secret}`} help="Header name: Authorization. The value above already includes “Bearer”." /><details><summary className="cursor-pointer text-xs text-muted-foreground">My app asks for the key only</summary><div className="pt-3"><CopyField label="Secret key" value={secret} /></div></details></>}
<Button variant="outline" size="sm" onClick={() => { setKeyVisible(false); if (stage === "credentials") setStage("waiting"); }}><Check className="h-3.5 w-3.5" />I’ve saved the key</Button>
</div> : <div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border p-3"><div className="flex items-center gap-2"><KeyRound className="h-4 w-4 text-muted-foreground" /><div><p className="text-sm">{github ? "GitHub signature verification" : mode === "hmac" ? "Signed requests" : "Secret key authentication"}</p><p className="text-xs text-muted-foreground">The key is hidden after setup.</p></div></div><Button variant="ghost" size="sm" onClick={() => setDialog("rotate")}>Replace key</Button></div>)}
{!authenticated && <p className="text-xs text-muted-foreground">No authentication · Anyone with this URL can start the routine.</p>}
<div className="flex flex-wrap items-center gap-3"><Button variant="outline" size="sm" disabled={!active} onClick={showConnection}><Radio className="h-3.5 w-3.5" />Check connection</Button><span className="text-xs text-muted-foreground">Watch for an event sent from your app.</span></div>
{github && <p className="text-xs text-muted-foreground">After saving in GitHub, use Redeliver on a recent delivery to check the full connection.</p>}
{!github && mode === "hmac" && <details><summary className="cursor-pointer text-xs text-muted-foreground">Signing instructions</summary><p className="pt-2 text-xs text-muted-foreground">Sign the timestamp and raw request body using HMAC-SHA256. This prototype is reviewing the setup flow; exact sender code will accompany implementation.</p></details>}
</div>
</div>;
const webhookCard = <RoutineTriggerCard kind="webhook" icon={<Webhook className="h-4 w-4" />} title={github ? "GitHub webhook" : "Webhook"} summary={`${github ? "GitHub" : "Another app or script"} · ${status}`} expanded={editingTrigger === "webhook"} onEdit={() => setEditingTrigger(editingTrigger === "webhook" ? null : "webhook")} onRemove={() => { setWebhookRemoved(true); setEditingTrigger(null); }}>
{connection}
<div className="flex justify-end"><Button variant="outline" onClick={() => setEditingTrigger(null)}>Done</Button></div>
</RoutineTriggerCard>;
if (adding && section === "triggers") return <RoutineTriggerWizard initialDraft={wizardDraft} checkResult={checkResult}
onSaveExit={saveAndExit}
onFinish={(draft) => { sessionStorage.removeItem(draftKey); setSavedDraft(null); setAdding(false); if (draft.kind === "schedule") { setFrequency(draft.frequency); setTime(draft.time); setTimezone(draft.timezone); setWeekday(draft.weekday); setScheduleSaved(true); setScheduleRemoved(false); } else { setSender(draft.sender); setStage("waiting"); setKeyVisible(false); setWebhookRemoved(false); } }} />;
return <div className="-m-4 flex h-full min-h-0 flex-col overflow-hidden md:-m-6">
<header className="flex flex-wrap items-center gap-3 border-b border-border px-6 py-4"><h1 className="min-w-0 flex-1 text-xl font-bold">Verify a deployment</h1><Button variant="outline" size="sm" onClick={() => { setTested(false); setDialog("routine"); }}><Play className="h-3.5 w-3.5" />Test routine</Button><div className="flex items-center gap-2"><ToggleSwitch checked={active} onCheckedChange={setActive} aria-label="Automatic triggers" /><span className="text-sm text-muted-foreground">{active ? "Active" : "Paused"}</span></div></header>
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-6 md:px-8"><div className="mx-auto max-w-3xl space-y-5"><h2 className="text-lg font-semibold">{section === "activity" ? "Activity" : section === "overview" ? "Overview" : "Triggers"}</h2>
{section === "triggers" ? <div className="space-y-6">
{scheduleSaved && scheduleCard}
{created && webhookCard}
{scheduleRemoved && <div role="status" className="flex items-center justify-between gap-3 text-sm"><span className="text-muted-foreground">Schedule removed.</span><Button variant="ghost" size="sm" onClick={() => { setScheduleSaved(true); setScheduleRemoved(false); }}>Undo remove schedule</Button></div>}
{webhookRemoved && <div role="status" className="flex items-center justify-between gap-3 text-sm"><span className="text-muted-foreground">Webhook removed.</span><Button variant="ghost" size="sm" onClick={() => setWebhookRemoved(false)}>Undo remove webhook</Button></div>}
{savedDraft && <div className="flex items-center justify-between gap-3 rounded-md border border-border p-4"><div><p className="text-sm font-medium">Trigger setup saved</p><p className="text-xs text-muted-foreground">Pick up where you left off.</p></div><Button onClick={() => { setWizardDraft(savedDraft); setAdding(true); }}>Resume setup</Button></div>}
<Button variant="outline" size="sm" onClick={() => { setWizardDraft({ ...defaultTriggerDraft }); setAdding(true); }}>{created || scheduleSaved ? "Add another trigger" : "Add trigger"}</Button>
</div> : section === "activity" ? <HumanActivity received={received} sample={sample} /> : <div className="space-y-6"><div className="space-y-2"><h3 className="text-sm font-medium">What this routine does</h3><p className="text-sm text-muted-foreground">Check the deployment from the incoming webhook. Verify the service is healthy and report the result.</p></div><div className="space-y-3"><div className="flex items-center justify-between"><h3 className="text-sm font-medium">When it runs</h3><Link className="text-xs underline underline-offset-4" to={`${root}/triggers`}>{created || scheduleSaved ? "Manage triggers" : "Add a trigger"}</Link></div>{scheduleSaved && scheduleCard}{created && <><p className="flex items-center gap-2 text-sm"><Webhook className="h-4 w-4 text-muted-foreground" />When another app sends a webhook</p>{connectionStatus}</>}{!created && !scheduleSaved && <p className="text-xs text-muted-foreground">Choose a schedule or webhook to start this routine automatically.</p>}</div><div className="space-y-3"><div className="flex justify-between"><h3 className="text-sm font-medium">Recent runs</h3><Link className="text-xs underline underline-offset-4" to={`${root}/runs`}>View all runs</Link></div>{received && !sample ? <Link to={`${root}/runs`} className="flex items-center gap-3 text-sm"><CheckCircle2 className="h-4 w-4 text-(--status-task-done)" /><span className="font-mono text-xs text-muted-foreground">PAP-123</span>Verify the production deployment</Link> : <p className="text-sm text-muted-foreground">No runs yet. Test the routine or send an event from your app.</p>}</div></div>}
</div></main>
<Dialog open={dialog !== null} onOpenChange={(open) => { if (!open) setDialog(null); }}><DialogContent className="sm:max-w-xl"><DialogHeader><DialogTitle>{dialog === "rotate" ? "Replace webhook key?" : dialog === "routine" ? "Test this routine" : "Check webhook connection"}</DialogTitle><DialogDescription>{dialog === "rotate" ? "The old key will stop working immediately. Update your sending app with the new key." : dialog === "routine" ? "Run the routine with sample data. This creates a task and lets the agent do real work." : "Send an event from your other app. Paperclip will show you whether it arrived and passed authentication."}</DialogDescription></DialogHeader>
{dialog === "connection" ? <div className="space-y-4">
<ol className="list-decimal space-y-3 pl-5 text-sm"><li><span className="font-medium">Save the connection in {github ? "GitHub" : "your app"}.</span><p className="text-xs text-muted-foreground">Use this webhook URL{authenticated ? " and the key you copied during setup" : ""}.</p></li><li><span className="font-medium">{github ? "Send a delivery from GitHub." : "Send a test event from that app."}</span><p className="text-xs text-muted-foreground">{github ? "Open repository Settings → Webhooks → this webhook → Recent Deliveries, then choose Redeliver." : "Look for “Send test” in its webhook settings. If there isn’t one, perform the action that should trigger the webhook, such as completing a deployment."}</p></li><li><span className="font-medium">Keep this window open to see the result.</span><p className="text-xs text-muted-foreground">Only events arriving after you open this check are shown here.</p></li></ol>
<CopyField label="Webhook URL" value={endpoint} />
<p className="rounded-md bg-muted/40 p-3 text-xs text-muted-foreground">This observes normal deliveries. An accepted event can start the routine and create a task, just like any other webhook.</p>
<div role="status" className="flex items-start gap-3 rounded-md border border-border p-4">{checkState === "received" ? <CheckCircle2 className="h-5 w-5 shrink-0 text-(--status-task-done)" /> : checkState === "rejected" ? <AlertCircle className="h-5 w-5 shrink-0 text-destructive" /> : <Radio className="h-5 w-5 shrink-0 text-muted-foreground" />}<div className="min-w-0 space-y-1"><p className="text-sm font-medium">{checkState === "received" ? "Event received · Connection working" : checkState === "rejected" ? "Event arrived, but the key was rejected" : checkState === "no_event" ? "No event received yet" : "Waiting for an event from your app…"}</p><p className="text-xs text-muted-foreground">{checkState === "received" ? "Authentication passed · Created task PAP-123" : checkState === "rejected" ? "Update the secret in your sending app and resend. No task was created." : checkState === "no_event" ? "Check that you saved the exact URL, enabled the webhook in your app, and sent an event. For custom scripts, use POST with a JSON body." : "Nothing to send from Paperclip. Send the event from your app, then return here."}</p></div></div>
{checkState === "received" && <details><summary className="cursor-pointer text-xs text-muted-foreground">View received event</summary><pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs">{payload}</pre></details>}
<DialogFooter><Button variant="ghost" onClick={() => setDialog(null)}>{checkState === "received" ? "Done" : "Stop checking"}</Button>{checkState === "received" ? <Button onClick={() => { setDialog(null); navigate(`${root}/runs`); }}>View task in runs</Button> : checkState !== "waiting" && <Button onClick={() => setCheckState("waiting")}>Check again</Button>}</DialogFooter>
</div> : dialog === "rotate" ? <DialogFooter><Button variant="ghost" onClick={() => setDialog(null)}>Cancel</Button><Button onClick={() => { setKeyVisible(true); setStage("credentials"); setDialog(null); }}>Replace key</Button></DialogFooter> : tested ? <div className="space-y-4"><div role="status" className="flex items-start gap-3 rounded-md bg-muted p-4"><CheckCircle2 className="h-5 w-5 text-(--status-task-done)" /><div><p className="text-sm font-medium">{dialog === "routine" ? "Test task created" : "Sample event accepted"}</p><p className="text-xs text-muted-foreground">{dialog === "routine" ? "PAP-123 · Verify production deployment v2.8.4" : "Authentication passed. No task was created. Next, send a test event from your app to verify its setup."}</p></div></div><DialogFooter><Button onClick={() => { const showRuns = dialog === "routine"; setDialog(null); if (showRuns) navigate(`${root}/runs`); }}>{dialog === "routine" ? "View runs" : "Done"}</Button></DialogFooter></div> : <div className="space-y-4"><div className="space-y-2"><Label htmlFor="test-payload">Sample event</Label><textarea id="test-payload" rows={5} value={payload} onChange={(event) => setPayload(event.target.value)} className="w-full rounded-md border border-input bg-background p-3 font-mono text-xs" />{payloadError && <p role="alert" className="text-xs text-destructive">{payloadError}</p>}</div><p className="text-xs text-muted-foreground">Storybook preview: this test uses simulated data.</p><DialogFooter><Button variant="ghost" onClick={() => setDialog(null)}>Cancel</Button><Button onClick={() => { if (!validatePayload()) return; setTested(true); }}>{dialog === "routine" ? "Run test" : "Send sample event"}</Button></DialogFooter></div>}
</DialogContent></Dialog>
</div>;
}
function Prototype(props: Props) {
return <WebhookReview signingMode={props.sender === "github" ? "github_hmac" : "bearer"} state={props.page === "triggers" ? "configured" : props.page} preview={<WebhookPrototypePage key={`${props.stage}:${props.sender}:${props.triggerKind}:${props.scheduleSaved}:${props.wizardStep}:${props.expandedWebhook ?? false}`} {...props} />} />;
}
const meta = {
title: "Product/Routines/Webhooks UX preview",
component: Prototype,
parameters: { layout: "fullscreen" },
args: { stage: "setup", sender: "custom", page: "triggers", triggerKind: "choose", wizardStep: 0, scheduleSaved: false, checkResult: "waiting" },
argTypes: { triggerKind: { control: "radio", options: ["choose", "schedule", "webhook"] }, scheduleSaved: { control: "boolean" }, checkResult: { control: "select", options: ["waiting", "received", "rejected", "no_event"] }, stage: { control: "select", options: ["setup", "credentials", "waiting", "received", "failure"] }, sender: { control: "radio", options: ["custom", "github"] }, page: { control: "radio", options: ["triggers", "overview", "activity"] } },
} satisfies Meta<typeof Prototype>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Setup: Story = { name: "01 · Choose trigger" };
export const ConnectYourApp: Story = { name: "02 · Connect your app", args: { triggerKind: "webhook", wizardStep: 1 } };
export const CheckConnection: Story = { name: "03 · Check connection", args: { triggerKind: "webhook", wizardStep: 2 } };
export const WaitingForFirstEvent: Story = { name: "04 · Waiting for first event", args: { stage: "waiting", expandedWebhook: true } };
export const ReceivingEvents: Story = { name: "05 · Receiving events", args: { stage: "received", expandedWebhook: true } };
export const AuthenticationError: Story = { name: "06 · Fix authentication", args: { stage: "failure", expandedWebhook: true } };
export const GitHubSetup: Story = { name: "07 · Connect GitHub", args: { sender: "github", triggerKind: "webhook", wizardStep: 1 } };
export const Overview: Story = { name: "08 · Routine overview", args: { stage: "received", page: "overview" } };
export const Activity: Story = { name: "09 · Human-readable activity", args: { stage: "received", page: "activity" } };
export const InteractiveWalkthrough: Story = {
name: "10 · Setup walkthrough", play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole("radio", { name: /When another app sends a webhook/ }));
await userEvent.click(canvas.getByRole("button", { name: "Continue" }));
await expect(canvas.getByRole("button", { name: "Copy for your agent" })).toBeVisible();
await expect(canvas.queryByRole("checkbox")).not.toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Check connection" }));
const dialog = within(canvasElement.ownerDocument.body);
await expect(await dialog.findByText("Waiting for an event from your app…")).toBeVisible();
await expect(dialog.getByText("Send an event from your app")).toBeVisible();
await expect(dialog.getByText("Events received during setup won’t start the routine or create tasks.")).toBeVisible();
},
};
export const Schedule: Story = { name: "11 · Set up a schedule", args: { triggerKind: "schedule", wizardStep: 1 } };
export const ScheduleAndWebhook: Story = { name: "12 · Schedule and webhook together", args: { stage: "waiting", scheduleSaved: true } };
export const ConnectionReceived: Story = { ...CheckConnection, name: "13 · Connection check · Event received", args: { triggerKind: "webhook", wizardStep: 2, checkResult: "received" } };
export const ConnectionRejected: Story = { ...CheckConnection, name: "14 · Connection check · Invalid key", args: { triggerKind: "webhook", wizardStep: 2, checkResult: "rejected" } };
export const ConnectionNoEvent: Story = { ...CheckConnection, name: "15 · Connection check · Nothing arrived", args: { triggerKind: "webhook", wizardStep: 2, checkResult: "no_event" } };
export const ScheduleWalkthrough: Story = { name: "16 · Schedule walkthrough", play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole("radio", { name: /On a schedule/ }));
await userEvent.click(canvas.getByRole("button", { name: "Continue" }));
await userEvent.click(canvas.getByRole("button", { name: "Review schedule" }));
await userEvent.click(canvas.getByRole("button", { name: "Add schedule" }));
await expect(canvas.getByText("Every weekday at 09:00")).toBeVisible();
await expect(canvas.getByRole("navigation", { name: "Routine navigation" })).toBeVisible();
} };
export const EditWebhook: Story = { name: "17 · Edit webhook in its card", args: { stage: "waiting", scheduleSaved: true }, play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole("button", { name: "Edit webhook" }));
await expect(canvas.getByRole("button", { name: "Copy Webhook URL" })).toBeVisible();
await expect(canvas.getByRole("navigation", { name: "Routine navigation" })).toBeVisible();
} };
export const EditSchedule: Story = { name: "18 · Edit schedule in its card", args: { stage: "waiting", scheduleSaved: true }, play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole("button", { name: "Edit schedule" }));
await userEvent.selectOptions(canvas.getByRole("combobox", { name: "Repeat" }), "daily");
await userEvent.click(canvas.getByRole("button", { name: "Save schedule" }));
await expect(canvas.getByRole("heading", { name: "Every day at 09:00" })).toBeVisible();
await expect(canvas.getByRole("button", { name: "Edit webhook" })).toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Edit schedule" }));
await expect(canvas.getByRole("combobox", { name: "Repeat" })).toHaveValue("daily");
} };
export const RemoveTriggers: Story = { name: "19 · Remove and restore triggers", args: { stage: "waiting", scheduleSaved: true }, play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole("button", { name: "Remove schedule" }));
await expect(canvas.queryByRole("button", { name: "Edit schedule" })).not.toBeInTheDocument();
await expect(canvas.getByRole("button", { name: "Edit webhook" })).toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Remove webhook" }));
await expect(canvas.queryByRole("button", { name: "Edit webhook" })).not.toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Undo remove schedule" }));
await userEvent.click(canvas.getByRole("button", { name: "Undo remove webhook" }));
await expect(canvas.getByRole("button", { name: "Edit schedule" })).toBeVisible();
await expect(canvas.getByRole("button", { name: "Edit webhook" })).toBeVisible();
} };
@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { WebhookReview } from "../fixtures/routineWebhooks";
const meta = {
title: "Product/Routines/Webhooks",
component: WebhookReview,
parameters: { layout: "fullscreen" },
args: { signingMode: "bearer", state: "configured" },
} satisfies Meta<typeof WebhookReview>;
export default meta;
type Story = StoryObj<typeof meta>;
const openWebhookForm: NonNullable<Story["play"]> = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole("button", { name: "Add trigger" }));
await userEvent.click(canvas.getByText("When another app sends a webhook", { exact: true }));
await expect(canvas.getByRole("button", { name: "Continue" })).toBeEnabled();
};
const openSavedWebhook: NonNullable<Story["play"]> = async ({ canvasElement }) => {
await userEvent.click(await within(canvasElement).findByRole("button", { name: "Edit webhook" }));
};
export const RoutineList: Story = { name: "00 · Routines page", args: { state: "list" } };
export const Setup: Story = { name: "01 · Add a webhook", args: { state: "setup" }, play: openWebhookForm };
export const Credentials: Story = {
name: "02 · Save the new secret", args: { state: "credentials" },
play: async (context) => {
await openWebhookForm(context);
const canvas = within(context.canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Continue" }));
await expect(await canvas.findByRole("button", { name: "Copy Authorization header value" })).toBeVisible();
},
};
export const Bearer: Story = { name: "03 · Bearer · After delivery", play: openSavedWebhook };
export const TimestampedHmac: Story = { name: "04 · Timestamped HMAC", args: { signingMode: "hmac_sha256" }, play: openSavedWebhook };
export const GitHub: Story = { name: "05 · GitHub HMAC", args: { signingMode: "github_hmac" }, play: openSavedWebhook };
export const Unsigned: Story = { name: "06 · Unsigned", args: { signingMode: "none" }, play: openSavedWebhook };
export const FailedDelivery: Story = { name: "07 · Failed delivery", args: { state: "failure" }, play: openSavedWebhook };
export const Overview: Story = { name: "08 · Webhook routine overview", args: { state: "overview" } };
export const Runs: Story = {
name: "09 · Runs · Issue list", args: { state: "runs" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText("Verify production deployment v2.8.4")).toBeVisible();
await expect(canvas.getByRole("navigation", { name: "Routine navigation" })).toBeVisible();
},
};
export const Activity: Story = {
name: "10 · Activity · Routine timeline", args: { state: "activity" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText("Routine started")).toBeVisible();
await expect(canvas.getByRole("navigation", { name: "Routine navigation" })).toBeVisible();
},
};