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) } }