mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Apps gives agents governed access to external tools through stored credentials. > - Routines start work when an external service sends an event. > - Fireflies provides meeting transcripts and summaries through an official hosted MCP server. > - This PR adds that connection and accepts signed meeting events through the shared app webhook flow. > - Agents can review completed meetings with the same permissions and audit records as other work. ## Linked Issues or Issue Description **Problem or motivation** Operators need agents to read Fireflies meetings and start follow-up work when a summary is ready. The Apps catalog lacks Fireflies. The shared app webhook flow needs to accept its signed deliveries. **Proposed solution** Use the official Fireflies MCP endpoint with OAuth or a vaulted bearer API key. Extend the existing Another app or script flow with signed webhook support. Verify the raw-body signature and pass the JSON payload as external data. Select Meeting Summarized in Fireflies. Deduplicate identical signed deliveries, including setup deliveries. **Alternatives considered** A separate REST connector would duplicate the governed MCP path. Polling, legacy V1 payloads, and automatic provider-side webhook registration are outside this change. **Roadmap alignment** This extends the existing MCP Tool Gateway & Apps and Scheduled Routines surfaces. It adds a provider to those systems. It does not introduce a second integration framework. **Additional context** A GitHub search found no existing Fireflies issues or PRs. Provider references and verification limits are in `doc/connections/FIREFLIES.md`. ## What Changed - Add the official Fireflies catalog definition, generated registry, provider evidence, and branded artwork. - Reuse Access → Connect, dynamic discovery, Permissions, vault storage, policy, and audit behavior. - Classify Fireflies sharing, movement, and access revocation as writes. - Preserve Off and Ask first restrictions during OAuth reauthorization and API-key replacement. New actions retain normal defaults. - Add `app_webhook` authentication to the shared Another app or script flow. Accept bearer tokens or raw-body HMAC-SHA256. Preserve earlier `fireflies_hmac` triggers and revision snapshots for compatibility. Existing text columns need no migration. - Verify `X-Hub-Signature` or `X-Hub-Signature-256` against the exact request body. Preserve generic event payloads and deduplicate identical signed requests. - Keep the routine wizard generic. Show one webhook URL and secret in Another app or script. Keep all new app webhook event names provider-neutral. Keep provider setup instructions in the connector documentation. - Pass generic webhook JSON to the task in an explicit external-data block, capped at 16,384 characters. Keep strict meeting validation for existing legacy Fireflies triggers. ## Verification - Feature implementation commit `0882dc8a1`: all 54 CI checks passed; two conditional Storybook checks skipped. This includes full tests, typecheck, build, browser E2E, canary dry run, and security checks. Greptile rated this commit 5/5; all review threads are resolved. - Full local `pnpm -r typecheck`, `pnpm build`, and token gates passed on the final code. Targeted connector, gateway, webhook, revision, and UI suites passed during implementation. After the provider-neutral follow-up, all 84 app-webhook and routine-service tests passed; the final payload-to-task assertion also passed in the 72-test routine suite and a clean-config rerun. - The long local `pnpm test:run` invocation started before the final edits and was stopped after the final-commit CI suites passed. It reported one generic webhook test failure while those files were changing; that test and the entire routine suite passed on the final source, including a clean-config reproduction. The interrupted local run is not counted as a full-suite pass. - In the embedded browser, completed official OAuth consent and discovered 20 live actions. Real meeting listing, transcript retrieval, and summary/action-item retrieval succeeded as the selected agent. Turning a live read Off blocked its test; catalog refresh preserved the restriction. - Embedded-browser Another app or script setup, back/save/resume, narrow layout, and a signed synthetic Fireflies delivery succeeded. The UI reported authentication passed without creating a task. Fixtures cover signature tampering, malformed requests, ordinary app event names, duplicate/setup deliveries, rotation, revisions, pause/archive, and company isolation. - Existing MCP browser suite: 8 passed and 2 provider-dependent cases skipped. Branding checks passed; connector artwork and webhook setup were checked at desktop/mobile widths and in light/dark modes. - An unauthenticated POST to a correctly formatted public webhook URL reached the staging tenant verifier through the existing Cloud gateway. - A real Fireflies webhook delivery remains unverified. A staging callback is available for the operator walkthrough. Live API-key authorization, credential expiry, and a new meeting's summary completion were not tested against the provider. Fixtures cover these protocol and lifecycle paths where applicable. - Storybook follow-up `c54174faa`: 27 production-component stories cover every UI change, with a source-to-story map in the connector documentation. Static Storybook build, UI typecheck, token gates, and Playwright checks for all stories and the mobile footer pass. All PR checks passed for this Storybook follow-up; Greptile reviewed `c54174faa` at 5/5. ## Risks - Fireflies may change its hosted MCP tools or OAuth behavior. Tool discovery stays dynamic. Experimental search/fetch tools are not required. - Public webhook setup requires HTTPS and a separate signing secret. Fireflies normally emits events for meetings owned by the configuring account. - Reauthorization touches shared MCP permission code. Regression tests cover existing restrictions, new actions, connection removal, and other gateway callers. - Webhook receipt grants no tool access. The routine agent still needs an authorized Fireflies connection. - New generic triggers rely on provider event subscriptions. Without a sender-supplied idempotency key, changed request bytes count as a new event. Existing legacy Fireflies triggers retain summary-only filtering and per-meeting deduplication. ## Model Used OpenAI Codex, model `gpt-6-astra`. Used reasoning, repository editing, code execution, and embedded-browser testing. The runtime did not expose a context-window size. ## 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>
863 lines
32 KiB
TypeScript
863 lines
32 KiB
TypeScript
import { AgentAvatar } from "@/components/AgentAvatar";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
ArrowRight,
|
|
Braces,
|
|
Clock3,
|
|
Edit3,
|
|
KeyRound,
|
|
Play,
|
|
Plus,
|
|
X,
|
|
} from "lucide-react";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { RadioCardGroup } from "@/components/ui/radio-card";
|
|
import { cn } from "@/lib/utils";
|
|
import { nextCronFires, previewFirePolicies } from "../../lib/cron-fires";
|
|
import { timeAgo } from "../../lib/timeAgo";
|
|
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 type { EnvBinding, RoutineDetail as RoutineDetailType } from "@paperclipai/shared";
|
|
|
|
const concurrencyPolicyOptions = [
|
|
{
|
|
value: "coalesce_if_active",
|
|
title: "Coalesce if active",
|
|
description: "Keep one follow-up run queued while an active run is still working.",
|
|
},
|
|
{
|
|
value: "always_enqueue",
|
|
title: "Always enqueue",
|
|
description: "Queue every trigger occurrence, even if several runs stack up.",
|
|
},
|
|
{
|
|
value: "skip_if_active",
|
|
title: "Skip if active",
|
|
description: "Drop overlapping trigger occurrences while the routine is already active.",
|
|
},
|
|
];
|
|
|
|
const catchUpPolicyOptions = [
|
|
{
|
|
value: "skip_missed",
|
|
title: "Skip missed",
|
|
description: "Ignore schedule windows that were missed while paused.",
|
|
},
|
|
{
|
|
value: "enqueue_missed_with_cap",
|
|
title: "Enqueue missed with cap",
|
|
description: "Catch up missed schedule windows after recovery; sub-hourly schedules are combined into one catch-up run, slower schedules replay each missed window up to a cap.",
|
|
},
|
|
];
|
|
|
|
const activityGatePolicyOptions = [
|
|
{
|
|
value: "always",
|
|
title: "Run on every scheduled tick",
|
|
description: "Fire on the schedule no matter what — the default behavior.",
|
|
},
|
|
{
|
|
value: "require_external_activity",
|
|
title: "Skip when there's been no activity since the last run",
|
|
description:
|
|
"On a scheduled tick, only run if something happened since the last run that finished. Lets a watcher-style routine stay asleep while the system is settled instead of burning tokens.",
|
|
},
|
|
];
|
|
|
|
const activityGateScopeOptions = [
|
|
{
|
|
value: "company",
|
|
title: "Company-wide",
|
|
description: "Any activity across the company counts as a reason to run.",
|
|
},
|
|
{
|
|
value: "project",
|
|
title: "This project",
|
|
description: "Only activity in the routine's project counts as a reason to run.",
|
|
},
|
|
];
|
|
|
|
const triggerKinds = ["schedule", "webhook"];
|
|
const signingModes = ["app_webhook", "bearer", "hmac_sha256", "github_hmac", "none"];
|
|
const signingModeDescriptions: Record<string, string> = {
|
|
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).",
|
|
app_webhook: "Accept a bearer token or an HMAC-SHA256 signature over the exact request body in X-Hub-Signature or X-Hub-Signature-256.",
|
|
fireflies_hmac: "Signed webhook (legacy).",
|
|
none: "No authentication — the webhook URL itself acts as a shared secret.",
|
|
};
|
|
const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["app_webhook", "bearer", "github_hmac", "fireflies_hmac", "none"]);
|
|
|
|
export function OverviewSection({
|
|
defaultDescriptionAnnotationsOpen = false,
|
|
}: {
|
|
defaultDescriptionAnnotationsOpen?: boolean;
|
|
} = {}) {
|
|
const ctx = useRoutineDetail();
|
|
const {
|
|
routine,
|
|
editDraft,
|
|
setEditDraft,
|
|
assigneeOptions,
|
|
projectOptions,
|
|
recentAssigneeIds,
|
|
recentProjectIds,
|
|
agentById,
|
|
projectById,
|
|
currentAssignee,
|
|
currentProject,
|
|
mentionOptions,
|
|
assigneeSelectorRef,
|
|
projectSelectorRef,
|
|
descriptionEditorRef,
|
|
routineRuns,
|
|
activity,
|
|
saveRoutine,
|
|
saveConflict,
|
|
isSectionDirty,
|
|
navigateToSection,
|
|
} = ctx;
|
|
const [descriptionAnnotationsOpen, setDescriptionAnnotationsOpen] = useState(defaultDescriptionAnnotationsOpen);
|
|
|
|
const activeTriggers = routine.triggers.length;
|
|
const nextFire = useMemo(() => {
|
|
const upcoming = routine.triggers
|
|
.filter((trigger) => trigger.kind === "schedule" && trigger.nextRunAt)
|
|
.map((trigger) => new Date(trigger.nextRunAt as Date))
|
|
.sort((a, b) => a.getTime() - b.getTime())[0];
|
|
return upcoming ? upcoming.toLocaleString() : null;
|
|
}, [routine.triggers]);
|
|
const boundSecrets = editDraft.env ? Object.keys(editDraft.env).length : 0;
|
|
const lastRun = (routineRuns ?? [])[0] ?? null;
|
|
const recentActivity = (activity ?? []).slice(0, 5);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Assignment row */}
|
|
<div className="overflow-x-auto overscroll-x-contain">
|
|
<div className="inline-flex min-w-full flex-wrap items-center gap-2 text-sm text-muted-foreground sm:min-w-max sm:flex-nowrap">
|
|
<span>For</span>
|
|
<InlineEntitySelector
|
|
ref={assigneeSelectorRef}
|
|
value={editDraft.assigneeAgentId}
|
|
options={assigneeOptions}
|
|
recentOptionIds={recentAssigneeIds}
|
|
placeholder="Responsible"
|
|
noneLabel="No responsible"
|
|
searchPlaceholder="Search responsible..."
|
|
emptyMessage="No responsible found."
|
|
onChange={(assigneeAgentId) =>
|
|
setEditDraft((current) => ({ ...current, assigneeAgentId }))
|
|
}
|
|
onConfirm={() => {
|
|
if (editDraft.projectId) {
|
|
descriptionEditorRef.current?.focus();
|
|
} else {
|
|
projectSelectorRef.current?.focus();
|
|
}
|
|
}}
|
|
renderTriggerValue={(option) =>
|
|
option ? (
|
|
currentAssignee ? (
|
|
<>
|
|
<AgentAvatar agent={currentAssignee} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
|
|
<span className="truncate">{option.label}</span>
|
|
</>
|
|
) : (
|
|
<span className="truncate">{option.label}</span>
|
|
)
|
|
) : (
|
|
<span className="text-muted-foreground">Responsible</span>
|
|
)
|
|
}
|
|
renderOption={(option) => {
|
|
if (!option.id) return <span className="truncate">{option.label}</span>;
|
|
const assignee = agentById.get(option.id);
|
|
return (
|
|
<>
|
|
{assignee ? (
|
|
<AgentAvatar agent={assignee} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
|
|
) : null}
|
|
<span className="truncate">{option.label}</span>
|
|
</>
|
|
);
|
|
}}
|
|
/>
|
|
<span>in</span>
|
|
<InlineEntitySelector
|
|
ref={projectSelectorRef}
|
|
value={editDraft.projectId}
|
|
options={projectOptions}
|
|
recentOptionIds={recentProjectIds}
|
|
placeholder="Project"
|
|
noneLabel="No project"
|
|
searchPlaceholder="Search projects..."
|
|
emptyMessage="No projects found."
|
|
onChange={(projectId) => setEditDraft((current) => ({ ...current, projectId }))}
|
|
onConfirm={() => descriptionEditorRef.current?.focus()}
|
|
renderTriggerValue={(option) =>
|
|
option && currentProject ? (
|
|
<>
|
|
<span
|
|
className="h-3.5 w-3.5 shrink-0 rounded-sm"
|
|
style={{ backgroundColor: currentProject.color ?? "var(--project-none)" }}
|
|
/>
|
|
<span className="truncate">{option.label}</span>
|
|
</>
|
|
) : (
|
|
<span className="text-muted-foreground">Project</span>
|
|
)
|
|
}
|
|
renderOption={(option) => {
|
|
if (!option.id) return <span className="truncate">{option.label}</span>;
|
|
const project = projectById.get(option.id);
|
|
return (
|
|
<>
|
|
<span
|
|
className="h-3.5 w-3.5 shrink-0 rounded-sm"
|
|
style={{ backgroundColor: project?.color ?? "var(--project-none)" }}
|
|
/>
|
|
<span className="truncate">{option.label}</span>
|
|
</>
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{!routine.assigneeAgentId ? (
|
|
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 text-sm text-amber-900 dark:text-amber-200">
|
|
Default agent required. This routine can stay as a draft and still run manually, but
|
|
automation stays paused until you assign a default agent.
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Instructions */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-end">
|
|
{routine.descriptionDocument ? (
|
|
<DocumentAnnotationsCountChip
|
|
issueId={routine.id}
|
|
docKey="description"
|
|
target={{ kind: "routine", routineId: routine.id, documentKey: "description" }}
|
|
panelOpen={descriptionAnnotationsOpen}
|
|
onToggle={() => setDescriptionAnnotationsOpen((open) => !open)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
{routine.descriptionDocument ? (
|
|
<IssueDocumentAnnotations
|
|
issueId={routine.id}
|
|
doc={routine.descriptionDocument}
|
|
target={{ kind: "routine", routineId: routine.id, documentKey: "description" }}
|
|
bodyMarkdown={editDraft.description}
|
|
draftDirty={isSectionDirty("overview") || saveRoutine.isPending}
|
|
draftConflicted={saveConflict}
|
|
historicalPreview={false}
|
|
locationHash={typeof window === "undefined" ? "" : window.location.hash}
|
|
panelOpen={descriptionAnnotationsOpen}
|
|
onPanelOpenChange={setDescriptionAnnotationsOpen}
|
|
>
|
|
<MarkdownEditor
|
|
ref={descriptionEditorRef}
|
|
value={editDraft.description}
|
|
onChange={(description) => setEditDraft((current) => ({ ...current, description }))}
|
|
placeholder="Add instructions..."
|
|
bordered={false}
|
|
contentClassName="min-h-(--sz-120px) text-sm leading-7"
|
|
mentions={mentionOptions}
|
|
onSubmit={() => {
|
|
if (!saveRoutine.isPending && editDraft.title.trim()) {
|
|
saveRoutine.mutate();
|
|
}
|
|
}}
|
|
/>
|
|
</IssueDocumentAnnotations>
|
|
) : (
|
|
<MarkdownEditor
|
|
ref={descriptionEditorRef}
|
|
value={editDraft.description}
|
|
onChange={(description) => setEditDraft((current) => ({ ...current, description }))}
|
|
placeholder="Add instructions..."
|
|
bordered={false}
|
|
contentClassName="min-h-(--sz-120px) text-sm leading-7"
|
|
mentions={mentionOptions}
|
|
onSubmit={() => {
|
|
if (!saveRoutine.isPending && editDraft.title.trim()) {
|
|
saveRoutine.mutate();
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Variables peek */}
|
|
<div className="space-y-3">
|
|
<RoutineVariablesHint />
|
|
<RoutineVariablesEditor
|
|
title={editDraft.title}
|
|
description={editDraft.description}
|
|
value={editDraft.variables}
|
|
onChange={(variables) => setEditDraft((current) => ({ ...current, variables }))}
|
|
/>
|
|
</div>
|
|
|
|
{/* Summary cards */}
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<SummaryCard
|
|
icon={Clock3}
|
|
label="Triggers"
|
|
value={activeTriggers === 0 ? "None" : `${activeTriggers} active`}
|
|
hint={nextFire ? `Next fire ${nextFire}` : "No schedule"}
|
|
to={() => navigateToSection("triggers")}
|
|
ariaLabel={`${activeTriggers} triggers. Open triggers.`}
|
|
/>
|
|
<SummaryCard
|
|
icon={KeyRound}
|
|
label="Secrets"
|
|
value={boundSecrets === 0 ? "None" : `${boundSecrets} bound`}
|
|
hint="Manage bound secrets"
|
|
to={() => navigateToSection("secrets")}
|
|
ariaLabel={`${boundSecrets} secrets bound. Open secrets.`}
|
|
/>
|
|
<SummaryCard
|
|
icon={Play}
|
|
label="Last run"
|
|
value={lastRun ? lastRun.status.replaceAll("_", " ") : "No runs"}
|
|
hint={lastRun ? timeAgo(lastRun.triggeredAt) : "Trigger a run"}
|
|
to={() => navigateToSection("runs")}
|
|
ariaLabel={lastRun ? `Last run ${lastRun.status}. Open runs.` : "No runs. Open runs."}
|
|
/>
|
|
</div>
|
|
|
|
{/* Recent activity */}
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
Recent activity
|
|
</p>
|
|
{recentActivity.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">No activity yet.</p>
|
|
) : (
|
|
<div className="divide-y divide-border/60">
|
|
{recentActivity.map((event) => (
|
|
<div key={event.id} className="flex items-center gap-2 py-1.5 text-xs">
|
|
<Badge variant="outline" className="shrink-0 font-mono">
|
|
{event.action}
|
|
</Badge>
|
|
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
|
{event.details && Object.keys(event.details).length > 0
|
|
? Object.keys(event.details).slice(0, 3).join(" · ")
|
|
: ""}
|
|
</span>
|
|
<span className="shrink-0 text-muted-foreground/60">{timeAgo(event.createdAt)}</span>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={() => navigateToSection("activity")}
|
|
className="flex items-center gap-1 pt-2 text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
View all activity <ArrowRight className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SummaryCard({
|
|
icon: Icon,
|
|
label,
|
|
value,
|
|
hint,
|
|
to,
|
|
ariaLabel,
|
|
}: {
|
|
icon: typeof Clock3;
|
|
label: string;
|
|
value: string;
|
|
hint: string;
|
|
to: () => void;
|
|
ariaLabel: string;
|
|
}) {
|
|
return (
|
|
<button type="button" onClick={to} aria-label={ariaLabel} className="text-left">
|
|
<Card className="gap-2 p-4 transition-colors hover:border-border hover:bg-accent/30">
|
|
<CardContent className="space-y-1 p-0">
|
|
<div className="flex items-center gap-1.5 text-xs uppercase tracking-wide text-muted-foreground">
|
|
<Icon className="h-3.5 w-3.5" />
|
|
{label}
|
|
<ArrowRight className="ml-auto h-3.5 w-3.5 text-muted-foreground/60" />
|
|
</div>
|
|
<p className="text-lg font-semibold">{value}</p>
|
|
<p className="text-xs text-muted-foreground">{hint}</p>
|
|
</CardContent>
|
|
</Card>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function TriggersSection() {
|
|
const ctx = useRoutineDetail();
|
|
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(
|
|
() => 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}>
|
|
{kind}
|
|
</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}
|
|
|
|
{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 trigger"
|
|
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 function VariablesSection() {
|
|
const ctx = useRoutineDetail();
|
|
const { editDraft, setEditDraft, navigateToSection } = ctx;
|
|
const hasVariables = editDraft.variables.length > 0;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-3 rounded-md border border-border bg-muted/20 px-4 py-3 text-xs">
|
|
<span className="flex-1 text-muted-foreground">
|
|
Variables are auto-detected from <code className="font-mono">{"{{placeholders}}"}</code> in
|
|
the title & instructions. The variable name is read-only — rename by editing the
|
|
placeholder.
|
|
</span>
|
|
<Button variant="secondary" size="sm" onClick={() => navigateToSection("overview")}>
|
|
<Edit3 className="mr-1.5 h-3.5 w-3.5" />
|
|
Edit instructions
|
|
</Button>
|
|
</div>
|
|
|
|
{hasVariables ? (
|
|
<RoutineVariablesEditor
|
|
title={editDraft.title}
|
|
description={editDraft.description}
|
|
value={editDraft.variables}
|
|
onChange={(variables) => setEditDraft((current) => ({ ...current, variables }))}
|
|
/>
|
|
) : (
|
|
<EmptyState
|
|
icon={Braces}
|
|
message="No variables yet. Add a {{placeholder}} in the title or instructions to create one."
|
|
action="Edit instructions"
|
|
onAction={() => navigateToSection("overview")}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SecretsSection() {
|
|
const ctx = useRoutineDetail();
|
|
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.
|
|
const recentlyUsedSecrets = useMemo(
|
|
() =>
|
|
[...availableSecrets]
|
|
.filter((secret) => secret.status === "active")
|
|
.sort((a, b) => {
|
|
const refDelta = (b.referenceCount ?? 0) - (a.referenceCount ?? 0);
|
|
if (refDelta !== 0) return refDelta;
|
|
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
|
})
|
|
.slice(0, 8),
|
|
[availableSecrets],
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="rounded-md border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
|
|
Routine secrets apply to every task this routine creates. They override matching keys in
|
|
project and agent env. <span className="font-mono">PAPERCLIP_*</span> names are reserved.
|
|
</div>
|
|
|
|
|
|
<EnvironmentVariablesEditor
|
|
value={(editDraft.env ?? {}) as Record<string, EnvBinding>}
|
|
secrets={availableSecrets}
|
|
recentlyUsedSecrets={recentlyUsedSecrets}
|
|
onCreateSecret={async (name, value) => createSecret.mutateAsync({ name, value })}
|
|
onChange={(env) => setEditDraft((current) => ({ ...current, env: env ?? null }))}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DeliverySection() {
|
|
const ctx = useRoutineDetail();
|
|
const { editDraft, setEditDraft, routine } = ctx;
|
|
|
|
// The activity gate only affects schedule ticks (webhook/manual/API fires are
|
|
// themselves activity and always run), so the control is only meaningful for
|
|
// routines that have a schedule trigger. Disable — rather than hide — it
|
|
// elsewhere so the capability stays discoverable.
|
|
const hasScheduleTrigger = routine.triggers.some((trigger) => trigger.kind === "schedule");
|
|
const gateEnabled = editDraft.activityGatePolicy === "require_external_activity";
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="space-y-3">
|
|
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">
|
|
Concurrency
|
|
</p>
|
|
<RadioCardGroup
|
|
ariaLabel="Concurrency policy"
|
|
value={editDraft.concurrencyPolicy}
|
|
onValueChange={(concurrencyPolicy) =>
|
|
setEditDraft((current) => ({ ...current, concurrencyPolicy }))
|
|
}
|
|
options={concurrencyPolicyOptions}
|
|
/>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">
|
|
Catch-up
|
|
</p>
|
|
<RadioCardGroup
|
|
ariaLabel="Catch-up policy"
|
|
value={editDraft.catchUpPolicy}
|
|
onValueChange={(catchUpPolicy) =>
|
|
setEditDraft((current) => ({ ...current, catchUpPolicy }))
|
|
}
|
|
options={catchUpPolicyOptions}
|
|
/>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">
|
|
Advanced run policy
|
|
</p>
|
|
<RadioCardGroup
|
|
ariaLabel="Advanced run policy"
|
|
value={editDraft.activityGatePolicy}
|
|
onValueChange={(activityGatePolicy) =>
|
|
setEditDraft((current) => ({ ...current, activityGatePolicy }))
|
|
}
|
|
options={activityGatePolicyOptions}
|
|
disabled={!hasScheduleTrigger}
|
|
/>
|
|
{!hasScheduleTrigger ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
Add a schedule trigger to gate runs on activity. Webhook, manual, and API fires always
|
|
run.
|
|
</p>
|
|
) : gateEnabled ? (
|
|
<div className="space-y-2 rounded-lg border border-border p-3">
|
|
<Label className="text-xs font-medium">Activity scope</Label>
|
|
<RadioCardGroup
|
|
ariaLabel="Activity gate scope"
|
|
value={editDraft.activityGateScope}
|
|
onValueChange={(activityGateScope) =>
|
|
setEditDraft((current) => ({ ...current, activityGateScope }))
|
|
}
|
|
options={activityGateScopeOptions}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<NextFiresPreview
|
|
triggers={routine.triggers}
|
|
concurrencyPolicy={editDraft.concurrencyPolicy}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const dispositionToneClass: Record<string, string> = {
|
|
queued: "text-emerald-600 dark:text-emerald-400",
|
|
coalesced: "text-amber-600 dark:text-amber-400",
|
|
skipped: "text-muted-foreground",
|
|
};
|
|
|
|
/**
|
|
* "Next 5 fires" preview (§3.5) — the strongest "what does this policy mean?"
|
|
* surface. Picks the soonest-firing schedule trigger, computes its next fires
|
|
* client-side, and annotates each with how the chosen concurrency policy would
|
|
* treat it.
|
|
*/
|
|
function NextFiresPreview({
|
|
triggers,
|
|
concurrencyPolicy,
|
|
}: {
|
|
triggers: RoutineDetailType["triggers"];
|
|
concurrencyPolicy: string;
|
|
}) {
|
|
const preview = useMemo(() => {
|
|
const schedule = triggers
|
|
.filter((trigger) => trigger.kind === "schedule" && trigger.enabled && trigger.cronExpression)
|
|
.map((trigger) => {
|
|
const fires = nextCronFires(trigger.cronExpression, 5, {
|
|
timeZone: trigger.timezone ?? "UTC",
|
|
});
|
|
return { trigger, fires };
|
|
})
|
|
.filter((entry) => entry.fires.length > 0)
|
|
.sort((a, b) => a.fires[0]!.getTime() - b.fires[0]!.getTime())[0];
|
|
if (!schedule) return null;
|
|
return {
|
|
timeZone: schedule.trigger.timezone ?? "UTC",
|
|
entries: previewFirePolicies(schedule.fires, concurrencyPolicy),
|
|
};
|
|
}, [triggers, concurrencyPolicy]);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<p className="text-xs font-medium uppercase tracking-(--tracking-caps) text-muted-foreground">
|
|
Next 5 fires
|
|
</p>
|
|
{preview ? (
|
|
<>
|
|
<div className="space-y-1.5 rounded-lg border border-border p-3 font-mono text-xs">
|
|
{preview.entries.map((entry, index) => (
|
|
<div key={index} className="flex items-center gap-2">
|
|
<span className="text-muted-foreground/40">·</span>
|
|
<span className="tabular-nums">{formatFireTime(entry.at, preview.timeZone)}</span>
|
|
<ArrowRight className="h-3 w-3 shrink-0 text-muted-foreground/50" />
|
|
<span className={cn("font-medium", dispositionToneClass[entry.disposition])}>
|
|
{entry.label}
|
|
</span>
|
|
{entry.note ? (
|
|
<span className="truncate text-muted-foreground/60">({entry.note})</span>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="text-(length:--text-micro) text-muted-foreground/60">
|
|
Preview assumes the previous run is still in flight when the next fires. Times shown in{" "}
|
|
{preview.timeZone}.
|
|
</p>
|
|
</>
|
|
) : (
|
|
<p className="rounded-lg border border-dashed border-border p-3 text-xs text-muted-foreground">
|
|
No enabled schedule trigger to preview. Add a schedule in Triggers to see how this policy
|
|
treats upcoming fires.
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatFireTime(date: Date, timeZone: string): string {
|
|
try {
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
timeZone,
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hourCycle: "h23",
|
|
})
|
|
.format(date)
|
|
.replace(",", "");
|
|
} catch {
|
|
return date.toISOString();
|
|
}
|
|
}
|