krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

430741f0236ab21583acda8e2774d95e6e0e4ffe

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-03-19T00:34:59Z

stabilize build task identity and log cache keys
 Hutch/Models/Builds.swift                     | 27 +++++++++++++++++++++++++--
 Hutch/Views/Builds/BuildDetailView.swift      |  4 ++--
 Hutch/Views/Builds/BuildDetailViewModel.swift | 17 +++++++++++------
 HutchTests/BuildTaskTests.swift               | 27 +++++++++++++++++++++++++++
 4 files changed, 65 insertions(+), 10 deletions(-)

diff --git a/Hutch/Models/Builds.swift b/Hutch/Models/Builds.swift
index 6694c35..b0c0e8d 100644
--- a/Hutch/Models/Builds.swift
+++ b/Hutch/Models/Builds.swift
@@ -34,10 +34,33 @@ enum TaskStatus: String, Codable, Sendable {
 
 /// A single task within a build job.
 struct BuildTask: Codable, Sendable, Identifiable {
-    var id: String { name }
+    private(set) var ordinal: Int?
     let name: String
     let status: TaskStatus
     let log: BuildLog?
+
+    var id: String {
+        if let ordinal {
+            return "\(ordinal):\(name)"
+        }
+        return [name, log?.fullURL, status.rawValue]
+            .compactMap { $0 }
+            .joined(separator: "::")
+    }
+
+    var logCacheKey: String {
+        id
+    }
+
+    func withOrdinal(_ ordinal: Int) -> BuildTask {
+        var task = self
+        task.ordinal = ordinal
+        return task
+    }
+
+    private enum CodingKeys: String, CodingKey {
+        case name, status, log
+    }
 }
 
 // MARK: - Job Summary (for list view)
@@ -90,7 +113,7 @@ struct JobDetail: Codable, Sendable {
     let visibility: Visibility?
     let image: String?
     let manifest: String?
-    let tasks: [BuildTask]
+    var tasks: [BuildTask]
     let log: BuildLog?
     let owner: Entity
 }
diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift
index 977cd87..0479f76 100644
--- a/Hutch/Views/Builds/BuildDetailView.swift
+++ b/Hutch/Views/Builds/BuildDetailView.swift
@@ -312,13 +312,13 @@ private struct TaskLogSection: View {
 
     var body: some View {
         DisclosureGroup(isExpanded: $isExpanded) {
-            if viewModel.loadingTaskLogs.contains(task.name) {
+            if viewModel.loadingTaskLogs.contains(task.logCacheKey) {
                 HStack {
                     Spacer()
                     ProgressView("Loading log…")
                     Spacer()
                 }
-            } else if let logText = viewModel.taskLogs[task.name] {
+            } else if let logText = viewModel.taskLogs[task.logCacheKey] {
                 ScrollView(.horizontal, showsIndicators: false) {
                     Text(logText)
                         .font(.caption2.monospaced())
diff --git a/Hutch/Views/Builds/BuildDetailViewModel.swift b/Hutch/Views/Builds/BuildDetailViewModel.swift
index c14e47b..1768e47 100644
--- a/Hutch/Views/Builds/BuildDetailViewModel.swift
+++ b/Hutch/Views/Builds/BuildDetailViewModel.swift
@@ -104,7 +104,11 @@ final class BuildDetailViewModel {
                 variables: ["id": jobId],
                 responseType: JobDetailResponse.self
             )
-            job = result.job
+            var loadedJob = result.job
+            loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in
+                task.withOrdinal(index)
+            }
+            job = loadedJob
         } catch {
             self.error = error.localizedDescription
         }
@@ -113,19 +117,20 @@ final class BuildDetailViewModel {
     }
 
     func loadTaskLog(task: BuildTask) async {
+        let cacheKey = task.logCacheKey
         guard let log = task.log,
               let logURL = URL(string: log.fullURL),
-              !loadingTaskLogs.contains(task.name),
-              taskLogs[task.name] == nil else { return }
-        loadingTaskLogs.insert(task.name)
+              !loadingTaskLogs.contains(cacheKey),
+              taskLogs[cacheKey] == nil else { return }
+        loadingTaskLogs.insert(cacheKey)
 
         do {
-            taskLogs[task.name] = try await client.fetchText(url: logURL)
+            taskLogs[cacheKey] = try await client.fetchText(url: logURL)
         } catch {
             self.error = error.localizedDescription
         }
 
-        loadingTaskLogs.remove(task.name)
+        loadingTaskLogs.remove(cacheKey)
     }
 
     func cancelJob() async {
diff --git a/HutchTests/BuildTaskTests.swift b/HutchTests/BuildTaskTests.swift
new file mode 100644
index 0000000..2e2a1e0
--- /dev/null
+++ b/HutchTests/BuildTaskTests.swift
@@ -0,0 +1,27 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct BuildTaskTests {
+
+    @Test
+    @MainActor
+    func duplicateTaskNamesProduceDistinctIDsAndLogCacheKeys() {
+        let firstTask = BuildTask(
+            name: "test",
+            status: .failed,
+            log: BuildLog(fullURL: "https://builds.sr.ht/job/1/task/1")
+        ).withOrdinal(0)
+
+        let secondTask = BuildTask(
+            name: "test",
+            status: .failed,
+            log: BuildLog(fullURL: "https://builds.sr.ht/job/1/task/2")
+        ).withOrdinal(1)
+
+        #expect(firstTask.id == "0:test")
+        #expect(secondTask.id == "1:test")
+        #expect(firstTask.id != secondTask.id)
+        #expect(firstTask.logCacheKey != secondTask.logCacheKey)
+    }
+}