Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/docs/content/docs/cli/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2242,9 +2242,10 @@ a JSON trigger set; see [Automations](/automations#triggers).
name="superset automations list"
options={[
{ flag: "--name <text>", description: "Case-insensitive substring filter on the automation name. Alias: -n." },
{ flag: "--group <name>", description: "Only automations in this group (case-insensitive)." },
]}
output="Array<Automation>"
humanColumns={["ID", "NAME", "AGENT", "SCHEDULE", "ENABLED", "NEXT RUN"]}
humanColumns={["ID", "NAME", "GROUP", "AGENT", "SCHEDULE", "ENABLED", "NEXT RUN"]}
quiet="automation IDs"
>
List automations in the active organization.
Expand Down Expand Up @@ -2279,6 +2280,7 @@ Use [`automations logs`](#superset-automations-logs-id) for run history.
{ flag: "--agent <agent>", description: "Host agent presetId, instance UUID, or 'superset' for built-in chat. Default: claude." },
{ flag: "--continue-session", description: "Deliver each run's prompt into the agent session the previous run left, instead of starting another. Requires --workspace." },
{ flag: "--tag <tag>", description: "Workspace tag applied to each run's created workspace. Repeatable. Default: automation." },
{ flag: "--group <name>", description: "Group the automation is listed under in the automations table. Default: none." },
]}
output="Automation"
>
Expand All @@ -2294,7 +2296,8 @@ session workspace on the target host.
Every run's created workspace carries the automation's tags, which group it
into the matching sidebar folders. By default automations are tagged
`automation`, so their runs collect in an **automation** folder per project;
pass `--tag` to file runs elsewhere.
pass `--tag` to file runs elsewhere. Tags file the runs; `--group` files the
automation itself in the automations table.

```bash
superset automations create \
Expand Down Expand Up @@ -2343,6 +2346,8 @@ superset automations trigger-options --group slack
{ flag: "--mcp-scope <a,b,c>", description: "Comma-separated MCP scope strings." },
{ flag: "--tag <tag>", description: "Replace the tag set applied to each run's created workspace. Repeatable." },
{ flag: "--clear-tags", description: "Remove every tag (runs stop grouping). Mutually exclusive with --tag." },
{ flag: "--group <name>", description: "Move the automation to this group in the automations table." },
{ flag: "--clear-group", description: "Remove the automation from its group. Mutually exclusive with --group." },
{ flag: "--enabled / --no-enabled", description: "Calls automation.setEnabled first." },
]}
output="Automation"
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/commands/automations/create/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ export default command({
.desc(
"Workspace tag applied to each run's created workspace. Repeatable. Each tag files the workspace into a sidebar folder of the same name",
),
group: string().desc(
"Group the automation is listed under in the automations table",
),
},
run: async ({ ctx, options }) => {
const prompt = options.prompt
Expand Down Expand Up @@ -138,6 +141,7 @@ export default command({
timezone: options.timezone ?? DEFAULT_TIMEZONE,
...(triggers ? { triggers } : {}),
...(options.tag?.length ? { tags: options.tag } : {}),
...(options.group ? { group: options.group } : {}),
});

const schedule = result.nextRunAt
Expand Down
12 changes: 9 additions & 3 deletions packages/cli/src/commands/automations/list/command.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { string, table } from "@superset/cli-framework";
import { normalizeAutomationGroup } from "@superset/shared/automation-group";
import { command } from "../../../lib/command";
import { formatAutomationDate } from "../format";

Expand All @@ -8,17 +9,22 @@ export default command({
name: string()
.alias("n")
.desc("Filter by name (case-insensitive substring match)"),
group: string().desc("Only automations in this group (case-insensitive)"),
},
run: async ({ ctx, options }) => {
return await ctx.api.automation.list.query(
const rows = await ctx.api.automation.list.query(
options.name ? { name: options.name } : undefined,
);
const group = normalizeAutomationGroup(options.group)?.toLowerCase();
if (!group) return rows;
return rows.filter((row) => row.group?.toLowerCase() === group);
},
display: (data) =>
table(
(data as Record<string, unknown>[]).map((row) => ({
id: row.id,
name: row.name,
group: row.group ?? "—",
agent: row.agent,
schedule:
row.scheduleText ??
Expand All @@ -32,7 +38,7 @@ export default command({
row.timezone as string | null | undefined,
),
})),
["id", "name", "agent", "schedule", "enabled", "nextRun"],
["ID", "NAME", "AGENT", "SCHEDULE", "ENABLED", "NEXT RUN"],
["id", "name", "group", "agent", "schedule", "enabled", "nextRun"],
["ID", "NAME", "GROUP", "AGENT", "SCHEDULE", "ENABLED", "NEXT RUN"],
),
});
16 changes: 16 additions & 0 deletions packages/cli/src/commands/automations/update/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ export default command({
"Replace the tag set applied to each run's created workspace. Repeatable",
),
clearTags: boolean().desc("Remove every tag from the automation"),
group: string().desc(
"Move the automation to this group in the automations table",
),
clearGroup: boolean().desc("Remove the automation from its group"),
},
run: async ({ ctx, args, options }) => {
const id = args.id as string;
Expand All @@ -76,6 +80,13 @@ export default command({
);
}

if (options.group && options.clearGroup) {
throw new CLIError(
"Cannot combine --group and --clear-group",
"Pass one or the other",
);
}

// Ahead of every mutation: `setEnabled` runs before the update, and a
// combination the server will refuse must not flip `enabled` first.
if (options.session && options.continueSession) {
Expand Down Expand Up @@ -213,6 +224,11 @@ export default command({
: options.tag?.length
? { tags: options.tag }
: {}),
...(options.clearGroup
? { group: null }
: options.group
? { group: options.group }
: {}),
Comment on lines +227 to +231

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Grouping deletes saved schedules

Changing only the group through the CLI or MCP sends the new field through automation.update. For an automation with several schedules, that procedure reads the soonest schedule and calls syncScheduleTrigger, which deletes the others. Users lose scheduled runs even though they requested no schedule change.

Use setGroup for group-only edits, or skip schedule writes when schedule inputs are absent.

Knowledge Base Used: API, SDK, and RPC contracts

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/cli/src/commands/automations/update/command.ts
Line: 227-231

Comment:
**Grouping deletes saved schedules**

Changing only the group through the CLI or MCP sends the new field through `automation.update`. For an automation with several schedules, that procedure reads the soonest schedule and calls `syncScheduleTrigger`, which deletes the others. Users lose scheduled runs even though they requested no schedule change.

Use `setGroup` for group-only edits, or skip schedule writes when schedule inputs are absent.

**Knowledge Base Used:** [API, SDK, and RPC contracts](https://app.greptile.com/-/switch-tenant?tenant=personal-org-6810&next=%2Fcustom-context%2Fknowledge-base%2Fparadevai%2Fparadev%2F-%2Fdocs%2Fapi-sdk-and-rpc-contracts.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

...target,
...cloudTarget,
});
Expand Down
6 changes: 6 additions & 0 deletions packages/mcp/src/tools/automations/create.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { automationGroupInputSchema } from "@superset/shared/automation-group";
import {
describeEnvironments,
selectCloudEnvironment,
Expand Down Expand Up @@ -102,6 +103,11 @@ export function register(server: McpServer): void {
.describe(
"Workspace tags applied to each run's created workspace; each tag files it into a sidebar folder of the same name. Defaults to ['automation'] so runs group out of the box.",
),
group: automationGroupInputSchema
.optional()
.describe(
"Group the automation is listed under in the automations table. Omit to leave it ungrouped. At most 64 characters; a name matching an existing group in any case joins that group.",
),
},
handler: async (input, ctx) => {
const caller = createMcpCaller(ctx);
Expand Down
6 changes: 6 additions & 0 deletions packages/mcp/src/tools/automations/update.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { automationGroupInputSchema } from "@superset/shared/automation-group";
import { workspaceTagsInputSchema } from "@superset/shared/workspace-tags";
import { z } from "zod";
import { createMcpCaller } from "../../caller";
Expand Down Expand Up @@ -56,6 +57,11 @@ export function register(server: McpServer): void {
.describe(
"Workspace tags applied to each run's created workspace; each tag files it into a sidebar folder of the same name. Update replaces the whole set.",
),
group: automationGroupInputSchema
.optional()
.describe(
"Group the automation is listed under in the automations table; null removes it from its group. Omit to keep the current group. At most 64 characters; a name matching an existing group in any case joins that group.",
),
},
handler: async (input, ctx) => {
const caller = createMcpCaller(ctx);
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,10 @@
"./missing-procedure-error": {
"types": "./src/missing-procedure-error.ts",
"default": "./src/missing-procedure-error.ts"
},
"./automation-group": {
"types": "./src/automation-group.ts",
"default": "./src/automation-group.ts"
}
},
"scripts": {
Expand Down
35 changes: 35 additions & 0 deletions packages/shared/src/automation-group.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from "bun:test";
import {
AUTOMATION_GROUP_MAX_LENGTH,
automationGroupInputSchema,
matchExistingAutomationGroup,
} from "./automation-group";

describe("automationGroupInputSchema", () => {
it("trims and collapses whitespace, keeping case", () => {
expect(automationGroupInputSchema.parse(" Daily Ops ")).toBe(
"Daily Ops",
);
});

it("reads blank and null as ungrouped", () => {
expect(automationGroupInputSchema.parse(" ")).toBeNull();
expect(automationGroupInputSchema.parse(null)).toBeNull();
});

it("rejects a name over the limit", () => {
const long = "x".repeat(AUTOMATION_GROUP_MAX_LENGTH + 1);
expect(automationGroupInputSchema.safeParse(long).success).toBe(false);
});
});

describe("matchExistingAutomationGroup", () => {
it("reuses the existing spelling of a group", () => {
expect(matchExistingAutomationGroup("marketing", ["Marketing"])).toBe(
"Marketing",
);
expect(matchExistingAutomationGroup("Support", ["Marketing"])).toBe(
"Support",
);
});
});
30 changes: 30 additions & 0 deletions packages/shared/src/automation-group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { z } from "zod";

export const AUTOMATION_GROUP_MAX_LENGTH = 64;

/**
* Trim and collapse inner whitespace. Returns null for empty input, which
* means "ungrouped". Case is kept: the group name is shown as typed.
*/
export function normalizeAutomationGroup(
group: string | null | undefined,
): string | null {
if (group == null) return null;
const normalized = group.trim().replace(/\s+/g, " ");
return normalized.length === 0 ? null : normalized;
}

/** The existing group that matches `group` without regard to case, else `group`. */
export function matchExistingAutomationGroup(
group: string,
existing: readonly string[],
): string {
const lower = group.toLocaleLowerCase();
return existing.find((name) => name.toLocaleLowerCase() === lower) ?? group;
}

export const automationGroupInputSchema = z
.string()
.transform(normalizeAutomationGroup)
.pipe(z.string().max(AUTOMATION_GROUP_MAX_LENGTH).nullable())
.nullable();
38 changes: 37 additions & 1 deletion packages/trpc/src/router/automation/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
recordPromptVersion,
refreshScheduleNextRuns,
requireAutomationDeleteAccess,
resolveAutomationGroup,
scheduleSummariesFor,
summarizeSchedules,
syncScheduleTrigger,
Expand All @@ -80,6 +81,7 @@ import {
listRunsSchema,
parseRruleSchema,
runPayloadSchema,
setAutomationGroupSchema,
setAutomationPromptSchema,
updateAutomationSchema,
} from "./schema";
Expand Down Expand Up @@ -595,6 +597,10 @@ export const automationRouter = {
// Every automation groups its runs out of the box; explicit
// tags (including []) override the default.
tags: input.tags ?? ["automation"],
group: await resolveAutomationGroup(
organizationId,
input.group ?? null,
),
})
.returning();

Expand Down Expand Up @@ -711,6 +717,14 @@ export const automationRouter = {
? { machineSize: input.machineSize }
: {}),
tags: input.tags ?? existing.tags,
...(input.group !== undefined
? {
group: await resolveAutomationGroup(
organizationId,
input.group,
),
}
: {}),
prompt: input.prompt ?? existing.prompt,
})
.where(eq(automations.id, input.id))
Expand Down Expand Up @@ -740,7 +754,14 @@ export const automationRouter = {
organizationId,
triggers: input.triggers,
});
} else if (nextRrule && nextDtstart && nextTimezone) {
} else if (
recurrenceChanged &&
nextRrule &&
nextDtstart &&
nextTimezone
) {
// Rewriting through the single-schedule shape keeps only one
// schedule, so an edit that leaves the schedule alone skips it.
await syncScheduleTrigger(tx, {
automationId: row.id,
organizationId,
Expand Down Expand Up @@ -798,6 +819,21 @@ export const automationRouter = {
return existing;
}),

setGroup: protectedProcedure
.input(setAutomationGroupSchema)
.mutation(async ({ ctx, input }) => {
const organizationId = await requireActiveOrgMembership(ctx);
await getAutomationForUser(ctx.session.user.id, organizationId, input.id);

const group = await resolveAutomationGroup(organizationId, input.group);
await db
.update(automations)
.set({ group })
.where(eq(automations.id, input.id));

return { id: input.id, group };
}),

setPrompt: protectedProcedure
.input(setAutomationPromptSchema)
.mutation(async ({ ctx, input }) => {
Expand Down
23 changes: 23 additions & 0 deletions packages/trpc/src/router/automation/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ export const automationBaseColumns = {
cloudWorkspaceId: automations.cloudWorkspaceId,
environmentId: automations.environmentId,
tags: automations.tags,
group: automations.group,
continueAgentSession: automations.continueAgentSession,
machineSize: automations.machineSize,
enabled: automations.enabled,
Expand Down Expand Up @@ -412,6 +413,28 @@ export async function requireAutomationDeleteAccess(
});
}

/**
* The organization's existing spelling of a group, matched without regard to
* case, so "launch watch" from the CLI joins the "Launch watch" group.
*/
export async function resolveAutomationGroup(
organizationId: string,
group: string | null,
): Promise<string | null> {
if (group === null) return null;
const [existing] = await db
.select({ group: automations.group })
.from(automations)
.where(
and(
eq(automations.organizationId, organizationId),
sql`lower(${automations.group}) = lower(${group})`,
),
)
.limit(1);
return existing?.group ?? group;
}

export async function getAutomationForUser(
userId: string,
organizationId: string,
Expand Down
9 changes: 9 additions & 0 deletions packages/trpc/src/router/automation/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { automationSessionKindValues } from "@superset/db/schema";
import { automationGroupInputSchema } from "@superset/shared/automation-group";
import { draftTriggerSchema } from "@superset/shared/automation-triggers";
import { MACHINE_SIZES } from "@superset/shared/billing";
import { workspaceTagsInputSchema } from "@superset/shared/workspace-tags";
Expand Down Expand Up @@ -56,6 +57,7 @@ export const createAutomationSchema = z
// Workspace tags applied to each run's created workspace, so runs file
// themselves into the matching sidebar folders.
tags: workspaceTagsInputSchema.optional(),
group: automationGroupInputSchema.optional(),
// Deliver each run's prompt into the session the previous run left
// behind instead of starting another beside it. Requires v2WorkspaceId.
continueAgentSession: z.boolean().optional(),
Expand Down Expand Up @@ -91,6 +93,8 @@ export const updateAutomationSchema = z.object({
environmentId: z.string().uuid().nullish(),
// Full replacement of the tag set; undefined keeps the existing tags.
tags: workspaceTagsInputSchema.optional(),
// Undefined keeps the existing group; null ungroups.
group: automationGroupInputSchema.optional(),
continueAgentSession: z.boolean().optional(),
// Undefined keeps the existing size; null resets it to medium.
machineSize: z.enum(MACHINE_SIZES).nullish(),
Expand All @@ -100,6 +104,11 @@ export const updateAutomationSchema = z.object({
triggers,
});

export const setAutomationGroupSchema = z.object({
id: z.string().uuid(),
group: automationGroupInputSchema,
});

export const setAutomationPromptSchema = z.object({
id: z.string().uuid(),
// Empty is allowed so an untitled automation's editor can save its way
Expand Down
Loading