From 2ee2f3e6f875f1b8ac1df99526e367e1f6d17112 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 4 Aug 2026 23:45:01 +0000 Subject: [PATCH 01/19] feat(automations): add manual trigger type excluded from scheduling --- .changeset/calm-ravens-run.md | 5 ++ packages/core/src/automations/service.spec.ts | 32 ++++++++ packages/core/src/automations/service.ts | 40 +++++++--- packages/core/src/jobs/frontmatter.spec.ts | 30 +++++++ packages/core/src/jobs/frontmatter.ts | 29 +++++-- packages/core/src/jobs/scheduler.spec.ts | 78 +++++++++++++++++++ packages/core/src/jobs/scheduler.ts | 6 ++ packages/core/src/triggers/actions.spec.ts | 30 +++++++ packages/core/src/triggers/actions.ts | 71 ++++++++++++----- .../core/src/triggers/actions/actions.spec.ts | 34 ++++++++ .../src/triggers/actions/list-automations.ts | 24 +++--- packages/core/src/triggers/dispatcher.spec.ts | 33 ++++++++ packages/core/src/triggers/routes.spec.ts | 36 +++++++++ packages/core/src/triggers/routes.ts | 33 +++++--- packages/core/src/triggers/types.ts | 5 +- 15 files changed, 428 insertions(+), 58 deletions(-) create mode 100644 .changeset/calm-ravens-run.md diff --git a/.changeset/calm-ravens-run.md b/.changeset/calm-ravens-run.md new file mode 100644 index 0000000000..3e65eaa99a --- /dev/null +++ b/.changeset/calm-ravens-run.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": minor +--- + +Add canonical manual automations that run only through run-now and are excluded from scheduled and event dispatch. diff --git a/packages/core/src/automations/service.spec.ts b/packages/core/src/automations/service.spec.ts index 9dc0eb8e9a..f769058805 100644 --- a/packages/core/src/automations/service.spec.ts +++ b/packages/core/src/automations/service.spec.ts @@ -117,6 +117,38 @@ describe("automation domain service", () => { ); }); + it("creates a manual automation without automatic trigger fields", async () => { + resourceGetByPathMock.mockResolvedValueOnce(null); + + const definition = await defineAutomation(actor, { + name: "on-demand-report", + scope: "organization", + triggerType: "manual", + body: "Build the report.", + schedule: "0 8 * * *", + timezone: "Not/A-Timezone", + event: "mail.received", + condition: "only for urgent messages", + }); + + expect(definition.meta).toMatchObject({ + schedule: "", + enabled: true, + triggerType: "manual", + createdBy: "alice@example.com", + orgId: "org-1", + runAs: "creator", + }); + expect(definition.meta).not.toHaveProperty("timezone"); + expect(definition.meta).not.toHaveProperty("event"); + expect(definition.meta).not.toHaveProperty("condition"); + expect(definition.meta).not.toHaveProperty("nextRun"); + + const content = resourcePutMock.mock.calls[0]?.[2] as string; + expect(content).toContain("triggerType: manual"); + expect(content).not.toMatch(/^(event|condition|nextRun|timezone):/m); + }); + it("creates an organization event automation owned by the org but run as its creator", async () => { resourceGetByPathMock .mockResolvedValueOnce(null) diff --git a/packages/core/src/automations/service.ts b/packages/core/src/automations/service.ts index 932cf03696..cb69800385 100644 --- a/packages/core/src/automations/service.ts +++ b/packages/core/src/automations/service.ts @@ -30,7 +30,7 @@ export interface AutomationDefinition { name: string; scope: AutomationScope; meta: JobFrontmatter & { - triggerType: "schedule" | "event"; + triggerType: "schedule" | "event" | "manual"; mode: "agentic" | "deterministic"; }; body: string; @@ -48,7 +48,7 @@ export interface AutomationDelivery { export interface DefineAutomationInput { name: string; scope: AutomationScope; - triggerType: "schedule" | "event"; + triggerType: "schedule" | "event" | "manual"; body: string; schedule?: string; timezone?: string; @@ -313,7 +313,11 @@ export async function defineAutomation( throw httpError("event is required for event-triggered automations.", 400); } - if (input.timezone && !isValidTimezone(input.timezone)) { + if ( + input.triggerType === "schedule" && + input.timezone && + !isValidTimezone(input.timezone) + ) { throw httpError(`Unknown timezone "${input.timezone}".`, 400); } // Resolve now and persist it: a schedule whose zone is implicit means @@ -326,21 +330,24 @@ export async function defineAutomation( const mcpTools = normalizeJobMcpTools(input.mcpTools); const meta: JobFrontmatter = { schedule: input.triggerType === "schedule" ? schedule : "", - timezone, + ...(timezone ? { timezone } : {}), enabled: true, triggerType: input.triggerType, - event: input.triggerType === "event" ? event : undefined, - condition: input.condition?.trim() || undefined, + ...(input.triggerType === "event" ? { event } : {}), + ...(input.triggerType !== "manual" && input.condition?.trim() + ? { condition: input.condition.trim() } + : {}), mode: "agentic", domain: input.domain?.trim() || undefined, delegatedPolicyId: input.delegatedPolicyId?.trim() || undefined, createdBy: actor.userEmail, orgId: input.scope === "organization" ? actor.orgId! : undefined, runAs: "creator", - nextRun: - input.triggerType === "schedule" - ? nextOccurrence(schedule, undefined, timezone).toISOString() - : undefined, + ...(input.triggerType === "schedule" + ? { + nextRun: nextOccurrence(schedule, undefined, timezone).toISOString(), + } + : {}), model: input.model?.trim() || undefined, mcpTools: mcpTools?.length ? mcpTools : undefined, originScopeId: input.delivery?.originScopeId, @@ -374,7 +381,10 @@ export async function updateAutomation( const { meta } = definition; if (input.schedule !== undefined) { if (meta.triggerType !== "schedule") { - throw httpError("Event automations do not have a cron schedule.", 400); + throw httpError( + `${meta.triggerType === "manual" ? "Manual" : "Event"} automations do not have a cron schedule.`, + 400, + ); } if (!isValidCron(input.schedule)) { throw httpError(`Invalid cron expression "${input.schedule}".`, 400); @@ -386,7 +396,10 @@ export async function updateAutomation( throw httpError(`Unknown timezone "${input.timezone}".`, 400); } if (meta.triggerType !== "schedule") { - throw httpError("Event automations do not have a timezone.", 400); + throw httpError( + `${meta.triggerType === "manual" ? "Manual" : "Event"} automations do not have a timezone.`, + 400, + ); } meta.timezone = input.timezone; } @@ -412,6 +425,9 @@ export async function updateAutomation( } } if (input.condition !== undefined) { + if (meta.triggerType === "manual") { + throw httpError("Manual automations do not have a condition.", 400); + } meta.condition = input.condition?.trim() || undefined; } if (input.delegatedPolicyId !== undefined) { diff --git a/packages/core/src/jobs/frontmatter.spec.ts b/packages/core/src/jobs/frontmatter.spec.ts index bfe0578a87..6878383ae4 100644 --- a/packages/core/src/jobs/frontmatter.spec.ts +++ b/packages/core/src/jobs/frontmatter.spec.ts @@ -46,6 +46,36 @@ describe("job resource frontmatter", () => { }); }); + it("round-trips a canonical manual automation without automatic trigger fields", () => { + const content = buildJobResourceContent( + { + schedule: "", + enabled: true, + triggerType: "manual", + mode: "agentic", + createdBy: "alice@example.com", + runAs: "creator", + }, + "Run only when requested.", + ); + + expect(content).toContain("triggerType: manual"); + expect(content).not.toMatch(/^(event|condition|nextRun):/m); + expect(parseJobResource(content)).toMatchObject({ + meta: { + schedule: "", + enabled: true, + triggerType: "manual", + }, + body: "Run only when requested.", + classification: { + kind: "automation", + hasExplicitTriggerType: true, + triggerType: "manual", + }, + }); + }); + it("distinguishes legacy jobs from explicit scheduled automations", () => { const legacy = `--- schedule: "0 9 * * *" diff --git a/packages/core/src/jobs/frontmatter.ts b/packages/core/src/jobs/frontmatter.ts index 404f789bc2..c991630255 100644 --- a/packages/core/src/jobs/frontmatter.ts +++ b/packages/core/src/jobs/frontmatter.ts @@ -1,5 +1,5 @@ export type JobLastStatus = "success" | "error" | "running" | "skipped"; -export type JobTriggerType = "schedule" | "event"; +export type JobTriggerType = "schedule" | "event" | "manual"; export type JobExecutionMode = "agentic" | "deterministic"; /** @@ -196,7 +196,8 @@ function parseKnownField( case "triggerType": // The field's presence is the durable legacy-job/automation boundary. // Preserve that marker even if an old writer stored an invalid value. - meta.triggerType = value === "event" ? "event" : "schedule"; + meta.triggerType = + value === "event" || value === "manual" ? value : "schedule"; break; case "event": meta.event = value; @@ -251,6 +252,14 @@ export function parseJobResource(content: string): ParsedJobResource { ); } + if (meta.triggerType === "manual") { + meta.schedule = ""; + delete meta.timezone; + delete meta.event; + delete meta.condition; + delete meta.nextRun; + } + return { meta, body: match[2].trim(), @@ -293,12 +302,14 @@ export function buildJobResourceContent( const lines = [ "---", - `schedule: ${JSON.stringify(meta.schedule)}`, + `schedule: ${JSON.stringify(meta.triggerType === "manual" ? "" : meta.schedule)}`, `enabled: ${meta.enabled}`, ]; if (meta.triggerType) lines.push(`triggerType: ${meta.triggerType}`); - pushString(lines, "event", meta.event); - pushString(lines, "condition", meta.condition); + if (meta.triggerType !== "manual") { + pushString(lines, "event", meta.event); + pushString(lines, "condition", meta.condition); + } if (meta.mode) lines.push(`mode: ${meta.mode}`); pushString(lines, "domain", meta.domain); pushString(lines, "delegatedPolicyId", meta.delegatedPolicyId); @@ -308,12 +319,16 @@ export function buildJobResourceContent( pushString(lines, "createdBy", meta.createdBy, false); pushString(lines, "orgId", meta.orgId); if (meta.runAs) lines.push(`runAs: ${meta.runAs}`); - pushString(lines, "timezone", meta.timezone); + if (meta.triggerType !== "manual") { + pushString(lines, "timezone", meta.timezone); + } pushString(lines, "lastRun", meta.lastRun); pushString(lines, "lastCheck", meta.lastCheck); if (meta.lastStatus) lines.push(`lastStatus: ${meta.lastStatus}`); pushString(lines, "lastError", meta.lastError); - pushString(lines, "nextRun", meta.nextRun); + if (meta.triggerType !== "manual") { + pushString(lines, "nextRun", meta.nextRun); + } pushString(lines, "originScopeId", meta.originScopeId); pushString(lines, "deliveryPlatform", meta.deliveryPlatform); pushString(lines, "deliveryDestination", meta.deliveryDestination); diff --git a/packages/core/src/jobs/scheduler.spec.ts b/packages/core/src/jobs/scheduler.spec.ts index 969b001678..476ce68fd6 100644 --- a/packages/core/src/jobs/scheduler.spec.ts +++ b/packages/core/src/jobs/scheduler.spec.ts @@ -252,6 +252,84 @@ Summarize the inbox.`, expect(runAgentLoopMock).not.toHaveBeenCalled(); }); + it("executes a manual automation through run-now", async () => { + const manualResource = { + id: "manual-run-now", + owner: "alice+jobs@agent-native.test", + path: "jobs/on-demand-report.md", + content: `--- +schedule: "" +enabled: true +triggerType: manual +mode: agentic +createdBy: alice+jobs@agent-native.test +--- + +Build the report.`, + }; + resourceListAllOwnersMock.mockResolvedValue([manualResource]); + resourceGetByPathMock.mockResolvedValueOnce(manualResource); + + const result = await runJobNow(manualResource.owner, "on-demand-report", { + getActions: () => ({}), + getSystemPrompt: async () => "system", + engine: testEngine, + model: "test-model", + }); + + expect(result).toMatchObject({ status: "success" }); + expect(createThreadMock).toHaveBeenCalledWith( + manualResource.owner, + expect.objectContaining({ + title: expect.stringContaining("Automation:"), + }), + ); + expect(runAgentLoopMock).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [ + expect.objectContaining({ + content: [ + expect.objectContaining({ + text: expect.stringContaining("[Manual Automation Run:"), + }), + ], + }), + ], + }), + ); + }); + + it("never acquires a manual automation even with stale schedule metadata", async () => { + resourceListAllOwnersMock.mockResolvedValueOnce([ + { + id: "manual-automation", + owner: "alice+jobs@agent-native.test", + path: "jobs/on-demand-report.md", + content: `--- +schedule: "* * * * *" +nextRun: "1970-01-01T00:00:00.000Z" +enabled: true +triggerType: manual +mode: agentic +createdBy: alice+jobs@agent-native.test +--- + +Build the report.`, + }, + ]); + + await processRecurringJobs({ + getActions: () => ({}), + getSystemPrompt: async () => "system", + engine: testEngine, + model: "test-model", + }); + + expect(resourcePutIfCurrentMock).not.toHaveBeenCalled(); + expect(createThreadMock).not.toHaveBeenCalled(); + expect(runAgentLoopMock).not.toHaveBeenCalled(); + }); + it("seeds a scheduled automation without dropping its automation metadata", async () => { resourceListAllOwnersMock.mockResolvedValueOnce([ { diff --git a/packages/core/src/jobs/scheduler.ts b/packages/core/src/jobs/scheduler.ts index 1ddf0248a7..cc9ea4b3b4 100644 --- a/packages/core/src/jobs/scheduler.ts +++ b/packages/core/src/jobs/scheduler.ts @@ -138,6 +138,12 @@ export async function processRecurringJobs(deps: SchedulerDeps): Promise { const { meta, body } = parseJobFrontmatter(resource.content); + // Legacy jobs have no explicit trigger type. Explicit automations are + // acquired here only when they positively declare a schedule trigger. + const isScheduleTrigger = + meta.triggerType === undefined || meta.triggerType === "schedule"; + if (!isScheduleTrigger) continue; + // Skip disabled or missing schedule if (!meta.enabled || !meta.schedule) continue; if (!isValidCron(meta.schedule)) continue; diff --git a/packages/core/src/triggers/actions.spec.ts b/packages/core/src/triggers/actions.spec.ts index b3633e0d4b..668fbde741 100644 --- a/packages/core/src/triggers/actions.spec.ts +++ b/packages/core/src/triggers/actions.spec.ts @@ -275,6 +275,30 @@ Updated body.`, expect(resourcePutMock).not.toHaveBeenCalled(); }); + it("defines a manual automation with no automatic trigger fields", async () => { + const result = await tool().run({ + action: "define", + name: "on-demand-report", + trigger_type: "manual", + body: "Build the report.", + schedule: "0 9 * * *", + event: "test.event.fired", + condition: "only when urgent", + }); + + expect(JSON.parse(result)).toMatchObject({ + created: true, + triggerType: "manual", + event: null, + schedule: null, + timezone: null, + nextRun: null, + }); + const content = resourcePutMock.mock.calls[0]?.[2] as string; + expect(content).toContain("triggerType: manual"); + expect(content).not.toMatch(/^(event|condition|nextRun|timezone):/m); + }); + it("seeds the next run for scheduled automations", async () => { await tool().run({ action: "define", @@ -289,6 +313,12 @@ Updated body.`, expect(content).toMatch(/nextRun: "/); }); + it("accepts manual as a canonical trigger type in the tool schema", () => { + expect(tool().tool.parameters.properties.trigger_type.enum).toContain( + "manual", + ); + }); + it("leaves legacy scheduled jobs on the compatibility tool", async () => { resourceGetByPathMock.mockResolvedValueOnce({ id: "legacy-job", diff --git a/packages/core/src/triggers/actions.ts b/packages/core/src/triggers/actions.ts index 8136d8c9b0..98f22f53bd 100644 --- a/packages/core/src/triggers/actions.ts +++ b/packages/core/src/triggers/actions.ts @@ -71,13 +71,18 @@ async function handleList( name, scope, triggerType: meta.triggerType, - event: meta.event ?? null, - schedule: meta.schedule || null, - timezone: meta.timezone ? effectiveTimezone(meta.timezone) : null, - scheduleDescription: meta.schedule - ? describeCron(meta.schedule, effectiveTimezone(meta.timezone)) - : null, - condition: meta.condition ?? null, + event: meta.triggerType === "event" ? (meta.event ?? null) : null, + schedule: meta.triggerType === "schedule" ? meta.schedule || null : null, + timezone: + meta.triggerType === "schedule" && meta.timezone + ? effectiveTimezone(meta.timezone) + : null, + scheduleDescription: + meta.triggerType === "schedule" && meta.schedule + ? describeCron(meta.schedule, effectiveTimezone(meta.timezone)) + : null, + condition: + meta.triggerType === "manual" ? null : (meta.condition ?? null), mode: meta.mode, domain: meta.domain ?? null, enabled: meta.enabled, @@ -106,9 +111,13 @@ function automationScope(value: unknown): AutomationScope { throw new Error('scope must be "personal" or "organization".'); } -function automationTriggerType(value: unknown): "schedule" | "event" { - if (value === "schedule" || value === "event") return value; - throw new Error('trigger_type must be "schedule" or "event".'); +function automationTriggerType( + value: unknown, +): "schedule" | "event" | "manual" { + if (value === "schedule" || value === "event" || value === "manual") { + return value; + } + throw new Error('trigger_type must be "schedule", "event", or "manual".'); } async function handleDefine( @@ -168,10 +177,22 @@ async function handleDefine( name: definition.name, scope: definition.scope, triggerType: definition.meta.triggerType, - event: definition.meta.event ?? null, - schedule: definition.meta.schedule || null, - timezone: definition.meta.timezone ?? null, - nextRun: definition.meta.nextRun ?? null, + event: + definition.meta.triggerType === "event" + ? (definition.meta.event ?? null) + : null, + schedule: + definition.meta.triggerType === "schedule" + ? definition.meta.schedule || null + : null, + timezone: + definition.meta.triggerType === "schedule" + ? (definition.meta.timezone ?? null) + : null, + nextRun: + definition.meta.triggerType === "schedule" + ? (definition.meta.nextRun ?? null) + : null, createdBy: definition.meta.createdBy, runAs: definition.meta.runAs, model: definition.meta.model ?? null, @@ -232,9 +253,18 @@ async function handleUpdate( scope: definition.scope, triggerType: definition.meta.triggerType, enabled: definition.meta.enabled, - schedule: definition.meta.schedule || null, - timezone: definition.meta.timezone ?? null, - nextRun: definition.meta.nextRun ?? null, + schedule: + definition.meta.triggerType === "schedule" + ? definition.meta.schedule || null + : null, + timezone: + definition.meta.triggerType === "schedule" + ? (definition.meta.timezone ?? null) + : null, + nextRun: + definition.meta.triggerType === "schedule" + ? (definition.meta.nextRun ?? null) + : null, createdBy: definition.meta.createdBy, runAs: definition.meta.runAs, model: definition.meta.model ?? null, @@ -332,7 +362,7 @@ export function createAutomationToolEntries( return { "manage-automations": { tool: { - description: `Manage automations (event-triggered and scheduled tasks). Use the "action" parameter to choose an operation: + description: `Manage automations (manual, event-triggered, and scheduled tasks). Use the "action" parameter to choose an operation: - **list-events**: List all registered event types that automations can subscribe to. Returns event names, descriptions, and payload schemas. Call this BEFORE defining an automation to discover available events. - **list**: List all automations (triggers). Shows trigger, status, model, MCP allowlist, and delivery metadata. Optional params: scope, domain, enabled_only. @@ -363,8 +393,9 @@ export function createAutomationToolEntries( }, trigger_type: { type: "string", - description: '"event" or "schedule". Required for define.', - enum: ["event", "schedule"], + description: + '"manual", "event", or "schedule". Manual automations run only through run-now. Required for define.', + enum: ["manual", "event", "schedule"], }, event: { type: "string", diff --git a/packages/core/src/triggers/actions/actions.spec.ts b/packages/core/src/triggers/actions/actions.spec.ts index 1c2901fd59..b6d8ef70ff 100644 --- a/packages/core/src/triggers/actions/actions.spec.ts +++ b/packages/core/src/triggers/actions/actions.spec.ts @@ -103,6 +103,40 @@ describe("automation actions", () => { }); }); + it("lists manual automations without automatic trigger fields", async () => { + resourceListMock.mockResolvedValue([{ path: "jobs/on-demand.md" }]); + resourceGetByPathMock.mockResolvedValue({ + id: "manual-automation", + owner: "alice@example.com", + path: "jobs/on-demand.md", + content: `--- +schedule: "0 9 * * *" +timezone: UTC +enabled: true +triggerType: manual +event: stale.event +condition: stale condition +nextRun: 2030-01-01T09:00:00.000Z +mode: agentic +createdBy: alice@example.com +--- + +Run on demand.`, + }); + + const [automation] = await listAutomations.run({ scope: "personal" }, ctx); + + expect(automation).toMatchObject({ + triggerType: "manual", + event: null, + schedule: null, + timezone: null, + scheduleDescription: null, + condition: null, + nextRun: null, + }); + }); + it("lists organization automations for a current member", async () => { resourceListMock.mockResolvedValue([{ path: "jobs/digest.md" }]); resourceGetByPathMock.mockResolvedValue({ diff --git a/packages/core/src/triggers/actions/list-automations.ts b/packages/core/src/triggers/actions/list-automations.ts index 19a1f3aa2d..91cc1763f2 100644 --- a/packages/core/src/triggers/actions/list-automations.ts +++ b/packages/core/src/triggers/actions/list-automations.ts @@ -26,9 +26,10 @@ function nextRun( // A stored `nextRun` in the past means the dispatcher kept declining to run // this automation, not that it is overdue. Report the real next occurrence // and let `lastError` carry the reason it keeps being passed over. + if (!scheduled) return null; if (meta.nextRun) { const stored = new Date(meta.nextRun).getTime(); - if (!Number.isFinite(stored) || stored > Date.now() || !scheduled) { + if (!Number.isFinite(stored) || stored > Date.now()) { return meta.nextRun; } } @@ -42,7 +43,7 @@ export interface AutomationActionItem { name: string; path: string; scope: "personal" | "organization"; - triggerType: "event" | "schedule"; + triggerType: "event" | "schedule" | "manual"; event: string | null; schedule: string | null; timezone: string | null; @@ -89,13 +90,18 @@ export default defineAction({ path: resource.path, scope: scope as AutomationScope, triggerType: meta.triggerType, - event: meta.event ?? null, - schedule: meta.schedule || null, - timezone: meta.schedule ? effectiveTimezone(meta.timezone) : null, - scheduleDescription: meta.schedule - ? describeCron(meta.schedule, effectiveTimezone(meta.timezone)) - : null, - condition: meta.condition ?? null, + event: meta.triggerType === "event" ? (meta.event ?? null) : null, + schedule: meta.triggerType === "schedule" ? meta.schedule || null : null, + timezone: + meta.triggerType === "schedule" && meta.schedule + ? effectiveTimezone(meta.timezone) + : null, + scheduleDescription: + meta.triggerType === "schedule" && meta.schedule + ? describeCron(meta.schedule, effectiveTimezone(meta.timezone)) + : null, + condition: + meta.triggerType === "manual" ? null : (meta.condition ?? null), body, enabled: meta.enabled, lastRun: meta.lastRun ?? null, diff --git a/packages/core/src/triggers/dispatcher.spec.ts b/packages/core/src/triggers/dispatcher.spec.ts index 592f2f6fb4..72ae2cb720 100644 --- a/packages/core/src/triggers/dispatcher.spec.ts +++ b/packages/core/src/triggers/dispatcher.spec.ts @@ -217,6 +217,39 @@ Respond to the event.`, recordUsageMock.mockResolvedValue(undefined); }); + it("does not subscribe to a manual automation even with stale event metadata", async () => { + resourceListAllOwnersMock.mockResolvedValue([ + { + id: "manual-automation", + owner: "alice+triggers@agent-native.test", + path: "jobs/on-demand.md", + content: `--- +schedule: "" +enabled: true +triggerType: manual +event: manual.stale.event +mode: agentic +createdBy: alice+triggers@agent-native.test +--- + +Run on demand.`, + }, + ]); + + await initTriggerDispatcher({ + getActions: () => ({}), + getSystemPrompt: async () => "system", + engine: { name: "test-engine", defaultModel: "test-model" } as any, + model: "test-model", + }); + + expect(subscribeMock).not.toHaveBeenCalledWith( + "manual.stale.event", + expect.any(Function), + ); + expect(runAgentLoopMock).not.toHaveBeenCalled(); + }); + it("defers framework-added tools behind tool-search on the first trigger request when an initial tool list is supplied", async () => { // Use a distinct event/resource path from the module-level default so // this test doesn't collide with `_eventSubscriptions` state left behind diff --git a/packages/core/src/triggers/routes.spec.ts b/packages/core/src/triggers/routes.spec.ts index 4ee31605c1..d583d5259e 100644 --- a/packages/core/src/triggers/routes.spec.ts +++ b/packages/core/src/triggers/routes.spec.ts @@ -132,6 +132,42 @@ Hidden legacy organization job.`, }); }); + it("lists manual automations without automatic trigger fields", async () => { + resourceListAllOwnersMock.mockResolvedValue([ + { + id: "manual", + owner, + path: "jobs/on-demand.md", + content: `--- +schedule: "0 9 * * *" +timezone: UTC +enabled: true +triggerType: manual +event: stale.event +condition: stale condition +nextRun: 2030-01-01T09:00:00.000Z +mode: agentic +createdBy: ${owner} +--- + +Run on demand.`, + }, + ]); + + const [automation] = await listAutomationsForOwner(event, owner); + + expect(automation).toMatchObject({ + triggerType: "manual", + enabled: true, + }); + expect(automation).not.toHaveProperty("event"); + expect(automation).not.toHaveProperty("schedule"); + expect(automation).not.toHaveProperty("timezone"); + expect(automation).not.toHaveProperty("scheduleDescription"); + expect(automation).not.toHaveProperty("condition"); + expect(automation).not.toHaveProperty("nextRun"); + }); + it("lists and updates automations for the active organization only", async () => { const organizationOwner = "__organization__:org-1"; const organizationResource = { diff --git a/packages/core/src/triggers/routes.ts b/packages/core/src/triggers/routes.ts index 79f3bf638b..4142917b99 100644 --- a/packages/core/src/triggers/routes.ts +++ b/packages/core/src/triggers/routes.ts @@ -108,13 +108,14 @@ function scheduleDescription(schedule?: string, timezone?: string) { function nextRunForMeta(meta: TriggerFrontmatter): string | undefined { const scheduled = Boolean( meta.enabled && - meta.triggerType !== "event" && + meta.triggerType === "schedule" && meta.schedule && isValidCron(meta.schedule), ); + if (!scheduled) return undefined; if (meta.nextRun) { const stored = new Date(meta.nextRun).getTime(); - if (!scheduled || !Number.isFinite(stored) || stored > Date.now()) { + if (!Number.isFinite(stored) || stored > Date.now()) { return meta.nextRun; } } @@ -198,6 +199,7 @@ async function resourceToAutomationItem( ): Promise { const parsed = parseJobResource(resource.content); const meta = asTriggerFrontmatter(parsed.meta); + const nextRun = nextRunForMeta(meta); return { id: resource.id, name: automationName(resource.path), @@ -212,19 +214,32 @@ async function resourceToAutomationItem( meta, ), triggerType: meta.triggerType, - event: meta.event, - schedule: meta.schedule || undefined, - scheduleDescription: scheduleDescription(meta.schedule, meta.timezone), - condition: meta.condition, + ...(meta.triggerType === "event" && meta.event + ? { event: meta.event } + : {}), + ...(meta.triggerType === "schedule" && meta.schedule + ? { + schedule: meta.schedule, + scheduleDescription: scheduleDescription( + meta.schedule, + meta.timezone, + ), + } + : {}), + ...(meta.triggerType !== "manual" && meta.condition + ? { condition: meta.condition } + : {}), mode: meta.mode, domain: meta.domain, enabled: meta.enabled, - timezone: meta.timezone, + ...(meta.triggerType === "schedule" && meta.timezone + ? { timezone: meta.timezone } + : {}), lastStatus: meta.lastStatus, lastRun: meta.lastRun, lastCheck: meta.lastCheck, lastError: meta.lastError, - nextRun: nextRunForMeta(meta), + ...(nextRun ? { nextRun } : {}), createdBy: meta.createdBy, body: parsed.body, }; @@ -310,7 +325,7 @@ export async function setAutomationEnabledForOwner( parsed.meta.enabled = input.enabled; if ( parsed.meta.enabled && - meta.triggerType !== "event" && + meta.triggerType === "schedule" && meta.schedule && isValidCron(meta.schedule) ) { diff --git a/packages/core/src/triggers/types.ts b/packages/core/src/triggers/types.ts index b16e9327e5..99318a9958 100644 --- a/packages/core/src/triggers/types.ts +++ b/packages/core/src/triggers/types.ts @@ -13,7 +13,10 @@ import type { } from "../jobs/frontmatter.js"; export interface TriggerFrontmatter extends JobFrontmatter { - /** "schedule" = cron-based (legacy jobs). "event" = fires on bus event. */ + /** + * "schedule" = cron-based, "event" = event-bus dispatch, and "manual" = + * explicit run-now only. + */ triggerType: JobTriggerType; /** * "agentic" = full runAgentLoop; the only mode `manage-automations` will From 4be4541f16770af48a4ae46678fb036a0c066d50 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 4 Aug 2026 23:55:59 +0000 Subject: [PATCH 02/19] feat(automations): expose events and add direct editor mutations --- .../friendly-automation-editor-actions.md | 5 + packages/core/src/automations/service.ts | 85 +++++--- .../core/src/client/agent-page/use-jobs.ts | 205 +++++++++++++++--- .../core/src/server/action-discovery.spec.ts | 16 ++ packages/core/src/server/action-discovery.ts | 4 + packages/core/src/triggers/actions.spec.ts | 6 +- packages/core/src/triggers/actions.ts | 9 +- .../core/src/triggers/actions/actions.spec.ts | 193 +++++++++++++++++ .../actions/list-automation-events.ts | 59 +++++ .../src/triggers/actions/manage-automation.ts | 139 +++++++++--- 10 files changed, 635 insertions(+), 86 deletions(-) create mode 100644 .changeset/friendly-automation-editor-actions.md create mode 100644 packages/core/src/triggers/actions/list-automation-events.ts diff --git a/.changeset/friendly-automation-editor-actions.md b/.changeset/friendly-automation-editor-actions.md new file mode 100644 index 0000000000..eb080bc058 --- /dev/null +++ b/.changeset/friendly-automation-editor-actions.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": minor +--- + +Expose registered automation events and direct create/update editor mutations with optimistic client state. diff --git a/packages/core/src/automations/service.ts b/packages/core/src/automations/service.ts index cb69800385..68bff3707c 100644 --- a/packages/core/src/automations/service.ts +++ b/packages/core/src/automations/service.ts @@ -50,6 +50,7 @@ export interface DefineAutomationInput { scope: AutomationScope; triggerType: "schedule" | "event" | "manual"; body: string; + enabled?: boolean; schedule?: string; timezone?: string; event?: string; @@ -66,8 +67,10 @@ export type DefinedAutomation = Omit; export interface UpdateAutomationInput { name: string; scope: AutomationScope; + triggerType?: "schedule" | "event" | "manual"; enabled?: boolean; body?: string; + event?: string; condition?: string | null; delegatedPolicyId?: string | null; schedule?: string; @@ -331,7 +334,7 @@ export async function defineAutomation( const meta: JobFrontmatter = { schedule: input.triggerType === "schedule" ? schedule : "", ...(timezone ? { timezone } : {}), - enabled: true, + enabled: input.enabled ?? true, triggerType: input.triggerType, ...(input.triggerType === "event" ? { event } : {}), ...(input.triggerType !== "manual" && input.condition?.trim() @@ -379,44 +382,71 @@ export async function updateAutomation( ); } const { meta } = definition; - if (input.schedule !== undefined) { - if (meta.triggerType !== "schedule") { + const triggerType = input.triggerType ?? meta.triggerType; + const schedule = input.schedule?.trim() ?? meta.schedule; + const event = input.event?.trim() ?? meta.event; + + if (triggerType === "schedule") { + if (!isValidCron(schedule)) { throw httpError( - `${meta.triggerType === "manual" ? "Manual" : "Event"} automations do not have a cron schedule.`, + schedule + ? `Invalid cron expression "${schedule}".` + : "schedule is required for scheduled automations.", 400, ); } - if (!isValidCron(input.schedule)) { - throw httpError(`Invalid cron expression "${input.schedule}".`, 400); + if (input.event !== undefined) { + throw httpError("Scheduled automations do not have an event.", 400); } - meta.schedule = input.schedule; - } - if (input.timezone !== undefined) { - if (!isValidTimezone(input.timezone)) { - throw httpError(`Unknown timezone "${input.timezone}".`, 400); + const timezone = + input.timezone ?? + meta.timezone ?? + (await resolveUserSchedulingTimezone( + definition.meta.createdBy?.trim() || actorInput.userEmail, + )); + if (!isValidTimezone(timezone)) { + throw httpError(`Unknown timezone "${timezone}".`, 400); } - if (meta.triggerType !== "schedule") { + meta.triggerType = "schedule"; + meta.schedule = schedule; + meta.timezone = timezone; + meta.event = undefined; + meta.nextRun = nextOccurrence(schedule, undefined, timezone).toISOString(); + } else if (triggerType === "event") { + if (!event) { throw httpError( - `${meta.triggerType === "manual" ? "Manual" : "Event"} automations do not have a timezone.`, + "event is required for event-triggered automations.", 400, ); } - meta.timezone = input.timezone; - } - if (input.schedule !== undefined || input.timezone !== undefined) { - meta.nextRun = nextOccurrence( - meta.schedule, - undefined, - meta.timezone, - ).toISOString(); + if (input.schedule !== undefined || input.timezone !== undefined) { + throw httpError("Event automations do not have schedule settings.", 400); + } + meta.triggerType = "event"; + meta.schedule = ""; + meta.timezone = undefined; + meta.event = event; + meta.nextRun = undefined; + } else { + if ( + input.event !== undefined || + input.schedule !== undefined || + input.timezone !== undefined || + input.condition !== undefined + ) { + throw httpError("Manual automations do not have trigger settings.", 400); + } + meta.triggerType = "manual"; + meta.schedule = ""; + meta.timezone = undefined; + meta.event = undefined; + meta.condition = undefined; + meta.nextRun = undefined; } + if (input.enabled !== undefined) { meta.enabled = input.enabled; - if ( - input.enabled && - meta.triggerType === "schedule" && - isValidCron(meta.schedule) - ) { + if (input.enabled && meta.triggerType === "schedule") { meta.nextRun = nextOccurrence( meta.schedule, undefined, @@ -425,9 +455,6 @@ export async function updateAutomation( } } if (input.condition !== undefined) { - if (meta.triggerType === "manual") { - throw httpError("Manual automations do not have a condition.", 400); - } meta.condition = input.condition?.trim() || undefined; } if (input.delegatedPolicyId !== undefined) { diff --git a/packages/core/src/client/agent-page/use-jobs.ts b/packages/core/src/client/agent-page/use-jobs.ts index dcf2b7cfd6..1ab703962f 100644 --- a/packages/core/src/client/agent-page/use-jobs.ts +++ b/packages/core/src/client/agent-page/use-jobs.ts @@ -62,7 +62,62 @@ export type ManageJobInput = { timezone?: string; }; -export type ManageAutomationInput = ManageJobInput; +export interface AutomationEvent { + name: string; + description: string; + payloadSchema: Record | null; + example: Record | null; +} + +interface AutomationEditorFields { + enabled?: boolean; + triggerType?: "event" | "schedule"; + event?: string; + schedule?: string; + timezone?: string; + condition?: string | null; + body?: string; + model?: string | null; + mcpTools?: string[]; +} + +export type ManageAutomationInput = + | ({ + operation: "create"; + name: string; + scope: "personal" | "organization"; + triggerType: "event" | "schedule"; + body: string; + } & Omit) + | ({ + operation: "update"; + name: string; + scope: "personal" | "organization"; + } & AutomationEditorFields) + | { + operation: "delete"; + name: string; + scope: "personal" | "organization"; + }; + +export interface ManageAutomationResult { + created?: true; + updated?: true; + deleted?: true; + name: string; + scope?: "personal" | "organization"; + triggerType?: "event" | "schedule" | "manual"; + event?: string | null; + schedule?: string | null; + timezone?: string | null; + condition?: string | null; + body?: string; + enabled?: boolean; + nextRun?: string | null; + createdBy?: string | null; + model?: string | null; + mcpTools?: string[]; +} export interface RunAutomationNowInput { name: string; @@ -111,6 +166,16 @@ export function useAutomations(scope: JobsScope) { ); } +export function useAutomationEvents() { + return useActionQuery( + "list-automation-events", + {}, + { + staleTime: 30_000, + }, + ); +} + export function useManageRecurringJob(scope: JobsScope) { const queryClient = useQueryClient(); const params = recurringParams(scope); @@ -150,35 +215,38 @@ export function useManageAutomation(scope: JobsScope) { const params = automationParams(scope); const queryKey = ["action", "list-automations", params] as const; - return useActionMutation< - { deleted?: boolean; name: string; enabled?: boolean }, - ManageAutomationInput - >("manage-automation", { - onMutate: async (variables) => { - await queryClient.cancelQueries({ queryKey }); - const previous = queryClient.getQueryData(queryKey); - queryClient.setQueryData(queryKey, (current) => { - if (!current) return current; - if (variables.operation === "delete") { - return current.filter( - (automation) => automation.name !== variables.name, + return useActionMutation( + "manage-automation", + { + onMutate: async (variables) => { + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + queryClient.setQueryData(queryKey, (current) => { + if (!current) return current; + if (variables.operation === "delete") { + return current.filter( + (automation) => automation.name !== variables.name, + ); + } + if (variables.operation === "create") { + return [...current, optimisticAutomation(variables)]; + } + return current.map((automation) => + automation.name === variables.name + ? { ...automation, ...optimisticAutomationPatch(variables) } + : automation, ); + }); + return { previous }; + }, + onError: (_error, _variables, context) => { + const rollback = context as { previous?: Automation[] } | undefined; + if (rollback && "previous" in rollback) { + queryClient.setQueryData(queryKey, rollback.previous); } - return current.map((automation) => - automation.name === variables.name - ? { ...automation, ...optimisticPatch(variables) } - : automation, - ); - }); - return { previous }; - }, - onError: (_error, _variables, context) => { - const rollback = context as { previous?: Automation[] } | undefined; - if (rollback && "previous" in rollback) { - queryClient.setQueryData(queryKey, rollback.previous); - } + }, }, - }); + ); } export function useRunAutomationNow() { @@ -219,6 +287,89 @@ function optimisticPatch(variables: ManageJobInput) { return patch; } +function optimisticAutomation( + variables: Extract, +): Automation { + const name = variables.name + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-"); + return { + id: `optimistic:${variables.scope}:${name}`, + name, + path: `jobs/${name}.md`, + scope: variables.scope, + triggerType: variables.triggerType, + event: variables.triggerType === "event" ? (variables.event ?? null) : null, + schedule: + variables.triggerType === "schedule" + ? (variables.schedule ?? null) + : null, + timezone: + variables.triggerType === "schedule" + ? (variables.timezone ?? null) + : null, + scheduleDescription: null, + condition: variables.condition ?? null, + body: variables.body, + enabled: variables.enabled ?? true, + lastRun: null, + lastCheck: null, + lastStatus: null, + lastError: null, + nextRun: null, + createdBy: null, + model: variables.model ?? null, + mcpTools: variables.mcpTools ?? [], + originScopeId: null, + deliveryPlatform: null, + deliveryDestination: null, + deliveryThreadRef: null, + deliveryTenantId: null, + canUpdate: true, + }; +} + +function optimisticAutomationPatch( + variables: Extract, +): Partial { + const patch: Partial = {}; + if (variables.enabled !== undefined) patch.enabled = variables.enabled; + if (variables.body !== undefined) patch.body = variables.body; + if (variables.model !== undefined) patch.model = variables.model; + if (variables.mcpTools !== undefined) patch.mcpTools = variables.mcpTools; + if (variables.condition !== undefined) patch.condition = variables.condition; + + if (variables.triggerType !== undefined) { + patch.triggerType = variables.triggerType; + patch.event = + variables.triggerType === "event" ? (variables.event ?? null) : null; + patch.schedule = + variables.triggerType === "schedule" + ? (variables.schedule ?? null) + : null; + patch.timezone = + variables.triggerType === "schedule" + ? (variables.timezone ?? null) + : null; + patch.scheduleDescription = null; + patch.nextRun = null; + } else { + if (variables.event !== undefined) patch.event = variables.event; + if (variables.schedule !== undefined) { + patch.schedule = variables.schedule; + patch.scheduleDescription = null; + patch.nextRun = null; + } + if (variables.timezone !== undefined) { + patch.timezone = variables.timezone; + patch.scheduleDescription = null; + patch.nextRun = null; + } + } + return patch; +} + export function useAutomationRuns( scope: JobsScope, name: string | null, diff --git a/packages/core/src/server/action-discovery.spec.ts b/packages/core/src/server/action-discovery.spec.ts index 56da235144..e2319356c4 100644 --- a/packages/core/src/server/action-discovery.spec.ts +++ b/packages/core/src/server/action-discovery.spec.ts @@ -404,6 +404,22 @@ describe("action discovery", () => { expect(registry["unshare-resource"]).toBeDefined(); }); + it("merges automation editor actions without exposing them as agent tools", async () => { + const registry: Record = {}; + await mergeCoreSharingActions(registry); + + for (const name of [ + "list-automations", + "list-automation-events", + "manage-automation", + ]) { + expect(registry[name], `${name} should be merged`).toBeDefined(); + expect(registry[name].agentTool).toBe(false); + } + expect(registry["list-automation-events"].http).toEqual({ method: "GET" }); + expect(registry["list-automation-events"].readOnly).toBe(true); + }); + it("merges localization preference actions", async () => { const registry: Record = {}; await mergeCoreSharingActions(registry); diff --git a/packages/core/src/server/action-discovery.ts b/packages/core/src/server/action-discovery.ts index 0284908146..c907206a54 100644 --- a/packages/core/src/server/action-discovery.ts +++ b/packages/core/src/server/action-discovery.ts @@ -637,6 +637,10 @@ export async function mergeCoreSharingActions( "list-automations", () => import("../triggers/actions/list-automations.js"), ], + [ + "list-automation-events", + () => import("../triggers/actions/list-automation-events.js"), + ], [ "manage-automation", () => import("../triggers/actions/manage-automation.js"), diff --git a/packages/core/src/triggers/actions.spec.ts b/packages/core/src/triggers/actions.spec.ts index 668fbde741..65e4269bae 100644 --- a/packages/core/src/triggers/actions.spec.ts +++ b/packages/core/src/triggers/actions.spec.ts @@ -166,6 +166,8 @@ Record the QA signal.`, await tool().run({ action: "update", name: "qa-alert", + trigger_type: "event", + event: "agent.turn.completed", enabled: "false", body: "Updated body.", }); @@ -173,7 +175,9 @@ Record the QA signal.`, expect(resourcePutMock).toHaveBeenLastCalledWith( owner, "jobs/qa-alert.md", - expect.stringContaining("enabled: false"), + expect.stringMatching( + /enabled: false[\s\S]*event: "agent\.turn\.completed"/, + ), ); resourceGetByPathMock.mockResolvedValueOnce({ diff --git a/packages/core/src/triggers/actions.ts b/packages/core/src/triggers/actions.ts index 98f22f53bd..ad459448fd 100644 --- a/packages/core/src/triggers/actions.ts +++ b/packages/core/src/triggers/actions.ts @@ -218,10 +218,15 @@ async function handleUpdate( { name: typeof args.name === "string" ? args.name : "", scope: automationScope(args.scope), + triggerType: + args.trigger_type === undefined + ? undefined + : automationTriggerType(args.trigger_type), enabled: args.enabled === undefined ? undefined : args.enabled === true || args.enabled === "true", + event: typeof args.event === "string" ? args.event : undefined, condition: args.condition === undefined ? undefined @@ -367,7 +372,7 @@ export function createAutomationToolEntries( - **list-events**: List all registered event types that automations can subscribe to. Returns event names, descriptions, and payload schemas. Call this BEFORE defining an automation to discover available events. - **list**: List all automations (triggers). Shows trigger, status, model, MCP allowlist, and delivery metadata. Optional params: scope, domain, enabled_only. - **define**: Create a new automation. IMPORTANT: Always confirm with the user before calling — show them a summary of what will be created. Required params: name, trigger_type, body. Optional: scope, event, schedule, timezone, condition, mode, domain, delegated_policy_id, model, mcpTools. -- **update**: Update an existing automation's settings without changing its creator (enabled, schedule, timezone, condition, body, policy, model, MCP allowlist). Required param: name. Use the same scope it was created in. +- **update**: Update an existing automation's settings without changing its creator (trigger type, event, enabled, schedule, timezone, condition, body, policy, model, MCP allowlist). Required param: name. Use the same scope it was created in. - **delete**: Delete an automation. Always confirm with the user first. Required param: name. - **fire-test**: Fire a test event to validate automations. Emits a test.event.fired event. Optional param: data (JSON string). - **run-now**: Run one automation immediately using its real actions and side effects. This is an explicit user-authorized run and returns a durable run id; it does not change the automation's next scheduled run. Required params: name; optional scope.`, @@ -394,7 +399,7 @@ export function createAutomationToolEntries( trigger_type: { type: "string", description: - '"manual", "event", or "schedule". Manual automations run only through run-now. Required for define.', + '"manual", "event", or "schedule". Manual automations run only through run-now. Used by define and update.', enum: ["manual", "event", "schedule"], }, event: { diff --git a/packages/core/src/triggers/actions/actions.spec.ts b/packages/core/src/triggers/actions/actions.spec.ts index b6d8ef70ff..c826952c5d 100644 --- a/packages/core/src/triggers/actions/actions.spec.ts +++ b/packages/core/src/triggers/actions/actions.spec.ts @@ -29,6 +29,13 @@ vi.mock("../dispatcher.js", async (importOriginal) => ({ refreshEventSubscriptions: refreshEventSubscriptionsMock, })); +import { z } from "zod"; + +import { + __resetEventRegistry, + registerEvent, +} from "../../event-bus/registry.js"; +import listAutomationEvents from "./list-automation-events.js"; import listAutomations from "./list-automations.js"; import manageAutomation from "./manage-automation.js"; @@ -60,11 +67,14 @@ describe("automation actions", () => { resourceDeleteMock.mockResolvedValue(true); refreshEventSubscriptionsMock.mockResolvedValue(undefined); executeMock.mockResolvedValue({ rows: [{ role: "member" }] }); + __resetEventRegistry(); }); it("exposes a frontend-only GET list and a frontend-only mutation", () => { expect(listAutomations.http).toEqual({ method: "GET" }); expect(listAutomations.agentTool).toBe(false); + expect(listAutomationEvents.http).toEqual({ method: "GET" }); + expect(listAutomationEvents.agentTool).toBe(false); expect(manageAutomation.agentTool).toBe(false); }); @@ -184,6 +194,122 @@ Run on demand.`, expect(automations[0]?.nextRun).toBeNull(); }); + it("lists registered events with structured payload schemas", async () => { + registerEvent({ + name: "mail.message.received", + description: "A message arrived.", + payloadSchema: z.object({ + messageId: z.string(), + unread: z.boolean().optional(), + }), + example: { messageId: "message-example", unread: true }, + }); + + const events = await listAutomationEvents.run({}, ctx); + + expect(events).toContainEqual({ + name: "mail.message.received", + description: "A message arrived.", + payloadSchema: expect.objectContaining({ + type: "object", + properties: expect.objectContaining({ + messageId: expect.objectContaining({ type: "string" }), + }), + }), + example: { messageId: "message-example", unread: true }, + }); + }); + + it("creates personal and organization automations with creator ownership", async () => { + await manageAutomation.run( + { + operation: "create", + name: "personal-notify", + scope: "personal", + triggerType: "event", + event: "mail.message.received", + condition: "only unread messages", + body: "Send me a notification.", + }, + ctx, + ); + expect(resourcePutMock).toHaveBeenCalledWith( + "alice@example.com", + "jobs/personal-notify.md", + expect.stringMatching( + /triggerType: event[\s\S]*event: "mail\.message\.received"[\s\S]*createdBy: alice@example\.com/, + ), + ); + + await manageAutomation.run( + { + operation: "create", + name: "org-notify", + scope: "organization", + triggerType: "manual", + body: "Build the organization report.", + }, + { ...ctx, orgId: "org-1" }, + ); + expect(resourcePutMock).toHaveBeenCalledWith( + "__organization__:org-1", + "jobs/org-notify.md", + expect.stringMatching( + /createdBy: alice@example\.com[\s\S]*orgId: "org-1"[\s\S]*runAs: creator/, + ), + ); + expect(refreshEventSubscriptionsMock).toHaveBeenCalledTimes(2); + }); + + it("rejects duplicate names and invalid trigger settings", async () => { + resourceGetByPathMock.mockResolvedValueOnce({ + id: "existing", + owner: "alice@example.com", + path: "jobs/notify.md", + content: automationContent, + }); + await expect( + manageAutomation.run( + { + operation: "create", + name: "notify", + scope: "personal", + triggerType: "manual", + body: "Run the notification.", + }, + ctx, + ), + ).rejects.toMatchObject({ statusCode: 409 }); + + resourceGetByPathMock.mockResolvedValue(null); + await expect( + manageAutomation.run( + { + operation: "create", + name: "missing-event", + scope: "personal", + triggerType: "event", + body: "Run the notification.", + }, + ctx, + ), + ).rejects.toMatchObject({ statusCode: 400 }); + await expect( + manageAutomation.run( + { + operation: "create", + name: "bad-schedule", + scope: "personal", + triggerType: "schedule", + schedule: "not a cron", + body: "Run the notification.", + }, + ctx, + ), + ).rejects.toMatchObject({ statusCode: 400 }); + expect(resourcePutMock).not.toHaveBeenCalled(); + }); + it("updates and deletes only personal automations", async () => { resourceGetByPathMock.mockResolvedValue({ id: "automation-1", @@ -215,6 +341,48 @@ Run on demand.`, expect(refreshEventSubscriptionsMock).toHaveBeenCalled(); }); + it("updates all editable event fields", async () => { + resourceGetByPathMock.mockResolvedValue({ + id: "automation-1", + owner: "alice@example.com", + path: "jobs/digest.md", + content: automationContent, + }); + + const result = await manageAutomation.run( + { + operation: "update", + name: "digest", + scope: "personal", + triggerType: "event", + event: "mail.message.received", + condition: "only unread messages", + body: "Send the updated notification.", + model: "model-example", + mcpTools: ["mcp__mail__read"], + }, + ctx, + ); + + expect(result).toMatchObject({ + updated: true, + triggerType: "event", + event: "mail.message.received", + schedule: null, + condition: "only unread messages", + body: "Send the updated notification.", + model: "model-example", + mcpTools: ["mcp__mail__read"], + }); + expect(resourcePutMock).toHaveBeenCalledWith( + "alice@example.com", + "jobs/digest.md", + expect.stringMatching( + /triggerType: event[\s\S]*event: "mail\.message\.received"[\s\S]*condition: "only unread messages"/, + ), + ); + }); + it("updates organization automations as their current creator", async () => { resourceGetByPathMock.mockResolvedValue({ id: "automation-1", @@ -242,4 +410,29 @@ Run on demand.`, expect.stringContaining("runAs: creator"), ); }); + + it("rejects a scoped organization update from a non-creator member", async () => { + resourceGetByPathMock.mockResolvedValue({ + id: "automation-1", + owner: "__organization__:org-1", + path: "jobs/digest.md", + content: automationContent.replace( + "createdBy: alice@example.com", + 'createdBy: creator@example.com\norgId: "org-1"\nrunAs: creator', + ), + }); + + await expect( + manageAutomation.run( + { + operation: "update", + name: "digest", + scope: "organization", + enabled: false, + }, + { ...ctx, orgId: "org-1" }, + ), + ).rejects.toMatchObject({ statusCode: 403 }); + expect(resourcePutMock).not.toHaveBeenCalled(); + }); }); diff --git a/packages/core/src/triggers/actions/list-automation-events.ts b/packages/core/src/triggers/actions/list-automation-events.ts new file mode 100644 index 0000000000..866c1a10be --- /dev/null +++ b/packages/core/src/triggers/actions/list-automation-events.ts @@ -0,0 +1,59 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { z } from "zod"; + +import { defineAction } from "../../action.js"; +import { listEvents } from "../../event-bus/index.js"; + +export interface AutomationEventActionItem { + name: string; + description: string; + payloadSchema: Record | null; + example: Record | null; +} + +function payloadJsonSchema( + schema: StandardSchemaV1, +): Record | null { + const standard = schema["~standard"] as StandardSchemaV1["~standard"] & { + jsonSchema?: { + input?: (options: { target: "draft-07" }) => unknown; + }; + }; + if (standard.jsonSchema?.input) { + const converted = standard.jsonSchema.input({ target: "draft-07" }); + if ( + converted && + typeof converted === "object" && + !Array.isArray(converted) + ) { + return converted as Record; + } + return null; + } + + try { + return z.toJSONSchema(schema as z.ZodType, { + io: "input", + target: "draft-7", + }) as Record; + } catch { + return null; + } +} + +export default defineAction({ + description: + "List registered events and their structured payload schemas for the automation editor.", + agentTool: false, + schema: z.object({}), + http: { method: "GET" }, + readOnly: true, + parallelSafe: true, + run: async (): Promise => + listEvents().map((event) => ({ + name: event.name, + description: event.description, + payloadSchema: payloadJsonSchema(event.payloadSchema), + example: event.example ?? null, + })), +}); diff --git a/packages/core/src/triggers/actions/manage-automation.ts b/packages/core/src/triggers/actions/manage-automation.ts index 6ada3256c3..1ce5b9d883 100644 --- a/packages/core/src/triggers/actions/manage-automation.ts +++ b/packages/core/src/triggers/actions/manage-automation.ts @@ -2,57 +2,142 @@ import { z } from "zod"; import { defineAction } from "../../action.js"; import { + defineAutomation, deleteAutomation, updateAutomation, } from "../../automations/service.js"; import { refreshEventSubscriptions } from "../dispatcher.js"; -export default defineAction({ - description: - "Enable, disable, or delete a personal or organization automation from the Agent Automations page.", - agentTool: false, - schema: z.object({ - operation: z.enum(["update", "delete"]), +const scopeSchema = z.enum(["personal", "organization"]); +const triggerTypeSchema = z.enum(["schedule", "event", "manual"]); +const mcpToolsSchema = z.array(z.string().min(1)); + +const automationFieldsSchema = { + enabled: z.boolean().optional(), + triggerType: triggerTypeSchema.optional(), + event: z.string().min(1).optional(), + schedule: z.string().min(1).optional(), + timezone: z.string().min(1).optional(), + condition: z.string().nullable().optional(), + body: z.string().min(1).optional(), + model: z.string().nullable().optional(), + mcpTools: mcpToolsSchema.optional(), +}; + +const schema = z.discriminatedUnion("operation", [ + z.object({ + operation: z.literal("create"), name: z.string().min(1), - scope: z.enum(["personal", "organization"]).default("personal"), + scope: scopeSchema.default("personal"), + triggerType: triggerTypeSchema, + body: z.string().min(1), enabled: z.boolean().optional(), + event: z.string().min(1).optional(), schedule: z.string().min(1).optional(), timezone: z.string().min(1).optional(), + condition: z.string().nullable().optional(), + model: z.string().nullable().optional(), + mcpTools: mcpToolsSchema.optional(), }), - run: async ({ operation, name, scope, enabled, schedule, timezone }, ctx) => { + z.object({ + operation: z.literal("update"), + name: z.string().min(1), + scope: scopeSchema.default("personal"), + ...automationFieldsSchema, + }), + z.object({ + operation: z.literal("delete"), + name: z.string().min(1), + scope: scopeSchema.default("personal"), + }), +]); + +export default defineAction({ + description: + "Create, update, or delete a personal or organization automation from the Agent Automations page.", + agentTool: false, + schema, + run: async (input, ctx) => { const userEmail = ctx?.userEmail; if (!userEmail) throw new Error("Not authenticated."); const actor = { userEmail, orgId: ctx?.orgId }; - if (operation === "delete") { - await deleteAutomation(actor, scope, name); + if (input.operation === "delete") { + await deleteAutomation(actor, input.scope, input.name); await refreshEventSubscriptions(); - return { deleted: true, name }; + return { deleted: true, name: input.name }; } - if ( - enabled === undefined && - schedule === undefined && - timezone === undefined - ) { - throw Object.assign( - new Error("enabled, schedule, or timezone is required for update."), - { statusCode: 400 }, - ); + + if (input.operation === "create") { + const definition = await defineAutomation(actor, { + name: input.name, + scope: input.scope, + triggerType: input.triggerType, + body: input.body, + enabled: input.enabled, + event: input.event, + schedule: input.schedule, + timezone: input.timezone, + condition: input.condition ?? undefined, + model: input.model ?? undefined, + mcpTools: input.mcpTools, + }); + await refreshEventSubscriptions(); + return { + created: true, + name: definition.name, + scope: definition.scope, + triggerType: definition.meta.triggerType, + event: definition.meta.event ?? null, + schedule: definition.meta.schedule || null, + timezone: definition.meta.timezone ?? null, + condition: definition.meta.condition ?? null, + body: definition.body, + enabled: definition.meta.enabled, + nextRun: definition.meta.nextRun ?? null, + createdBy: definition.meta.createdBy ?? null, + model: definition.meta.model ?? null, + mcpTools: definition.meta.mcpTools ?? [], + }; + } + + const hasUpdate = Object.keys(automationFieldsSchema).some( + (field) => input[field as keyof typeof input] !== undefined, + ); + if (!hasUpdate) { + throw Object.assign(new Error("At least one update field is required."), { + statusCode: 400, + }); } const definition = await updateAutomation(actor, { - name, - scope, - ...(enabled === undefined ? {} : { enabled }), - ...(schedule === undefined ? {} : { schedule }), - ...(timezone === undefined ? {} : { timezone }), + name: input.name, + scope: input.scope, + triggerType: input.triggerType, + enabled: input.enabled, + event: input.event, + schedule: input.schedule, + timezone: input.timezone, + condition: input.condition, + body: input.body, + model: input.model, + mcpTools: input.mcpTools, }); await refreshEventSubscriptions(); return { - name, - enabled: definition.meta.enabled, + updated: true, + name: definition.name, + scope: definition.scope, + triggerType: definition.meta.triggerType, + event: definition.meta.event ?? null, schedule: definition.meta.schedule || null, timezone: definition.meta.timezone ?? null, + condition: definition.meta.condition ?? null, + body: definition.body, + enabled: definition.meta.enabled, nextRun: definition.meta.nextRun ?? null, + createdBy: definition.meta.createdBy ?? null, + model: definition.meta.model ?? null, + mcpTools: definition.meta.mcpTools ?? [], }; }, }); From 2b59d93c44522cf750c717f811056b16032d6b61 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 4 Aug 2026 23:59:14 +0000 Subject: [PATCH 03/19] Support manual trigger type for automations --- .../core/src/client/agent-page/AgentJobsTab.tsx | 2 +- packages/core/src/client/agent-page/use-jobs.ts | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/core/src/client/agent-page/AgentJobsTab.tsx b/packages/core/src/client/agent-page/AgentJobsTab.tsx index f6cad57286..19c40a4d7d 100644 --- a/packages/core/src/client/agent-page/AgentJobsTab.tsx +++ b/packages/core/src/client/agent-page/AgentJobsTab.tsx @@ -51,7 +51,7 @@ type ListedAutomation = | { kind: "automation"; resource: Automation; - triggerType: "event" | "schedule"; + triggerType: Automation["triggerType"]; }; function listRecurringJobs(jobs: RecurringJob[]): ListedAutomation[] { diff --git a/packages/core/src/client/agent-page/use-jobs.ts b/packages/core/src/client/agent-page/use-jobs.ts index 1ab703962f..1a30db0b22 100644 --- a/packages/core/src/client/agent-page/use-jobs.ts +++ b/packages/core/src/client/agent-page/use-jobs.ts @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useActionMutation, useActionQuery } from "../use-action.js"; export type JobsScope = "user" | "org"; +export type AutomationTriggerType = "event" | "schedule" | "manual"; export interface RecurringJob { id: string; @@ -29,7 +30,7 @@ export interface Automation { name: string; path: string; scope: "personal" | "organization"; - triggerType: "event" | "schedule"; + triggerType: AutomationTriggerType; event: string | null; schedule: string | null; timezone: string | null; @@ -71,7 +72,7 @@ export interface AutomationEvent { interface AutomationEditorFields { enabled?: boolean; - triggerType?: "event" | "schedule"; + triggerType?: AutomationTriggerType; event?: string; schedule?: string; timezone?: string; @@ -86,7 +87,7 @@ export type ManageAutomationInput = operation: "create"; name: string; scope: "personal" | "organization"; - triggerType: "event" | "schedule"; + triggerType: AutomationTriggerType; body: string; } & Omit) | ({ @@ -106,7 +107,7 @@ export interface ManageAutomationResult { deleted?: true; name: string; scope?: "personal" | "organization"; - triggerType?: "event" | "schedule" | "manual"; + triggerType?: AutomationTriggerType; event?: string | null; schedule?: string | null; timezone?: string | null; @@ -310,7 +311,8 @@ function optimisticAutomation( ? (variables.timezone ?? null) : null, scheduleDescription: null, - condition: variables.condition ?? null, + condition: + variables.triggerType === "manual" ? null : (variables.condition ?? null), body: variables.body, enabled: variables.enabled ?? true, lastRun: null, @@ -354,6 +356,7 @@ function optimisticAutomationPatch( : null; patch.scheduleDescription = null; patch.nextRun = null; + if (variables.triggerType === "manual") patch.condition = null; } else { if (variables.event !== undefined) patch.event = variables.event; if (variables.schedule !== undefined) { From b1fa247dbdac625cfb1caa3bfdb3d5c9b656f3b0 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 5 Aug 2026 00:06:51 +0000 Subject: [PATCH 04/19] Refactor automation schedule dialog with friendly controls --- .../AutomationScheduleDialog.spec.tsx | 110 +++++- .../agent-page/AutomationScheduleDialog.tsx | 94 +---- .../agent-page/AutomationScheduleFields.tsx | 359 ++++++++++++++++++ .../automation-schedule-fields.spec.ts | 56 +++ .../agent-page/automation-schedule-fields.ts | 155 ++++++++ 5 files changed, 687 insertions(+), 87 deletions(-) create mode 100644 packages/core/src/client/agent-page/AutomationScheduleFields.tsx create mode 100644 packages/core/src/client/agent-page/automation-schedule-fields.spec.ts create mode 100644 packages/core/src/client/agent-page/automation-schedule-fields.ts diff --git a/packages/core/src/client/agent-page/AutomationScheduleDialog.spec.tsx b/packages/core/src/client/agent-page/AutomationScheduleDialog.spec.tsx index 53cb72f651..3f410c76b2 100644 --- a/packages/core/src/client/agent-page/AutomationScheduleDialog.spec.tsx +++ b/packages/core/src/client/agent-page/AutomationScheduleDialog.spec.tsx @@ -8,7 +8,36 @@ vi.mock("../i18n.js", () => ({ useT: () => (key: string, options?: Record): string => - String(options?.defaultValue ?? key), + String(options?.defaultValue ?? key).replace( + /{{(\w+)}}/g, + (_, name: string) => options?.[name] ?? `{{${name}}}`, + ), +})); + +vi.mock("./TimezoneSelect.js", () => ({ + browserTimezone: () => "UTC", + TimezoneSelect: ({ + id, + value, + disabled, + onChange, + }: { + id?: string; + value: string; + disabled?: boolean; + onChange: (value: string) => void; + }) => ( + + ), })); import { AutomationScheduleDialog } from "./AutomationScheduleDialog.js"; @@ -21,6 +50,23 @@ function findButton(container: HTMLElement, text: string): HTMLButtonElement { return match as HTMLButtonElement; } +function changeValue( + element: HTMLInputElement | HTMLSelectElement, + value: string, +) { + const prototype = + element instanceof HTMLSelectElement + ? HTMLSelectElement.prototype + : HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; + if (!setter) throw new Error("value setter unavailable"); + act(() => { + setter.call(element, value); + element.dispatchEvent(new Event("change", { bubbles: true })); + element.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + describe("AutomationScheduleDialog", () => { let container: HTMLDivElement; let root: Root; @@ -58,22 +104,68 @@ describe("AutomationScheduleDialog", () => { }); } - it("saves the automation's stored zone alongside an edited schedule", () => { + it("saves the automation's stored zone alongside a friendly schedule edit", () => { render({ schedule: "0 8 * * *", timezone: "America/New_York" }); - act(() => { - findButton(document.body, "Every hour").click(); - }); - act(() => { - findButton(document.body, "Save").click(); - }); + const time = document.querySelector("#automation-time"); + if (!time) throw new Error("time field unavailable"); + changeValue(time, "08:30"); + act(() => findButton(document.body, "Save").click()); expect(onSave).toHaveBeenCalledWith({ - schedule: "0 * * * *", + schedule: "30 8 * * *", timezone: "America/New_York", }); }); + it("shows friendly controls and a live schedule summary", () => { + render({ schedule: "0 9 * * 1-5", timezone: "Europe/Paris" }); + + expect(document.body.textContent).toContain("Frequency"); + expect(document.body.textContent).toContain("Weekdays"); + expect(document.body.textContent).toContain( + "Weekdays at 09:00 (Europe/Paris)", + ); + }); + + it("opens irregular valid cron in Advanced mode and preserves its bytes", () => { + const irregular = " */15 * * * * "; + render({ schedule: irregular, timezone: "UTC" }); + + const cron = document.querySelector( + "#automation-schedule", + ); + expect(cron?.value).toBe(irregular); + expect(document.body.textContent).toContain("custom cron pattern"); + + const timezone = document.querySelector( + "#automation-timezone", + ); + if (!timezone) throw new Error("timezone field unavailable"); + changeValue(timezone, "Europe/Paris"); + act(() => findButton(document.body, "Save").click()); + + expect(onSave).toHaveBeenCalledWith({ + schedule: irregular, + timezone: "Europe/Paris", + }); + }); + + it("keeps Advanced input synchronized with friendly edits", () => { + render({ schedule: "0 8 * * *", timezone: "UTC" }); + act(() => findButton(document.body, "Advanced").click()); + + const cron = document.querySelector( + "#automation-schedule", + ); + expect(cron?.value).toBe("0 8 * * *"); + + const time = document.querySelector("#automation-time"); + if (!time) throw new Error("time field unavailable"); + changeValue(time, "17:45"); + expect(cron?.value).toBe("45 17 * * *"); + }); + it("keeps Save disabled until something actually changes", () => { // A legacy automation has no stored zone, so the picker defaults to the // browser's. That default is not an edit and must not arm the button. diff --git a/packages/core/src/client/agent-page/AutomationScheduleDialog.tsx b/packages/core/src/client/agent-page/AutomationScheduleDialog.tsx index 0d8ba261ce..3b33e72ae1 100644 --- a/packages/core/src/client/agent-page/AutomationScheduleDialog.tsx +++ b/packages/core/src/client/agent-page/AutomationScheduleDialog.tsx @@ -1,5 +1,4 @@ import { Button } from "@agent-native/toolkit/ui/button"; -import { Input } from "@agent-native/toolkit/ui/input"; import { IconLoader2 } from "@tabler/icons-react"; import { useEffect, useState } from "react"; @@ -12,20 +11,9 @@ import { DialogTitle, } from "../components/ui/dialog.js"; import { useT } from "../i18n.js"; -import { TimezoneSelect, browserTimezone } from "./TimezoneSelect.js"; - -const PRESETS: { label: string; cron: string }[] = [ - { label: "Every hour", cron: "0 * * * *" }, - { label: "Every day at 8:00", cron: "0 8 * * *" }, - { label: "Every weekday at 9:00", cron: "0 9 * * 1-5" }, - { label: "Every Monday at 8:00", cron: "0 8 * * 1" }, -]; - -const CRON_FIELD_COUNT = 5; - -function looksLikeCron(value: string): boolean { - return value.trim().split(/\s+/).length === CRON_FIELD_COUNT; -} +import { isValidAutomationSchedule } from "./automation-schedule-fields.js"; +import { AutomationScheduleFields } from "./AutomationScheduleFields.js"; +import { browserTimezone } from "./TimezoneSelect.js"; export interface AutomationScheduleDialogProps { open: boolean; @@ -58,10 +46,9 @@ export function AutomationScheduleDialog({ setZone(timezone || browserTimezone()); }, [open, schedule, timezone]); - const trimmed = value.trim(); - const valid = looksLikeCron(trimmed); + const valid = isValidAutomationSchedule(value); const changed = - trimmed !== schedule.trim() || zone !== (timezone || browserTimezone()); + value !== schedule || zone !== (timezone || browserTimezone()); return (
-
- - setValue(event.target.value)} - /> -

- {t("jobs.cronFormatHint", { - defaultValue: "minute hour day-of-month month day-of-week", - })} -

-
- -
- {PRESETS.map((preset) => ( - - ))} -
- -
- -
- -
-
+ - {trimmed && !valid ? ( + {value && !valid ? (

- {t("jobs.cronFieldCount", { - defaultValue: "A cron expression needs exactly 5 fields.", + {t("jobs.cronInvalid", { + defaultValue: "Enter a valid cron expression.", })}

) : null} @@ -168,7 +106,7 @@ export function AutomationScheduleDialog({ type="button" className="cursor-pointer" disabled={saving || !valid || !changed} - onClick={() => onSave({ schedule: trimmed, timezone: zone })} + onClick={() => onSave({ schedule: value, timezone: zone })} > {saving ? : null} {t("jobs.saveSchedule", { defaultValue: "Save schedule" })} diff --git a/packages/core/src/client/agent-page/AutomationScheduleFields.tsx b/packages/core/src/client/agent-page/AutomationScheduleFields.tsx new file mode 100644 index 0000000000..a9c29d8cf5 --- /dev/null +++ b/packages/core/src/client/agent-page/AutomationScheduleFields.tsx @@ -0,0 +1,359 @@ +import { Button } from "@agent-native/toolkit/ui/button"; +import { Input } from "@agent-native/toolkit/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@agent-native/toolkit/ui/select"; +import { IconChevronDown, IconChevronRight } from "@tabler/icons-react"; +import { useEffect, useState } from "react"; + +import { useT } from "../i18n.js"; +import { + DEFAULT_FRIENDLY_AUTOMATION_SCHEDULE, + friendlyAutomationScheduleToCron, + parseFriendlyAutomationSchedule, + timeValue, + withTimeValue, + type AutomationFrequency, + type FriendlyAutomationSchedule, +} from "./automation-schedule-fields.js"; +import { TimezoneSelect, browserTimezone } from "./TimezoneSelect.js"; + +const FREQUENCIES: AutomationFrequency[] = [ + "hourly", + "daily", + "weekdays", + "weekly", + "monthly", +]; + +const WEEKDAYS = [0, 1, 2, 3, 4, 5, 6]; + +export interface AutomationScheduleFieldsProps { + schedule: string; + timezone: string; + disabled?: boolean; + onScheduleChange: (schedule: string) => void; + onTimezoneChange: (timezone: string) => void; +} + +export function AutomationScheduleFields({ + schedule, + timezone, + disabled, + onScheduleChange, + onTimezoneChange, +}: AutomationScheduleFieldsProps) { + const t = useT(); + const friendly = parseFriendlyAutomationSchedule(schedule); + const [advancedOpen, setAdvancedOpen] = useState(() => friendly === null); + + useEffect(() => { + if (!friendly) setAdvancedOpen(true); + }, [friendly]); + + const frequencyLabels: Record = { + hourly: t("jobs.scheduleFrequencyHourly", { defaultValue: "Hourly" }), + daily: t("jobs.scheduleFrequencyDaily", { defaultValue: "Daily" }), + weekdays: t("jobs.scheduleFrequencyWeekdays", { + defaultValue: "Weekdays", + }), + weekly: t("jobs.scheduleFrequencyWeekly", { defaultValue: "Weekly" }), + monthly: t("jobs.scheduleFrequencyMonthly", { defaultValue: "Monthly" }), + }; + const weekdayLabels = [ + t("jobs.weekdaySunday", { defaultValue: "Sunday" }), + t("jobs.weekdayMonday", { defaultValue: "Monday" }), + t("jobs.weekdayTuesday", { defaultValue: "Tuesday" }), + t("jobs.weekdayWednesday", { defaultValue: "Wednesday" }), + t("jobs.weekdayThursday", { defaultValue: "Thursday" }), + t("jobs.weekdayFriday", { defaultValue: "Friday" }), + t("jobs.weekdaySaturday", { defaultValue: "Saturday" }), + ]; + + function updateFriendly(next: FriendlyAutomationSchedule) { + onScheduleChange(friendlyAutomationScheduleToCron(next)); + } + + function chooseFrequency(frequency: AutomationFrequency) { + updateFriendly({ + ...(friendly ?? DEFAULT_FRIENDLY_AUTOMATION_SCHEDULE), + frequency, + }); + } + + const friendlySummary = friendly + ? scheduleSummary(friendly, timezone, frequencyLabels, weekdayLabels, t) + : null; + + return ( +
+ {friendly ? ( + <> +
+ + +
+ +
+ {friendly.frequency === "hourly" ? ( +
+ + { + const minute = event.currentTarget.valueAsNumber; + if ( + Number.isInteger(minute) && + minute >= 0 && + minute <= 59 + ) { + updateFriendly({ ...friendly, minute }); + } + }} + /> +
+ ) : ( +
+ + { + const next = withTimeValue( + friendly, + event.currentTarget.value, + ); + if (next) updateFriendly(next); + }} + /> +
+ )} + + {friendly.frequency === "weekly" ? ( +
+ + +
+ ) : null} + + {friendly.frequency === "monthly" ? ( +
+ + { + const dayOfMonth = event.currentTarget.valueAsNumber; + if ( + Number.isInteger(dayOfMonth) && + dayOfMonth >= 1 && + dayOfMonth <= 31 + ) { + updateFriendly({ ...friendly, dayOfMonth }); + } + }} + /> +
+ ) : null} +
+ + ) : ( +

+ {t("jobs.scheduleAdvancedDetected", { + defaultValue: + "This schedule uses a custom cron pattern. Edit it in Advanced mode.", + })} +

+ )} + +
+ +
+ +
+
+ + {friendlySummary ? ( +
+ + {t("jobs.scheduleSummaryLabel", { defaultValue: "Runs" })} + {" "} + {friendlySummary} +
+ ) : null} + +
+ + + {advancedOpen ? ( +
+ + onScheduleChange(event.currentTarget.value)} + /> +

+ {t("jobs.cronFormatHint", { + defaultValue: "minute hour day-of-month month day-of-week", + })} +

+
+ ) : null} +
+
+ ); +} + +function scheduleSummary( + schedule: FriendlyAutomationSchedule, + timezone: string, + frequencyLabels: Record, + weekdayLabels: string[], + t: ReturnType, +): string { + const time = timeValue(schedule); + switch (schedule.frequency) { + case "hourly": + return t("jobs.scheduleSummaryHourly", { + defaultValue: "hourly at minute {{minute}} ({{timezone}})", + minute: String(schedule.minute).padStart(2, "0"), + timezone, + }); + case "weekly": + return t("jobs.scheduleSummaryWeekly", { + defaultValue: "every {{weekday}} at {{time}} ({{timezone}})", + weekday: weekdayLabels[schedule.weekday], + time, + timezone, + }); + case "monthly": + return t("jobs.scheduleSummaryMonthly", { + defaultValue: "monthly on day {{day}} at {{time}} ({{timezone}})", + day: String(schedule.dayOfMonth), + time, + timezone, + }); + default: + return t("jobs.scheduleSummaryTimed", { + defaultValue: "{{frequency}} at {{time}} ({{timezone}})", + frequency: frequencyLabels[schedule.frequency], + time, + timezone, + }); + } +} diff --git a/packages/core/src/client/agent-page/automation-schedule-fields.spec.ts b/packages/core/src/client/agent-page/automation-schedule-fields.spec.ts new file mode 100644 index 0000000000..03bbf0b46f --- /dev/null +++ b/packages/core/src/client/agent-page/automation-schedule-fields.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { + friendlyAutomationScheduleToCron, + isValidAutomationSchedule, + parseFriendlyAutomationSchedule, +} from "./automation-schedule-fields.js"; + +describe("automation schedule fields", () => { + it.each([ + ["15 * * * *", "hourly"], + ["30 8 * * *", "daily"], + ["0 9 * * 1-5", "weekdays"], + ["0 9 * * MON-FRI", "weekdays"], + ["45 17 * * 3", "weekly"], + ["0 7 21 * *", "monthly"], + ] as const)("parses friendly cron %s as %s", (cron, frequency) => { + expect(parseFriendlyAutomationSchedule(cron)?.frequency).toBe(frequency); + }); + + it.each([ + "*/15 * * * *", + "0 9,17 * * *", + "0 9 * * 1,3,5", + "0 9 1 6 *", + "@midnight", + ])("leaves valid but irregular cron in advanced mode: %s", (cron) => { + expect(isValidAutomationSchedule(cron)).toBe(true); + expect(parseFriendlyAutomationSchedule(cron)).toBeNull(); + }); + + it("converts every friendly frequency to deterministic five-field cron", () => { + const base = { hour: 8, minute: 5, weekday: 4, dayOfMonth: 12 }; + + expect( + friendlyAutomationScheduleToCron({ ...base, frequency: "hourly" }), + ).toBe("5 * * * *"); + expect( + friendlyAutomationScheduleToCron({ ...base, frequency: "daily" }), + ).toBe("5 8 * * *"); + expect( + friendlyAutomationScheduleToCron({ ...base, frequency: "weekdays" }), + ).toBe("5 8 * * 1-5"); + expect( + friendlyAutomationScheduleToCron({ ...base, frequency: "weekly" }), + ).toBe("5 8 * * 4"); + expect( + friendlyAutomationScheduleToCron({ ...base, frequency: "monthly" }), + ).toBe("5 8 12 * *"); + }); + + it("rejects invalid cron without treating it as a friendly schedule", () => { + expect(isValidAutomationSchedule("99 99 * * *")).toBe(false); + expect(parseFriendlyAutomationSchedule("99 99 * * *")).toBeNull(); + }); +}); diff --git a/packages/core/src/client/agent-page/automation-schedule-fields.ts b/packages/core/src/client/agent-page/automation-schedule-fields.ts new file mode 100644 index 0000000000..690e10f0ed --- /dev/null +++ b/packages/core/src/client/agent-page/automation-schedule-fields.ts @@ -0,0 +1,155 @@ +import { isValidCron } from "../../jobs/cron.js"; + +export type AutomationFrequency = + | "hourly" + | "daily" + | "weekdays" + | "weekly" + | "monthly"; + +export interface FriendlyAutomationSchedule { + frequency: AutomationFrequency; + hour: number; + minute: number; + weekday: number; + dayOfMonth: number; +} + +export const DEFAULT_FRIENDLY_AUTOMATION_SCHEDULE: FriendlyAutomationSchedule = + { + frequency: "daily", + hour: 9, + minute: 0, + weekday: 1, + dayOfMonth: 1, + }; + +function integerInRange( + value: string, + minimum: number, + maximum: number, +): number | null { + if (!/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= minimum && parsed <= maximum + ? parsed + : null; +} + +/** Parse only schedules whose meaning can be represented by the friendly fields. */ +export function parseFriendlyAutomationSchedule( + cron: string, +): FriendlyAutomationSchedule | null { + const parts = cron.trim().split(/\s+/); + if (parts.length !== 5) return null; + + const [minuteField, hourField, dayOfMonth, month, dayOfWeek] = parts; + const minute = integerInRange(minuteField, 0, 59); + if (minute === null || month !== "*") return null; + + if (hourField === "*" && dayOfMonth === "*" && dayOfWeek === "*") { + return { + ...DEFAULT_FRIENDLY_AUTOMATION_SCHEDULE, + frequency: "hourly", + minute, + }; + } + + const hour = integerInRange(hourField, 0, 23); + if (hour === null) return null; + + if (dayOfMonth === "*" && dayOfWeek === "*") { + return { + ...DEFAULT_FRIENDLY_AUTOMATION_SCHEDULE, + frequency: "daily", + hour, + minute, + }; + } + + if ( + dayOfMonth === "*" && + (dayOfWeek === "1-5" || dayOfWeek.toUpperCase() === "MON-FRI") + ) { + return { + ...DEFAULT_FRIENDLY_AUTOMATION_SCHEDULE, + frequency: "weekdays", + hour, + minute, + }; + } + + if (dayOfMonth === "*") { + const parsedWeekday = integerInRange(dayOfWeek, 0, 7); + if (parsedWeekday !== null) { + return { + ...DEFAULT_FRIENDLY_AUTOMATION_SCHEDULE, + frequency: "weekly", + hour, + minute, + weekday: parsedWeekday === 7 ? 0 : parsedWeekday, + }; + } + } + + if (dayOfWeek === "*") { + const parsedDay = integerInRange(dayOfMonth, 1, 31); + if (parsedDay !== null) { + return { + ...DEFAULT_FRIENDLY_AUTOMATION_SCHEDULE, + frequency: "monthly", + hour, + minute, + dayOfMonth: parsedDay, + }; + } + } + + return null; +} + +/** Convert friendly fields to one deterministic, five-field cron expression. */ +export function friendlyAutomationScheduleToCron( + schedule: FriendlyAutomationSchedule, +): string { + const minute = Math.min(59, Math.max(0, Math.trunc(schedule.minute))); + const hour = Math.min(23, Math.max(0, Math.trunc(schedule.hour))); + + switch (schedule.frequency) { + case "hourly": + return `${minute} * * * *`; + case "daily": + return `${minute} ${hour} * * *`; + case "weekdays": + return `${minute} ${hour} * * 1-5`; + case "weekly": { + const weekday = Math.min(6, Math.max(0, Math.trunc(schedule.weekday))); + return `${minute} ${hour} * * ${weekday}`; + } + case "monthly": { + const day = Math.min(31, Math.max(1, Math.trunc(schedule.dayOfMonth))); + return `${minute} ${hour} ${day} * *`; + } + } +} + +export function isValidAutomationSchedule(cron: string): boolean { + return isValidCron(cron); +} + +export function timeValue(schedule: FriendlyAutomationSchedule): string { + return `${String(schedule.hour).padStart(2, "0")}:${String(schedule.minute).padStart(2, "0")}`; +} + +export function withTimeValue( + schedule: FriendlyAutomationSchedule, + value: string, +): FriendlyAutomationSchedule | null { + const match = /^(\d{2}):(\d{2})$/.exec(value); + if (!match) return null; + const hour = integerInRange(match[1], 0, 23); + const minute = integerInRange(match[2], 0, 59); + return hour === null || minute === null + ? null + : { ...schedule, hour, minute }; +} From 86c89827434d7d61769500a837229d921a4e9f09 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 5 Aug 2026 00:19:20 +0000 Subject: [PATCH 05/19] Refactor automation UI and improve event trigger handling --- .../agent-page/AgentJobsTab.blocked.spec.tsx | 12 +- .../client/agent-page/AgentJobsTab.spec.tsx | 97 ++-- .../src/client/agent-page/AgentJobsTab.tsx | 193 +++++--- .../AutomationEditorDialog.spec.tsx | 386 ++++++++++++++++ .../agent-page/AutomationEditorDialog.tsx | 429 ++++++++++++++++++ .../AutomationEditorTriggerFields.tsx | 366 +++++++++++++++ 6 files changed, 1377 insertions(+), 106 deletions(-) create mode 100644 packages/core/src/client/agent-page/AutomationEditorDialog.spec.tsx create mode 100644 packages/core/src/client/agent-page/AutomationEditorDialog.tsx create mode 100644 packages/core/src/client/agent-page/AutomationEditorTriggerFields.tsx diff --git a/packages/core/src/client/agent-page/AgentJobsTab.blocked.spec.tsx b/packages/core/src/client/agent-page/AgentJobsTab.blocked.spec.tsx index c858705ea8..fb9c7d158b 100644 --- a/packages/core/src/client/agent-page/AgentJobsTab.blocked.spec.tsx +++ b/packages/core/src/client/agent-page/AgentJobsTab.blocked.spec.tsx @@ -23,12 +23,6 @@ vi.mock("./use-jobs.js", () => ({ useRunAutomationNow: jobMocks.useRunAutomationNow, })); -vi.mock("../AgentAskPopover.js", () => ({ - AgentAskPopover: ({ title, label }: { title: string; label?: string }) => ( - - ), -})); - vi.mock("../i18n.js", () => ({ useFormatters: () => ({ formatDate: (value: string) => value }), useT: @@ -147,6 +141,12 @@ describe("AgentJobsTab blocked automation", () => { act(() => { editButton?.click(); }); + const advancedButton = Array.from( + document.body.querySelectorAll("button"), + ).find((button) => button.textContent?.trim() === "Advanced"); + act(() => { + advancedButton?.click(); + }); const input = document.querySelector( "#automation-schedule", diff --git a/packages/core/src/client/agent-page/AgentJobsTab.spec.tsx b/packages/core/src/client/agent-page/AgentJobsTab.spec.tsx index e36fa60d90..fdbdb30ca8 100644 --- a/packages/core/src/client/agent-page/AgentJobsTab.spec.tsx +++ b/packages/core/src/client/agent-page/AgentJobsTab.spec.tsx @@ -10,6 +10,7 @@ const jobMocks = vi.hoisted(() => ({ user: vi.fn(), }, useRunAutomationNow: vi.fn(), + useAutomationEvents: vi.fn(), useAutomations: vi.fn(), useManageAutomation: vi.fn(), useManageRecurringJob: vi.fn(), @@ -17,6 +18,7 @@ const jobMocks = vi.hoisted(() => ({ })); vi.mock("./use-jobs.js", () => ({ + useAutomationEvents: jobMocks.useAutomationEvents, useAutomations: jobMocks.useAutomations, useManageAutomation: jobMocks.useManageAutomation, useManageRecurringJob: jobMocks.useManageRecurringJob, @@ -24,22 +26,6 @@ vi.mock("./use-jobs.js", () => ({ useRunAutomationNow: jobMocks.useRunAutomationNow, })); -vi.mock("../AgentAskPopover.js", () => ({ - AgentAskPopover: ({ - context, - label, - title, - }: { - context: string; - label?: string; - title: string; - }) => ( - - ), -})); - vi.mock("../i18n.js", () => ({ useFormatters: () => ({ formatDate: (value: string) => value, @@ -58,10 +44,7 @@ vi.mock("../i18n.js", () => ({ }, })); -import { - AgentJobsTab, - organizationAutomationCreationContext, -} from "./AgentJobsTab.js"; +import { AgentJobsTab } from "./AgentJobsTab.js"; function queryResult(data: T) { return { @@ -141,6 +124,7 @@ describe("AgentJobsTab organization automations", () => { : [], ), ); + jobMocks.useAutomationEvents.mockReturnValue(queryResult([])); jobMocks.useManageRecurringJob.mockReturnValue(mutationResult()); jobMocks.useRunAutomationNow.mockReturnValue(mutationResult()); jobMocks.useManageAutomation.mockImplementation((scope: "user" | "org") => @@ -163,7 +147,7 @@ describe("AgentJobsTab organization automations", () => { expect(jobMocks.useAutomations).toHaveBeenCalledWith("org"); expect(container.textContent).toContain("weekly report"); expect(container.textContent).toContain("new lead alert"); - expect(container.textContent).toContain("On lead.created"); + expect(container.textContent).toContain("Runs when lead.created."); expect(container.textContent).toContain( "Scheduled and event-triggered automations shared with this organization.", ); @@ -198,22 +182,73 @@ describe("AgentJobsTab organization automations", () => { expect(jobMocks.manageAutomation.user).not.toHaveBeenCalled(); }); - it("creates organization automations through the scoped automation tool", () => { + it("opens organization creation with scope fixed by its section", () => { + act(() => { + root.render(); + }); + + const organizationSection = [...container.querySelectorAll("section")].find( + (section) => section.textContent?.includes("Organization"), + ); + const createButton = [ + ...(organizationSection?.querySelectorAll("button") ?? []), + ].find((button) => button.textContent?.trim() === "New automation"); + act(() => createButton?.click()); + + expect(document.body.textContent).toContain( + "fixed to the organization scope", + ); + }); + + it("closes the full editor after an explicit automation update succeeds", () => { + jobMocks.useManageAutomation.mockImplementation( + (scope: "user" | "org") => ({ + error: null, + isPending: false, + mutate: (input: unknown, options?: { onSuccess?: () => void }) => { + jobMocks.manageAutomation[scope](input); + options?.onSuccess?.(); + }, + }), + ); act(() => { root.render(); }); - const orgCreationButton = Array.from( - container.querySelectorAll("[data-creation-context]"), - ).find((button) => - button - .getAttribute("data-creation-context") - ?.includes("scope=organization"), + const eventRow = [...container.querySelectorAll("article")].find((row) => + row.textContent?.includes("new lead alert"), + ); + const editButton = [...(eventRow?.querySelectorAll("button") ?? [])].find( + (button) => button.textContent?.trim() === "Edit", + ); + act(() => editButton?.click()); + expect(document.body.textContent).toContain("Edit automation"); + + const body = + document.querySelector("#automation-body"); + if (!body) throw new Error("No automation instructions field"); + const setter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + act(() => { + setter?.call(body, "Notify the account team."); + body.dispatchEvent(new Event("input", { bubbles: true })); + body.dispatchEvent(new Event("change", { bubbles: true })); + }); + const saveButton = [...document.body.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Save changes", ); + act(() => saveButton?.click()); - expect(orgCreationButton).not.toBeUndefined(); - expect(organizationAutomationCreationContext()).toContain( - "manage-automations with action=define and scope=organization", + expect(jobMocks.manageAutomation.org).toHaveBeenCalledWith( + expect.objectContaining({ + operation: "update", + name: "new-lead-alert", + scope: "organization", + body: "Notify the account team.", + }), ); + expect(document.body.textContent).not.toContain("Edit automation"); }); }); diff --git a/packages/core/src/client/agent-page/AgentJobsTab.tsx b/packages/core/src/client/agent-page/AgentJobsTab.tsx index 19c40a4d7d..929fc2f788 100644 --- a/packages/core/src/client/agent-page/AgentJobsTab.tsx +++ b/packages/core/src/client/agent-page/AgentJobsTab.tsx @@ -1,11 +1,12 @@ import { Button } from "@agent-native/toolkit/ui/button"; import { + IconAlertTriangle, IconBolt, IconCalendarEvent, IconClock, - IconAlertTriangle, IconEye, IconLoader2, + IconMail, IconPencil, IconPlayerPause, IconPlayerPlay, @@ -13,7 +14,6 @@ import { } from "@tabler/icons-react"; import { useState } from "react"; -import { AgentAskPopover } from "../AgentAskPopover.js"; import { Dialog, DialogContent, @@ -23,13 +23,13 @@ import { DialogTitle, } from "../components/ui/dialog.js"; import { useFormatters, useT } from "../i18n.js"; -import { automationCreationContext } from "../settings/AutomationsSection.js"; import { AgentEmptyState } from "./AgentEmptyState.js"; import { AgentTabFrame } from "./AgentTabFrame.js"; import { AutomationDetailsDialog, type AutomationDetailsField, } from "./AutomationDetailsDialog.js"; +import { AutomationEditorDialog } from "./AutomationEditorDialog.js"; import { AutomationScheduleDialog } from "./AutomationScheduleDialog.js"; import type { AgentPageTabProps } from "./types.js"; import { @@ -72,13 +72,42 @@ function listAutomations(automations: Automation[]): ListedAutomation[] { type Translate = ReturnType; +const EMAIL_EVENT = "mail.message.received"; + +function triggerLabel(entry: ListedAutomation, t: Translate): string { + if (entry.triggerType === "manual") { + return t("jobs.manualTrigger", { defaultValue: "On demand" }); + } + if ( + entry.kind === "automation" && + entry.triggerType === "event" && + entry.resource.event === EMAIL_EVENT + ) { + return t("jobs.emailTrigger", { defaultValue: "Email received" }); + } + return entry.triggerType === "event" + ? t("jobs.eventTrigger", { defaultValue: "App event" }) + : t("jobs.scheduledTrigger", { defaultValue: "Scheduled" }); +} + function describeTrigger(entry: ListedAutomation, t: Translate): string { - if (entry.kind === "automation" && entry.triggerType === "event") { - return t("jobs.automationEventDetails", { - defaultValue: "Runs when {{event}}.", - event: entry.resource.event ?? "an event fires", + if (entry.triggerType === "manual") { + return t("jobs.automationManualDetails", { + defaultValue: "Runs only when started on demand.", }); } + if (entry.kind === "automation" && entry.triggerType === "event") { + return entry.resource.event === EMAIL_EVENT + ? t("jobs.automationEmailDetails", { + defaultValue: "Runs when an email is received.", + }) + : t("jobs.automationEventDetails", { + defaultValue: "Runs when {{event}}.", + event: + entry.resource.event ?? + t("jobs.unknownEvent", { defaultValue: "an app event fires" }), + }); + } return ( entry.resource.scheduleDescription || entry.resource.schedule || @@ -102,10 +131,7 @@ function detailsFields( }, { label: t("jobs.trigger", { defaultValue: "Trigger" }), - value: - entry.triggerType === "event" - ? t("jobs.eventTrigger", { defaultValue: "Event-triggered" }) - : t("jobs.scheduledTrigger", { defaultValue: "Scheduled" }), + value: triggerLabel(entry, t), }, ]; @@ -165,10 +191,6 @@ function detailsFields( return fields; } -export function organizationAutomationCreationContext(): string { - return "The user wants to create a new organization automation. Use manage-automations with action=define and scope=organization to create it. Ask clarifying questions if needed about whether it runs on a schedule or event, any conditions, and what actions to take."; -} - export function AgentJobsTab({ canManageOrg = false }: AgentPageTabProps) { const t = useT(); const formatters = useFormatters(); @@ -190,6 +212,10 @@ export function AgentJobsTab({ canManageOrg = false }: AgentPageTabProps) { const [scheduleTarget, setScheduleTarget] = useState( null, ); + const [editorTarget, setEditorTarget] = useState<{ + scope: "personal" | "organization"; + automation: Automation | null; + } | null>(null); const [runTarget, setRunTarget] = useState(null); const formatDateTime = (value: string | null) => { @@ -277,19 +303,15 @@ export function AgentJobsTab({ canManageOrg = false }: AgentPageTabProps) { })} ) : null} - +
) : null} @@ -334,15 +356,15 @@ export function AgentJobsTab({ canManageOrg = false }: AgentPageTabProps) { } action={ organization ? null : ( - + ) } /> @@ -353,17 +375,7 @@ export function AgentJobsTab({ canManageOrg = false }: AgentPageTabProps) { const lastRun = formatDateTime(resource.lastRun); const lastCheck = formatDateTime(resource.lastCheck); const nextRun = formatDateTime(resource.nextRun); - const triggerDescription = - entry.kind === "automation" && entry.triggerType === "event" - ? t("jobs.automationEventTrigger", { - defaultValue: "On {{event}}", - event: entry.resource.event ?? "event", - }) - : resource.scheduleDescription || - resource.schedule || - t("jobs.scheduledTrigger", { - defaultValue: "Scheduled", - }); + const triggerDescription = describeTrigger(entry, t); const instructions = entry.kind === "automation" ? entry.resource.body @@ -376,7 +388,13 @@ export function AgentJobsTab({ canManageOrg = false }: AgentPageTabProps) { >
- {entry.triggerType === "event" ? ( + {entry.triggerType === "manual" ? ( + + ) : entry.kind === "automation" && + entry.triggerType === "event" && + entry.resource.event === EMAIL_EVENT ? ( + + ) : entry.triggerType === "event" ? ( ) : ( @@ -388,13 +406,7 @@ export function AgentJobsTab({ canManageOrg = false }: AgentPageTabProps) { {resource.name.replace(/-/g, " ")} - {entry.triggerType === "event" - ? t("jobs.eventTrigger", { - defaultValue: "Event-triggered", - }) - : t("jobs.scheduledTrigger", { - defaultValue: "Scheduled", - })} + {triggerLabel(entry, t)} {t("jobs.runNow", { defaultValue: "Run now" })} - {entry.triggerType === "schedule" ? ( + {entry.kind === "automation" ? ( + + ) : entry.triggerType === "schedule" ? ( } >
@@ -745,6 +771,35 @@ export function AgentJobsTab({ canManageOrg = false }: AgentPageTabProps) { /> ) : null} + {editorTarget ? ( + setEditorTarget(null)} + onSave={(input) => { + const mutation = + editorTarget.scope === "organization" + ? organizationAutomationsMutation + : personalAutomationsMutation; + mutation.mutate(input, { + onSuccess: () => setEditorTarget(null), + }); + }} + /> + ) : null} + {scheduleTarget ? ( ({ + events: [] as Array<{ + name: string; + description: string; + payloadSchema: null; + example: null; + }>, + openAgentSettings: vi.fn(), +})); + +vi.mock("./use-jobs.js", () => ({ + useAutomationEvents: () => ({ + data: mocks.events, + error: null, + isLoading: false, + }), +})); + +vi.mock("../CommandMenu.js", () => ({ + openAgentSettings: mocks.openAgentSettings, +})); + +vi.mock("../i18n.js", () => ({ + useT: + () => + ( + key: string, + options?: Record, + ): string => { + let result = String(options?.defaultValue ?? key); + for (const [name, value] of Object.entries(options ?? {})) { + result = result.replaceAll(`{{${name}}}`, String(value)); + } + return result; + }, +})); + +vi.mock("./TimezoneSelect.js", () => ({ + browserTimezone: () => "UTC", + TimezoneSelect: ({ + id, + value, + disabled, + onChange, + }: { + id?: string; + value: string; + disabled?: boolean; + onChange: (value: string) => void; + }) => ( + + ), +})); + +import { AutomationEditorDialog } from "./AutomationEditorDialog.js"; +import type { Automation } from "./use-jobs.js"; + +function findButton(text: string): HTMLButtonElement { + const button = [...document.body.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim().startsWith(text), + ); + if (!button) throw new Error(`No button named "${text}"`); + return button as HTMLButtonElement; +} + +function changeValue( + element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, + value: string, +) { + const prototype = + element instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : element instanceof HTMLSelectElement + ? HTMLSelectElement.prototype + : HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; + if (!setter) throw new Error("Value setter unavailable"); + act(() => { + setter.call(element, value); + element.dispatchEvent(new Event("input", { bubbles: true })); + element.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +function input(id: string): HTMLInputElement { + const element = document.querySelector(`#${id}`); + if (!element) throw new Error(`No input #${id}`); + return element; +} + +function click(text: string) { + act(() => findButton(text).click()); +} + +function explicitAutomation(patch: Partial = {}): Automation { + return { + id: "automation-1", + name: "customer-digest", + path: "jobs/customer-digest.md", + scope: "personal", + triggerType: "schedule", + event: null, + schedule: " */15 * * * * ", + timezone: "UTC", + scheduleDescription: null, + condition: null, + body: "Summarize customer updates.", + enabled: true, + lastRun: null, + lastCheck: null, + lastStatus: null, + lastError: null, + nextRun: null, + createdBy: null, + model: null, + mcpTools: [], + originScopeId: null, + deliveryPlatform: null, + deliveryDestination: null, + deliveryThreadRef: null, + deliveryTenantId: null, + canUpdate: true, + ...patch, + }; +} + +describe("AutomationEditorDialog", () => { + let container: HTMLDivElement; + let root: Root; + const onSave = vi.fn(); + const onCancel = vi.fn(); + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + Element.prototype.scrollIntoView = () => {}; + vi.stubGlobal( + "ResizeObserver", + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + mocks.events = [ + { + name: "issue.created", + description: "A new issue was created.", + payloadSchema: null, + example: null, + }, + { + name: "mail.message.received", + description: "A new email arrived.", + payloadSchema: null, + example: null, + }, + ]; + mocks.openAgentSettings.mockReset(); + onSave.mockReset(); + onCancel.mockReset(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + function render( + props: Partial> = {}, + ) { + act(() => { + root.render( + , + ); + }); + } + + function fillRequired() { + changeValue(input("automation-name"), "Customer digest"); + const body = + document.querySelector("#automation-body"); + if (!body) throw new Error("No instructions field"); + changeValue(body, "Summarize customer updates."); + } + + it.each(["personal", "organization"] as const)( + "fixes create payloads to the %s opener scope", + (scope) => { + render({ scope }); + fillRequired(); + click("On demand"); + click("Create automation"); + + expect(onSave).toHaveBeenCalledWith({ + operation: "create", + name: "Customer digest", + scope, + triggerType: "manual", + body: "Summarize customer updates.", + event: undefined, + schedule: undefined, + timezone: undefined, + condition: null, + }); + expect(document.body.textContent).toContain( + `fixed to the ${scope} scope`, + ); + }, + ); + + it("submits and preserves an advanced cron schedule", () => { + render(); + fillRequired(); + click("Advanced"); + changeValue(input("automation-schedule"), " */15 * * * * "); + const zone = document.querySelector( + "#automation-timezone", + ); + if (!zone) throw new Error("No timezone field"); + changeValue(zone, "Europe/Paris"); + click("Create automation"); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + triggerType: "schedule", + schedule: " */15 * * * * ", + timezone: "Europe/Paris", + }), + ); + }); + + it("submits a selected registered app event and optional condition", () => { + render(); + fillRequired(); + click("App event"); + click("Select an event"); + const eventOption = [...document.querySelectorAll("[cmdk-item]")].find( + (item) => item.textContent?.includes("issue.created"), + ); + if (!eventOption) throw new Error("No issue.created event option"); + act(() => + eventOption.dispatchEvent(new MouseEvent("click", { bubbles: true })), + ); + changeValue( + input("automation-event-condition"), + "Only customer-reported issues", + ); + click("Create automation"); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + triggerType: "event", + event: "issue.created", + condition: "Only customer-reported issues", + }), + ); + }); + + it("submits the email event with explicit natural-language field filters", () => { + render({ scope: "organization" }); + fillRequired(); + click("Email received"); + changeValue(input("automation-email-from"), "alerts@example.test"); + changeValue(input("automation-email-to"), "team@example.test"); + changeValue(input("automation-email-subject"), "Urgent"); + changeValue( + input("automation-email-condition"), + "Only messages with attachments", + ); + click("Create automation"); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + scope: "organization", + triggerType: "event", + event: "mail.message.received", + condition: + 'The event field from must contain "alerts@example.test".\n' + + 'The event field to must contain "team@example.test".\n' + + 'The event field subject must contain "Urgent".\n' + + "Also: Only messages with attachments", + }), + ); + }); + + it("keeps Email received visible but unavailable and opens connection settings", () => { + mocks.events = mocks.events.filter( + (event) => event.name !== "mail.message.received", + ); + render(); + + const email = findButton( + "Email receivedConnect Mail to use email-triggered automations.", + ); + expect(email.getAttribute("aria-disabled")).toBe("true"); + act(() => email.click()); + expect(document.querySelector("#automation-email-from")).toBeNull(); + + click("Open connections"); + expect(mocks.openAgentSettings).toHaveBeenCalledWith("connections"); + }); + + it("retains entered values and shows a service error without closing", () => { + render({ error: null }); + fillRequired(); + click("On demand"); + click("Create automation"); + + render({ error: "The automation name is already in use." }); + + expect(input("automation-name").value).toBe("Customer digest"); + expect(document.body.textContent).toContain( + "The automation name is already in use.", + ); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + }); + + it("edits an explicit automation with an immutable name and preserved cron", () => { + const automation = explicitAutomation(); + render({ automation }); + + expect(input("automation-name").readOnly).toBe(true); + expect(input("automation-name").value).toBe("customer digest"); + expect(input("automation-schedule").value).toBe(" */15 * * * * "); + + const body = + document.querySelector("#automation-body"); + if (!body) throw new Error("No instructions field"); + changeValue(body, "Create a concise customer summary."); + click("Save changes"); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + operation: "update", + name: "customer-digest", + scope: "personal", + triggerType: "schedule", + schedule: " */15 * * * * ", + body: "Create a concise customer summary.", + }), + ); + }); + + it("restores specialized email fields while editing", () => { + render({ + automation: explicitAutomation({ + triggerType: "event", + event: "mail.message.received", + schedule: null, + timezone: null, + condition: + 'The event field from must contain "billing@example.test".\nAlso: Only unread messages', + }), + }); + + expect(input("automation-email-from").value).toBe("billing@example.test"); + expect(input("automation-email-condition").value).toBe( + "Only unread messages", + ); + }); +}); diff --git a/packages/core/src/client/agent-page/AutomationEditorDialog.tsx b/packages/core/src/client/agent-page/AutomationEditorDialog.tsx new file mode 100644 index 0000000000..8c0ab40892 --- /dev/null +++ b/packages/core/src/client/agent-page/AutomationEditorDialog.tsx @@ -0,0 +1,429 @@ +import { Button } from "@agent-native/toolkit/ui/button"; +import { Input } from "@agent-native/toolkit/ui/input"; +import { Textarea } from "@agent-native/toolkit/ui/textarea"; +import { IconLoader2 } from "@tabler/icons-react"; +import { useEffect, useMemo, useState } from "react"; + +import { openAgentSettings } from "../CommandMenu.js"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../components/ui/dialog.js"; +import { useT } from "../i18n.js"; +import { + friendlyAutomationScheduleToCron, + isValidAutomationSchedule, + DEFAULT_FRIENDLY_AUTOMATION_SCHEDULE, +} from "./automation-schedule-fields.js"; +import { + AutomationEmailFields, + AutomationEventFields, + AutomationTriggerCards, + type EditorTrigger, + type EmailFilters, +} from "./AutomationEditorTriggerFields.js"; +import { AutomationScheduleFields } from "./AutomationScheduleFields.js"; +import { browserTimezone } from "./TimezoneSelect.js"; +import { + useAutomationEvents, + type Automation, + type ManageAutomationInput, +} from "./use-jobs.js"; + +const EMAIL_EVENT = "mail.message.received"; + +export interface AutomationEditorDialogProps { + open: boolean; + scope: "personal" | "organization"; + automation?: Automation | null; + saving: boolean; + error?: string | null; + onCancel: () => void; + onSave: (input: ManageAutomationInput) => void; +} + +function editorTrigger(automation?: Automation | null): EditorTrigger { + if (!automation) return "schedule"; + if (automation.triggerType === "manual") return "manual"; + if (automation.triggerType === "schedule") return "schedule"; + return automation.event === EMAIL_EVENT ? "email" : "event"; +} + +function parseEmailCondition(condition: string | null): EmailFilters { + const filters: EmailFilters = { + from: "", + to: "", + subject: "", + additional: "", + }; + if (!condition) return filters; + + const unmatched: string[] = []; + for (const line of condition.split("\n")) { + const fieldMatch = + /^The event field (from|to|subject) must contain ("(?:[^"\\]|\\.)*")\.$/.exec( + line, + ); + if (fieldMatch) { + try { + filters[fieldMatch[1] as keyof Omit] = + JSON.parse(fieldMatch[2]) as string; + continue; + } catch { + unmatched.push(line); + continue; + } + } + const additionalMatch = /^Also: (.*)$/.exec(line); + unmatched.push(additionalMatch?.[1] ?? line); + } + filters.additional = unmatched.filter(Boolean).join("\n"); + return filters; +} + +function emailCondition(filters: EmailFilters): string | null { + const lines: string[] = []; + for (const field of ["from", "to", "subject"] as const) { + const value = filters[field].trim(); + if (value) { + lines.push( + `The event field ${field} must contain ${JSON.stringify(value)}.`, + ); + } + } + if (filters.additional.trim()) + lines.push(`Also: ${filters.additional.trim()}`); + return lines.length ? lines.join("\n") : null; +} + +export function AutomationEditorDialog({ + open, + scope, + automation, + saving, + error, + onCancel, + onSave, +}: AutomationEditorDialogProps) { + const t = useT(); + const eventsQuery = useAutomationEvents(); + const defaultSchedule = friendlyAutomationScheduleToCron( + DEFAULT_FRIENDLY_AUTOMATION_SCHEDULE, + ); + const [name, setName] = useState(""); + const [trigger, setTrigger] = useState("schedule"); + const [schedule, setSchedule] = useState(defaultSchedule); + const [timezone, setTimezone] = useState(browserTimezone()); + const [eventName, setEventName] = useState(""); + const [eventCondition, setEventCondition] = useState(""); + const [emailFilters, setEmailFilters] = useState(() => + parseEmailCondition(null), + ); + const [body, setBody] = useState(""); + const [eventPickerOpen, setEventPickerOpen] = useState(false); + const [submitted, setSubmitted] = useState(false); + + useEffect(() => { + if (!open) return; + const nextTrigger = editorTrigger(automation); + setName(automation?.name.replace(/-/g, " ") ?? ""); + setTrigger(nextTrigger); + setSchedule(automation?.schedule ?? defaultSchedule); + setTimezone(automation?.timezone ?? browserTimezone()); + setEventName( + automation?.triggerType === "event" && automation.event !== EMAIL_EVENT + ? (automation.event ?? "") + : "", + ); + setEventCondition( + automation?.triggerType === "event" && automation.event !== EMAIL_EVENT + ? (automation.condition ?? "") + : "", + ); + setEmailFilters( + parseEmailCondition( + automation?.triggerType === "event" && automation.event === EMAIL_EVENT + ? automation.condition + : null, + ), + ); + setBody(automation?.body ?? ""); + setEventPickerOpen(false); + setSubmitted(false); + }, [automation, defaultSchedule, open, scope]); + + const events = eventsQuery.data ?? []; + const emailAvailable = events.some((event) => event.name === EMAIL_EVENT); + const nameInvalid = !automation && !name.trim(); + const bodyInvalid = !body.trim(); + const eventInvalid = trigger === "event" && !eventName; + const scheduleInvalid = + trigger === "schedule" && !isValidAutomationSchedule(schedule); + const invalid = nameInvalid || bodyInvalid || eventInvalid || scheduleInvalid; + + const reviewSummary = useMemo(() => { + switch (trigger) { + case "schedule": + return t("jobs.editorReviewSchedule", { + defaultValue: "Runs on {{schedule}} in {{timezone}}.", + schedule, + timezone, + }); + case "manual": + return t("jobs.editorReviewManual", { + defaultValue: "Runs only when someone starts it on demand.", + }); + case "email": { + const filters = [ + emailFilters.from.trim() + ? t("jobs.editorReviewEmailFromFilter", { + defaultValue: "from contains “{{value}}”", + value: emailFilters.from.trim(), + }) + : null, + emailFilters.to.trim() + ? t("jobs.editorReviewEmailToFilter", { + defaultValue: "to contains “{{value}}”", + value: emailFilters.to.trim(), + }) + : null, + emailFilters.subject.trim() + ? t("jobs.editorReviewEmailSubjectFilter", { + defaultValue: "subject contains “{{value}}”", + value: emailFilters.subject.trim(), + }) + : null, + emailFilters.additional.trim() || null, + ].filter((value): value is string => value !== null); + return filters.length + ? t("jobs.editorReviewEmailFiltered", { + defaultValue: "Runs when an email is received and {{condition}}.", + condition: filters.join(", "), + }) + : t("jobs.editorReviewEmail", { + defaultValue: "Runs whenever an email is received.", + }); + } + default: + return eventName + ? eventCondition.trim() + ? t("jobs.editorReviewConditionalEvent", { + defaultValue: "Runs when {{event}} occurs and {{condition}}.", + event: eventName, + condition: eventCondition.trim(), + }) + : t("jobs.editorReviewEvent", { + defaultValue: "Runs when {{event}} occurs.", + event: eventName, + }) + : t("jobs.editorReviewEventPending", { + defaultValue: "Choose the app event that starts this automation.", + }); + } + }, [emailFilters, eventCondition, eventName, schedule, t, timezone, trigger]); + + function submit() { + setSubmitted(true); + if (invalid) return; + + const input: ManageAutomationInput = { + operation: automation ? "update" : "create", + name: automation?.name ?? name.trim(), + scope, + triggerType: + trigger === "email" + ? "event" + : trigger === "manual" + ? "manual" + : trigger, + body: body.trim(), + event: + trigger === "email" + ? EMAIL_EVENT + : trigger === "event" + ? eventName + : undefined, + schedule: trigger === "schedule" ? schedule : undefined, + timezone: trigger === "schedule" ? timezone : undefined, + condition: + trigger === "email" + ? emailCondition(emailFilters) + : trigger === "event" + ? eventCondition.trim() || null + : null, + }; + onSave(input); + } + + return ( + { + if (!next && !saving) onCancel(); + }} + > + + + + {automation + ? t("jobs.editorEditTitle", { defaultValue: "Edit automation" }) + : t("jobs.editorCreateTitle", { + defaultValue: "Create an automation", + })} + + + {t("jobs.editorScopeDescription", { + defaultValue: "This automation is fixed to the {{scope}} scope.", + scope: + scope === "organization" + ? t("jobs.organization", { defaultValue: "organization" }) + : t("jobs.personal", { defaultValue: "personal" }), + })} + + + +
+
+ + setName(event.currentTarget.value)} + /> + {automation ? ( +

+ {t("jobs.editorNameImmutable", { + defaultValue: "The name cannot be changed after creation.", + })} +

+ ) : submitted && nameInvalid ? ( +

+ {t("jobs.editorNameRequired", { + defaultValue: "Enter a name.", + })} +

+ ) : null} +
+ + openAgentSettings("connections")} + /> + + {trigger === "schedule" ? ( + + ) : null} + + {trigger === "event" ? ( + + ) : null} + + {trigger === "email" ? ( + + ) : null} + +
+ +