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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/windows-advisory.yml
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ jobs:
test/ide
test/installation
test/auth
test/automation
report_path: packages/opencode/.artifacts/unit/junit-windows-server-tools.xml
- package: desktop
uses_turbo: true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Automation definitions and runs persisted before PR5.5 lack the now-required
-- `model` field on AutomationDefinition. Since automations are still gated
-- behind the env-flagged `automate` tool and no UI entry exists yet, any rows
-- in these tables are pre-release dev/QA data. Drop them so the new schema
-- parses cleanly without per-row backfill.
DELETE FROM `automation_run`;--> statement-breakpoint
DELETE FROM `automation_definition`;
17 changes: 17 additions & 0 deletions packages/opencode/src/automation/__test_hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Automation } from "./index"

/**
* @internal Test-only injection points for the automation module.
*
* Production code MUST NOT import or write to this module — the only reader
* is `recordRunOutcome` in `./index`, which checks `beforeReplaceDefinition`
* to support a deterministic ConflictError retry test. By living outside the
* `Automation` namespace, this seam is invisible to anyone consuming the
* public `Automation.*` API surface.
*
* Tests assign hooks here and MUST clear them in a `finally` block so a
* failing test cannot leak state to a sibling test.
*/
export const internalTestHooks: {
beforeReplaceDefinition?: (previous: Automation.Definition) => void
} = {}
97 changes: 97 additions & 0 deletions packages/opencode/src/automation/cron.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { DateTime } from "luxon"

export type CronSchedule = {
minutes: Set<number>
hours: Set<number>
days: Set<number>
months: Set<number>
weekdays: Set<number>
dayRestricted: boolean
weekdayRestricted: boolean
}

function cronValues(field: string, min: number, max: number, options?: { sundayAlias?: boolean }) {
const values = new Set<number>()
for (const item of field.split(",")) {
const [base, stepRaw] = item.split("/")
if (!base || item.split("/").length > 2) throw new Error(`Invalid cron field: ${item}`)
const step = stepRaw === undefined ? 1 : Number(stepRaw)
if (!Number.isInteger(step) || step <= 0) throw new Error(`Invalid cron step: ${item}`)
const range = base === "*" ? [min, max] : base.split("-").map(Number)
if (range.length === 0 || range.length > 2 || range.some((value) => !Number.isInteger(value))) {
throw new Error(`Invalid cron field: ${item}`)
}
const start = range[0]
const end = base === "*" || (range.length === 1 && stepRaw !== undefined) ? max : range.length === 1 ? range[0] : range[1]
if (start < min || end > max || start > end) throw new Error(`Invalid cron range: ${item}`)
for (let value = start; value <= end; value += step) {
values.add(options?.sundayAlias && value === 7 ? 0 : value)
}
}
return values
}

export function parseCronSchedule(expression: string): CronSchedule {
const fields = expression.trim().split(/\s+/)
if (fields.length !== 5) throw new Error(`Invalid cron expression: ${expression}`)
const [minuteField, hourField, dayField, monthField, weekdayField] = fields
return {
minutes: cronValues(minuteField, 0, 59),
hours: cronValues(hourField, 0, 23),
days: cronValues(dayField, 1, 31),
months: cronValues(monthField, 1, 12),
weekdays: cronValues(weekdayField, 0, 7, { sundayAlias: true }),
dayRestricted: dayField !== "*",
weekdayRestricted: weekdayField !== "*",
}
}

const monthMaxDays = new Map([
[1, 31],
[2, 29],
[3, 31],
[4, 30],
[5, 31],
[6, 30],
[7, 31],
[8, 31],
[9, 30],
[10, 31],
[11, 30],
[12, 31],
])

function hasReachableDayMonth(schedule: CronSchedule) {
for (const month of schedule.months) {
const maxDay = monthMaxDays.get(month)
if (maxDay === undefined) continue
for (const day of schedule.days) {
if (day <= maxDay) return true
}
}
return false
}

export function isValidCronExpression(expression: string): boolean {
try {
const schedule = parseCronSchedule(expression)
if (!schedule.weekdayRestricted && !hasReachableDayMonth(schedule)) return false
return true
} catch {
return false
}
}

export function cronMatches(schedule: CronSchedule, time: DateTime) {
const weekday = time.weekday === 7 ? 0 : time.weekday
const dayMatches = schedule.days.has(time.day)
const weekdayMatches = schedule.weekdays.has(weekday)
const calendarMatches =
schedule.dayRestricted && schedule.weekdayRestricted ? dayMatches || weekdayMatches : dayMatches && weekdayMatches
return (
schedule.minutes.has(time.minute) &&
schedule.hours.has(time.hour) &&
schedule.months.has(time.month) &&
calendarMatches
)
}
75 changes: 75 additions & 0 deletions packages/opencode/src/automation/derived.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { DateTime } from "luxon"
import type { Automation } from "."
import { type CronSchedule, parseCronSchedule } from "./cron"

const CRON_LOOKAHEAD_MINUTES = 527_040 * 5
const NEXT_FIRES_PREVIEW = 5

type RecurringDefinition = Extract<Automation.Definition, { kind: "recurring" }>

const PLUS_ONE_MONTH = { months: 1 }
const PLUS_ONE_DAY = { days: 1 }
const PLUS_ONE_HOUR = { hours: 1 }
const PLUS_ONE_MINUTE = { minutes: 1 }

function nextCronFires(definition: RecurringDefinition, from: number, count: number): number[] {
if (definition.rhythm.kind !== "cron" || count <= 0) return []
let schedule: CronSchedule
try {
schedule = parseCronSchedule(definition.rhythm.expression)
} catch {
return []
}
const fires: number[] = []
const maxTimestamp = from + CRON_LOOKAHEAD_MINUTES * 60 * 1000
let cursor = DateTime.fromMillis(from, { zone: definition.timezone }).plus(PLUS_ONE_MINUTE).startOf("minute")
while (cursor.toMillis() < maxTimestamp && fires.length < count) {
if (!schedule.months.has(cursor.month)) {
cursor = cursor.plus(PLUS_ONE_MONTH).startOf("month")
continue
}
const weekday = cursor.weekday === 7 ? 0 : cursor.weekday
const dayMatches = schedule.days.has(cursor.day)
const weekdayMatches = schedule.weekdays.has(weekday)
const calendarMatches =
schedule.dayRestricted && schedule.weekdayRestricted ? dayMatches || weekdayMatches : dayMatches && weekdayMatches
if (!calendarMatches) {
cursor = cursor.plus(PLUS_ONE_DAY).startOf("day")
continue
}
if (!schedule.hours.has(cursor.hour)) {
cursor = cursor.plus(PLUS_ONE_HOUR).startOf("hour")
continue
}
if (schedule.minutes.has(cursor.minute)) fires.push(cursor.toMillis())
cursor = cursor.plus(PLUS_ONE_MINUTE)
}
return fires
}
Comment thread
Astro-Han marked this conversation as resolved.

function nextIntervalFires(definition: RecurringDefinition, from: number, count: number): number[] {
if (definition.rhythm.kind !== "interval" || count <= 0) return []
const fires: number[] = []
let cursor = from + definition.rhythm.everyMs
for (let index = 0; index < count; index++) {
fires.push(cursor)
cursor += definition.rhythm.everyMs
}
return fires
}

export function computeDerivedFields(
definition: RecurringDefinition,
from: number,
completedRunCount: number,
): { nextFireAt: number | null; nextFires: number[] } {
if (definition.paused) return { nextFireAt: null, nextFires: [] }
if (definition.stop.kind === "condition") return { nextFireAt: null, nextFires: [] }
const remaining =
definition.stop.kind === "count" ? Math.max(0, definition.stop.count - completedRunCount) : NEXT_FIRES_PREVIEW
if (remaining <= 0) return { nextFireAt: null, nextFires: [] }
const count = Math.min(NEXT_FIRES_PREVIEW, remaining)
const fires =
definition.rhythm.kind === "cron" ? nextCronFires(definition, from, count) : nextIntervalFires(definition, from, count)
return { nextFireAt: fires[0] ?? null, nextFires: fires }
}
12 changes: 10 additions & 2 deletions packages/opencode/src/automation/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,18 @@ export const automationDefinitionFixture = Automation.Definition.parse({
updatedAt: 1_800_000_030_000,
timezone: "UTC",
normalizationWarnings: [],
model: Automation.Model.parse({ providerID: "anthropic", modelID: "claude-sonnet-4-6" }),
variant: "high",
rhythm: { kind: "interval", everyMs: 3_600_000 },
stop: { kind: "never" },
nextFireAt: null,
nextFires: [],
nextFireAt: 1_800_003_600_000,
nextFires: [
1_800_003_600_000,
1_800_007_200_000,
1_800_010_800_000,
1_800_014_400_000,
1_800_018_000_000,
],
failureStreak: 0,
})

Expand Down
Loading
Loading