mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Display valid multi-day automation schedules (#26850)
Co-authored-by: ggbdpq <ggbdpq@gmail.com>
This commit is contained in:
@@ -129,6 +129,17 @@ describe('AutomationSchedulePicker', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['en', 'zh', 'ja', 'ko', 'es'])(
|
||||
'shows a valid multi-day weekly rule without an invalid label in %s (#24985)',
|
||||
async (locale) => {
|
||||
await i18n.changeLanguage(locale)
|
||||
const rrule = 'FREQ=WEEKLY;BYDAY=TU,TH,SA;BYHOUR=8;BYMINUTE=0'
|
||||
|
||||
expect(formatUiAutomationSchedule(rrule)).toBe(rrule)
|
||||
expect(formatUiAutomationSchedule('*/30 9-17 * * MON-FRI')).not.toBe('Invalid schedule')
|
||||
}
|
||||
)
|
||||
|
||||
it('localizes the valid-custom-cron status without matching English copy (#14404)', async () => {
|
||||
await i18n.changeLanguage('zh')
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ export function formatUiAutomationScheduleDescriptor(
|
||||
)
|
||||
}
|
||||
if (descriptor.kind === 'custom') {
|
||||
if (descriptor.expression) {
|
||||
return descriptor.expression
|
||||
}
|
||||
return translate(
|
||||
'auto.components.automations.automation.schedule.label.ba20c92073',
|
||||
'Custom schedule'
|
||||
|
||||
@@ -109,6 +109,50 @@ describe('automation schedules', () => {
|
||||
expect(formatAutomationSchedule('FREQ=YEARLY')).toBe('Invalid schedule')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['TU,TH,SA', new Date(2026, 9, 6, 8)],
|
||||
['MO,WE,FR,SU', new Date(2026, 9, 7, 8)],
|
||||
['MO,TU,WE,TH,FR,SA,SU', new Date(2026, 9, 6, 8)]
|
||||
])(
|
||||
'labels a runnable multi-day weekly rule %s without calling it invalid (#24985)',
|
||||
(days, next) => {
|
||||
const rrule = `FREQ=WEEKLY;BYDAY=${days};BYHOUR=8;BYMINUTE=0`
|
||||
const start = new Date(2026, 9, 5, 9).getTime()
|
||||
|
||||
expect(isValidAutomationSchedule(rrule)).toBe(true)
|
||||
expect(nextAutomationOccurrenceAfter(rrule, start, start)).toBe(next.getTime())
|
||||
expect(formatAutomationSchedule(` ${rrule} `)).toBe(rrule)
|
||||
expect(describeAutomationSchedule(` ${rrule} `)).toEqual({
|
||||
kind: 'custom',
|
||||
expression: rrule
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['FREQ=WEEKLY;BYDAY=TU,NO;BYHOUR=8;BYMINUTE=0', 'FREQ=YEARLY', '0 0 31 2 *'])(
|
||||
'keeps the invalid label for schedules the scheduler rejects: %s',
|
||||
(schedule) => {
|
||||
expect(isValidAutomationSchedule(schedule)).toBe(false)
|
||||
expect(formatAutomationSchedule(schedule)).toBe('Invalid schedule')
|
||||
expect(describeAutomationSchedule(schedule)).toEqual({ kind: 'invalid' })
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['\u001b[2J', '\\x1b[2J'],
|
||||
['\u001b]52;c;ZXZpbA==\u0007', '\\x1b]52;c;ZXZpbA==\\x07'],
|
||||
['\u009b2J', '\\x9b2J'],
|
||||
['\t\r\n', '\\x09\\x0d\\x0a']
|
||||
])('renders accepted control bytes as printable schedule text', (control, escaped) => {
|
||||
const prefix = 'FREQ=WEEKLY;BYDAY=TU,TH;BYHOUR=8;BYMINUTE=0;X='
|
||||
const expression = `${prefix}${control};Y=keep`
|
||||
const expected = `${prefix}${escaped};Y=keep`
|
||||
|
||||
expect(isValidAutomationSchedule(expression)).toBe(true)
|
||||
expect(formatAutomationSchedule(expression)).toBe(expected)
|
||||
expect(describeAutomationSchedule(expression)).toEqual({ kind: 'custom', expression: expected })
|
||||
})
|
||||
|
||||
it('formats hourly schedules using the stored minute', () => {
|
||||
expect(formatAutomationSchedule('FREQ=HOURLY;BYMINUTE=5')).toBe('Hourly at :05')
|
||||
})
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {
|
||||
parseAutomationRrule,
|
||||
parseCronExpression,
|
||||
parseSchedule,
|
||||
tryParseAutomationRrule,
|
||||
type ParsedCron
|
||||
} from './automation-schedule-parsing'
|
||||
import { cronHasPossibleOccurrence } from './automation-cron-occurrence'
|
||||
import { escapeTerminalControlCharacters } from './terminal-control-character-escape'
|
||||
|
||||
export type AutomationCronScheduleClassification =
|
||||
| { kind: 'hourly'; minute: number; label: string }
|
||||
@@ -20,7 +21,8 @@ export type AutomationScheduleDescriptor =
|
||||
| { kind: 'daily'; hour: number; minute: number }
|
||||
| { kind: 'weekdays'; hour: number; minute: number }
|
||||
| { kind: 'weekly'; hour: number; minute: number; dayOfWeek: number }
|
||||
| { kind: 'custom' }
|
||||
// `expression` carries a scheduler-valid RRULE no preset sentence can name (#24985).
|
||||
| { kind: 'custom'; expression?: string }
|
||||
| { kind: 'invalid' }
|
||||
|
||||
// Why: shared labels feed the CLI, which must stay English regardless of OS or UI locale.
|
||||
@@ -74,7 +76,9 @@ function setContainsRange(values: Set<number>, min: number, max: number): boolea
|
||||
return true
|
||||
}
|
||||
|
||||
function formatParsedRruleSchedule(schedule: ReturnType<typeof parseAutomationRrule>): string {
|
||||
function formatParsedRruleSchedule(
|
||||
schedule: NonNullable<ReturnType<typeof tryParseAutomationRrule>>
|
||||
): string {
|
||||
if (schedule.preset === 'hourly') {
|
||||
return `Hourly at :${String(schedule.minute).padStart(2, '0')}`
|
||||
}
|
||||
@@ -151,7 +155,11 @@ export function formatAutomationSchedule(scheduleExpression: string): string {
|
||||
if (schedule.kind === 'cron') {
|
||||
return classifyParsedCronSchedule(schedule).label
|
||||
}
|
||||
return formatParsedRruleSchedule(parseAutomationRrule(trimmed))
|
||||
const rrule = tryParseAutomationRrule(trimmed)
|
||||
if (rrule === null) {
|
||||
return escapeTerminalControlCharacters(trimmed, true)
|
||||
}
|
||||
return formatParsedRruleSchedule(rrule)
|
||||
} catch {
|
||||
return 'Invalid schedule'
|
||||
}
|
||||
@@ -177,7 +185,10 @@ export function describeAutomationSchedule(
|
||||
if (schedule.kind === 'cron') {
|
||||
return toScheduleDescriptor(classifyParsedCronSchedule(schedule))
|
||||
}
|
||||
const rrule = parseAutomationRrule(trimmed)
|
||||
const rrule = tryParseAutomationRrule(trimmed)
|
||||
if (rrule === null) {
|
||||
return { kind: 'custom', expression: escapeTerminalControlCharacters(trimmed, true) }
|
||||
}
|
||||
if (rrule.preset === 'hourly') {
|
||||
return { kind: 'hourly', minute: rrule.minute }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ORCHESTRATION_LEGACY_RUN_ID } from './orchestration-rpc-contract'
|
||||
import { escapeTerminalControlCharacters } from './terminal-control-character-escape'
|
||||
|
||||
export type OrchestrationMessageSummary = {
|
||||
id: string
|
||||
@@ -132,18 +133,6 @@ function formatMessagePriorityTag(message: OrchestrationMessageSummary): string
|
||||
return message.priority === 'urgent' ? ' [URGENT]' : message.priority === 'high' ? ' [HIGH]' : ''
|
||||
}
|
||||
|
||||
function escapeTerminalControlCharacters(value: string): string {
|
||||
return [...value]
|
||||
.map((character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
if (character === '\n' || (code >= 0x20 && code < 0x7f) || code > 0x9f) {
|
||||
return character
|
||||
}
|
||||
return `\\x${code.toString(16).padStart(2, '0')}`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function formatQuotedMessageField(label: string, value?: string): string {
|
||||
return `[${label}]\n${escapeTerminalControlCharacters(value ?? '')
|
||||
.split('\n')
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export function escapeTerminalControlCharacters(value: string, escapeLineBreaks = false): string {
|
||||
return [...value]
|
||||
.map((character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
if (
|
||||
(!escapeLineBreaks && character === '\n') ||
|
||||
(code >= 0x20 && code < 0x7f) ||
|
||||
code > 0x9f
|
||||
) {
|
||||
return character
|
||||
}
|
||||
return `\\x${code.toString(16).padStart(2, '0')}`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
Reference in New Issue
Block a user