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 {
3434
3535/// A single task within a build job.
3636struct BuildTask: Codable, Sendable, Identifiable {
37 var id: String { name }
37 private(set) var ordinal: Int?
3838 let name: String
3939 let status: TaskStatus
4040 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 }
4164}
4265
4366// MARK: - Job Summary (for list view)
@@ -90,7 +113,7 @@ struct JobDetail: Codable, Sendable {
90113 let visibility: Visibility?
91114 let image: String?
92115 let manifest: String?
93 let tasks: [BuildTask]
116 var tasks: [BuildTask]
94117 let log: BuildLog?
95118 let owner: Entity
96119}
Hutch/Views/Builds/BuildDetailView.swift +2 −2
@@ -312,13 +312,13 @@ private struct TaskLogSection: View {
312312
313313 var body: some View {
314314 DisclosureGroup(isExpanded: $isExpanded) {
315 if viewModel.loadingTaskLogs.contains(task.name) {
315 if viewModel.loadingTaskLogs.contains(task.logCacheKey) {
316316 HStack {
317317 Spacer()
318318 ProgressView("Loading log…")
319319 Spacer()
320320 }
321 } else if let logText = viewModel.taskLogs[task.name] {
321 } else if let logText = viewModel.taskLogs[task.logCacheKey] {
322322 ScrollView(.horizontal, showsIndicators: false) {
323323 Text(logText)
324324 .font(.caption2.monospaced())
Hutch/Views/Builds/BuildDetailViewModel.swift +11 −6
@@ -104,7 +104,11 @@ final class BuildDetailViewModel {
104104 variables: ["id": jobId],
105105 responseType: JobDetailResponse.self
106106 )
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
108112 } catch {
109113 self.error = error.localizedDescription
110114 }
@@ -113,19 +117,20 @@ final class BuildDetailViewModel {
113117 }
114118
115119 func loadTaskLog(task: BuildTask) async {
120 let cacheKey = task.logCacheKey
116121 guard let log = task.log,
117122 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)
121126
122127 do {
123 taskLogs[task.name] = try await client.fetchText(url: logURL)
128 taskLogs[cacheKey] = try await client.fetchText(url: logURL)
124129 } catch {
125130 self.error = error.localizedDescription
126131 }
127132
128 loadingTaskLogs.remove(task.name)
133 loadingTaskLogs.remove(cacheKey)
129134 }
130135
131136 func cancelJob() async {
HutchTests/BuildTaskTests.swift added +27
@@ -0,0 +1,27 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct 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}