krz/hutch

an ios client for sourcehut

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

v2.13.0: Hutch/Views/Home/HomeViewModel.swift · raw

  1import Foundation
  2import os
  3
  4private let homeLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Home")
  5
  6private struct HomeJobsResponse: Decodable, Sendable {
  7    let jobs: HomeJobsPage
  8}
  9
 10private struct HomeJobsPage: Decodable, Sendable {
 11    let results: [HomeJobPayload]
 12}
 13
 14private struct HomeTrackersResponse: Decodable, Sendable {
 15    let trackers: HomeTrackersPage
 16}
 17
 18private struct HomeTrackersPage: Decodable, Sendable {
 19    let results: [TrackerSummary]
 20    let cursor: String?
 21}
 22
 23private struct HomeTrackerTicketsResponse: Decodable, Sendable {
 24    let user: HomeTrackerTicketsUser
 25}
 26
 27private struct HomeTrackerTicketsUser: Decodable, Sendable {
 28    let tracker: HomeTrackerTicketsTracker
 29}
 30
 31private struct HomeTrackerTicketsTracker: Decodable, Sendable {
 32    let tickets: HomeTrackerTicketsPage
 33}
 34
 35private struct HomeTrackerTicketsPage: Decodable, Sendable {
 36    let results: [HomeTicketPayload]
 37}
 38
 39private struct HomeInboxSubscriptionsResponse: Decodable, Sendable {
 40    let subscriptions: HomeInboxSubscriptionPage
 41}
 42
 43private struct HomeInboxSubscriptionPage: Decodable, Sendable {
 44    let results: [HomeInboxSubscription]
 45    let cursor: String?
 46}
 47
 48private struct HomeInboxSubscription: Decodable, Sendable {
 49    let list: InboxMailingListReference?
 50}
 51
 52private struct HomeInboxListThreadsResponse: Decodable, Sendable {
 53    let list: HomeInboxMailingListThreads
 54}
 55
 56private struct HomeInboxMailingListThreads: Decodable, Sendable {
 57    let threads: HomeInboxThreadPage
 58}
 59
 60private struct HomeInboxThreadPage: Decodable, Sendable {
 61    let results: [HomeInboxThreadPayload]
 62    let cursor: String?
 63}
 64
 65private struct HomeInboxThreadPayload: Decodable, Sendable {
 66    let updated: Date
 67    let subject: String
 68}
 69
 70private struct HomeTicketPayload: Decodable, Sendable {
 71    let id: Int
 72    let title: String
 73    let status: TicketStatus
 74    let resolution: TicketResolution?
 75    let created: Date
 76    let submitter: Entity
 77    let labels: [TicketLabel]
 78    let assignees: [Entity]
 79
 80    enum CodingKeys: String, CodingKey {
 81        case id
 82        case title = "subject"
 83        case status
 84        case resolution
 85        case created
 86        case submitter
 87        case labels
 88        case assignees
 89    }
 90
 91    var ticketSummary: TicketSummary {
 92        TicketSummary(
 93            id: id,
 94            title: title,
 95            status: status,
 96            resolution: resolution,
 97            created: created,
 98            submitter: submitter,
 99            labels: labels,
100            assignees: assignees
101        )
102    }
103}
104
105struct HomeAssignedTicket: Identifiable, Hashable, Sendable {
106    let trackerId: Int
107    let trackerRid: String
108    let trackerName: String
109    let ownerCanonicalName: String
110    let ticket: TicketSummary
111
112    var id: String {
113        "\(trackerRid)#\(ticket.id)"
114    }
115
116    var ownerUsername: String {
117        if ownerCanonicalName.hasPrefix("~") {
118            return String(ownerCanonicalName.dropFirst())
119        }
120        return ownerCanonicalName
121    }
122}
123
124struct HomeBuildItem: Identifiable, Hashable, Sendable {
125    let job: JobSummary
126    let repositoryName: String?
127    let repositoryOwner: String?
128
129    var id: Int { job.id }
130
131    var repositoryDisplayName: String? {
132        guard let repositoryName else { return nil }
133        if let repositoryOwner {
134            return "\(repositoryOwner)/\(repositoryName)"
135        }
136        return repositoryName
137    }
138}
139
140@Observable
141@MainActor
142final class HomeViewModel {
143    private(set) var projects: [Project] = []
144    var assignedTickets: [HomeAssignedTicket] = []
145    var recentBuilds: [HomeBuildItem] = []
146    private(set) var systemStatusSnapshot: SystemStatusSnapshot?
147    private(set) var hasUnreadInboxThreads = false
148    private(set) var unreadInboxThreadCount: Int?
149    private(set) var isLoadingProjects = false
150    private(set) var isLoadingAssignedTickets = false
151    private(set) var isLoadingRecentBuilds = false
152    private(set) var assignedTicketsError: String?
153    private(set) var recentBuildsError: String?
154
155    private let currentUser: User
156    private let client: SRHTClient
157    private let systemStatusRepository: SystemStatusRepository
158    private let projectService: ProjectService
159    private let ticketFetchConcurrencyLimit = 6
160    private let inboxUnreadConcurrencyLimit = 4
161
162    private static let jobsQuery = """
163    query jobs {
164        jobs {
165            results {
166                id
167                created
168                updated
169                status
170                note
171                tags
172                visibility
173                image
174                tasks { name status }
175                manifest
176            }
177        }
178    }
179    """
180
181    private static let trackersQuery = """
182    query trackers($cursor: Cursor) {
183        trackers(cursor: $cursor) {
184            results {
185                id
186                rid
187                name
188                description
189                visibility
190                updated
191                owner { canonicalName }
192            }
193            cursor
194        }
195    }
196    """
197
198    private static let trackerTicketsQuery = """
199    query tickets($owner: String!, $tracker: String!) {
200        user(username: $owner) {
201            tracker(name: $tracker) {
202                tickets {
203                    results {
204                        id
205                        subject
206                        status
207                        resolution
208                        created
209                        submitter { canonicalName }
210                        labels { id name backgroundColor foregroundColor }
211                        assignees { canonicalName }
212                    }
213                }
214            }
215        }
216    }
217    """
218
219    private static let inboxSubscriptionsQuery = """
220    query inboxSubscriptions($cursor: Cursor) {
221        subscriptions(cursor: $cursor) {
222            results {
223                ... on MailingListSubscription {
224                    list {
225                        id
226                        rid
227                        name
228                        owner { canonicalName }
229                    }
230                }
231            }
232            cursor
233        }
234    }
235    """
236
237    private static let inboxListThreadsQuery = """
238    query inboxListThreads($rid: ID!, $cursor: Cursor) {
239        list(rid: $rid) {
240            threads(cursor: $cursor) {
241                results {
242                    updated
243                    subject
244                }
245                cursor
246            }
247        }
248    }
249    """
250
251    private static let updateTicketStatusMutation = """
252    mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
253        updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
254            eventType: __typename
255        }
256    }
257    """
258
259    private static let unassignUserMutation = """
260    mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
261        unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
262    }
263    """
264
265    private static let cancelBuildMutation = """
266    mutation cancel($id: Int!) {
267        cancel(jobId: $id) { id }
268    }
269    """
270
271    init(currentUser: User, client: SRHTClient, systemStatusRepository: SystemStatusRepository) {
272        self.currentUser = currentUser
273        self.client = client
274        self.systemStatusRepository = systemStatusRepository
275        self.projectService = ProjectService(client: client)
276    }
277
278    func loadDashboard() async {
279        isLoadingProjects = true
280        isLoadingAssignedTickets = true
281        isLoadingRecentBuilds = true
282        assignedTicketsError = nil
283        recentBuildsError = nil
284
285        async let projectsTask = loadProjects()
286        async let jobsTask = loadRecentJobs()
287        async let assignedTicketsTask = loadAssignedTickets()
288        async let inboxUnreadTask = loadInboxUnreadCount()
289        async let systemStatusTask = loadSystemStatusSnapshot()
290
291        let projectsResult = await projectsTask
292        switch projectsResult {
293        case .success(let projects):
294            self.projects = projects
295        case .failure:
296            self.projects = []
297        }
298        isLoadingProjects = false
299
300        let recentJobsResult = await jobsTask
301
302        switch recentJobsResult {
303        case .success(let recentJobs):
304            let buildItems = Self.buildItems(from: recentJobs)
305            self.recentBuilds = buildItems
306            self.recentBuildsError = nil
307        case .failure(let error):
308            self.recentBuilds = []
309            self.recentBuildsError = error.userFacingMessage
310        }
311        isLoadingRecentBuilds = false
312
313        let assignedTicketsResult = await assignedTicketsTask
314
315        switch assignedTicketsResult {
316        case .success(let assignedTickets):
317            self.assignedTickets = assignedTickets
318            self.assignedTicketsError = nil
319        case .failure(let error):
320            self.assignedTickets = []
321            self.assignedTicketsError = error.userFacingMessage
322        }
323        isLoadingAssignedTickets = false
324
325        unreadInboxThreadCount = await inboxUnreadTask
326        hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
327        systemStatusSnapshot = await systemStatusTask
328        persistNeedsAttentionSnapshot()
329    }
330
331    var systemStatusBannerTitle: String? {
332        guard let systemStatusSnapshot, systemStatusSnapshot.hasDisruption else { return nil }
333        return systemStatusSnapshot.bannerSummary
334    }
335
336    func resolveTicket(_ ticket: HomeAssignedTicket) async {
337        let input: [String: any Sendable] = [
338            "status": TicketStatus.resolved.rawValue,
339            "resolution": TicketResolution.fixed.rawValue
340        ]
341        await performTicketStatusUpdate(ticket: ticket, input: input)
342    }
343
344    func reopenTicket(_ ticket: HomeAssignedTicket) async {
345        let input: [String: any Sendable] = [
346            "status": TicketStatus.reported.rawValue
347        ]
348        await performTicketStatusUpdate(ticket: ticket, input: input)
349    }
350
351    func unassignFromMe(_ ticket: HomeAssignedTicket) async {
352        do {
353            _ = try await client.execute(
354                service: .todo,
355                query: Self.unassignUserMutation,
356                variables: [
357                    "trackerId": ticket.trackerId,
358                    "ticketId": ticket.ticket.id,
359                    "userId": currentUser.id
360                ],
361                responseType: UnassignResponse.self
362            )
363            assignedTickets.removeAll { $0.id == ticket.id }
364            persistNeedsAttentionSnapshot()
365        } catch {
366            homeLogger.error("Unassign from me failed: \(error, privacy: .public)")
367        }
368    }
369
370    func cancelBuild(_ build: HomeBuildItem) async {
371        guard build.job.status.isCancellable else { return }
372
373        do {
374            _ = try await client.execute(
375                service: .builds,
376                query: Self.cancelBuildMutation,
377                variables: ["id": build.job.id],
378                responseType: CancelBuildResponse.self
379            )
380            if let index = recentBuilds.firstIndex(where: { $0.id == build.id }) {
381                let updatedJob = JobSummary(
382                    id: build.job.id,
383                    created: build.job.created,
384                    updated: build.job.updated,
385                    status: .cancelled,
386                    note: build.job.note,
387                    tags: build.job.tags,
388                    visibility: build.job.visibility,
389                    image: build.job.image,
390                    tasks: build.job.tasks
391                )
392                recentBuilds[index] = HomeBuildItem(
393                    job: updatedJob,
394                    repositoryName: build.repositoryName,
395                    repositoryOwner: build.repositoryOwner
396                )
397            }
398            persistNeedsAttentionSnapshot()
399        } catch {
400            homeLogger.error("Cancel build failed: \(error, privacy: .public)")
401        }
402    }
403
404    private func loadProjects() async -> Result<[Project], Error> {
405        do {
406            return .success(try await projectService.fetchProjects())
407        } catch {
408            return .failure(error)
409        }
410    }
411
412    private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> {
413        do {
414            let response = try await client.execute(
415                service: .builds,
416                query: Self.jobsQuery,
417                responseType: HomeJobsResponse.self
418            )
419            return .success(response.jobs.results)
420        } catch {
421            return .failure(error)
422        }
423    }
424
425    private func loadInboxUnreadCount() async -> Int? {
426        do {
427            return try await fetchUnreadInboxThreadCount()
428        } catch {
429            return nil
430        }
431    }
432
433    private func loadSystemStatusSnapshot() async -> SystemStatusSnapshot? {
434        do {
435            return try await systemStatusRepository.snapshot()
436        } catch {
437            return systemStatusSnapshot
438        }
439    }
440
441    private func fetchUnreadInboxThreadCount() async throws -> Int {
442        let mailingLists = try await fetchInboxMailingLists()
443        guard !mailingLists.isEmpty else { return 0 }
444
445        var startIndex = mailingLists.startIndex
446        var unreadCount = 0
447        var successfulFetchCount = 0
448        while startIndex < mailingLists.endIndex {
449            let endIndex = mailingLists.index(
450                startIndex,
451                offsetBy: inboxUnreadConcurrencyLimit,
452                limitedBy: mailingLists.endIndex
453            ) ?? mailingLists.endIndex
454            let batch = Array(mailingLists[startIndex..<endIndex])
455
456            let batchResult = await withTaskGroup(of: Result<Int, Error>.self) { group in
457                for mailingList in batch {
458                    group.addTask {
459                        do {
460                            return .success(try await self.fetchUnreadThreadCount(for: mailingList))
461                        } catch {
462                            return .failure(error)
463                        }
464                    }
465                }
466
467                var counts: [Int] = []
468                var errors: [Error] = []
469                for await result in group {
470                    switch result {
471                    case .success(let count):
472                        counts.append(count)
473                    case .failure(let error):
474                        errors.append(error)
475                    }
476                }
477                return (counts, errors)
478            }
479
480            unreadCount += batchResult.0.reduce(0, +)
481            successfulFetchCount += batchResult.0.count
482
483            startIndex = endIndex
484        }
485
486        guard successfulFetchCount > 0 else {
487            throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)])
488        }
489
490        return unreadCount
491    }
492
493    private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
494        var subscriptions: [HomeInboxSubscription] = []
495        var cursor: String?
496
497        while true {
498            var variables: [String: any Sendable] = [:]
499            if let cursor {
500                variables["cursor"] = cursor
501            }
502
503            let response = try await client.execute(
504                service: .lists,
505                query: Self.inboxSubscriptionsQuery,
506                variables: variables.isEmpty ? nil : variables,
507                responseType: HomeInboxSubscriptionsResponse.self
508            )
509
510            subscriptions.append(contentsOf: response.subscriptions.results)
511            guard let nextCursor = response.subscriptions.cursor else {
512                break
513            }
514            cursor = nextCursor
515        }
516
517        var seen = Set<String>()
518        return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
519    }
520
521    private func fetchUnreadThreadCount(for mailingList: InboxMailingListReference) async throws -> Int {
522        var unreadCount = 0
523        var cursor: String?
524
525        while true {
526            var variables: [String: any Sendable] = ["rid": mailingList.rid]
527            if let cursor {
528                variables["cursor"] = cursor
529            }
530
531            let response = try await client.execute(
532                service: .lists,
533                query: Self.inboxListThreadsQuery,
534                variables: variables,
535                responseType: HomeInboxListThreadsResponse.self
536            )
537
538            unreadCount += response.list.threads.results.filter { thread in
539                let normalizedSubject = thread.subject
540                    .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
541                    .trimmingCharacters(in: .whitespacesAndNewlines)
542                    .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
543                    .lowercased()
544                let threadID = "\(mailingList.rid)#\(normalizedSubject)"
545                return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
546            }.count
547
548            guard let nextCursor = response.list.threads.cursor else {
549                break
550            }
551            cursor = nextCursor
552        }
553
554        return unreadCount
555    }
556
557    private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
558        do {
559            let trackers = try await fetchAllTrackers()
560            let tickets = try await fetchAssignedTickets(for: trackers)
561                .sorted { $0.ticket.created > $1.ticket.created }
562            return .success(tickets)
563        } catch {
564            return .failure(error)
565        }
566    }
567
568    private func fetchAllTrackers() async throws -> [TrackerSummary] {
569        var allTrackers: [TrackerSummary] = []
570        var cursor: String?
571
572        while true {
573            var variables: [String: any Sendable] = [:]
574            if let cursor {
575                variables["cursor"] = cursor
576            }
577
578            let response = try await client.execute(
579                service: .todo,
580                query: Self.trackersQuery,
581                variables: variables.isEmpty ? nil : variables,
582                responseType: HomeTrackersResponse.self
583            )
584
585            allTrackers.append(contentsOf: response.trackers.results)
586            guard let nextCursor = response.trackers.cursor else {
587                break
588            }
589            cursor = nextCursor
590        }
591
592        return allTrackers
593    }
594
595    private func fetchAssignedTickets(for trackers: [TrackerSummary]) async throws -> [HomeAssignedTicket] {
596        guard !trackers.isEmpty else { return [] }
597
598        var assignedTickets: [HomeAssignedTicket] = []
599        var startIndex = trackers.startIndex
600
601        while startIndex < trackers.endIndex {
602            let endIndex = trackers.index(startIndex, offsetBy: ticketFetchConcurrencyLimit, limitedBy: trackers.endIndex) ?? trackers.endIndex
603            let batch = Array(trackers[startIndex..<endIndex])
604
605            let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in
606                for tracker in batch {
607                    group.addTask {
608                        try await self.fetchAssignedTickets(for: tracker)
609                    }
610                }
611
612                var ticketsForBatch: [HomeAssignedTicket] = []
613                for try await tickets in group {
614                    ticketsForBatch.append(contentsOf: tickets)
615                }
616                return ticketsForBatch
617            }
618
619            assignedTickets.append(contentsOf: batchTickets)
620            startIndex = endIndex
621        }
622
623        return assignedTickets
624    }
625
626    private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] {
627        let response = try await client.execute(
628            service: .todo,
629            query: Self.trackerTicketsQuery,
630            variables: [
631                "owner": tracker.owner.canonicalName.hasPrefix("~")
632                    ? String(tracker.owner.canonicalName.dropFirst())
633                    : tracker.owner.canonicalName,
634                "tracker": tracker.name
635            ],
636            responseType: HomeTrackerTicketsResponse.self
637        )
638
639        return response.user.tracker.tickets.results.compactMap { payload in
640            guard payload.status.isOpen else {
641                return nil
642            }
643            guard payload.assignees.contains(where: { Self.matchesCurrentUserAssignee($0, currentUser: currentUser) }) else {
644                return nil
645            }
646
647            return HomeAssignedTicket(
648                trackerId: tracker.id,
649                trackerRid: tracker.rid,
650                trackerName: tracker.name,
651                ownerCanonicalName: tracker.owner.canonicalName,
652                ticket: payload.ticketSummary
653            )
654        }
655    }
656
657    private func performTicketStatusUpdate(
658        ticket: HomeAssignedTicket,
659        input: [String: any Sendable]
660    ) async {
661        do {
662            _ = try await client.execute(
663                service: .todo,
664                query: Self.updateTicketStatusMutation,
665                variables: [
666                    "trackerId": ticket.trackerId,
667                    "ticketId": ticket.ticket.id,
668                    "input": input
669                ],
670                responseType: StatusEventResponse.self
671            )
672            assignedTickets.removeAll { $0.id == ticket.id }
673            persistNeedsAttentionSnapshot()
674        } catch {
675            homeLogger.error("Ticket status update failed: \(error, privacy: .public)")
676        }
677    }
678
679    private func persistNeedsAttentionSnapshot() {
680        NeedsAttentionSnapshotStore.save(
681            NeedsAttentionSnapshot(
682                unreadInboxThreads: unreadInboxThreadCount,
683                assignedOpenTickets: assignedTicketsError == nil ? assignedTickets.count : nil,
684                failedBuilds: recentBuildsError == nil
685                    ? recentBuilds.filter {
686                        switch $0.job.status {
687                        case .failed, .timeout:
688                            true
689                        default:
690                            false
691                        }
692                    }.count
693                    : nil,
694                updatedAt: .now
695            )
696        )
697    }
698
699    nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
700        jobs.map { job in
701            let repository = primaryRepositoryReference(in: job.manifest)
702            return HomeBuildItem(
703                job: job.jobSummary,
704                repositoryName: repository?.name,
705                repositoryOwner: repository?.ownerCanonicalName
706            )
707        }
708    }
709
710    nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
711        buildItems(from: jobs).filter { build in
712            switch build.job.status {
713            case .failed, .timeout:
714                true
715            default:
716                false
717            }
718        }
719    }
720
721    nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool {
722        let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
723        let currentCanonical = normalizedCanonicalName(currentUser.canonicalName)
724        if assigneeCanonical == currentCanonical {
725            return true
726        }
727
728        let assigneeUsername = normalizedUsername(entity.canonicalName)
729        let currentUsername = normalizedUsername(currentUser.username)
730        return assigneeUsername == currentUsername
731    }
732
733    nonisolated static func primaryRepositoryReference(in manifest: String?) -> (ownerCanonicalName: String, name: String)? {
734        guard let manifest else { return nil }
735        let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(?:git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"#
736        guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
737            return nil
738        }
739        let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest)
740        guard let match = regex.firstMatch(in: manifest, options: [], range: nsRange),
741              let ownerRange = Range(match.range(at: 1), in: manifest),
742              let nameRange = Range(match.range(at: 2), in: manifest) else {
743            return nil
744        }
745
746        let owner = String(manifest[ownerRange])
747        var name = String(manifest[nameRange])
748        if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) {
749            name.removeSubrange(suffixRange)
750        }
751        name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
752        guard !name.isEmpty else { return nil }
753        return (owner, name)
754    }
755
756    private nonisolated static func normalizedCanonicalName(_ value: String) -> String {
757        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
758        guard !trimmed.isEmpty else { return trimmed }
759        if trimmed.hasPrefix("~") {
760            return trimmed
761        }
762        return "~\(trimmed)"
763    }
764
765    private nonisolated static func normalizedUsername(_ value: String) -> String {
766        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
767        if trimmed.hasPrefix("~") {
768            return String(trimmed.dropFirst())
769        }
770        return trimmed
771    }
772
773    private struct StatusEventResponse: Decodable, Sendable {
774        struct EventRef: Decodable, Sendable {
775            let eventType: String
776        }
777
778        let updateTicketStatus: EventRef
779    }
780
781    private struct UnassignResponse: Decodable, Sendable {
782        struct EventRef: Decodable, Sendable {
783            let id: Int
784        }
785
786        let unassignUser: EventRef
787    }
788
789    private struct CancelBuildResponse: Decodable, Sendable {
790        struct CancelResult: Decodable, Sendable {
791            let id: Int
792        }
793
794        let cancel: CancelResult
795    }
796}
797struct HomeJobPayload: Decodable, Sendable {
798    let id: Int
799    let created: Date
800    let updated: Date
801    let status: JobStatus
802    let note: String?
803    let tags: [String]
804    let visibility: Visibility?
805    let image: String?
806    let tasks: [JobTaskSummary]
807    let manifest: String?
808
809    nonisolated var jobSummary: JobSummary {
810        JobSummary(
811            id: id,
812            created: created,
813            updated: updated,
814            status: status,
815            note: note,
816            tags: tags,
817            visibility: visibility,
818            image: image,
819            tasks: tasks
820        )
821    }
822}