feat(platform): Add recommended run schedule for agent execution (#10827)

## Summary

<img width="1000" alt="Screenshot 2025-09-02 at 9 46 49 PM"
src="https://github.com/user-attachments/assets/d78100c7-7974-4d37-a788-757764d8b6b7"
/>

<img width="1000" alt="Screenshot 2025-09-02 at 9 20 24 PM"
src="https://github.com/user-attachments/assets/cd092963-8e26-4198-b65a-4416b2307a50"
/>

<img width="1000" alt="Screenshot 2025-09-02 at 9 22 30 PM"
src="https://github.com/user-attachments/assets/e16b3bdb-c48c-4dec-9281-b2a35b3e21d0"
/>

<img width="1000" alt="Screenshot 2025-09-02 at 9 20 38 PM"
src="https://github.com/user-attachments/assets/11d74a39-f4b4-4fce-8d30-0e6a925f3a9b"
/>

• Added recommended schedule cron expression as an optional input
throughout the platform
• Implemented complete data flow from builder → store submission → agent
library → run page
• Fixed UI layout issues including button text overflow and ensured
proper component reusability

## Changes

### Backend
- Added `recommended_schedule_cron` field to `AgentGraph` schema and
database migration
- Updated API models (`LibraryAgent`, `MyAgent`,
`StoreSubmissionRequest`) to include the new field
- Enhanced store submission approval flow to persist recommended
schedule to database

### Frontend
- Added recommended schedule input to builder page (SaveControl
component) with overflow-safe styling
- Updated store submission modal (PublishAgentModal) with schedule
configuration
- Enhanced agent run page with schedule tip display and pre-filled
schedule dialog
- Refactored `CronSchedulerDialog` with discriminated union types for
better reusability
- Fixed layout issues including button text truncation and popover width
constraints
- Implemented robust cron expression parsing with 100% reversibility
between UI and cron format

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Zamil Majdy
2025-09-06 03:57:03 +02:00
committed by GitHub
parent c03af5c196
commit 46e0f6cc45
32 changed files with 585 additions and 82 deletions

View File

@@ -160,6 +160,7 @@ class BaseGraph(BaseDbModel):
is_active: bool = True
name: str
description: str
recommended_schedule_cron: str | None = None
nodes: list[Node] = []
links: list[Link] = []
forked_from_id: str | None = None
@@ -696,6 +697,7 @@ class GraphModel(Graph):
is_active=graph.isActive,
name=graph.name or "",
description=graph.description or "",
recommended_schedule_cron=graph.recommendedScheduleCron,
nodes=[NodeModel.from_db(node, for_export) for node in graph.Nodes or []],
links=list(
{
@@ -1083,6 +1085,7 @@ async def __create_graph(tx, graph: Graph, user_id: str):
version=graph.version,
name=graph.name,
description=graph.description,
recommendedScheduleCron=graph.recommended_schedule_cron,
isActive=graph.is_active,
userId=user_id,
forkedFromId=graph.forked_from_id,

View File

@@ -64,6 +64,9 @@ class LibraryAgent(pydantic.BaseModel):
# Indicates if this agent is the latest version
is_latest_version: bool
# Recommended schedule cron (from marketplace agents)
recommended_schedule_cron: str | None = None
@staticmethod
def from_db(
agent: prisma.models.LibraryAgent,
@@ -130,6 +133,7 @@ class LibraryAgent(pydantic.BaseModel):
new_output=new_output,
can_access_graph=can_access_graph,
is_latest_version=is_latest_version,
recommended_schedule_cron=agent.AgentGraph.recommendedScheduleCron,
)

View File

@@ -50,6 +50,7 @@ async def test_get_library_agents_success(
credentials_input_schema={"type": "object", "properties": {}},
has_external_trigger=False,
status=library_model.LibraryAgentStatus.COMPLETED,
recommended_schedule_cron=None,
new_output=False,
can_access_graph=True,
is_latest_version=True,
@@ -69,6 +70,7 @@ async def test_get_library_agents_success(
credentials_input_schema={"type": "object", "properties": {}},
has_external_trigger=False,
status=library_model.LibraryAgentStatus.COMPLETED,
recommended_schedule_cron=None,
new_output=False,
can_access_graph=False,
is_latest_version=True,

View File

@@ -183,6 +183,13 @@ async def get_store_agent_details(
store_listing.hasApprovedVersion if store_listing else False
)
if store_listing and store_listing.ActiveVersion:
recommended_schedule_cron = (
store_listing.ActiveVersion.recommendedScheduleCron
)
else:
recommended_schedule_cron = None
logger.debug(f"Found agent details for {username}/{agent_name}")
return backend.server.v2.store.model.StoreAgentDetails(
store_listing_version_id=agent.storeListingVersionId,
@@ -201,6 +208,7 @@ async def get_store_agent_details(
last_updated=agent.updated_at,
active_version_id=active_version_id,
has_approved_version=has_approved_version,
recommended_schedule_cron=recommended_schedule_cron,
)
except backend.server.v2.store.exceptions.AgentNotFoundError:
raise
@@ -562,6 +570,7 @@ async def create_store_submission(
sub_heading: str = "",
categories: list[str] = [],
changes_summary: str | None = "Initial Submission",
recommended_schedule_cron: str | None = None,
) -> backend.server.v2.store.model.StoreSubmission:
"""
Create the first (and only) store listing and thus submission as a normal user
@@ -655,6 +664,7 @@ async def create_store_submission(
submissionStatus=prisma.enums.SubmissionStatus.PENDING,
submittedAt=datetime.now(tz=timezone.utc),
changesSummary=changes_summary,
recommendedScheduleCron=recommended_schedule_cron,
)
]
},
@@ -710,6 +720,7 @@ async def edit_store_submission(
sub_heading: str = "",
categories: list[str] = [],
changes_summary: str | None = "Update submission",
recommended_schedule_cron: str | None = None,
) -> backend.server.v2.store.model.StoreSubmission:
"""
Edit an existing store listing submission.
@@ -789,6 +800,7 @@ async def edit_store_submission(
sub_heading=sub_heading,
categories=categories,
changes_summary=changes_summary,
recommended_schedule_cron=recommended_schedule_cron,
)
# For PENDING submissions, we can update the existing version
@@ -804,6 +816,7 @@ async def edit_store_submission(
categories=categories,
subHeading=sub_heading,
changesSummary=changes_summary,
recommendedScheduleCron=recommended_schedule_cron,
),
)
@@ -866,6 +879,7 @@ async def create_store_version(
sub_heading: str = "",
categories: list[str] = [],
changes_summary: str | None = "Initial submission",
recommended_schedule_cron: str | None = None,
) -> backend.server.v2.store.model.StoreSubmission:
"""
Create a new version for an existing store listing
@@ -935,6 +949,7 @@ async def create_store_version(
submissionStatus=prisma.enums.SubmissionStatus.PENDING,
submittedAt=datetime.now(),
changesSummary=changes_summary,
recommendedScheduleCron=recommended_schedule_cron,
storeListingId=store_listing_id,
)
)
@@ -1150,6 +1165,7 @@ async def get_my_agents(
last_edited=graph.updatedAt or graph.createdAt,
description=graph.description or "",
agent_image=library_agent.imageUrl,
recommended_schedule_cron=graph.recommendedScheduleCron,
)
for library_agent in library_agents
if (graph := library_agent.AgentGraph)
@@ -1351,6 +1367,21 @@ async def review_store_submission(
]
)
# Update the AgentGraph with store listing data
await prisma.models.AgentGraph.prisma().update(
where={
"graphVersionId": {
"id": store_listing_version.agentGraphId,
"version": store_listing_version.agentGraphVersion,
}
},
data={
"name": store_listing_version.name,
"description": store_listing_version.description,
"recommendedScheduleCron": store_listing_version.recommendedScheduleCron,
},
)
await prisma.models.StoreListing.prisma(tx).update(
where={"id": store_listing_version.StoreListing.id},
data={

View File

@@ -90,6 +90,8 @@ async def test_get_store_agent_details(mocker):
mock_store_listing = mocker.MagicMock()
mock_store_listing.activeVersionId = "active-version-id"
mock_store_listing.hasApprovedVersion = True
mock_store_listing.ActiveVersion = mocker.MagicMock()
mock_store_listing.ActiveVersion.recommendedScheduleCron = None
# Mock StoreAgent prisma call
mock_store_agent = mocker.patch("prisma.models.StoreAgent.prisma")

View File

@@ -14,6 +14,7 @@ class MyAgent(pydantic.BaseModel):
agent_image: str | None = None
description: str
last_edited: datetime.datetime
recommended_schedule_cron: str | None = None
class MyAgentsResponse(pydantic.BaseModel):
@@ -53,6 +54,7 @@ class StoreAgentDetails(pydantic.BaseModel):
rating: float
versions: list[str]
last_updated: datetime.datetime
recommended_schedule_cron: str | None = None
active_version_id: str | None = None
has_approved_version: bool = False
@@ -157,6 +159,7 @@ class StoreSubmissionRequest(pydantic.BaseModel):
description: str = ""
categories: list[str] = []
changes_summary: str | None = None
recommended_schedule_cron: str | None = None
class StoreSubmissionEditRequest(pydantic.BaseModel):
@@ -167,6 +170,7 @@ class StoreSubmissionEditRequest(pydantic.BaseModel):
description: str = ""
categories: list[str] = []
changes_summary: str | None = None
recommended_schedule_cron: str | None = None
class ProfileDetails(pydantic.BaseModel):

View File

@@ -535,6 +535,7 @@ async def create_submission(
sub_heading=submission_request.sub_heading,
categories=submission_request.categories,
changes_summary=submission_request.changes_summary or "Initial Submission",
recommended_schedule_cron=submission_request.recommended_schedule_cron,
)
except Exception:
logger.exception("Exception occurred whilst creating store submission")
@@ -580,6 +581,7 @@ async def edit_submission(
sub_heading=submission_request.sub_heading,
categories=submission_request.categories,
changes_summary=submission_request.changes_summary,
recommended_schedule_cron=submission_request.recommended_schedule_cron,
)

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "StoreListingVersion" ADD COLUMN "recommendedScheduleCron" TEXT;
ALTER TABLE "AgentGraph" ADD COLUMN "recommendedScheduleCron" TEXT;

View File

@@ -110,6 +110,7 @@ model AgentGraph {
name String?
description String?
recommendedScheduleCron String?
isActive Boolean @default(true)
@@ -785,6 +786,8 @@ model StoreListingVersion {
reviewComments String? // Comments visible to creator
reviewedAt DateTime?
recommendedScheduleCron String? // cron expression like "0 9 * * *"
// Reviews for this specific version
Reviews StoreListingReview[]

View File

@@ -22,6 +22,7 @@
"1.1.0"
],
"last_updated": "2023-01-01T00:00:00",
"recommended_schedule_cron": null,
"active_version_id": null,
"has_approved_version": false
}

View File

@@ -23,6 +23,7 @@
"required": [],
"type": "object"
},
"recommended_schedule_cron": null,
"sub_graphs": [],
"trigger_setup_info": null,
"user_id": "test-user-id",

View File

@@ -22,6 +22,7 @@
"required": [],
"type": "object"
},
"recommended_schedule_cron": null,
"sub_graphs": [],
"trigger_setup_info": null,
"user_id": "test-user-id",

View File

@@ -27,7 +27,8 @@
"trigger_setup_info": null,
"new_output": false,
"can_access_graph": true,
"is_latest_version": true
"is_latest_version": true,
"recommended_schedule_cron": null
},
{
"id": "test-agent-2",
@@ -56,7 +57,8 @@
"trigger_setup_info": null,
"new_output": false,
"can_access_graph": false,
"is_latest_version": true
"is_latest_version": true,
"recommended_schedule_cron": null
}
],
"pagination": {

View File

@@ -180,6 +180,7 @@ export function RunAgentModal({ triggerSlot, agent }: Props) {
<ScheduleView
scheduleName={scheduleName}
cronExpression={cronExpression}
recommendedScheduleCron={agent.recommended_schedule_cron}
onScheduleNameChange={handleSetScheduleName}
onCronExpressionChange={handleSetCronExpression}
onValidityChange={setIsScheduleFormValid}
@@ -190,6 +191,11 @@ export function RunAgentModal({ triggerSlot, agent }: Props) {
<Text variant="body" className="mb-3 !text-zinc-500">
No schedule configured. Create a schedule to run this
agent automatically at a specific time.{" "}
{agent.recommended_schedule_cron && (
<span className="text-blue-600">
This agent has a recommended schedule.
</span>
)}
</Text>
<Button
variant="secondary"

View File

@@ -9,6 +9,7 @@ import { validateSchedule } from "./helpers";
interface Props {
scheduleName: string;
cronExpression: string;
recommendedScheduleCron?: string | null;
onScheduleNameChange: (name: string) => void;
onCronExpressionChange: (expression: string) => void;
onValidityChange?: (valid: boolean) => void;
@@ -17,6 +18,7 @@ interface Props {
export function ScheduleView({
scheduleName,
cronExpression: _cronExpression,
recommendedScheduleCron,
onScheduleNameChange,
onCronExpressionChange,
onValidityChange,
@@ -72,6 +74,15 @@ export function ScheduleView({
error={errors.scheduleName}
/>
{recommendedScheduleCron && (
<div className="mb-4 rounded-md bg-blue-50 p-3">
<Text variant="body" className="text-blue-800">
💡 This agent has a recommended schedule that has been pre-filled
below. You can modify it as needed.
</Text>
</div>
)}
<Select
id="repeat"
label="Repeats"

View File

@@ -44,7 +44,9 @@ export function useAgentRunModal(
const [presetDescription, setPresetDescription] = useState<string>("");
const defaultScheduleName = useMemo(() => `Run ${agent.name}`, [agent.name]);
const [scheduleName, setScheduleName] = useState(defaultScheduleName);
const [cronExpression, setCronExpression] = useState("0 9 * * 1");
const [cronExpression, setCronExpression] = useState(
agent.recommended_schedule_cron || "0 9 * * 1",
);
// Determine the default run type based on agent capabilities
const defaultRunType: RunVariant = agent.has_external_trigger
@@ -322,7 +324,9 @@ export function useAgentRunModal(
function handleShowSchedule() {
// Initialize with sensible defaults when entering schedule view
setScheduleName((prev) => prev || defaultScheduleName);
setCronExpression((prev) => prev || "0 9 * * 1");
setCronExpression(
(prev) => prev || agent.recommended_schedule_cron || "0 9 * * 1",
);
setShowScheduleView(true);
}
@@ -330,7 +334,7 @@ export function useAgentRunModal(
setShowScheduleView(false);
// Reset schedule fields on exit
setScheduleName(defaultScheduleName);
setCronExpression("0 9 * * 1");
setCronExpression(agent.recommended_schedule_cron || "0 9 * * 1");
}
function handleSetScheduleName(name: string) {

View File

@@ -558,6 +558,7 @@ export function OldAgentLibraryView() {
onCreateSchedule={onCreateSchedule}
onCreatePreset={onCreatePreset}
agentActions={agentActions}
recommendedScheduleCron={agent?.recommended_schedule_cron || null}
/>
) : selectedView.type == "preset" ? (
/* Edit & update presets */
@@ -567,6 +568,7 @@ export function OldAgentLibraryView() {
agentPresets.find((preset) => preset.id == selectedView.id)!
}
onRun={selectRun}
recommendedScheduleCron={agent?.recommended_schedule_cron || null}
onCreateSchedule={onCreateSchedule}
onUpdatePreset={onUpdatePreset}
doDeletePreset={setConfirmingDeleteAgentPreset}

View File

@@ -16,8 +16,9 @@ import ActionButtonGroup from "@/components/agptui/action-button-group";
import type { ButtonAction } from "@/components/agptui/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { IconCross, IconPlay, IconSave } from "@/components/ui/icons";
import { CalendarClockIcon, Trash2Icon } from "lucide-react";
import { CronSchedulerDialog } from "@/components/cron-scheduler-dialog";
import { CalendarClockIcon, Trash2Icon, ClockIcon } from "lucide-react";
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
import { ScheduleTaskDialog } from "@/components/cron-scheduler-dialog";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/CredentialsInputs/CredentialsInputs";
import { RunAgentInputs } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunAgentInputs/RunAgentInputs";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
@@ -45,9 +46,11 @@ export function AgentRunDraftView({
onCreateSchedule,
agentActions,
className,
recommendedScheduleCron,
}: {
graph: GraphMeta;
agentActions?: ButtonAction[];
recommendedScheduleCron?: string | null;
doRun?: (
inputs: Record<string, any>,
credentialsInputs: Record<string, CredentialsMetaInput>,
@@ -380,20 +383,21 @@ export function AgentRunDraftView({
{
label: (
<>
<IconPlay className="mr-2 size-4" /> Run
<CalendarClockIcon className="mr-2 size-4" /> Schedule run
</>
),
variant: "accent",
callback: doRun,
extraProps: { "data-testid": "agent-run-button" },
callback: openScheduleDialog,
extraProps: { "data-testid": "agent-schedule-button" },
},
{
label: (
<>
<CalendarClockIcon className="mr-2 size-4" /> Schedule
<IconPlay className="mr-2 size-4" /> Manual run
</>
),
callback: openScheduleDialog,
callback: doRun,
extraProps: { "data-testid": "agent-run-button" },
},
// {
// label: (
@@ -570,6 +574,19 @@ export function AgentRunDraftView({
<CardTitle className="font-poppins text-lg">Input</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{/* Schedule recommendation tip */}
{recommendedScheduleCron && !graph.has_external_trigger && (
<div className="flex items-center gap-2 rounded-md border border-violet-200 bg-violet-50 p-3">
<ClockIcon className="h-4 w-4 text-violet-600" />
<p className="text-sm text-violet-800">
<strong>Tip:</strong> For best results, run this agent{" "}
{humanizeCronExpression(
recommendedScheduleCron,
).toLowerCase()}
</p>
</div>
)}
{(agentPreset || graph.has_external_trigger) && (
<>
{/* Preset name and description */}
@@ -677,11 +694,12 @@ export function AgentRunDraftView({
title={`${graph.has_external_trigger ? "Trigger" : agentPreset ? "Preset" : "Run"} actions`}
actions={runActions}
/>
<CronSchedulerDialog
<ScheduleTaskDialog
open={cronScheduleDialogOpen}
setOpen={setCronScheduleDialogOpen}
afterCronCreation={doSetupSchedule}
onSubmit={doSetupSchedule}
defaultScheduleName={graph.name}
defaultCronExpression={recommendedScheduleCron || undefined}
/>
{agentActions && agentActions.length > 0 && (

View File

@@ -4654,6 +4654,10 @@
},
"name": { "type": "string", "title": "Name" },
"description": { "type": "string", "title": "Description" },
"recommended_schedule_cron": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Recommended Schedule Cron"
},
"nodes": {
"items": { "$ref": "#/components/schemas/Node" },
"type": "array",
@@ -4690,6 +4694,10 @@
},
"name": { "type": "string", "title": "Name" },
"description": { "type": "string", "title": "Description" },
"recommended_schedule_cron": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Recommended Schedule Cron"
},
"nodes": {
"items": { "$ref": "#/components/schemas/Node" },
"type": "array",
@@ -5155,6 +5163,10 @@
},
"name": { "type": "string", "title": "Name" },
"description": { "type": "string", "title": "Description" },
"recommended_schedule_cron": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Recommended Schedule Cron"
},
"nodes": {
"items": { "$ref": "#/components/schemas/Node" },
"type": "array",
@@ -5491,6 +5503,10 @@
},
"name": { "type": "string", "title": "Name" },
"description": { "type": "string", "title": "Description" },
"recommended_schedule_cron": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Recommended Schedule Cron"
},
"forked_from_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Forked From Id"
@@ -5561,6 +5577,10 @@
},
"name": { "type": "string", "title": "Name" },
"description": { "type": "string", "title": "Description" },
"recommended_schedule_cron": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Recommended Schedule Cron"
},
"nodes": {
"items": { "$ref": "#/components/schemas/NodeModel" },
"type": "array",
@@ -5788,6 +5808,10 @@
"is_latest_version": {
"type": "boolean",
"title": "Is Latest Version"
},
"recommended_schedule_cron": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Recommended Schedule Cron"
}
},
"type": "object",
@@ -6095,6 +6119,10 @@
"type": "string",
"format": "date-time",
"title": "Last Edited"
},
"recommended_schedule_cron": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Recommended Schedule Cron"
}
},
"type": "object",
@@ -7170,6 +7198,10 @@
"format": "date-time",
"title": "Last Updated"
},
"recommended_schedule_cron": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Recommended Schedule Cron"
},
"active_version_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Active Version Id"
@@ -7400,6 +7432,10 @@
"changes_summary": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Changes Summary"
},
"recommended_schedule_cron": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Recommended Schedule Cron"
}
},
"type": "object",
@@ -7437,6 +7473,10 @@
"changes_summary": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Changes Summary"
},
"recommended_schedule_cron": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Recommended Schedule Cron"
}
},
"type": "object",

View File

@@ -117,6 +117,8 @@ const FlowEditor: React.FC<{
setAgentName,
agentDescription,
setAgentDescription,
agentRecommendedScheduleCron,
setAgentRecommendedScheduleCron,
savedAgent,
availableBlocks,
availableFlows,
@@ -899,6 +901,10 @@ const FlowEditor: React.FC<{
onDescriptionChange={setAgentDescription}
agentName={agentName}
onNameChange={setAgentName}
agentRecommendedScheduleCron={agentRecommendedScheduleCron}
onRecommendedScheduleCronChange={
setAgentRecommendedScheduleCron
}
pinSavePopover={pinSavePopover}
/>
}

View File

@@ -6,6 +6,9 @@ import { StepHeader } from "../StepHeader";
import { Input } from "@/components/atoms/Input/Input";
import { Select } from "@/components/atoms/Select/Select";
import { Form, FormField } from "@/components/ui/form";
import { CronExpressionDialog } from "@/components/cron-scheduler-dialog";
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
import { CalendarClockIcon } from "lucide-react";
import { Props, useAgentInfoStep } from "./useAgentInfoStep";
import { ThumbnailImages } from "./components/ThumbnailImages";
@@ -32,6 +35,13 @@ export function AgentInfoStep({
initialData,
});
const [cronScheduleDialogOpen, setCronScheduleDialogOpen] =
React.useState(false);
const handleScheduleChange = (cronExpression: string) => {
form.setValue("recommendedScheduleCron", cronExpression);
};
const categoryOptions = [
{ value: "productivity", label: "Productivity" },
{ value: "writing", label: "Writing & Content" },
@@ -153,6 +163,32 @@ export function AgentInfoStep({
)}
/>
<FormField
control={form.control}
name="recommendedScheduleCron"
render={({ field }) => (
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium">
Recommended Schedule
</label>
<p className="text-xs text-gray-600">
Suggest when users should run this agent for best results
</p>
<Button
type="button"
variant="outline"
onClick={() => setCronScheduleDialogOpen(true)}
className="w-full justify-start text-sm"
>
<CalendarClockIcon className="mr-2 h-4 w-4" />
{field.value
? humanizeCronExpression(field.value)
: "Set schedule"}
</Button>
</div>
)}
/>
<div className="flex justify-between gap-4 pt-6">
<Button
type="button"
@@ -175,6 +211,14 @@ export function AgentInfoStep({
</div>
</form>
</Form>
<CronExpressionDialog
open={cronScheduleDialogOpen}
setOpen={setCronScheduleDialogOpen}
onSubmit={handleScheduleChange}
defaultCronExpression={form.getValues("recommendedScheduleCron") || ""}
title="Recommended Schedule"
/>
</div>
);
}

View File

@@ -40,6 +40,7 @@ export const publishAgentSchema = z.object({
.string()
.min(1, "Description is required")
.max(1000, "Description must be less than 1000 characters"),
recommendedScheduleCron: z.string().optional(),
});
export type PublishAgentFormData = z.infer<typeof publishAgentSchema>;
@@ -54,4 +55,5 @@ export interface PublishAgentInfoInitialData {
category: string;
description: string;
additionalImages?: string[];
recommendedScheduleCron?: string;
}

View File

@@ -44,6 +44,7 @@ export function useAgentInfoStep({
youtubeLink: "",
category: "",
description: "",
recommendedScheduleCron: "",
},
});
@@ -64,6 +65,7 @@ export function useAgentInfoStep({
youtubeLink: initialData.youtubeLink,
category: initialData.category,
description: initialData.description,
recommendedScheduleCron: initialData.recommendedScheduleCron || "",
});
}
}, [initialData, form]);
@@ -98,6 +100,7 @@ export function useAgentInfoStep({
agent_version: selectedAgentVersion || 0,
slug: data.slug.replace(/\s+/g, "-"),
categories: filteredCategories,
recommended_schedule_cron: data.recommendedScheduleCron || null,
});
await queryClient.invalidateQueries({

View File

@@ -1,6 +1,7 @@
"use client";
import * as React from "react";
import Image from "next/image";
import { Text } from "../../../../atoms/Text/Text";
import { Button } from "../../../../atoms/Button/Button";
import { StepHeader } from "../StepHeader";
@@ -15,7 +16,12 @@ interface Props {
onNext: (
agentId: string,
agentVersion: number,
agentData: { name: string; description: string; imageSrc: string },
agentData: {
name: string;
description: string;
imageSrc: string;
recommendedScheduleCron: string | null;
},
) => void;
onOpenBuilder: () => void;
}
@@ -147,11 +153,12 @@ export function AgentSelectStep({
aria-pressed={selectedAgentId === agent.id}
>
<div className="relative h-32 bg-zinc-400 sm:h-40">
<img
<Image
src={agent.imageSrc}
alt={agent.name}
className="h-full w-full object-cover"
loading="lazy"
className="object-cover"
fill
sizes="(max-width: 640px) 100vw, (max-width: 768px) 50vw, 33vw"
/>
</div>
<div className="flex flex-col gap-2 p-3">

View File

@@ -8,6 +8,7 @@ export interface Agent {
lastEdited: string;
imageSrc: string;
description: string;
recommendedScheduleCron: string | null;
}
interface UseAgentSelectStepProps {
@@ -15,7 +16,12 @@ interface UseAgentSelectStepProps {
onNext: (
agentId: string,
agentVersion: number,
agentData: { name: string; description: string; imageSrc: string },
agentData: {
name: string;
description: string;
imageSrc: string;
recommendedScheduleCron: string | null;
},
) => void;
}
@@ -43,6 +49,7 @@ export function useAgentSelectStep({
lastEdited: agent.last_edited.toLocaleDateString(),
imageSrc: agent.agent_image || "https://picsum.photos/300/200",
description: agent.description || "",
recommendedScheduleCron: agent.recommended_schedule_cron ?? null,
}),
)
.sort(
@@ -71,6 +78,7 @@ export function useAgentSelectStep({
name: selectedAgent.name,
description: selectedAgent.description,
imageSrc: selectedAgent.imageSrc,
recommendedScheduleCron: selectedAgent.recommendedScheduleCron,
});
}
}

View File

@@ -7,4 +7,5 @@ export const emptyModalState = {
youtubeLink: "",
category: "",
description: "",
recommendedScheduleCron: "",
};

View File

@@ -91,7 +91,12 @@ export function usePublishAgentModal({ targetState, onStateChange }: Props) {
function handleNextFromSelect(
agentId: string,
agentVersion: number,
agentData: { name: string; description: string; imageSrc: string },
agentData: {
name: string;
description: string;
imageSrc: string;
recommendedScheduleCron: string | null;
},
) {
setInitialData({
...emptyModalState,
@@ -100,6 +105,7 @@ export function usePublishAgentModal({ targetState, onStateChange }: Props) {
description: agentData.description,
thumbnailSrc: agentData.imageSrc,
slug: agentData.name.replace(/ /g, "-"),
recommendedScheduleCron: agentData.recommendedScheduleCron || "",
});
updateState({

View File

@@ -8,22 +8,46 @@ import { useGetV1GetUserTimezone } from "@/app/api/__generated__/endpoints/auth/
import { getTimezoneDisplayName } from "@/lib/timezone-utils";
import { InfoIcon } from "lucide-react";
// Base type for cron expression only
type CronOnlyCallback = (cronExpression: string) => void;
// Type for cron expression with schedule name
type CronWithNameCallback = (
cronExpression: string,
scheduleName: string,
) => void;
type CronSchedulerDialogProps = {
open: boolean;
setOpen: (open: boolean) => void;
afterCronCreation: (cronExpression: string, scheduleName: string) => void;
defaultScheduleName?: string;
};
defaultCronExpression?: string;
title?: string;
} & (
| {
// For cases where only cron expression is needed (builder, submission)
mode: "cron-only";
onSubmit: CronOnlyCallback;
}
| {
// For cases where schedule name is required (agent run)
mode: "with-name";
onSubmit: CronWithNameCallback;
defaultScheduleName?: string;
}
);
export function CronSchedulerDialog(props: CronSchedulerDialogProps) {
const {
open,
setOpen,
defaultCronExpression = "",
title = "Schedule Task",
} = props;
export function CronSchedulerDialog({
open,
setOpen,
afterCronCreation,
defaultScheduleName = "",
}: CronSchedulerDialogProps) {
const { toast } = useToast();
const [cronExpression, setCronExpression] = useState<string>("");
const [scheduleName, setScheduleName] = useState<string>(defaultScheduleName);
const [scheduleName, setScheduleName] = useState<string>(
props.mode === "with-name" ? props.defaultScheduleName || "" : "",
);
// Get user's timezone
const { data: userTimezone } = useGetV1GetUserTimezone({
@@ -36,13 +60,15 @@ export function CronSchedulerDialog({
// Reset state when dialog opens
useEffect(() => {
if (open) {
setScheduleName(defaultScheduleName);
setCronExpression("");
const defaultName =
props.mode === "with-name" ? props.defaultScheduleName || "" : "";
setScheduleName(defaultName);
setCronExpression(defaultCronExpression);
}
}, [open]);
}, [open, props, defaultCronExpression]);
const handleDone = () => {
if (!scheduleName.trim()) {
if (props.mode === "with-name" && !scheduleName.trim()) {
toast({
title: "Please enter a schedule name",
variant: "destructive",
@@ -60,28 +86,38 @@ export function CronSchedulerDialog({
return;
}
afterCronCreation(cronExpression, scheduleName);
if (props.mode === "with-name") {
props.onSubmit(cronExpression, scheduleName);
} else {
props.onSubmit(cronExpression);
}
setOpen(false);
};
return (
<Dialog
controlled={{ isOpen: open, set: setOpen }}
title="Schedule Task"
title={title}
styling={{ maxWidth: "600px" }}
>
<Dialog.Content>
<div className="flex flex-col gap-4">
<div className="flex max-w-[448px] flex-col space-y-2">
<label className="text-sm font-medium">Schedule Name</label>
<Input
value={scheduleName}
onChange={(e) => setScheduleName(e.target.value)}
placeholder="Enter a name for this schedule"
/>
</div>
{props.mode === "with-name" && (
<div className="flex max-w-[448px] flex-col space-y-2">
<label className="text-sm font-medium">Schedule Name</label>
<Input
value={scheduleName}
onChange={(e) => setScheduleName(e.target.value)}
placeholder="Enter a name for this schedule"
/>
</div>
)}
<CronScheduler onCronExpressionChange={setCronExpression} />
<CronScheduler
onCronExpressionChange={setCronExpression}
initialCronExpression={defaultCronExpression}
key={`${open}-${defaultCronExpression}`}
/>
{/* Timezone info */}
{userTimezone === "not-set" ? (
@@ -114,3 +150,57 @@ export function CronSchedulerDialog({
</Dialog>
);
}
// Convenience components for common use cases
export function CronExpressionDialog({
open,
setOpen,
onSubmit,
defaultCronExpression,
title = "Set Schedule",
}: {
open: boolean;
setOpen: (open: boolean) => void;
onSubmit: (cronExpression: string) => void;
defaultCronExpression?: string;
title?: string;
}) {
return (
<CronSchedulerDialog
open={open}
setOpen={setOpen}
mode="cron-only"
onSubmit={onSubmit}
defaultCronExpression={defaultCronExpression}
title={title}
/>
);
}
export function ScheduleTaskDialog({
open,
setOpen,
onSubmit,
defaultScheduleName,
defaultCronExpression,
title = "Schedule Task",
}: {
open: boolean;
setOpen: (open: boolean) => void;
onSubmit: (cronExpression: string, scheduleName: string) => void;
defaultScheduleName?: string;
defaultCronExpression?: string;
title?: string;
}) {
return (
<CronSchedulerDialog
open={open}
setOpen={setOpen}
mode="with-name"
onSubmit={onSubmit}
defaultScheduleName={defaultScheduleName}
defaultCronExpression={defaultCronExpression}
title={title}
/>
);
}

View File

@@ -38,10 +38,12 @@ const months = [
type CronSchedulerProps = {
onCronExpressionChange: (cronExpression: string) => void;
initialCronExpression?: string;
};
export function CronScheduler({
onCronExpressionChange,
initialCronExpression,
}: CronSchedulerProps): React.ReactElement {
const [frequency, setFrequency] = useState<CronFrequency>("daily");
const [selectedMinute, setSelectedMinute] = useState<string>("0");
@@ -53,7 +55,142 @@ export function CronScheduler({
value: number;
unit: "minutes" | "hours" | "days";
}>({ value: 1, unit: "minutes" });
const [showCustomDays, setShowCustomDays] = useState<boolean>(false);
// Parse initial cron expression and set state
useEffect(() => {
if (!initialCronExpression) {
// Reset to defaults when no initial expression
setFrequency("daily");
setSelectedWeekDays([]);
setSelectedMonthDays([]);
setSelectedMonths([]);
return;
}
const parts = initialCronExpression.trim().split(/\s+/);
if (parts.length !== 5) return;
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
// Reset all state first
setSelectedWeekDays([]);
setSelectedMonthDays([]);
setSelectedMonths([]);
// Parse patterns in order of specificity
if (
minute === "*" &&
hour === "*" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
setFrequency("every minute");
} else if (
hour === "*" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*" &&
!minute.includes("/")
) {
setFrequency("hourly");
setSelectedMinute(minute);
} else if (
minute.startsWith("*/") &&
hour === "*" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
setFrequency("custom");
const interval = parseInt(minute.substring(2));
if (!isNaN(interval)) {
setCustomInterval({ value: interval, unit: "minutes" });
}
} else if (
hour.startsWith("*/") &&
minute === "0" &&
dayOfMonth === "*" &&
month === "*" &&
dayOfWeek === "*"
) {
setFrequency("custom");
const interval = parseInt(hour.substring(2));
if (!isNaN(interval)) {
setCustomInterval({ value: interval, unit: "hours" });
}
} else if (
dayOfMonth.startsWith("*/") &&
month === "*" &&
dayOfWeek === "*" &&
!minute.includes("/") &&
!hour.includes("/")
) {
setFrequency("custom");
const interval = parseInt(dayOfMonth.substring(2));
if (!isNaN(interval)) {
setCustomInterval({ value: interval, unit: "days" });
const hourNum = parseInt(hour);
const minuteNum = parseInt(minute);
if (!isNaN(hourNum) && !isNaN(minuteNum)) {
setSelectedTime(
`${hourNum.toString().padStart(2, "0")}:${minuteNum.toString().padStart(2, "0")}`,
);
}
}
} else if (dayOfMonth === "*" && month === "*" && dayOfWeek === "*") {
setFrequency("daily");
const hourNum = parseInt(hour);
const minuteNum = parseInt(minute);
if (!isNaN(hourNum) && !isNaN(minuteNum)) {
setSelectedTime(
`${hourNum.toString().padStart(2, "0")}:${minuteNum.toString().padStart(2, "0")}`,
);
}
} else if (dayOfWeek !== "*" && dayOfMonth === "*" && month === "*") {
setFrequency("weekly");
const hourNum = parseInt(hour);
const minuteNum = parseInt(minute);
if (!isNaN(hourNum) && !isNaN(minuteNum)) {
setSelectedTime(
`${hourNum.toString().padStart(2, "0")}:${minuteNum.toString().padStart(2, "0")}`,
);
}
const days = dayOfWeek
.split(",")
.map((d) => parseInt(d))
.filter((d) => !isNaN(d));
setSelectedWeekDays(days);
} else if (dayOfMonth !== "*" && month === "*" && dayOfWeek === "*") {
setFrequency("monthly");
const hourNum = parseInt(hour);
const minuteNum = parseInt(minute);
if (!isNaN(hourNum) && !isNaN(minuteNum)) {
setSelectedTime(
`${hourNum.toString().padStart(2, "0")}:${minuteNum.toString().padStart(2, "0")}`,
);
}
const days = dayOfMonth
.split(",")
.map((d) => parseInt(d))
.filter((d) => !isNaN(d) && d >= 1 && d <= 31);
setSelectedMonthDays(days);
} else if (dayOfMonth !== "*" && month !== "*" && dayOfWeek === "*") {
setFrequency("yearly");
const hourNum = parseInt(hour);
const minuteNum = parseInt(minute);
if (!isNaN(hourNum) && !isNaN(minuteNum)) {
setSelectedTime(
`${hourNum.toString().padStart(2, "0")}:${minuteNum.toString().padStart(2, "0")}`,
);
}
const months = month
.split(",")
.map((m) => parseInt(m))
.filter((m) => !isNaN(m) && m >= 1 && m <= 12);
setSelectedMonths(months);
}
}, [initialCronExpression]);
useEffect(() => {
const cronExpr = makeCronExpression({
@@ -225,9 +362,8 @@ export function CronScheduler({
<Label>Days of Month</Label>
<div className="flex gap-2">
<Button
variant={!showCustomDays ? "default" : "outline"}
variant={selectedMonthDays.length === 31 ? "default" : "outline"}
onClick={() => {
setShowCustomDays(false);
setSelectedMonthDays(
Array.from({ length: 31 }, (_, i) => i + 1),
);
@@ -236,9 +372,12 @@ export function CronScheduler({
All Days
</Button>
<Button
variant={showCustomDays ? "default" : "outline"}
variant={
selectedMonthDays.length < 31 && selectedMonthDays.length > 0
? "default"
: "outline"
}
onClick={() => {
setShowCustomDays(true);
setSelectedMonthDays([]);
}}
>
@@ -257,7 +396,7 @@ export function CronScheduler({
Last Day
</Button>
</div>
{showCustomDays && (
{selectedMonthDays.length < 31 && (
<div className="flex flex-wrap gap-2">
{Array.from({ length: 31 }, (_, i) => (
<Button

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import {
Popover,
PopoverContent,
@@ -18,15 +18,20 @@ import {
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useQueryClient } from "@tanstack/react-query";
import { getGetV2ListMySubmissionsQueryKey } from "@/app/api/__generated__/endpoints/store/store";
import { CronExpressionDialog } from "@/components/cron-scheduler-dialog";
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
import { CalendarClockIcon } from "lucide-react";
interface SaveControlProps {
agentMeta: GraphMeta | null;
agentName: string;
agentDescription: string;
agentRecommendedScheduleCron: string;
canSave: boolean;
onSave: () => Promise<void>;
onNameChange: (name: string) => void;
onDescriptionChange: (description: string) => void;
onRecommendedScheduleCronChange: (cron: string) => void;
pinSavePopover: boolean;
}
@@ -50,6 +55,8 @@ export const SaveControl = ({
onNameChange,
agentDescription,
onDescriptionChange,
agentRecommendedScheduleCron,
onRecommendedScheduleCronChange,
pinSavePopover,
}: SaveControlProps) => {
/**
@@ -60,6 +67,11 @@ export const SaveControl = ({
const { toast } = useToast();
const queryClient = useQueryClient();
const [cronScheduleDialogOpen, setCronScheduleDialogOpen] = useState(false);
const handleScheduleChange = (cronExpression: string) => {
onRecommendedScheduleCronChange(cronExpression);
};
useEffect(() => {
const handleKeyDown = async (event: KeyboardEvent) => {
@@ -106,50 +118,77 @@ export const SaveControl = ({
sideOffset={15}
align="start"
data-id="save-control-popover-content"
className="w-96 max-w-[400px]"
>
<Card className="border-none shadow-none dark:bg-slate-900">
<CardContent className="p-4">
<div className="grid gap-3">
<Label htmlFor="name" className="dark:text-gray-300">
Name
</Label>
<Input
id="name"
placeholder="Enter your agent name"
className="col-span-3"
value={agentName}
onChange={(e) => onNameChange(e.target.value)}
data-id="save-control-name-input"
data-testid="save-control-name-input"
maxLength={100}
/>
<Label htmlFor="description" className="dark:text-gray-300">
Description
</Label>
<Input
id="description"
placeholder="Your agent description"
className="col-span-3"
value={agentDescription}
onChange={(e) => onDescriptionChange(e.target.value)}
data-id="save-control-description-input"
data-testid="save-control-description-input"
maxLength={500}
/>
<div className="space-y-3">
<div>
<Label htmlFor="name" className="dark:text-gray-300">
Name
</Label>
<Input
id="name"
placeholder="Enter your agent name"
value={agentName}
onChange={(e) => onNameChange(e.target.value)}
data-id="save-control-name-input"
data-testid="save-control-name-input"
maxLength={100}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="description" className="dark:text-gray-300">
Description
</Label>
<Input
id="description"
placeholder="Your agent description"
value={agentDescription}
onChange={(e) => onDescriptionChange(e.target.value)}
data-id="save-control-description-input"
data-testid="save-control-description-input"
maxLength={500}
className="mt-1"
/>
</div>
<div>
<Label className="dark:text-gray-300">
Recommended Schedule
</Label>
<Button
variant="outline"
onClick={() => setCronScheduleDialogOpen(true)}
className="mt-1 w-full min-w-0 justify-start text-sm"
data-id="save-control-recommended-schedule-button"
data-testid="save-control-recommended-schedule-button"
>
<CalendarClockIcon className="mr-2 h-4 w-4 flex-shrink-0" />
<span className="min-w-0 flex-1 truncate">
{agentRecommendedScheduleCron
? humanizeCronExpression(agentRecommendedScheduleCron)
: "Set schedule"}
</span>
</Button>
</div>
{agentMeta?.version && (
<>
<div>
<Label htmlFor="version" className="dark:text-gray-300">
Version
</Label>
<Input
id="version"
placeholder="Version"
className="col-span-3"
value={agentMeta?.version || "-"}
disabled
data-testid="save-control-version-output"
className="mt-1"
/>
</>
</div>
)}
</div>
</CardContent>
@@ -166,6 +205,13 @@ export const SaveControl = ({
</CardFooter>
</Card>
</PopoverContent>
<CronExpressionDialog
open={cronScheduleDialogOpen}
setOpen={setCronScheduleDialogOpen}
onSubmit={handleScheduleChange}
defaultCronExpression={agentRecommendedScheduleCron}
title="Recommended Schedule"
/>
</Popover>
);
};

View File

@@ -48,6 +48,8 @@ export default function useAgentGraph(
const [savedAgent, setSavedAgent] = useState<Graph | null>(null);
const [agentDescription, setAgentDescription] = useState<string>("");
const [agentName, setAgentName] = useState<string>("");
const [agentRecommendedScheduleCron, setAgentRecommendedScheduleCron] =
useState<string>("");
const [allBlocks, setAllBlocks] = useState<Block[]>([]);
const [availableFlows, setAvailableFlows] = useState<GraphMeta[]>([]);
const [updateQueue, setUpdateQueue] = useState<NodeExecutionResult[]>([]);
@@ -155,6 +157,7 @@ export default function useAgentGraph(
setSavedAgent(graph);
setAgentName(graph.name);
setAgentDescription(graph.description);
setAgentRecommendedScheduleCron(graph.recommended_schedule_cron || "");
const getGraphName = (node: Node) => {
if (node.input_default.agent_name) {
@@ -597,6 +600,7 @@ export default function useAgentGraph(
return {
name: agentName || `New Agent ${new Date().toISOString()}`,
description: agentDescription || "",
recommended_schedule_cron: agentRecommendedScheduleCron || null,
nodes: xyNodes.map(
(node): NodeCreatable => ({
id: node.id,
@@ -615,6 +619,7 @@ export default function useAgentGraph(
xyEdges,
agentName,
agentDescription,
agentRecommendedScheduleCron,
prepareNodeInputData,
getToolFuncName,
]);
@@ -932,6 +937,8 @@ export default function useAgentGraph(
setAgentName,
agentDescription,
setAgentDescription,
agentRecommendedScheduleCron,
setAgentRecommendedScheduleCron,
savedAgent,
availableBlocks,
availableFlows,

View File

@@ -298,6 +298,7 @@ export type GraphMeta = {
is_active: boolean;
name: string;
description: string;
recommended_schedule_cron: string | null;
forked_from_id?: GraphID | null;
forked_from_version?: number | null;
input_schema: GraphIOSchema;
@@ -431,6 +432,7 @@ export type LibraryAgent = {
new_output: boolean;
can_access_graph: boolean;
is_latest_version: boolean;
recommended_schedule_cron: string | null;
} & (
| {
has_external_trigger: true;
@@ -773,6 +775,7 @@ export type StoreSubmissionRequest = {
description: string;
categories: string[];
changes_summary?: string;
recommended_schedule_cron?: string | null;
};
export type ProfileDetails = {
@@ -815,6 +818,7 @@ export type MyAgent = {
agent_image: string | null;
last_edited: string;
description: string;
recommended_schedule_cron: string | null;
};
export type MyAgentsResponse = {