Commit 1d2aedc471
1d2aedc4715279096aae7387888973d08ba75d4d
parent: 00205e7daa
Unsigned
cmc <hello@cleberg.net> · 2026-03-19 00:34 UTC
stabilize build task identity and log cache keys
Layout: unified · split
Hutch/Models/Builds.swift
+25 −2
| @@ -34,10 +34,33 @@ enum TaskStatus: String, Codable, Sendable { |
| 34 | 34 | |
| 35 | 35 | /// A single task within a build job. |
| 36 | 36 | struct BuildTask: Codable, Sendable, Identifiable { |
| 37 | | var id: String { name } |
| 37 | private(set) var ordinal: Int? |
| 38 | 38 | let name: String |
| 39 | 39 | let status: TaskStatus |
| 40 | 40 | let log: BuildLog? |
| 41 | |
| 42 | var id: String { |
| 43 | if let ordinal { |
| 44 | return "\(ordinal):\(name)" |
| 45 | } |
| 46 | return [name, log?.fullURL, status.rawValue] |
| 47 | .compactMap { $0 } |
| 48 | .joined(separator: "::") |
| 49 | } |
| 50 | |
| 51 | var logCacheKey: String { |
| 52 | id |
| 53 | } |
| 54 | |
| 55 | func withOrdinal(_ ordinal: Int) -> BuildTask { |
| 56 | var task = self |
| 57 | task.ordinal = ordinal |
| 58 | return task |
| 59 | } |
| 60 | |
| 61 | private enum CodingKeys: String, CodingKey { |
| 62 | case name, status, log |
| 63 | } |
| 41 | 64 | } |
| 42 | 65 | |
| 43 | 66 | // MARK: - Job Summary (for list view) |
| @@ -90,7 +113,7 @@ struct JobDetail: Codable, Sendable { |
| 90 | 113 | let visibility: Visibility? |
| 91 | 114 | let image: String? |
| 92 | 115 | let manifest: String? |
| 93 | | let tasks: [BuildTask] |
| 116 | var tasks: [BuildTask] |
| 94 | 117 | let log: BuildLog? |
| 95 | 118 | let owner: Entity |
| 96 | 119 | } |
Hutch/Views/Builds/BuildDetailView.swift
+2 −2
| @@ -312,13 +312,13 @@ private struct TaskLogSection: View { |
| 312 | 312 | |
| 313 | 313 | var body: some View { |
| 314 | 314 | DisclosureGroup(isExpanded: $isExpanded) { |
| 315 | | if viewModel.loadingTaskLogs.contains(task.name) { |
| 315 | if viewModel.loadingTaskLogs.contains(task.logCacheKey) { |
| 316 | 316 | HStack { |
| 317 | 317 | Spacer() |
| 318 | 318 | ProgressView("Loading log…") |
| 319 | 319 | Spacer() |
| 320 | 320 | } |
| 321 | | } else if let logText = viewModel.taskLogs[task.name] { |
| 321 | } else if let logText = viewModel.taskLogs[task.logCacheKey] { |
| 322 | 322 | ScrollView(.horizontal, showsIndicators: false) { |
| 323 | 323 | Text(logText) |
| 324 | 324 | .font(.caption2.monospaced()) |
Hutch/Views/Builds/BuildDetailViewModel.swift
+11 −6
| @@ -104,7 +104,11 @@ final class BuildDetailViewModel { |
| 104 | 104 | variables: ["id": jobId], |
| 105 | 105 | responseType: JobDetailResponse.self |
| 106 | 106 | ) |
| 107 | | job = result.job |
| 107 | var loadedJob = result.job |
| 108 | loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in |
| 109 | task.withOrdinal(index) |
| 110 | } |
| 111 | job = loadedJob |
| 108 | 112 | } catch { |
| 109 | 113 | self.error = error.localizedDescription |
| 110 | 114 | } |
| @@ -113,19 +117,20 @@ final class BuildDetailViewModel { |
| 113 | 117 | } |
| 114 | 118 | |
| 115 | 119 | func loadTaskLog(task: BuildTask) async { |
| 120 | let cacheKey = task.logCacheKey |
| 116 | 121 | guard let log = task.log, |
| 117 | 122 | let logURL = URL(string: log.fullURL), |
| 118 | | !loadingTaskLogs.contains(task.name), |
| 119 | | taskLogs[task.name] == nil else { return } |
| 120 | | loadingTaskLogs.insert(task.name) |
| 123 | !loadingTaskLogs.contains(cacheKey), |
| 124 | taskLogs[cacheKey] == nil else { return } |
| 125 | loadingTaskLogs.insert(cacheKey) |
| 121 | 126 | |
| 122 | 127 | do { |
| 123 | | taskLogs[task.name] = try await client.fetchText(url: logURL) |
| 128 | taskLogs[cacheKey] = try await client.fetchText(url: logURL) |
| 124 | 129 | } catch { |
| 125 | 130 | self.error = error.localizedDescription |
| 126 | 131 | } |
| 127 | 132 | |
| 128 | | loadingTaskLogs.remove(task.name) |
| 133 | loadingTaskLogs.remove(cacheKey) |
| 129 | 134 | } |
| 130 | 135 | |
| 131 | 136 | func cancelJob() async { |
HutchTests/BuildTaskTests.swift
added
+27
| @@ -0,0 +1,27 @@ |
| 1 | import Foundation |
| 2 | import Testing |
| 3 | @testable import Hutch |
| 4 | |
| 5 | struct BuildTaskTests { |
| 6 | |
| 7 | @Test |
| 8 | @MainActor |
| 9 | func duplicateTaskNamesProduceDistinctIDsAndLogCacheKeys() { |
| 10 | let firstTask = BuildTask( |
| 11 | name: "test", |
| 12 | status: .failed, |
| 13 | log: BuildLog(fullURL: "https://builds.sr.ht/job/1/task/1") |
| 14 | ).withOrdinal(0) |
| 15 | |
| 16 | let secondTask = BuildTask( |
| 17 | name: "test", |
| 18 | status: .failed, |
| 19 | log: BuildLog(fullURL: "https://builds.sr.ht/job/1/task/2") |
| 20 | ).withOrdinal(1) |
| 21 | |
| 22 | #expect(firstTask.id == "0:test") |
| 23 | #expect(secondTask.id == "1:test") |
| 24 | #expect(firstTask.id != secondTask.id) |
| 25 | #expect(firstTask.logCacheKey != secondTask.logCacheKey) |
| 26 | } |
| 27 | } |