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

319 lines
12 KiB
Swift

import SwiftUI
typealias WorkflowRun = Components.Schemas.WorkflowRun
typealias RunStage = Components.Schemas.WorkflowRunStage
typealias StageAttempt = Components.Schemas.StageAttempt
private let stateColors: [RunStage.StatePayload: NodeColors] = [
.waiting: NodeColors(fill: Palette.surface, stroke: Palette.border, text: Palette.muted),
.running: NodeColors(fill: Palette.accentTint, stroke: Palette.accent, text: Palette.text),
.idle: NodeColors(fill: Palette.warnTint, stroke: Palette.warn, text: Palette.text),
.finished: NodeColors(fill: Palette.okTint, stroke: Palette.ok, text: Palette.text),
.merged: NodeColors(fill: Palette.okTint, stroke: Palette.ok, text: Palette.text),
.cleanedUp: NodeColors(fill: Palette.okTint, stroke: Palette.ok, text: Palette.text),
.discarded: NodeColors(fill: Palette.dangerTint, stroke: Palette.danger, text: Palette.text),
]
private func stageGraph(_ run: WorkflowRun) -> Flowchart {
var chart = Flowchart(direction: .td)
for (i, s) in run.stages.enumerated() {
let id = "s\(i)"
chart.node(id, label: s.name, shape: .box)
chart.nodes[id]!.style = stateColors[s.state]!
}
let ids = Dictionary(run.stages.enumerated().map { ($0.element.name, "s\($0.offset)") }, uniquingKeysWith: { a, _ in a })
for s in run.stages {
for a in s.after {
if let from = ids[a], let to = ids[s.name] {
chart.edges.append(FlowEdge(from: from, to: to, label: nil, style: .solid, arrow: true))
}
}
}
return chart
}
private func attemptLabel(_ a: StageAttempt) -> String {
switch a.state {
case .running: "running"
case .idle: "idle, not finished"
case .finished: "finished"
case .merged: "merged"
case .discarded: "discarded before finishing\(a.discardedBy.map { " by \($0)" } ?? "")"
case .cleanedUp: a.discardedBy == "byre" ? "finished, then discarded by the run's cleanup" : "finished, then discarded\(a.discardedBy.map { " by \($0)" } ?? "")"
}
}
private func stageLabel(_ s: RunStage) -> String {
if s.state != .waiting {
return attemptLabel(s.attempts.last { $0.output != nil } ?? s.attempts.last!)
}
if !s.waitingOn.isEmpty {
return "waiting on \(s.waitingOn.map { "“\($0)”" }.joined(separator: ", "))"
}
return "waiting to start"
}
private func stateColor(_ state: String) -> Color {
switch state {
case "running": Palette.accent
case "idle", "discarding": Palette.warn
case "finished", "merged": Palette.ok
default: Palette.muted
}
}
struct WorkflowView: View {
@Environment(Store.self) private var store
@ViewState private var run: WorkflowRun?
@ViewState private var error = ""
@ViewState private var target = 0
@ViewState private var loading = false
@ViewState private var again = false
var body: some View {
let task = store.selectedTaskId.flatMap { store.findTask($0) }
VStack(spacing: 0) {
if let task, let runId = task.workflowRun {
if !error.isEmpty {
EmptyText(text: error, error: true)
} else if let run, run.run == runId {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(run.name).font(Fonts.font(13, weight: 700))
Text(run.title).foregroundStyle(Palette.muted)
Text(run.finished ? "workflow finished" : "in progress")
.font(Fonts.font(11))
.foregroundStyle(run.finished ? Palette.ok : Palette.accent)
}
.modifier(HeaderBar())
ScrollView {
VStack(alignment: .leading, spacing: 12) {
FlowchartCanvas(layout: layoutFlowchart(stageGraph(run)))
.frame(maxWidth: .infinity, alignment: .leading)
if !trimmed(run.brief).isEmpty {
DetailsBox(boxed: false) {
Text("Brief").foregroundStyle(Palette.muted)
} content: {
MarkdownView(blocks: parseMarkdown(run.brief), ctx: MDContext(taskId: task.id))
.padding(.top, 6)
}
}
ForEach(run.stages, id: \.name) { s in
StageCard(run: run, stage: s)
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
}
} else {
EmptyText(text: "Loading…")
}
} else {
EmptyText(text: task != nil ? "This task isn't part of a workflow run." : "No task selected.")
}
}
.frame(maxHeight: .infinity, alignment: .top)
.task(id: store.selectedTaskId) {
target += 1
run = nil
error = ""
await load()
let off = store.events.add { ev in
if relevant(ev) {
Task {
await load()
}
}
}
await untilCancelled()
off()
target += 1
}
.onChange(of: task?.workflowRun) {
if run == nil && !loading && error.isEmpty {
Task {
await load()
}
}
}
}
private func relevant(_ ev: ByreEvent) -> Bool {
guard ev.seq != 0, let run else {
return false
}
if ev.kind == "task.created" {
return ev.payload.value["workflow_run"] as? String == run.run
}
let ids = Set(run.stages.flatMap { $0.attempts.map(\.taskId) })
guard let taskId = ev.taskId else {
return false
}
return ids.contains(taskId) && (ev.kind.hasPrefix("task.") || ev.kind.hasPrefix("session."))
}
private func load() async {
guard let taskId = store.selectedTaskId, store.findTask(taskId)?.workflowRun != nil else {
return
}
if loading {
again = true
return
}
loading = true
target += 1
let started = target
do {
let fresh = try await store.api.client.getWorkflowRun(path: .init(taskId: taskId)).ok.body.json
if started == target {
run = fresh
error = ""
}
} catch {
if started == target {
self.error = errorText(error)
}
}
loading = false
if again {
again = false
await load()
}
}
}
private struct StageCard: View {
let run: WorkflowRun
let stage: RunStage
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(stage.name).font(Fonts.font(13, weight: 700))
Text(stageLabel(stage))
.font(Fonts.font(11))
.foregroundStyle(stateColor(stage.state.rawValue))
}
Text(stage.after.isEmpty ? "starts from the brief" : "after \(stage.after.map { "“\($0)”" }.joined(separator: ", "))")
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
if !stage.attempts.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(stage.attempts.enumerated()), id: \.offset) { i, a in
AttemptRow(run: run, stage: stage, attempt: a, index: i)
}
}
.padding(.top, 8)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border))
}
}
private struct AttemptRow: View {
let run: WorkflowRun
let stage: RunStage
let attempt: StageAttempt
let index: Int
@Environment(Store.self) private var store
var body: some View {
let a = attempt
let fed = run.stages.flatMap { s in s.attempts.filter { d in d.inputs.contains { $0.taskId == a.taskId } }.map(\.taskId) }
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
if stage.attempts.count > 1 {
Text("#\(index + 1)").foregroundStyle(Palette.muted)
}
Text(link(a.taskId, a.title, bold: a.taskId == store.selectedTaskId))
Text(attemptLabel(a))
.font(Fonts.font(11))
.foregroundStyle(stateColor(a.state.rawValue))
if a.discarding {
Text("discarding…").font(Fonts.font(11)).foregroundStyle(Palette.warn)
}
}
if let from = a.forkedFrom {
line([("fork of ", nil), (attemptName(from), from)])
}
if !a.inputs.isEmpty {
line([("started from ", nil)] + joined(a.inputs.map(\.taskId)))
}
if !fed.isEmpty {
line([("fed ", nil)] + joined(fed))
}
if let output = a.output {
DetailsBox(boxed: false) {
HStack(spacing: 0) {
Text("finish_stage output").foregroundStyle(Palette.muted)
if let commit = a.commit {
Text(" \(String(commit.prefix(10)))").foregroundStyle(Palette.muted)
}
}
} content: {
MarkdownView(blocks: parseMarkdown(output), ctx: MDContext(taskId: a.taskId))
.padding(.leading, 10)
.overlay(alignment: .leading) {
Rectangle().fill(Palette.border).frame(width: 1)
}
.padding(.top, 6)
}
.padding(.top, 6)
}
}
.padding(.top, 8)
.overlay(alignment: .top) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
private func joined(_ ids: [String]) -> [(String, String?)] {
var out: [(String, String?)] = []
for (n, id) in ids.enumerated() {
if n > 0 {
out.append((", ", nil))
}
out.append((attemptName(id), id))
}
return out
}
private func attemptName(_ taskId: String) -> String {
for s in run.stages {
if let i = s.attempts.firstIndex(where: { $0.taskId == taskId }) {
return s.attempts.count > 1 ? "“\(s.name)” attempt \(i + 1)" : "“\(s.name)”"
}
}
return store.findTask(taskId)?.title ?? taskId
}
private func link(_ taskId: String, _ label: String, bold: Bool = false) -> AttributedString {
var a = AttributedString(label)
a.link = AppLink.task(taskId)
a.foregroundColor = Palette.accent
a.font = Fonts.font(13, weight: bold ? 700 : 400)
return a
}
private func line(_ parts: [(String, String?)]) -> some View {
var out = AttributedString()
for (text, taskId) in parts {
if let taskId {
var a = link(taskId, text)
a.font = Fonts.font(11)
out += a
} else {
out += AttributedString(text)
}
}
return Text(out)
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.padding(.top, 2)
}
}