Files
byre/mac/Sources/Byre/Schedules.swift
T

260 lines
10 KiB
Swift

import ByreAPI
import SwiftUI
typealias Schedule = Components.Schemas.Schedule
typealias ScheduleSave = Components.Schemas.ScheduleSave
typealias ScheduleEvery = Components.Schemas.ScheduleEvery
typealias Weekday = Components.Schemas.Weekday
private let weekdayNames: [(Weekday, String, String)] = [
(.mon, "M", "Monday"), (.tue, "T", "Tuesday"), (.wed, "W", "Wednesday"), (.thu, "T", "Thursday"), (.fri, "F", "Friday"), (.sat, "S", "Saturday"), (.sun, "S", "Sunday"),
]
private func twoDigits(_ n: Int) -> String {
n < 10 ? "0\(n)" : "\(n)"
}
private func clock(_ hour: Int, _ minute: Int) -> String {
"\(hour):\(twoDigits(minute))"
}
private func ordinal(_ n: Int) -> String {
let suffix = (11...13).contains(n % 100) ? "th" : [1: "st", 2: "nd", 3: "rd"][n % 10] ?? "th"
return "\(n)\(suffix)"
}
func scheduleTiming(_ sc: Schedule) -> String {
let at = clock(sc.hour, sc.minute)
switch sc.every {
case .hour:
return "Every hour at :\(twoDigits(sc.minute))"
case .day:
return "Daily at \(at)"
case .week:
let days = Set(sc.weekdays)
if days.count == 7 {
return "Daily at \(at)"
}
if days == [.mon, .tue, .wed, .thu, .fri] {
return "Weekdays at \(at)"
}
if days == [.sat, .sun] {
return "Weekends at \(at)"
}
let names = weekdayNames.filter { days.contains($0.0) }.map(\.2)
return names.count == 1 ? "\(names[0])s at \(at)" : "\(names.map { String($0.prefix(3)) }.joined(separator: ", ")) at \(at)"
case .month:
return "Monthly on the \(ordinal(sc.dayOfMonth)) at \(at)"
}
}
private let nextRunFormat: DateFormatter = {
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("EEE MMM d jmm")
return f
}()
struct ScheduleRows: View {
let project: Project
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
var body: some View {
let list = store.schedules[project.id] ?? []
if list.isEmpty {
Text("No schedules.")
.foregroundStyle(Palette.muted)
.padding(.horizontal, 10)
.padding(.vertical, 6)
}
VStack(alignment: .leading, spacing: 0) {
ForEach(list, id: \.id) { sc in
HoverRow {
Task {
await editSchedule(project, sc, store, overlays)
}
} content: {
Text(sc.title)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
Text(scheduleTiming(sc))
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
Text("next \(nextRunFormat.string(from: sc.nextRun))")
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
}
.help(sc.workflow.isEmpty ? "Runs one agent on \(sc.model)" : "Runs workflow \(sc.workflow)")
}
}
.padding(.bottom, 8)
}
}
@MainActor
@Observable
private final class ScheduleDraft {
var title: String
var every: ScheduleEvery
var weekdays: Set<Weekday>
var hour: Int
var minute: Int
var dayOfMonth: Int
var workflow: String
var model: String
var prompt: String
var workflows: [String] = []
var failure = ""
init(_ sc: Schedule?, defaultModel: String) {
title = sc?.title ?? ""
every = sc?.every ?? .week
weekdays = Set(sc?.weekdays ?? [.mon])
hour = sc?.hour ?? 9
minute = sc?.minute ?? 0
dayOfMonth = sc.map { $0.dayOfMonth == 0 ? 1 : $0.dayOfMonth } ?? 1
workflow = sc?.workflow ?? ""
model = sc.map { $0.model.isEmpty ? defaultModel : $0.model } ?? defaultModel
prompt = sc?.prompt ?? ""
}
var valid: Bool {
!trimmed(title).isEmpty && (every != .week || !weekdays.isEmpty) && (!workflow.isEmpty || !trimmed(prompt).isEmpty)
}
var save: ScheduleSave {
ScheduleSave(
title: title,
every: every,
weekdays: every == .week ? weekdayNames.map(\.0).filter { weekdays.contains($0) } : nil,
hour: every == .hour ? 0 : hour,
minute: minute,
dayOfMonth: every == .month ? dayOfMonth : nil,
prompt: prompt,
model: workflow.isEmpty ? model : nil,
workflow: workflow.isEmpty ? nil : workflow
)
}
}
@MainActor
func editSchedule(_ project: Project, _ existing: Schedule?, _ store: Store, _ overlays: Overlays) async {
let draft = ScheduleDraft(existing, defaultModel: store.defaultModel)
Task {
draft.workflows = (try? await store.api.client.listWorkflows().ok.body.json.map(\.name)) ?? []
}
while true {
let buttons = existing == nil
? [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Create", value: "save", primary: true)]
: [ModalButton(label: "Delete", value: "delete", danger: true), ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Save", value: "save", primary: true)]
let choice = await overlays.showModal(ScheduleEditor(draft: draft, project: project), buttons, inPane: true, canSubmit: { draft.valid })
switch choice {
case "save":
do {
if let existing {
_ = try await store.api.client.updateSchedule(path: .init(scheduleId: existing.id), body: .json(draft.save)).ok.body.json
} else {
_ = try await store.api.client.createSchedule(path: .init(projectId: project.id), body: .json(draft.save)).created.body.json
}
return
} catch {
draft.failure = errorText(error)
}
case "delete":
guard let existing, await overlays.showConfirm(["Delete the schedule \"\(existing.title)\"? Tasks it already created stay."], action: "Delete", danger: true, inPane: true) else {
continue
}
do {
_ = try await store.api.client.deleteSchedule(path: .init(scheduleId: existing.id)).noContent
} catch {
overlays.showAlert(errorText(error))
}
return
default:
return
}
}
}
private struct ScheduleEditor: View {
@Bindable var draft: ScheduleDraft
let project: Project
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Schedule a task in \(project.name), started from \(project.defaultBranch). Times are the server's.")
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
TextField("Task title", text: $draft.title)
.modifier(FieldStyle())
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("Every").foregroundStyle(Palette.muted)
ChoiceMenu(value: $draft.every, options: [(.hour, "hour"), (.day, "day"), (.week, "week"), (.month, "month")])
if draft.every == .week {
Text("on").foregroundStyle(Palette.muted)
HStack(spacing: 4) {
ForEach(weekdayNames, id: \.0) { day in
dayToggle(day)
}
}
}
if draft.every == .month {
Text("on the").foregroundStyle(Palette.muted)
ChoiceMenu(value: $draft.dayOfMonth, options: (1...31).map { ($0, ordinal($0)) })
}
Text("at").foregroundStyle(Palette.muted)
if draft.every == .hour {
ChoiceMenu(value: $draft.minute, options: (0...59).map { ($0, ":\(twoDigits($0))") })
} else {
ChoiceMenu(value: $draft.hour, options: (0...23).map { ($0, String($0)) })
Text(":").foregroundStyle(Palette.muted)
ChoiceMenu(value: $draft.minute, options: (0...59).map { ($0, twoDigits($0)) })
}
}
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("Run").foregroundStyle(Palette.muted)
let names = draft.workflow.isEmpty || draft.workflows.contains(draft.workflow) ? draft.workflows : [draft.workflow] + draft.workflows
ChoiceMenu(value: $draft.workflow, options: [("", "one agent")] + names.map { ($0, "workflow \($0)") })
if draft.workflow.isEmpty {
Text("on").foregroundStyle(Palette.muted)
ModelPicker(model: $draft.model)
}
}
Text(draft.workflow.isEmpty ? "The agent's first message: a complete brief, since it sees nothing else." : "The workflow's brief, given to every stage (optional).")
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
PromptEditor(text: $draft.prompt)
if !draft.failure.isEmpty {
Text(draft.failure)
.foregroundStyle(Palette.danger)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func dayToggle(_ day: (Weekday, String, String)) -> some View {
let on = draft.weekdays.contains(day.0)
return Button {
if on {
draft.weekdays.remove(day.0)
} else {
draft.weekdays.insert(day.0)
}
} label: {
Text(day.1)
.font(Fonts.font(11, weight: on ? 700 : 400))
.foregroundStyle(on ? Palette.text : Palette.muted)
.frame(width: 20, height: 20)
.background(on ? Palette.accentTint : .clear, in: RoundedRectangle(cornerRadius: 4))
.overlay(RoundedRectangle(cornerRadius: 4).stroke(on ? Palette.accent : Palette.border))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help(day.2)
.accessibilityLabel(day.2)
}
}