Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/MacRunner/MacRunnerApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {

func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
JobNotificationService.shared.configure()

statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)

Expand Down
7 changes: 7 additions & 0 deletions Sources/Models/Runner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ struct AppSettings: Codable, Sendable {
var quietHours: QuietHours?
var isolationMode: IsolationMode
var tools: ToolProvisioningSettings
var notificationsEnabled: Bool
var autoCheckForUpdates: Bool
var autoRestartEnabled: Bool
var autoRestartMaxRetries: Int
Expand All @@ -195,6 +196,7 @@ struct AppSettings: Codable, Sendable {
quietHours: nil,
isolationMode: .none,
tools: .default,
notificationsEnabled: true,
autoCheckForUpdates: true,
autoRestartEnabled: true,
autoRestartMaxRetries: 5,
Expand All @@ -207,6 +209,7 @@ struct AppSettings: Codable, Sendable {
quietHours: QuietHours? = nil,
isolationMode: IsolationMode = .none,
tools: ToolProvisioningSettings = .default,
notificationsEnabled: Bool = true,
autoCheckForUpdates: Bool = true,
autoRestartEnabled: Bool = true,
autoRestartMaxRetries: Int = 5,
Expand All @@ -217,6 +220,7 @@ struct AppSettings: Codable, Sendable {
self.quietHours = quietHours
self.isolationMode = isolationMode
self.tools = tools
self.notificationsEnabled = notificationsEnabled
self.autoCheckForUpdates = autoCheckForUpdates
self.autoRestartEnabled = autoRestartEnabled
self.autoRestartMaxRetries = max(1, autoRestartMaxRetries)
Expand All @@ -230,6 +234,9 @@ struct AppSettings: Codable, Sendable {
quietHours = try container.decodeIfPresent(QuietHours.self, forKey: .quietHours)
isolationMode = try container.decodeIfPresent(IsolationMode.self, forKey: .isolationMode) ?? .none
tools = try container.decodeIfPresent(ToolProvisioningSettings.self, forKey: .tools) ?? .default
notificationsEnabled = try container.decodeIfPresent(Bool.self, forKey: .notificationsEnabled) ?? true
tools = try container.decodeIfPresent(ToolProvisioningSettings.self, forKey: .tools) ?? .default
notificationsEnabled = try container.decodeIfPresent(Bool.self, forKey: .notificationsEnabled) ?? true
autoCheckForUpdates = try container.decodeIfPresent(Bool.self, forKey: .autoCheckForUpdates) ?? true
autoRestartEnabled = try container.decodeIfPresent(Bool.self, forKey: .autoRestartEnabled) ?? true
autoRestartMaxRetries = max(1, try container.decodeIfPresent(Int.self, forKey: .autoRestartMaxRetries) ?? 5)
Expand Down
2 changes: 1 addition & 1 deletion Sources/Services/ContainerIsolationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class ContainerIsolationService {
let kernel = Kernel(path: kernelPath, platform: .linuxArm)

// Create network configuration (vmnet shared mode)
let network = try ContainerManager.VmnetNetwork()
let network = try VmnetNetwork()

// Initialize container manager with kernel and network
// vminit will be fetched automatically from registry on first use
Expand Down
95 changes: 95 additions & 0 deletions Sources/Services/GHCLIService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,34 @@ final class GHCLIService: Sendable {
return Set(entries)
}

func currentJob(for repo: String, runnerName: String) async throws -> WorkflowJobSummary? {
let runs = try await listWorkflowRuns(for: repo, status: "in_progress")

for run in runs {
let jobs = try await listJobs(for: repo, runID: run.id, run: run)
if let job = jobs.first(where: {
$0.runnerName == runnerName && $0.status != "completed"
}) {
return job
}
}

return nil
}

func completedJob(for repo: String, runnerName: String, runID: Int) async throws -> WorkflowJobSummary? {
let runs = try await listWorkflowRuns(for: repo, status: "completed")

guard let run = runs.first(where: { $0.id == runID }) else {
return nil
}

let jobs = try await listJobs(for: repo, runID: run.id, run: run)
return jobs.first(where: {
$0.runnerName == runnerName && $0.status == "completed"
})
}

// MARK: - Runners

func getRegistrationToken(for repo: String) async throws -> String {
Expand Down Expand Up @@ -272,6 +300,73 @@ final class GHCLIService: Sendable {
throw GHError.apiFailed("Failed to delete runner: \(result.stderr)")
}
}

private func listWorkflowRuns(for repo: String, status: String) async throws -> [WorkflowRunSummary] {
let result = try await runGH([
"api", "repos/\(repo)/actions/runs?status=\(status)&per_page=10",
"--jq", ".workflow_runs"
])
guard result.exitCode == 0 else {
throw GHError.apiFailed("Failed to list workflow runs: \(result.stderr)")
}

struct APIWorkflowRun: Decodable {
let id: Int
let name: String?
let htmlURL: URL

enum CodingKeys: String, CodingKey {
case id
case name
case htmlURL = "html_url"
}
}

let data = Data(result.stdout.utf8)
let runs = try JSONDecoder().decode([APIWorkflowRun].self, from: data)
return runs.map {
WorkflowRunSummary(id: $0.id, name: $0.name ?? "GitHub Actions", htmlURL: $0.htmlURL)
}
}

private func listJobs(for repo: String, runID: Int, run: WorkflowRunSummary) async throws -> [WorkflowJobSummary] {
let result = try await runGH([
"api", "repos/\(repo)/actions/runs/\(runID)/jobs",
"--jq", ".jobs"
])
guard result.exitCode == 0 else {
throw GHError.apiFailed("Failed to list workflow jobs: \(result.stderr)")
}

struct APIJob: Decodable {
let id: Int
let name: String
let status: String
let conclusion: String?
let runnerName: String?

enum CodingKeys: String, CodingKey {
case id
case name
case status
case conclusion
case runnerName = "runner_name"
}
}

let data = Data(result.stdout.utf8)
let jobs = try JSONDecoder().decode([APIJob].self, from: data)
return jobs.map {
WorkflowJobSummary(
id: $0.id,
name: $0.name,
status: $0.status,
conclusion: $0.conclusion,
runnerName: $0.runnerName,
run: run
)
}
}
}

enum GHError: LocalizedError {
Expand Down
121 changes: 121 additions & 0 deletions Sources/Services/JobNotificationService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import AppKit
import Foundation
import UserNotifications

struct WorkflowRunSummary: Sendable, Equatable {
let id: Int
let name: String
let htmlURL: URL
}

struct WorkflowJobSummary: Sendable, Equatable {
let id: Int
let name: String
let status: String
let conclusion: String?
let runnerName: String?
let run: WorkflowRunSummary
}

struct JobNotificationPayload: Equatable {
let title: String
let body: String
let runURL: URL
}

enum JobNotificationEvent {
case started
case completed
}

enum JobNotificationPayloadFactory {
static func make(event: JobNotificationEvent, runner: Runner, job: WorkflowJobSummary) -> JobNotificationPayload {
let workflowName = job.run.name.isEmpty ? job.name : job.run.name

switch event {
case .started:
return JobNotificationPayload(
title: "Job started on \(runner.name)",
body: "\(runner.repo) - \(workflowName)",
runURL: job.run.htmlURL
)
case .completed:
let conclusion = job.conclusion ?? "completed"
let title = conclusion == "success"
? "Job completed on \(runner.name)"
: "Job failed on \(runner.name)"
return JobNotificationPayload(
title: title,
body: "\(runner.repo) - \(workflowName) (\(conclusion))",
runURL: job.run.htmlURL
)
}
}
}

@MainActor
final class JobNotificationService: NSObject, @preconcurrency UNUserNotificationCenterDelegate {
static let shared = JobNotificationService()

private let notificationCenter = UNUserNotificationCenter.current()
private var authorizationRequested = false

func configure() {
notificationCenter.delegate = self
requestAuthorizationIfNeededInternal()
}

func notify(event: JobNotificationEvent, runner: Runner, job: WorkflowJobSummary) async {
requestAuthorizationIfNeeded()

let payload = JobNotificationPayloadFactory.make(event: event, runner: runner, job: job)
let content = UNMutableNotificationContent()
content.title = payload.title
content.body = payload.body
content.sound = .default
content.userInfo = ["runURL": payload.runURL.absoluteString]

let request = UNNotificationRequest(
identifier: "job-\(runner.id.uuidString)-\(job.id)-\(eventIdentifier(for: event))",
content: content,
trigger: nil
)

notificationCenter.add(request, withCompletionHandler: nil)
}

private func requestAuthorizationIfNeeded() {
requestAuthorizationIfNeededInternal()
}

func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
defer { completionHandler() }

guard let rawURL = response.notification.request.content.userInfo["runURL"] as? String,
let url = URL(string: rawURL) else {
return
}

NSWorkspace.shared.open(url)
}

private func requestAuthorizationIfNeededInternal() {
guard !authorizationRequested else { return }
authorizationRequested = true

notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in }
}

private func eventIdentifier(for event: JobNotificationEvent) -> String {
switch event {
case .started:
return "started"
case .completed:
return "completed"
}
}
}
52 changes: 52 additions & 0 deletions Sources/Services/RunnerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class RunnerManager: ObservableObject {
private let ghService = GHCLIService.shared
private let isolationService = UserIsolationService.shared
private let toolProvisioningService = ToolProvisioningService()
private let jobNotificationService = JobNotificationService.shared
private let processManager = ProcessManager()
private let pidManager = PIDFileManager()
private let updateChecker = UpdateChecker()
Expand All @@ -51,6 +52,7 @@ class RunnerManager: ObservableObject {
private(set) var currentSettings: AppSettings = .default
private var statusPollingTask: Task<Void, Never>?
private var runnersToAutoRestart: Set<UUID> = []
private var activeWorkflowJobs: [UUID: WorkflowJobSummary] = [:]
/// Names reserved by in-flight addRunner calls to prevent duplicate naming race conditions.
private var pendingRunnerNames: Set<String> = []
private var manualStopRequests: Set<UUID> = []
Expand Down Expand Up @@ -87,6 +89,12 @@ class RunnerManager: ObservableObject {
}
}

for runner in runners where runner.busy {
Task { [weak self] in
await self?.restoreActiveJobState(for: runner)
}
}

reconcileRunnerStates()
syncLoginItem()
startStatusPolling()
Expand Down Expand Up @@ -664,6 +672,7 @@ class RunnerManager: ObservableObject {
// Use per-runner isolation mode if specified, otherwise use global setting
let isolation = runner.effectiveIsolationMode(global: currentSettings.isolationMode)
manualStopRequests.insert(id)
activeWorkflowJobs.removeValue(forKey: id)

// Check if this is a container-based runner
do {
Expand Down Expand Up @@ -798,8 +807,15 @@ class RunnerManager: ObservableObject {
if let index = runners.firstIndex(where: { $0.id == runner.id }),
let remoteRunner = remoteRunners.first(where: { $0.name == runner.name }) {
if runners[index].busy != remoteRunner.busy {
let becameBusy = remoteRunner.busy
runners[index].busy = remoteRunner.busy
changed = true

if becameBusy {
await handleJobStarted(for: runners[index])
} else {
await handleJobCompleted(for: runners[index])
}
}
}
}
Expand Down Expand Up @@ -1124,6 +1140,42 @@ class RunnerManager: ObservableObject {
gitHubAuthIssue = nil
}

private func restoreActiveJobState(for runner: Runner) async {
guard activeWorkflowJobs[runner.id] == nil else { return }
activeWorkflowJobs[runner.id] = try? await ghService.currentJob(for: runner.repo, runnerName: runner.name)
}

private func handleJobStarted(for runner: Runner) async {
guard activeWorkflowJobs[runner.id] == nil else { return }
guard let job = try? await ghService.currentJob(for: runner.repo, runnerName: runner.name) else {
return
}

activeWorkflowJobs[runner.id] = job
if currentSettings.notificationsEnabled {
await jobNotificationService.notify(event: .started, runner: runner, job: job)
}
}

private func handleJobCompleted(for runner: Runner) async {
guard let activeJob = activeWorkflowJobs[runner.id] else { return }
defer { activeWorkflowJobs.removeValue(forKey: runner.id) }

let completedJob = try? await ghService.completedJob(
for: runner.repo,
runnerName: runner.name,
runID: activeJob.run.id
)

if currentSettings.notificationsEnabled {
await jobNotificationService.notify(
event: .completed,
runner: runner,
job: completedJob ?? activeJob
)
}
}

private func cancelScheduledRestarts(clearHistory: Bool) {
for (id, task) in scheduledRestarts {
task.cancel()
Expand Down
Loading
Loading