@swarmclawai/swarmclaw 1.9.17 → 1.9.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/package.json +2 -2
- package/src/app/api/schedules/preview/route.test.ts +48 -0
- package/src/app/api/schedules/preview/route.ts +14 -0
- package/src/cli/index.js +1 -0
- package/src/cli/spec.js +1 -0
- package/src/components/schedules/schedule-console.tsx +2 -1
- package/src/components/schedules/schedule-sheet.tsx +190 -49
- package/src/lib/schedules/schedules.ts +4 -1
- package/src/lib/server/schedules/schedule-normalization.ts +17 -1
- package/src/lib/server/schedules/schedule-preview.test.ts +89 -0
- package/src/lib/server/schedules/schedule-preview.ts +171 -0
- package/src/lib/server/schedules/schedule-route-service.ts +25 -1
- package/src/types/schedule.ts +23 -0
package/README.md
CHANGED
|
@@ -407,6 +407,24 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
407
407
|
|
|
408
408
|
## Releases
|
|
409
409
|
|
|
410
|
+
### v1.9.18 Highlights
|
|
411
|
+
|
|
412
|
+
Schedule preflight release: schedules now show server-backed timing forecasts before save, with timezone-aware cron previews and warnings for risky drafts.
|
|
413
|
+
|
|
414
|
+
- **Schedule preview API.** `POST /api/schedules/preview` validates a draft schedule through the same normalization path as saved schedules and returns the next calculated runs.
|
|
415
|
+
- **Timezone-aware schedule sheet.** Cron schedules can set an explicit timezone, preview the next runs from the server, and see warnings before saving.
|
|
416
|
+
- **Stagger and one-shot controls.** Operators can add a stagger window to recurring schedules and choose a run-once delay from the schedule workflow.
|
|
417
|
+
- **CLI access.** `swarmclaw schedules preview --data '{...}'` exposes the same forecast for scripts and release automation.
|
|
418
|
+
|
|
419
|
+
### v1.9.17 Highlights
|
|
420
|
+
|
|
421
|
+
Agent configuration history release: SwarmClaw now surfaces saved agent versions directly in the agent editor, giving operators a fast rollback path for agent settings.
|
|
422
|
+
|
|
423
|
+
- **Agent sheet history.** Advanced settings list recent saved versions with relative time, actor, and provider/model snapshot.
|
|
424
|
+
- **One-click restore.** Operators can restore a prior agent configuration through the existing version-restore API without leaving the agent workflow.
|
|
425
|
+
- **Stale-form protection.** Successful restore reloads agent state and closes the sheet so operators reopen the refreshed record.
|
|
426
|
+
- **Regression coverage.** New tests cover config-version list/restore routes and summary formatting.
|
|
427
|
+
|
|
410
428
|
### v1.9.16 Highlights
|
|
411
429
|
|
|
412
430
|
Agent planning controls release: strict planning is now a first-class agent setting instead of a hidden persisted field, so operators can decide which agents must expose machine-readable plans before multi-step work.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.18",
|
|
4
4
|
"description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
|
|
5
5
|
"main": "electron-dist/main.js",
|
|
6
6
|
"license": "MIT",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/ensure-sandbox-browser-image.test.mjs scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
|
|
89
89
|
"test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
|
|
90
90
|
"test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/gateways/gateway-topology.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/gateways/topology-route.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
|
|
91
|
-
"test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/agent-planning-mode.test.ts src/lib/agent-config-history.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/prompt-sections.planning-mode.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/chats/session-context-pack.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/runs/run-handoff.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/quality/release-readiness.test.ts src/lib/quality/architecture-health.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-execution-policy.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-pack-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/config-versions/config-versions-route.test.ts src/app/api/runs/run-handoff-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
|
|
91
|
+
"test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/agent-planning-mode.test.ts src/lib/agent-config-history.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/prompt-sections.planning-mode.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/chats/session-context-pack.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/runs/run-handoff.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/server/schedules/schedule-preview.test.ts src/lib/quality/release-readiness.test.ts src/lib/quality/architecture-health.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-execution-policy.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-pack-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/config-versions/config-versions-route.test.ts src/app/api/runs/run-handoff-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/preview/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
|
|
92
92
|
"test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
|
|
93
93
|
"test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
|
|
94
94
|
"test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { POST as previewSchedule } from './route'
|
|
5
|
+
|
|
6
|
+
test('POST /api/schedules/preview returns a timing forecast without persisting', async () => {
|
|
7
|
+
const agentId = `missing-preview-agent-${Date.now()}`
|
|
8
|
+
const response = await previewSchedule(new Request('http://local/api/schedules/preview', {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
headers: { 'content-type': 'application/json' },
|
|
11
|
+
body: JSON.stringify({
|
|
12
|
+
agentId,
|
|
13
|
+
name: 'Preview smoke',
|
|
14
|
+
taskPrompt: 'Review the queue',
|
|
15
|
+
scheduleType: 'cron',
|
|
16
|
+
cron: '0 9 * * *',
|
|
17
|
+
timezone: 'UTC',
|
|
18
|
+
status: 'active',
|
|
19
|
+
}),
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
assert.equal(response.status, 200)
|
|
23
|
+
const payload = await response.json() as Record<string, unknown>
|
|
24
|
+
assert.equal(payload.ok, true)
|
|
25
|
+
assert.equal(payload.scheduleType, 'cron')
|
|
26
|
+
assert.equal(payload.timezone, 'UTC')
|
|
27
|
+
assert.equal(Array.isArray(payload.nextRuns), true)
|
|
28
|
+
assert.equal((payload.nextRuns as unknown[]).length, 5)
|
|
29
|
+
assert.match(String((payload.warnings as string[]).join('\n')), /Agent not found/)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('POST /api/schedules/preview rejects invalid draft timing', async () => {
|
|
33
|
+
const response = await previewSchedule(new Request('http://local/api/schedules/preview', {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'content-type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({
|
|
37
|
+
agentId: 'schedule-preview-agent-invalid',
|
|
38
|
+
taskPrompt: 'Review the queue',
|
|
39
|
+
scheduleType: 'cron',
|
|
40
|
+
cron: '0 9 * * *',
|
|
41
|
+
timezone: 'Not/AZone',
|
|
42
|
+
}),
|
|
43
|
+
}))
|
|
44
|
+
|
|
45
|
+
assert.equal(response.status, 400)
|
|
46
|
+
const payload = await response.json() as Record<string, unknown>
|
|
47
|
+
assert.match(String(payload.error || ''), /invalid timezone/)
|
|
48
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { safeParseBody } from '@/lib/server/safe-parse-body'
|
|
3
|
+
import { previewScheduleFromRoute } from '@/lib/server/schedules/schedule-route-service'
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic'
|
|
6
|
+
|
|
7
|
+
export async function POST(req: Request) {
|
|
8
|
+
const { data: body, error } = await safeParseBody(req)
|
|
9
|
+
if (error) return error
|
|
10
|
+
const result = previewScheduleFromRoute((body || {}) as Record<string, unknown>)
|
|
11
|
+
return result.ok
|
|
12
|
+
? NextResponse.json(result.payload)
|
|
13
|
+
: NextResponse.json(result.payload, { status: result.status })
|
|
14
|
+
}
|
package/src/cli/index.js
CHANGED
|
@@ -582,6 +582,7 @@ const COMMAND_GROUPS = [
|
|
|
582
582
|
cmd('list', 'GET', '/schedules', 'List schedules'),
|
|
583
583
|
cmd('get', 'GET', '/schedules/:id', 'Get schedule by id'),
|
|
584
584
|
cmd('history', 'GET', '/schedules/:id/history', 'Get schedule revision history'),
|
|
585
|
+
cmd('preview', 'POST', '/schedules/preview', 'Preview schedule timing and validation', { expectsJsonBody: true }),
|
|
585
586
|
cmd('create', 'POST', '/schedules', 'Create schedule', { expectsJsonBody: true }),
|
|
586
587
|
cmd('update', 'PUT', '/schedules/:id', 'Update schedule', { expectsJsonBody: true }),
|
|
587
588
|
cmd('delete', 'DELETE', '/schedules/:id', 'Delete schedule'),
|
package/src/cli/spec.js
CHANGED
|
@@ -418,6 +418,7 @@ const COMMAND_GROUPS = {
|
|
|
418
418
|
description: 'Scheduled task automation',
|
|
419
419
|
commands: {
|
|
420
420
|
list: { description: 'List schedules', method: 'GET', path: '/schedules' },
|
|
421
|
+
preview: { description: 'Preview schedule timing and validation', method: 'POST', path: '/schedules/preview' },
|
|
421
422
|
create: { description: 'Create schedule', method: 'POST', path: '/schedules' },
|
|
422
423
|
get: { description: 'Get schedule by id (from list)', virtualGet: true, collectionPath: '/schedules', params: ['id'] },
|
|
423
424
|
history: { description: 'Get schedule revision history', method: 'GET', path: '/schedules/:id/history', params: ['id'] },
|
|
@@ -59,7 +59,8 @@ function badgeClass(status: string): string {
|
|
|
59
59
|
|
|
60
60
|
function formatScheduleCadence(schedule: Schedule): string {
|
|
61
61
|
if (schedule.scheduleType === 'cron' && schedule.cron) {
|
|
62
|
-
|
|
62
|
+
const timezone = schedule.timezone ? ` (${schedule.timezone})` : ''
|
|
63
|
+
return `${cronToHuman(schedule.cron)}${timezone}`
|
|
63
64
|
}
|
|
64
65
|
if (schedule.scheduleType === 'interval' && schedule.intervalMs) {
|
|
65
66
|
const minutes = Math.round(schedule.intervalMs / 60_000)
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState, useMemo } from 'react'
|
|
4
4
|
import { useAppStore } from '@/stores/use-app-store'
|
|
5
|
-
import { createSchedule, updateSchedule, deleteSchedule } from '@/lib/schedules/schedules'
|
|
5
|
+
import { createSchedule, updateSchedule, deleteSchedule, previewSchedule } from '@/lib/schedules/schedules'
|
|
6
6
|
import { BottomSheet } from '@/components/shared/bottom-sheet'
|
|
7
7
|
import { AgentPickerList } from '@/components/shared/agent-picker-list'
|
|
8
8
|
import { ConfirmDialog } from '@/components/shared/confirm-dialog'
|
|
9
9
|
import { inputClass } from '@/components/shared/form-styles'
|
|
10
10
|
import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
11
|
-
import type { ScheduleTaskMode, ScheduleType, ScheduleStatus } from '@/types'
|
|
11
|
+
import type { Schedule, SchedulePreviewResponse, ScheduleTaskMode, ScheduleType, ScheduleStatus } from '@/types'
|
|
12
12
|
import cronstrue from 'cronstrue'
|
|
13
13
|
import { SectionLabel } from '@/components/shared/section-label'
|
|
14
14
|
import { SCHEDULE_TEMPLATES, type ScheduleTemplate } from '@/lib/schedules/schedule-templates'
|
|
@@ -32,20 +32,6 @@ const CRON_PRESETS = [
|
|
|
32
32
|
{ label: 'Weekly Mon 9am', cron: '0 9 * * 1' },
|
|
33
33
|
]
|
|
34
34
|
|
|
35
|
-
async function getNextRunsAsync(cron: string, count: number = 3): Promise<Date[]> {
|
|
36
|
-
try {
|
|
37
|
-
const { CronExpressionParser } = await import('cron-parser')
|
|
38
|
-
const interval = CronExpressionParser.parse(cron)
|
|
39
|
-
const runs: Date[] = []
|
|
40
|
-
for (let i = 0; i < count; i++) {
|
|
41
|
-
runs.push(interval.next().toDate())
|
|
42
|
-
}
|
|
43
|
-
return runs
|
|
44
|
-
} catch {
|
|
45
|
-
return []
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
35
|
function formatCronHuman(cron: string): string {
|
|
50
36
|
try {
|
|
51
37
|
return cronstrue.toString(cron, { use24HourTimeFormat: false })
|
|
@@ -54,11 +40,6 @@ function formatCronHuman(cron: string): string {
|
|
|
54
40
|
}
|
|
55
41
|
}
|
|
56
42
|
|
|
57
|
-
function formatDate(d: Date): string {
|
|
58
|
-
return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }) +
|
|
59
|
-
' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
60
|
-
}
|
|
61
|
-
|
|
62
43
|
const STEPS_CREATE = ['Template', 'What', 'When', 'Review'] as const
|
|
63
44
|
const STEPS_EDIT = ['What', 'When', 'Review'] as const
|
|
64
45
|
type Step = 0 | 1 | 2 | 3
|
|
@@ -107,6 +88,11 @@ export function ScheduleSheet() {
|
|
|
107
88
|
const [taskMode, setTaskMode] = useState<ScheduleTaskMode>('task')
|
|
108
89
|
const [message, setMessage] = useState('')
|
|
109
90
|
const [customCron, setCustomCron] = useState(false)
|
|
91
|
+
const [timezone, setTimezone] = useState('')
|
|
92
|
+
const [staggerSec, setStaggerSec] = useState(0)
|
|
93
|
+
const [timingPreview, setTimingPreview] = useState<SchedulePreviewResponse | null>(null)
|
|
94
|
+
const [timingPreviewLoading, setTimingPreviewLoading] = useState(false)
|
|
95
|
+
const [timingPreviewError, setTimingPreviewError] = useState('')
|
|
110
96
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
111
97
|
const [deleting, setDeleting] = useState(false)
|
|
112
98
|
|
|
@@ -136,6 +122,8 @@ export function ScheduleSheet() {
|
|
|
136
122
|
setTaskMode(editing.taskMode === 'wake_only' ? 'wake_only' : editing.taskMode === 'protocol' ? 'protocol' : 'task')
|
|
137
123
|
setMessage(editing.message || '')
|
|
138
124
|
setCustomCron(!CRON_PRESETS.some((p) => p.cron === editing.cron))
|
|
125
|
+
setTimezone(editing.timezone || '')
|
|
126
|
+
setStaggerSec(editing.staggerSec || 0)
|
|
139
127
|
} else if (templatePrefill) {
|
|
140
128
|
// Opened from a quick-start card with pre-filled values
|
|
141
129
|
setName(templatePrefill.name)
|
|
@@ -148,6 +136,10 @@ export function ScheduleSheet() {
|
|
|
148
136
|
if (templatePrefill.intervalMs) setIntervalMs(templatePrefill.intervalMs)
|
|
149
137
|
setAgentId('')
|
|
150
138
|
setStatus('active')
|
|
139
|
+
setTaskMode('task')
|
|
140
|
+
setMessage('')
|
|
141
|
+
setTimezone('')
|
|
142
|
+
setStaggerSec(0)
|
|
151
143
|
setStep(1) // Skip template picker, go to "What" step
|
|
152
144
|
setTemplatePrefill(null)
|
|
153
145
|
} else {
|
|
@@ -162,26 +154,22 @@ export function ScheduleSheet() {
|
|
|
162
154
|
setTaskMode('task')
|
|
163
155
|
setMessage('')
|
|
164
156
|
setCustomCron(false)
|
|
157
|
+
setTimezone('')
|
|
158
|
+
setStaggerSec(0)
|
|
165
159
|
}
|
|
160
|
+
setTimingPreview(null)
|
|
161
|
+
setTimingPreviewError('')
|
|
162
|
+
setTimingPreviewLoading(false)
|
|
166
163
|
}
|
|
167
164
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
168
165
|
}, [open, editingId])
|
|
169
166
|
|
|
170
167
|
const cronHuman = useMemo(() => formatCronHuman(cron), [cron])
|
|
171
|
-
const [nextRuns, setNextRuns] = useState<Date[]>([])
|
|
172
|
-
useEffect(() => {
|
|
173
|
-
getNextRunsAsync(cron).then(setNextRuns)
|
|
174
|
-
}, [cron])
|
|
175
|
-
|
|
176
|
-
const onClose = () => {
|
|
177
|
-
setConfirmDelete(false)
|
|
178
|
-
setDeleting(false)
|
|
179
|
-
setOpen(false)
|
|
180
|
-
setEditingId(null)
|
|
181
|
-
}
|
|
182
168
|
|
|
183
|
-
const
|
|
184
|
-
const
|
|
169
|
+
const buildScheduleData = () => {
|
|
170
|
+
const trimmedTimezone = timezone.trim()
|
|
171
|
+
const normalizedStaggerSec = Math.max(0, Math.trunc(staggerSec || 0))
|
|
172
|
+
return {
|
|
185
173
|
name: name.trim(),
|
|
186
174
|
agentId,
|
|
187
175
|
taskPrompt: taskMode === 'wake_only' ? message : taskPrompt,
|
|
@@ -192,8 +180,67 @@ export function ScheduleSheet() {
|
|
|
192
180
|
cron: scheduleType === 'cron' ? cron : undefined,
|
|
193
181
|
intervalMs: scheduleType === 'interval' ? intervalMs : undefined,
|
|
194
182
|
runAt: scheduleType === 'once' ? Date.now() + intervalMs : undefined,
|
|
183
|
+
timezone: scheduleType === 'cron' && trimmedTimezone ? trimmedTimezone : undefined,
|
|
184
|
+
staggerSec: normalizedStaggerSec > 0 ? normalizedStaggerSec : undefined,
|
|
195
185
|
status,
|
|
196
186
|
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (!open || step === templateStep) {
|
|
191
|
+
setTimingPreview(null)
|
|
192
|
+
setTimingPreviewError('')
|
|
193
|
+
setTimingPreviewLoading(false)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const taskText = taskMode === 'wake_only' ? message.trim() : taskPrompt.trim()
|
|
198
|
+
const hasTiming = scheduleType === 'cron'
|
|
199
|
+
? cron.trim().length > 0
|
|
200
|
+
: intervalMs > 0
|
|
201
|
+
if (!agentId || !taskText || !hasTiming) {
|
|
202
|
+
setTimingPreview(null)
|
|
203
|
+
setTimingPreviewError('')
|
|
204
|
+
setTimingPreviewLoading(false)
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let cancelled = false
|
|
209
|
+
const timer = window.setTimeout(() => {
|
|
210
|
+
setTimingPreviewLoading(true)
|
|
211
|
+
setTimingPreviewError('')
|
|
212
|
+
previewSchedule(buildScheduleData() as Partial<Schedule>)
|
|
213
|
+
.then((result) => {
|
|
214
|
+
if (cancelled) return
|
|
215
|
+
setTimingPreview(result)
|
|
216
|
+
setTimingPreviewError(result.ok ? '' : result.error)
|
|
217
|
+
})
|
|
218
|
+
.catch((err: unknown) => {
|
|
219
|
+
if (cancelled) return
|
|
220
|
+
setTimingPreview(null)
|
|
221
|
+
setTimingPreviewError(err instanceof Error ? err.message : 'Failed to preview schedule')
|
|
222
|
+
})
|
|
223
|
+
.finally(() => {
|
|
224
|
+
if (!cancelled) setTimingPreviewLoading(false)
|
|
225
|
+
})
|
|
226
|
+
}, 300)
|
|
227
|
+
|
|
228
|
+
return () => {
|
|
229
|
+
cancelled = true
|
|
230
|
+
window.clearTimeout(timer)
|
|
231
|
+
}
|
|
232
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
233
|
+
}, [open, step, templateStep, name, agentId, taskPrompt, taskMode, message, scheduleType, cron, intervalMs, status, timezone, staggerSec])
|
|
234
|
+
|
|
235
|
+
const onClose = () => {
|
|
236
|
+
setConfirmDelete(false)
|
|
237
|
+
setDeleting(false)
|
|
238
|
+
setOpen(false)
|
|
239
|
+
setEditingId(null)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const handleSave = async () => {
|
|
243
|
+
const data = buildScheduleData()
|
|
197
244
|
try {
|
|
198
245
|
if (editing) {
|
|
199
246
|
await updateSchedule(editing.id, data)
|
|
@@ -227,10 +274,56 @@ export function ScheduleSheet() {
|
|
|
227
274
|
|
|
228
275
|
// Step validation
|
|
229
276
|
const step0Valid = name.trim().length > 0 && agentId.length > 0 && (taskMode === 'wake_only' ? message.trim().length > 0 : taskPrompt.trim().length > 0)
|
|
230
|
-
const step1Valid = scheduleType === 'cron' ? cron.trim().length > 0 : intervalMs > 0
|
|
277
|
+
const step1Valid = (scheduleType === 'cron' ? cron.trim().length > 0 : intervalMs > 0) && !timingPreviewError
|
|
231
278
|
|
|
232
279
|
const selectedAgent = agentId ? agents[agentId] : null
|
|
233
280
|
const creatorAgent = editing?.createdByAgentId ? agents[editing.createdByAgentId] : null
|
|
281
|
+
const previewOk = timingPreview && timingPreview.ok ? timingPreview : null
|
|
282
|
+
|
|
283
|
+
const timingPreviewPanel = (
|
|
284
|
+
<div className="p-4 rounded-[14px] bg-surface border border-white/[0.06]">
|
|
285
|
+
<div className="flex flex-wrap items-center gap-2 mb-2">
|
|
286
|
+
<div className="text-[14px] text-text-2 font-600">
|
|
287
|
+
{scheduleType === 'cron' ? cronHuman : previewOk?.cadence || (scheduleType === 'once' ? 'Run once' : `Every ${Math.round(intervalMs / 60000)} minutes`)}
|
|
288
|
+
</div>
|
|
289
|
+
{previewOk?.timezone && (
|
|
290
|
+
<span className="rounded-[999px] bg-white/[0.05] px-2 py-0.5 text-[11px] font-600 text-text-3">
|
|
291
|
+
{previewOk.timezone}
|
|
292
|
+
</span>
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
{scheduleType === 'cron' && cron && (
|
|
296
|
+
<div className="font-mono text-[12px] text-text-3/50 mb-3">{cron}</div>
|
|
297
|
+
)}
|
|
298
|
+
{timingPreviewLoading && (
|
|
299
|
+
<div className="text-[12px] text-text-3">Checking schedule...</div>
|
|
300
|
+
)}
|
|
301
|
+
{timingPreviewError && (
|
|
302
|
+
<div className="text-[12px] text-red-400">{timingPreviewError}</div>
|
|
303
|
+
)}
|
|
304
|
+
{previewOk && !timingPreviewLoading && (
|
|
305
|
+
<>
|
|
306
|
+
{previewOk.nextRuns.length > 0 ? (
|
|
307
|
+
<div className="space-y-1.5">
|
|
308
|
+
<div className="text-[11px] text-text-3/60 uppercase tracking-wider font-600">Next runs</div>
|
|
309
|
+
{previewOk.nextRuns.map((run) => (
|
|
310
|
+
<div key={run.iso} className="text-[12px] text-text-3 font-mono">{run.label}</div>
|
|
311
|
+
))}
|
|
312
|
+
</div>
|
|
313
|
+
) : (
|
|
314
|
+
<div className="text-[12px] text-text-3">No future runs calculated.</div>
|
|
315
|
+
)}
|
|
316
|
+
{previewOk.warnings.length > 0 && (
|
|
317
|
+
<div className="mt-3 space-y-1.5">
|
|
318
|
+
{previewOk.warnings.map((warning) => (
|
|
319
|
+
<div key={warning} className="text-[12px] text-amber-300">{warning}</div>
|
|
320
|
+
))}
|
|
321
|
+
</div>
|
|
322
|
+
)}
|
|
323
|
+
</>
|
|
324
|
+
)}
|
|
325
|
+
</div>
|
|
326
|
+
)
|
|
234
327
|
|
|
235
328
|
return (
|
|
236
329
|
<BottomSheet open={open} onClose={onClose} wide>
|
|
@@ -501,21 +594,32 @@ export function ScheduleSheet() {
|
|
|
501
594
|
<input type="text" value={cron} onChange={(e) => setCron(e.target.value)} placeholder="0 * * * *" className={`${inputClass} font-mono text-[14px] mb-3`} />
|
|
502
595
|
)}
|
|
503
596
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
{
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
597
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
|
|
598
|
+
<div>
|
|
599
|
+
<SectionLabel>Timezone</SectionLabel>
|
|
600
|
+
<input
|
|
601
|
+
type="text"
|
|
602
|
+
value={timezone}
|
|
603
|
+
onChange={(e) => setTimezone(e.target.value)}
|
|
604
|
+
placeholder="UTC or America/New_York"
|
|
605
|
+
className={inputClass}
|
|
606
|
+
style={{ fontFamily: 'inherit' }}
|
|
607
|
+
/>
|
|
608
|
+
</div>
|
|
609
|
+
<div>
|
|
610
|
+
<SectionLabel>Stagger (seconds)</SectionLabel>
|
|
611
|
+
<input
|
|
612
|
+
type="number"
|
|
613
|
+
min={0}
|
|
614
|
+
value={staggerSec}
|
|
615
|
+
onChange={(e) => setStaggerSec(Math.max(0, parseInt(e.target.value, 10) || 0))}
|
|
616
|
+
className={inputClass}
|
|
617
|
+
style={{ fontFamily: 'inherit' }}
|
|
618
|
+
/>
|
|
619
|
+
</div>
|
|
518
620
|
</div>
|
|
621
|
+
|
|
622
|
+
{timingPreviewPanel}
|
|
519
623
|
</div>
|
|
520
624
|
)}
|
|
521
625
|
|
|
@@ -529,6 +633,34 @@ export function ScheduleSheet() {
|
|
|
529
633
|
className={inputClass}
|
|
530
634
|
style={{ fontFamily: 'inherit' }}
|
|
531
635
|
/>
|
|
636
|
+
<div className="mt-3 mb-3">
|
|
637
|
+
<SectionLabel>Stagger (seconds)</SectionLabel>
|
|
638
|
+
<input
|
|
639
|
+
type="number"
|
|
640
|
+
min={0}
|
|
641
|
+
value={staggerSec}
|
|
642
|
+
onChange={(e) => setStaggerSec(Math.max(0, parseInt(e.target.value, 10) || 0))}
|
|
643
|
+
className={inputClass}
|
|
644
|
+
style={{ fontFamily: 'inherit' }}
|
|
645
|
+
/>
|
|
646
|
+
</div>
|
|
647
|
+
{timingPreviewPanel}
|
|
648
|
+
</div>
|
|
649
|
+
)}
|
|
650
|
+
|
|
651
|
+
{scheduleType === 'once' && (
|
|
652
|
+
<div className="mb-8">
|
|
653
|
+
<SectionLabel>Run After (minutes)</SectionLabel>
|
|
654
|
+
<input
|
|
655
|
+
type="number"
|
|
656
|
+
value={Math.round(intervalMs / 60000)}
|
|
657
|
+
onChange={(e) => setIntervalMs(Math.max(1, parseInt(e.target.value) || 1) * 60000)}
|
|
658
|
+
className={inputClass}
|
|
659
|
+
style={{ fontFamily: 'inherit' }}
|
|
660
|
+
/>
|
|
661
|
+
<div className="mt-3">
|
|
662
|
+
{timingPreviewPanel}
|
|
663
|
+
</div>
|
|
532
664
|
</div>
|
|
533
665
|
)}
|
|
534
666
|
|
|
@@ -620,6 +752,15 @@ export function ScheduleSheet() {
|
|
|
620
752
|
{scheduleType === 'once' && (
|
|
621
753
|
<div className="text-[12px] text-text-3 font-mono mt-0.5">Run once</div>
|
|
622
754
|
)}
|
|
755
|
+
{scheduleType === 'cron' && timezone.trim() && (
|
|
756
|
+
<div className="text-[12px] text-text-3 mt-1">Timezone: {timezone.trim()}</div>
|
|
757
|
+
)}
|
|
758
|
+
{staggerSec > 0 && (
|
|
759
|
+
<div className="text-[12px] text-text-3 mt-1">Stagger: up to {staggerSec} seconds</div>
|
|
760
|
+
)}
|
|
761
|
+
{previewOk?.nextRuns[0] && (
|
|
762
|
+
<div className="text-[12px] text-text-3 mt-1">Next: {previewOk.nextRuns[0].label}</div>
|
|
763
|
+
)}
|
|
623
764
|
</div>
|
|
624
765
|
{editing && (
|
|
625
766
|
<div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { api } from '@/lib/app/api-client'
|
|
2
|
-
import type { Schedule, ScheduleHistoryEntry } from '@/types'
|
|
2
|
+
import type { Schedule, ScheduleHistoryEntry, SchedulePreviewResponse } from '@/types'
|
|
3
3
|
|
|
4
4
|
export interface ScheduleArchiveResponse {
|
|
5
5
|
ok: boolean
|
|
@@ -31,6 +31,9 @@ export const fetchSchedules = (includeArchived = false) =>
|
|
|
31
31
|
export const createSchedule = (data: Omit<Schedule, 'id' | 'createdAt' | 'lastRunAt' | 'nextRunAt'>) =>
|
|
32
32
|
api<Schedule>('POST', '/schedules', data)
|
|
33
33
|
|
|
34
|
+
export const previewSchedule = (data: Partial<Schedule>) =>
|
|
35
|
+
api<SchedulePreviewResponse>('POST', '/schedules/preview', data, { timeoutMs: 8_000 })
|
|
36
|
+
|
|
34
37
|
export const updateSchedule = (id: string, data: Partial<Schedule>) =>
|
|
35
38
|
api<Schedule>('PUT', `/schedules/${id}`, data)
|
|
36
39
|
|
|
@@ -51,6 +51,15 @@ function normalizeScheduleTimestamp(value: unknown): number | null {
|
|
|
51
51
|
return Math.trunc(parsedTime)
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
function isValidTimeZone(value: string): boolean {
|
|
55
|
+
try {
|
|
56
|
+
new Intl.DateTimeFormat('en-US', { timeZone: value })
|
|
57
|
+
return true
|
|
58
|
+
} catch {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
54
63
|
/**
|
|
55
64
|
* Parse natural "at HH:MM" time expressions into a cron string.
|
|
56
65
|
* Supports: "at 09:00", "at 14:30", "at 9am", "at 2:30pm", "daily at 09:00"
|
|
@@ -226,7 +235,14 @@ export function normalizeSchedulePayload(payload: SchedulePayload, opts: Normali
|
|
|
226
235
|
|
|
227
236
|
// Preserve timezone and stagger
|
|
228
237
|
const timezone = trimString(normalized.timezone)
|
|
229
|
-
if (timezone)
|
|
238
|
+
if (timezone) {
|
|
239
|
+
if (!isValidTimeZone(timezone)) {
|
|
240
|
+
return { ok: false, error: `Error: invalid timezone: ${timezone}.` }
|
|
241
|
+
}
|
|
242
|
+
normalized.timezone = timezone
|
|
243
|
+
} else if (normalized.timezone != null) {
|
|
244
|
+
delete normalized.timezone
|
|
245
|
+
}
|
|
230
246
|
const staggerSec = normalizePositiveInt(normalized.staggerSec)
|
|
231
247
|
if (staggerSec != null) normalized.staggerSec = staggerSec
|
|
232
248
|
else if (normalized.staggerSec != null) delete normalized.staggerSec
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { previewSchedulePayload } from './schedule-preview'
|
|
5
|
+
|
|
6
|
+
const NOW = Date.parse('2026-05-06T12:00:00.000Z')
|
|
7
|
+
|
|
8
|
+
function basePayload(overrides: Record<string, unknown> = {}) {
|
|
9
|
+
return {
|
|
10
|
+
agentId: 'agent-1',
|
|
11
|
+
name: 'Preview schedule',
|
|
12
|
+
taskPrompt: 'Review the queue',
|
|
13
|
+
status: 'active',
|
|
14
|
+
...overrides,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('previewSchedulePayload', () => {
|
|
19
|
+
it('forecasts cron schedules with an explicit timezone', () => {
|
|
20
|
+
const preview = previewSchedulePayload(basePayload({
|
|
21
|
+
scheduleType: 'cron',
|
|
22
|
+
cron: '0 9 * * *',
|
|
23
|
+
timezone: 'UTC',
|
|
24
|
+
}), { now: NOW })
|
|
25
|
+
|
|
26
|
+
assert.equal(preview.ok, true)
|
|
27
|
+
if (!preview.ok) return
|
|
28
|
+
assert.equal(preview.scheduleType, 'cron')
|
|
29
|
+
assert.equal(preview.timezone, 'UTC')
|
|
30
|
+
assert.equal(preview.nextRuns.length, 5)
|
|
31
|
+
assert.equal(preview.nextRunAt, Date.parse('2026-05-07T09:00:00.000Z'))
|
|
32
|
+
assert.equal(preview.nextRuns[0].iso, '2026-05-07T09:00:00.000Z')
|
|
33
|
+
assert.deepEqual(preview.warnings, [])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('warns when cron schedules rely on host local time', () => {
|
|
37
|
+
const preview = previewSchedulePayload(basePayload({
|
|
38
|
+
scheduleType: 'cron',
|
|
39
|
+
cron: '0 9 * * *',
|
|
40
|
+
}), { now: NOW, count: 2 })
|
|
41
|
+
|
|
42
|
+
assert.equal(preview.ok, true)
|
|
43
|
+
if (!preview.ok) return
|
|
44
|
+
assert.equal(preview.nextRuns.length, 2)
|
|
45
|
+
assert.match(preview.warnings.join('\n'), /host local timezone/)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('rejects invalid timezone values before saving', () => {
|
|
49
|
+
const preview = previewSchedulePayload(basePayload({
|
|
50
|
+
scheduleType: 'cron',
|
|
51
|
+
cron: '0 9 * * *',
|
|
52
|
+
timezone: 'Not/AZone',
|
|
53
|
+
}), { now: NOW })
|
|
54
|
+
|
|
55
|
+
assert.equal(preview.ok, false)
|
|
56
|
+
if (preview.ok) return
|
|
57
|
+
assert.match(preview.error, /invalid timezone/)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('forecasts interval schedules from the preview time', () => {
|
|
61
|
+
const preview = previewSchedulePayload(basePayload({
|
|
62
|
+
scheduleType: 'interval',
|
|
63
|
+
intervalMs: 30 * 60_000,
|
|
64
|
+
}), { now: NOW, count: 3 })
|
|
65
|
+
|
|
66
|
+
assert.equal(preview.ok, true)
|
|
67
|
+
if (!preview.ok) return
|
|
68
|
+
assert.equal(preview.scheduleType, 'interval')
|
|
69
|
+
assert.equal(preview.nextRuns.length, 3)
|
|
70
|
+
assert.deepEqual(preview.nextRuns.map((run) => run.at), [
|
|
71
|
+
NOW + 30 * 60_000,
|
|
72
|
+
NOW + 60 * 60_000,
|
|
73
|
+
NOW + 90 * 60_000,
|
|
74
|
+
])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('warns when a run-once schedule is already in the past', () => {
|
|
78
|
+
const preview = previewSchedulePayload(basePayload({
|
|
79
|
+
scheduleType: 'once',
|
|
80
|
+
runAt: NOW - 60_000,
|
|
81
|
+
}), { now: NOW })
|
|
82
|
+
|
|
83
|
+
assert.equal(preview.ok, true)
|
|
84
|
+
if (!preview.ok) return
|
|
85
|
+
assert.equal(preview.nextRunAt, null)
|
|
86
|
+
assert.equal(preview.nextRuns.length, 0)
|
|
87
|
+
assert.match(preview.warnings.join('\n'), /past/)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { CronExpressionParser } from 'cron-parser'
|
|
2
|
+
|
|
3
|
+
import { normalizeSchedulePayload } from '@/lib/server/schedules/schedule-normalization'
|
|
4
|
+
import type { SchedulePreviewResponse, SchedulePreviewRun, ScheduleType } from '@/types'
|
|
5
|
+
|
|
6
|
+
type SchedulePayload = Record<string, unknown>
|
|
7
|
+
|
|
8
|
+
export interface PreviewScheduleOptions {
|
|
9
|
+
cwd?: string | null
|
|
10
|
+
now?: number
|
|
11
|
+
count?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_PREVIEW_RUNS = 5
|
|
15
|
+
|
|
16
|
+
function trimString(value: unknown): string {
|
|
17
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function positiveNumber(value: unknown): number | null {
|
|
21
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null
|
|
22
|
+
return Math.trunc(value)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatDuration(ms: number): string {
|
|
26
|
+
const minutes = Math.round(ms / 60_000)
|
|
27
|
+
if (minutes <= 1) return 'Every minute'
|
|
28
|
+
if (minutes < 60) return `Every ${minutes} minutes`
|
|
29
|
+
const hours = Math.round(minutes / 60)
|
|
30
|
+
if (hours < 24) return hours === 1 ? 'Every hour' : `Every ${hours} hours`
|
|
31
|
+
const days = Math.round(hours / 24)
|
|
32
|
+
return days === 1 ? 'Every day' : `Every ${days} days`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatRunLabel(timestamp: number, timezone: string | null): string {
|
|
36
|
+
const date = new Date(timestamp)
|
|
37
|
+
try {
|
|
38
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
39
|
+
weekday: 'short',
|
|
40
|
+
month: 'short',
|
|
41
|
+
day: 'numeric',
|
|
42
|
+
hour: '2-digit',
|
|
43
|
+
minute: '2-digit',
|
|
44
|
+
...(timezone ? { timeZone: timezone, timeZoneName: 'short' as const } : {}),
|
|
45
|
+
}).format(date)
|
|
46
|
+
} catch {
|
|
47
|
+
return date.toISOString()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildPreviewRun(timestamp: number, timezone: string | null): SchedulePreviewRun {
|
|
52
|
+
return {
|
|
53
|
+
at: timestamp,
|
|
54
|
+
iso: new Date(timestamp).toISOString(),
|
|
55
|
+
label: formatRunLabel(timestamp, timezone),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildCronRuns(cron: string, timezone: string | null, now: number, count: number): SchedulePreviewRun[] {
|
|
60
|
+
const interval = CronExpressionParser.parse(cron, {
|
|
61
|
+
currentDate: new Date(now),
|
|
62
|
+
...(timezone ? { tz: timezone } : {}),
|
|
63
|
+
})
|
|
64
|
+
const runs: SchedulePreviewRun[] = []
|
|
65
|
+
for (let index = 0; index < count; index += 1) {
|
|
66
|
+
runs.push(buildPreviewRun(interval.next().getTime(), timezone))
|
|
67
|
+
}
|
|
68
|
+
return runs
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildIntervalRuns(intervalMs: number, timezone: string | null, now: number, count: number): SchedulePreviewRun[] {
|
|
72
|
+
const runs: SchedulePreviewRun[] = []
|
|
73
|
+
for (let index = 1; index <= count; index += 1) {
|
|
74
|
+
runs.push(buildPreviewRun(now + (intervalMs * index), timezone))
|
|
75
|
+
}
|
|
76
|
+
return runs
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cadenceLabel(scheduleType: ScheduleType, payload: SchedulePayload): string {
|
|
80
|
+
if (scheduleType === 'cron') {
|
|
81
|
+
const cron = trimString(payload.cron)
|
|
82
|
+
return cron ? `Cron ${cron}` : 'Cron'
|
|
83
|
+
}
|
|
84
|
+
if (scheduleType === 'interval') {
|
|
85
|
+
const intervalMs = positiveNumber(payload.intervalMs)
|
|
86
|
+
return intervalMs ? formatDuration(intervalMs) : 'Interval'
|
|
87
|
+
}
|
|
88
|
+
return 'Run once'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function previewSchedulePayload(
|
|
92
|
+
payload: SchedulePayload,
|
|
93
|
+
options: PreviewScheduleOptions = {},
|
|
94
|
+
): SchedulePreviewResponse {
|
|
95
|
+
const now = typeof options.now === 'number' ? options.now : Date.now()
|
|
96
|
+
const count = Math.max(1, Math.min(10, Math.trunc(options.count ?? DEFAULT_PREVIEW_RUNS)))
|
|
97
|
+
const normalized = normalizeSchedulePayload({
|
|
98
|
+
...payload,
|
|
99
|
+
nextRunAt: undefined,
|
|
100
|
+
}, {
|
|
101
|
+
cwd: options.cwd,
|
|
102
|
+
now,
|
|
103
|
+
})
|
|
104
|
+
if (!normalized.ok) {
|
|
105
|
+
return { ok: false, error: normalized.error }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const value = normalized.value
|
|
109
|
+
const scheduleType = value.scheduleType === 'cron' || value.scheduleType === 'once'
|
|
110
|
+
? value.scheduleType
|
|
111
|
+
: 'interval'
|
|
112
|
+
const timezone = trimString(value.timezone) || null
|
|
113
|
+
const warnings: string[] = []
|
|
114
|
+
const staggerSec = positiveNumber(value.staggerSec)
|
|
115
|
+
|
|
116
|
+
if (scheduleType === 'cron' && !timezone) {
|
|
117
|
+
warnings.push('Timezone is not set, so cron runs use the host local timezone.')
|
|
118
|
+
}
|
|
119
|
+
if (staggerSec) {
|
|
120
|
+
warnings.push(`Stagger may delay each run by up to ${staggerSec} seconds.`)
|
|
121
|
+
}
|
|
122
|
+
if (value.status === 'paused') {
|
|
123
|
+
warnings.push('Paused schedules keep their forecast but will not run until reactivated.')
|
|
124
|
+
}
|
|
125
|
+
if (value.status === 'archived') {
|
|
126
|
+
warnings.push('Archived schedules do not run until restored.')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let nextRuns: SchedulePreviewRun[] = []
|
|
130
|
+
try {
|
|
131
|
+
if (scheduleType === 'cron') {
|
|
132
|
+
const cron = trimString(value.cron)
|
|
133
|
+
if (!cron) {
|
|
134
|
+
return { ok: false, error: 'Error: cron schedules require a cron expression.' }
|
|
135
|
+
}
|
|
136
|
+
nextRuns = buildCronRuns(cron, timezone, now, count)
|
|
137
|
+
} else if (scheduleType === 'interval') {
|
|
138
|
+
const intervalMs = positiveNumber(value.intervalMs)
|
|
139
|
+
if (!intervalMs) {
|
|
140
|
+
return { ok: false, error: 'Error: interval schedules require intervalMs.' }
|
|
141
|
+
}
|
|
142
|
+
nextRuns = buildIntervalRuns(intervalMs, timezone, now, count)
|
|
143
|
+
} else {
|
|
144
|
+
const runAt = positiveNumber(value.runAt)
|
|
145
|
+
if (!runAt) {
|
|
146
|
+
warnings.push('Run-once schedules need a runAt timestamp before they can be queued.')
|
|
147
|
+
} else if (runAt <= now) {
|
|
148
|
+
warnings.push('Run-once timestamp is in the past.')
|
|
149
|
+
} else {
|
|
150
|
+
nextRuns = [buildPreviewRun(runAt, timezone)]
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
return { ok: false, error: 'Error: invalid schedule timing.' }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (nextRuns.length === 0 && value.status !== 'archived') {
|
|
158
|
+
warnings.push('No future runs could be calculated from this schedule.')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
scheduleType,
|
|
164
|
+
cadence: cadenceLabel(scheduleType, value),
|
|
165
|
+
timezone,
|
|
166
|
+
nextRunAt: nextRuns[0]?.at ?? null,
|
|
167
|
+
nextRuns,
|
|
168
|
+
warnings,
|
|
169
|
+
normalized: value,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -19,7 +19,8 @@ import { pushMainLoopEventToMainSessions } from '@/lib/server/agents/main-agent-
|
|
|
19
19
|
import { WORKSPACE_DIR } from '@/lib/server/data-dir'
|
|
20
20
|
import { notify } from '@/lib/server/ws-hub'
|
|
21
21
|
import { appendScheduleHistoryEntry } from '@/lib/server/schedules/schedule-history'
|
|
22
|
-
import
|
|
22
|
+
import { previewSchedulePayload } from '@/lib/server/schedules/schedule-preview'
|
|
23
|
+
import type { Schedule, SchedulePreviewResponse } from '@/types'
|
|
23
24
|
import type { ScheduleLike } from '@/lib/schedules/schedule-dedupe'
|
|
24
25
|
import type { ServiceResult } from '@/lib/server/service-result'
|
|
25
26
|
|
|
@@ -39,6 +40,29 @@ export function listSchedulesForApi(includeArchived: boolean) {
|
|
|
39
40
|
return filtered
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
export function previewScheduleFromRoute(body: Record<string, unknown>): ServiceResult<SchedulePreviewResponse> {
|
|
44
|
+
const result = previewSchedulePayload(body, {
|
|
45
|
+
cwd: WORKSPACE_DIR,
|
|
46
|
+
now: Date.now(),
|
|
47
|
+
})
|
|
48
|
+
if (!result.ok) return serviceFail(400, result.error)
|
|
49
|
+
|
|
50
|
+
const agents = loadAgents()
|
|
51
|
+
const agentId = typeof result.normalized.agentId === 'string' ? result.normalized.agentId : ''
|
|
52
|
+
const agent = agentId ? agents[agentId] : null
|
|
53
|
+
const warnings = [...result.warnings]
|
|
54
|
+
if (agentId && !agent) {
|
|
55
|
+
warnings.push(`Agent not found: ${agentId}`)
|
|
56
|
+
} else if (agent && isAgentDisabled(agent)) {
|
|
57
|
+
warnings.push(buildAgentDisabledMessage(agent, 'take scheduled work'))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return serviceOk({
|
|
61
|
+
...result,
|
|
62
|
+
warnings,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
42
66
|
export function createScheduleFromRoute(body: Record<string, unknown>): ServiceResult<ScheduleLike> {
|
|
43
67
|
const now = Date.now()
|
|
44
68
|
const schedules = loadSchedules()
|
package/src/types/schedule.ts
CHANGED
|
@@ -82,3 +82,26 @@ export interface Schedule {
|
|
|
82
82
|
createdAt: number
|
|
83
83
|
updatedAt?: number
|
|
84
84
|
}
|
|
85
|
+
|
|
86
|
+
export interface SchedulePreviewRun {
|
|
87
|
+
at: number
|
|
88
|
+
iso: string
|
|
89
|
+
label: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type SchedulePreviewResponse =
|
|
93
|
+
| {
|
|
94
|
+
ok: true
|
|
95
|
+
scheduleType: ScheduleType
|
|
96
|
+
cadence: string
|
|
97
|
+
timezone: string | null
|
|
98
|
+
nextRunAt: number | null
|
|
99
|
+
nextRuns: SchedulePreviewRun[]
|
|
100
|
+
warnings: string[]
|
|
101
|
+
normalized: Partial<Schedule> & Record<string, unknown>
|
|
102
|
+
}
|
|
103
|
+
| {
|
|
104
|
+
ok: false
|
|
105
|
+
error: string
|
|
106
|
+
warnings?: string[]
|
|
107
|
+
}
|