Skip to content

Commit 840bd54

Browse files
committed
feat(automations,artifacts): finish Composio event-triggers + delivery; add artifacts history
Automations (completes the marquee feature): - Event triggers: register/enable a Composio trigger on event-automation create (composio.triggers.create → persisted triggerId), delete on automation delete. - Webhook routing: trigger events (metadata.trigger_id) match running event-automations and enqueue the idempotent outbox (event:<automationId>:<logId>), with a bounded normalized context (never raw payloads). - Executor injects the trigger context into the run prompt. - Delivery: on run completion, deliver the final assistant summary to configured channels via composio.tools.execute (SLACK_SEND_MESSAGE / NOTION_CREATE_NOTION_PAGE / GMAIL_SEND_EMAIL), recorded per-channel, fail-soft. - Frontend: delivery-channel input + Composio trigger-slug field on the New dialog. Artifacts (#13): listGeneratedOutputsByUser + agent-worker GET /v1/outputs (signed download URLs) + gateway proxy + /artifacts history page + sidebar link.
1 parent 88bfccc commit 840bd54

17 files changed

Lines changed: 572 additions & 20 deletions

File tree

apps/agent-worker/src/index.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
createThreadMessage,
55
findGeneratedOutputOwner,
66
getThread,
7+
listGeneratedOutputsByUser,
78
withUserContext,
89
} from "@cheatcode/db";
910
import { AgentWorkerEnvSchema, type WorkerSecret } from "@cheatcode/env";
@@ -17,6 +18,7 @@ import {
1718
} from "@cheatcode/observability";
1819
import {
1920
ApprovalDecisionRequestSchema,
21+
GeneratedOutputsResponseSchema,
2022
SandboxConsoleQuerySchema,
2123
SandboxConsoleSnapshotSchema,
2224
SandboxFileKeySchema,
@@ -57,6 +59,7 @@ import {
5759
verifyAgentMaintenanceRequest,
5860
} from "./internal-maintenance";
5961
import {
62+
createSignedOutputDownloadUrl,
6063
OutputDownloadQuerySchema,
6164
OutputIdSchema,
6265
verifySignedOutputDownload,
@@ -245,6 +248,33 @@ agentApp.post("/internal/users/:userId/delete-state", async (c) => {
245248
);
246249
});
247250

251+
agentApp.get("/v1/outputs", async (c) => {
252+
const userId = readGatewayUserId(c.req.raw.headers);
253+
const { db, close } = createDb(c.env.HYPERDRIVE);
254+
try {
255+
const records = await listGeneratedOutputsByUser(db, UserId(userId), new Date());
256+
const outputs = await Promise.all(
257+
records.map(async (record) => ({
258+
id: record.id,
259+
kind: record.kind,
260+
filename: record.filename,
261+
mimeType: record.mimeType,
262+
sizeBytes: record.sizeBytes,
263+
createdAt: record.createdAt.toISOString(),
264+
expiresAt: record.expiresAt ? record.expiresAt.toISOString() : null,
265+
downloadUrl: await createSignedOutputDownloadUrl({
266+
baseUrl: c.env.OUTPUT_DOWNLOAD_BASE_URL,
267+
outputId: record.id,
268+
secret: c.env.OUTPUT_DOWNLOAD_SIGNING_SECRET,
269+
}),
270+
})),
271+
);
272+
return Response.json(GeneratedOutputsResponseSchema.parse({ outputs }));
273+
} finally {
274+
c.executionCtx.waitUntil(close());
275+
}
276+
});
277+
248278
agentApp.get("/v1/outputs/:outputId/download", async (c) => {
249279
const parsedOutputId = OutputIdSchema.safeParse(c.req.param("outputId"));
250280
if (!parsedOutputId.success) {
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { resolveWorkerSecret, type WorkerSecret } from "@cheatcode/env";
2+
import { createLogger } from "@cheatcode/observability";
3+
import { Composio } from "@composio/core";
4+
5+
export interface AutomationTriggerEnv {
6+
COMPOSIO_API_KEY?: WorkerSecret;
7+
}
8+
9+
async function resolveApiKey(env: AutomationTriggerEnv): Promise<string | null> {
10+
try {
11+
return (await resolveWorkerSecret(env.COMPOSIO_API_KEY)) || null;
12+
} catch {
13+
return null;
14+
}
15+
}
16+
17+
function client(apiKey: string): Composio {
18+
return new Composio({ allowTracking: false, apiKey, baseURL: "https://backend.composio.dev" });
19+
}
20+
21+
/** Register + enable a Composio trigger for a user. Returns the stable triggerId to
22+
* persist, or null on failure (fail-soft — the automation is still created, just inert
23+
* until a trigger is wired). `triggerSlug` is the Composio trigger type (e.g. GMAIL_NEW_GMAIL_MESSAGE). */
24+
export async function registerAutomationTrigger(
25+
env: AutomationTriggerEnv,
26+
userId: string,
27+
triggerSlug: string,
28+
): Promise<string | null> {
29+
const apiKey = await resolveApiKey(env);
30+
if (!apiKey) {
31+
return null;
32+
}
33+
try {
34+
const result = await client(apiKey).triggers.create(userId, triggerSlug);
35+
return result.triggerId;
36+
} catch (error) {
37+
createLogger().warn("automation_trigger_register_failed", {
38+
message: error instanceof Error ? error.message : "unknown",
39+
triggerSlug,
40+
});
41+
return null;
42+
}
43+
}
44+
45+
/** Pause (disable) / resume (enable) a registered trigger. Fail-soft. */
46+
export async function setAutomationTriggerState(
47+
env: AutomationTriggerEnv,
48+
triggerId: string,
49+
state: "enable" | "disable",
50+
): Promise<void> {
51+
const apiKey = await resolveApiKey(env);
52+
if (!apiKey) {
53+
return;
54+
}
55+
try {
56+
const composio = client(apiKey);
57+
if (state === "enable") {
58+
await composio.triggers.enable(triggerId);
59+
} else {
60+
await composio.triggers.disable(triggerId);
61+
}
62+
} catch (error) {
63+
createLogger().warn("automation_trigger_state_failed", {
64+
message: error instanceof Error ? error.message : "unknown",
65+
state,
66+
});
67+
}
68+
}
69+
70+
/** Permanently delete a registered trigger (on automation delete). Fail-soft. */
71+
export async function deleteAutomationTrigger(
72+
env: AutomationTriggerEnv,
73+
triggerId: string,
74+
): Promise<void> {
75+
const apiKey = await resolveApiKey(env);
76+
if (!apiKey) {
77+
return;
78+
}
79+
try {
80+
await client(apiKey).triggers.delete(triggerId);
81+
} catch (error) {
82+
createLogger().warn("automation_trigger_delete_failed", {
83+
message: error instanceof Error ? error.message : "unknown",
84+
});
85+
}
86+
}

apps/gateway-worker/src/automations-routes.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
import { Cron } from "croner";
2929
import { z } from "zod";
3030
import { requireVerifiedClerkEmail } from "./authenticate";
31+
import { deleteAutomationTrigger, registerAutomationTrigger } from "./automation-triggers";
3132
import type { GatewayEnv } from "./index";
3233
import { enforceActiveProjectLimit } from "./limits";
3334

@@ -157,6 +158,16 @@ export async function createAutomationRoute(
157158
});
158159
return automationToSummary(row);
159160
});
161+
// For event automations, register the Composio trigger (fail-soft) and persist its
162+
// triggerId so the webhook handler can route events to this automation.
163+
if (input.kind === "event" && input.triggerSlug) {
164+
const triggerId = await registerAutomationTrigger(env, userId, input.triggerSlug);
165+
if (triggerId) {
166+
await withUserContext(db, userId, (tx) =>
167+
updateAutomation(tx, userId, AutomationId(summary.id), { triggerId }),
168+
);
169+
}
170+
}
160171
return Response.json(AutomationSummarySchema.parse(summary), { status: 201 });
161172
} finally {
162173
ctx.waitUntil(close());
@@ -228,6 +239,9 @@ export async function deleteAutomationRoute(
228239
if (!row) {
229240
throw notFound("Automation not found");
230241
}
242+
if (row.triggerId) {
243+
await deleteAutomationTrigger(env, row.triggerId);
244+
}
231245
return new Response(null, { status: 204 });
232246
} finally {
233247
ctx.waitUntil(close());

apps/gateway-worker/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ export const gatewayRoutes = gatewayApp
283283
.post("/v1/user-events", async (c) => {
284284
return clientUserEventRoute(c, optionalTelemetryUser);
285285
})
286+
.get("/v1/outputs", (c) => forwardAgentRequest(c, "GET /v1/outputs"))
286287
.get("/v1/outputs/:outputId/download", (c) => c.env.AGENT.fetch(c.req.raw))
287288
// Public, unauthenticated featured-replay reads (replays plan §4). "featured"
288289
// MUST be chained before ":id" so it is not captured as a slug.

apps/web/next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
import "./.next/dev/types/routes.d.ts";
3+
import "./.next/types/routes.d.ts";
44

55
// NOTE: This file should not be edited
66
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"use client";
2+
3+
import type { GeneratedOutputSummary } from "@cheatcode/types";
4+
import { useAuth } from "@clerk/nextjs";
5+
import { useQuery } from "@tanstack/react-query";
6+
import { Download, FileText } from "@/components/ui/icons";
7+
import { listGeneratedOutputs } from "@/lib/api/outputs";
8+
9+
export default function ArtifactsPage() {
10+
return (
11+
<section className="chat-scrollbar min-w-0 flex-1 overflow-y-auto bg-white px-4 pt-12 pb-16 text-[#1b1b1b] sm:px-6 lg:px-10">
12+
<div className="mx-auto w-full max-w-[740px]">
13+
<h1 className="font-bold text-[30px] leading-9 tracking-[-0.01em]">Artifacts</h1>
14+
<p className="mt-2 text-[#707070] text-[15px]">
15+
Files your agents generated — slides, docs, spreadsheets, PDFs, and charts.
16+
</p>
17+
<ArtifactsList />
18+
</div>
19+
</section>
20+
);
21+
}
22+
23+
function ArtifactsList() {
24+
const { getToken, isSignedIn } = useAuth();
25+
const query = useQuery({
26+
enabled: Boolean(isSignedIn),
27+
queryFn: () => listGeneratedOutputs(getToken),
28+
queryKey: ["generated-outputs"],
29+
});
30+
31+
if (query.isLoading) {
32+
return <p className="mt-8 text-[#a0a0a0] text-[14px]">Loading…</p>;
33+
}
34+
if (query.isError) {
35+
return (
36+
<div className="mt-8 flex items-center gap-3">
37+
<p className="text-[#707070] text-[14px]">Couldn’t load your artifacts.</p>
38+
<button
39+
className="rounded-full border border-[#e5e5e5] px-4 py-1.5 font-medium text-[13px] hover:bg-[#f7f7f7]"
40+
onClick={() => void query.refetch()}
41+
type="button"
42+
>
43+
Retry
44+
</button>
45+
</div>
46+
);
47+
}
48+
const outputs = query.data ?? [];
49+
if (outputs.length === 0) {
50+
return (
51+
<div className="mt-8 rounded-2xl border border-[#f0f0f0] bg-[#fafafa] py-12 text-center">
52+
<p className="font-medium text-[#1b1b1b] text-[15px]">No artifacts yet.</p>
53+
<p className="mt-1 text-[#707070] text-[13px]">
54+
Ask an agent to build a deck, doc, or spreadsheet and it’ll show up here.
55+
</p>
56+
</div>
57+
);
58+
}
59+
return (
60+
<ul className="mt-6 flex flex-col gap-2">
61+
{outputs.map((output) => (
62+
<ArtifactRow key={output.id} output={output} />
63+
))}
64+
</ul>
65+
);
66+
}
67+
68+
function formatSize(bytes: number): string {
69+
if (bytes < 1024) {
70+
return `${bytes} B`;
71+
}
72+
if (bytes < 1024 * 1024) {
73+
return `${(bytes / 1024).toFixed(0)} KB`;
74+
}
75+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
76+
}
77+
78+
function ArtifactRow({ output }: { output: GeneratedOutputSummary }) {
79+
return (
80+
<li className="flex items-center gap-3 rounded-2xl border border-[#f0f0f0] bg-white px-4 py-3">
81+
<FileText aria-hidden="true" className="h-5 w-5 shrink-0 text-[#a0a0a0]" />
82+
<div className="min-w-0 flex-1">
83+
<p className="truncate font-medium text-[#1b1b1b] text-[14px]">{output.filename}</p>
84+
<p className="text-[#a0a0a0] text-[12px]">
85+
{output.kind} · {formatSize(output.sizeBytes)} ·{" "}
86+
{new Date(output.createdAt).toLocaleDateString()}
87+
</p>
88+
</div>
89+
<a
90+
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full bg-[#1b1b1b] px-3 font-medium text-[13px] text-white transition-colors hover:bg-black"
91+
download={output.filename}
92+
href={output.downloadUrl}
93+
rel="noopener noreferrer"
94+
target="_blank"
95+
>
96+
<Download aria-hidden="true" className="h-3.5 w-3.5" />
97+
Download
98+
</a>
99+
</li>
100+
);
101+
}

apps/web/src/app/(app)/automations/page.tsx

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,8 @@ interface NewAutomationForm {
306306
triggerToolkit: string;
307307
triggerSlug: string;
308308
prompt: string;
309+
deliveryType: "none" | "slack" | "notion" | "email";
310+
deliveryTarget: string;
309311
}
310312

311313
function NewAutomationDialog({ onClose }: { onClose: () => void }) {
@@ -318,6 +320,8 @@ function NewAutomationDialog({ onClose }: { onClose: () => void }) {
318320
triggerToolkit: "",
319321
triggerSlug: "",
320322
prompt: "",
323+
deliveryType: "none",
324+
deliveryTarget: "",
321325
});
322326

323327
const set = <K extends keyof NewAutomationForm>(key: K, value: NewAutomationForm[K]) =>
@@ -329,7 +333,10 @@ function NewAutomationDialog({ onClose }: { onClose: () => void }) {
329333
name: form.name.trim(),
330334
kind: form.kind,
331335
prompt: form.prompt.trim(),
332-
deliveryChannels: [],
336+
deliveryChannels:
337+
form.deliveryType === "none" || form.deliveryTarget.trim().length === 0
338+
? []
339+
: [{ type: form.deliveryType, target: form.deliveryTarget.trim() }],
333340
...(form.kind === "scheduled"
334341
? { schedule: form.schedule.trim() }
335342
: { triggerToolkit: form.triggerToolkit.trim(), triggerSlug: form.triggerSlug.trim() }),
@@ -416,11 +423,11 @@ function NewAutomationDialog({ onClose }: { onClose: () => void }) {
416423
value={form.triggerToolkit}
417424
/>
418425
</Field>
419-
<Field label="Trigger">
426+
<Field hint="Composio trigger slug — connect the app first." label="Trigger">
420427
<input
421428
className={inputClass}
422429
onChange={(event) => set("triggerSlug", event.target.value)}
423-
placeholder="new_email"
430+
placeholder="GMAIL_NEW_GMAIL_MESSAGE"
424431
value={form.triggerSlug}
425432
/>
426433
</Field>
@@ -436,6 +443,42 @@ function NewAutomationDialog({ onClose }: { onClose: () => void }) {
436443
/>
437444
</Field>
438445

446+
<Field
447+
hint={
448+
form.deliveryType === "slack"
449+
? "Slack channel ID (e.g. C0ABC12345)"
450+
: form.deliveryType === "notion"
451+
? "Notion parent page ID (UUID)"
452+
: form.deliveryType === "email"
453+
? "Recipient email address"
454+
: "Optional — also deliver the result to a connected app."
455+
}
456+
label="Deliver results to"
457+
>
458+
<div className="flex gap-2">
459+
<select
460+
className={cn(inputClass, "max-w-[140px]")}
461+
onChange={(event) =>
462+
set("deliveryType", event.target.value as NewAutomationForm["deliveryType"])
463+
}
464+
value={form.deliveryType}
465+
>
466+
<option value="none">No delivery</option>
467+
<option value="slack">Slack</option>
468+
<option value="notion">Notion</option>
469+
<option value="email">Email</option>
470+
</select>
471+
{form.deliveryType === "none" ? null : (
472+
<input
473+
className={inputClass}
474+
onChange={(event) => set("deliveryTarget", event.target.value)}
475+
placeholder="target"
476+
value={form.deliveryTarget}
477+
/>
478+
)}
479+
</div>
480+
</Field>
481+
439482
<div className="flex justify-end gap-2">
440483
<button
441484
className="rounded-full border border-[#ececec] px-4 py-1.5 font-medium text-[13px] hover:bg-[#f7f7f7]"

0 commit comments

Comments
 (0)