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 | /// A single task within a build job. |
35 | /// A single task within a build job. |
| 36 | struct BuildTask: Codable, Sendable, Identifiable { |
36 | struct BuildTask: Codable, Sendable, Identifiable { |
| 37 | var id: String { name } |
37 | private(set) var ordinal: Int? |
| 38 | let name: String |
38 | let name: String |
| 39 | let status: TaskStatus |
39 | let status: TaskStatus |
| 40 | let log: BuildLog? |
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 | // MARK: - Job Summary (for list view) |
66 | // MARK: - Job Summary (for list view) |
| @@ -90,7 +113,7 @@ struct JobDetail: Codable, Sendable { |
| 90 | let visibility: Visibility? |
113 | let visibility: Visibility? |
| 91 | let image: String? |
114 | let image: String? |
| 92 | let manifest: String? |
115 | let manifest: String? |
| 93 | let tasks: [BuildTask] |
116 | var tasks: [BuildTask] |
| 94 | let log: BuildLog? |
117 | let log: BuildLog? |
| 95 | let owner: Entity |
118 | let owner: Entity |
| 96 | } |
119 | } |
Hutch/Views/Builds/BuildDetailView.swift
+2 −2
| @@ -312,13 +312,13 @@ private struct TaskLogSection: View { |
| 312 | |
312 | |
| 313 | var body: some View { |
313 | var body: some View { |
| 314 | DisclosureGroup(isExpanded: $isExpanded) { |
314 | DisclosureGroup(isExpanded: $isExpanded) { |
| 315 | if viewModel.loadingTaskLogs.contains(task.name) { |
315 | if viewModel.loadingTaskLogs.contains(task.logCacheKey) { |
| 316 | HStack { |
316 | HStack { |
| 317 | Spacer() |
317 | Spacer() |
| 318 | ProgressView("Loading log…") |
318 | ProgressView("Loading log…") |
| 319 | Spacer() |
319 | Spacer() |
| 320 | } |
320 | } |
| 321 | } else if let logText = viewModel.taskLogs[task.name] { |
321 | } else if let logText = viewModel.taskLogs[task.logCacheKey] { |
| 322 | ScrollView(.horizontal, showsIndicators: false) { |
322 | ScrollView(.horizontal, showsIndicators: false) { |
| 323 | Text(logText) |
323 | Text(logText) |
| 324 | .font(.caption2.monospaced()) |
324 | .font(.caption2.monospaced()) |
Hutch/Views/Builds/BuildDetailViewModel.swift
+11 −6
| @@ -104,7 +104,11 @@ final class BuildDetailViewModel { |
| 104 | variables: ["id": jobId], |
104 | variables: ["id": jobId], |
| 105 | responseType: JobDetailResponse.self |
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 | } catch { |
112 | } catch { |
| 109 | self.error = error.localizedDescription |
113 | self.error = error.localizedDescription |
| 110 | } |
114 | } |
| @@ -113,19 +117,20 @@ final class BuildDetailViewModel { |
| 113 | } |
117 | } |
| 114 | |
118 | |
| 115 | func loadTaskLog(task: BuildTask) async { |
119 | func loadTaskLog(task: BuildTask) async { |
| |
120 | let cacheKey = task.logCacheKey |
| 116 | guard let log = task.log, |
121 | guard let log = task.log, |
| 117 | let logURL = URL(string: log.fullURL), |
122 | let logURL = URL(string: log.fullURL), |
| 118 | !loadingTaskLogs.contains(task.name), |
123 | !loadingTaskLogs.contains(cacheKey), |
| 119 | taskLogs[task.name] == nil else { return } |
124 | taskLogs[cacheKey] == nil else { return } |
| 120 | loadingTaskLogs.insert(task.name) |
125 | loadingTaskLogs.insert(cacheKey) |
| 121 | |
126 | |
| 122 | do { |
127 | do { |
| 123 | taskLogs[task.name] = try await client.fetchText(url: logURL) |
128 | taskLogs[cacheKey] = try await client.fetchText(url: logURL) |
| 124 | } catch { |
129 | } catch { |
| 125 | self.error = error.localizedDescription |
130 | self.error = error.localizedDescription |
| 126 | } |
131 | } |
| 127 | |
132 | |
| 128 | loadingTaskLogs.remove(task.name) |
133 | loadingTaskLogs.remove(cacheKey) |
| 129 | } |
134 | } |
| 130 | |
135 | |
| 131 | func cancelJob() async { |
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 | } |