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
175 changes: 144 additions & 31 deletions abgui/Sources/abgui/AppModel.swift

Large diffs are not rendered by default.

45 changes: 39 additions & 6 deletions abgui/Sources/abgui/Backend/ProcessRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,6 @@ actor ProcessRunner: AbctlRunner {

try process.run()

// Write stdin (e.g. profile XML for `create -f -`) then close so abctl sees EOF.
if let stdin {
try? inPipe.fileHandleForWriting.write(contentsOf: stdin)
}
try? inPipe.fileHandleForWriting.close()

// If the caller's Task is cancelled (e.g. a Cancel button), terminate the child so it
// doesn't linger; its pipes then close, the drains reach EOF, and we unwind below.
return try await withTaskCancellationHandler {
Expand All @@ -48,14 +42,37 @@ actor ProcessRunner: AbctlRunner {
process.terminate()
}

// Feed stdin (profile XML for `create -f -`) off the calling thread and INSIDE the
// watchdog's reach, then close so abctl sees EOF.
//
// It used to run before the cancellation handler and before the drains were
// started, so for the duration of the write there was no Cancel, no timeout, and
// nobody reading the child's output. A profile larger than the pipe buffer, sent to
// an abctl that narrates before consuming stdin, deadlocks both ends with no way
// out. The write error is captured rather than discarded, because a partially
// written profile is a truncated one, and sending that to Apple as a `create` is
// worse than failing.
async let written: Error? = Self.writeStdin(inPipe.fileHandleForWriting, data: stdin)

// Drain both pipes concurrently, THEN wait — reading first avoids the classic
// 64 KB-pipe-buffer deadlock. stderr streams line-by-line to `onStderrLine`.
async let out = Self.readToEnd(outPipe.fileHandleForReading)
async let err = Self.streamToEnd(errPipe.fileHandleForReading, onLine: onStderrLine)
let stdout = await out
let stderr = await err
let stdinError = await written
process.waitUntilExit()

// A failed stdin write means abctl received a TRUNCATED profile. It may well have
// sent that to Apple, so this is reported rather than swallowed — the exit code
// alone cannot distinguish "created what you meant" from "created half of it".
if let stdinError {
// `stderr` is still Data here — it is decoded once, below, for the result.
throw AbctlError.cli("failed to send the profile to abctl (it may have received only part of it): "
+ stdinError.localizedDescription + "\n"
+ String(decoding: stderr, as: UTF8.self))
}

// The watchdog ran to completion iff it fired (timeout); if it was still sleeping
// we cancel it and its sleep throws — telling success from timeout precisely.
watchdog.cancel()
Expand All @@ -79,6 +96,22 @@ actor ProcessRunner: AbctlRunner {
}
}

/// Write `data` to the child's stdin off the main thread and close the handle, returning the
/// error if the write failed. Closing happens either way: without EOF the child waits for
/// input that is never coming, which is a hang rather than an error.
private static func writeStdin(_ handle: FileHandle, data: Data?) async -> Error? {
await withCheckedContinuation { continuation in
DispatchQueue.global().async {
var failure: Error?
if let data, !data.isEmpty {
do { try handle.write(contentsOf: data) } catch { failure = error }
}
try? handle.close()
continuation.resume(returning: failure)
}
}
}

/// Read a pipe to EOF off the actor (and off the main thread).
private static func readToEnd(_ handle: FileHandle) async -> Data {
await withCheckedContinuation { continuation in
Expand Down
57 changes: 57 additions & 0 deletions abgui/Sources/abgui/Models/ApplyResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,69 @@ import Foundation
struct ApplyResult: Decodable, Equatable {
let configs: Phase
let blueprints: Phase
/// abctl's own post-apply verdict, from the `verification` key of the receipt.
///
/// This is the STRUCTURED answer to "did the tenant end up matching git", and it was being
/// thrown away: abgui reached the same conclusion by grepping stderr for the word FAILED,
/// against a hand-maintained list of abctl's literal narration strings. Rewording one Go
/// sentence silently downgraded the GUI's verdict to a generic "may not match git" — the
/// same contract-drift that misclassified every non-config plan row. Optional because an
/// older abctl, or a run that died before verification, does not emit it.
let verification: Verification?

var totalWrites: Int { configs.writes + blueprints.writes }
var totalErrors: Int { configs.errors + blueprints.errors }
var totalSkipped: Int { configs.skipped + blueprints.skipped }
var rows: [OutcomeRow] { configs.rows + blueprints.rows }

/// The post-apply read-back verdict (internal/cli/phase1.go `verificationReport`).
struct Verification: Decodable, Equatable {
let mode: String // targeted | full | none
let written: Int // configs this run pushed to Apple
let verified: Int // of those, shown to match git (always 0 for mode "none")
let mismatches: [Mismatch]

/// True when abctl positively established that something did NOT land. Distinct from
/// "not everything was checked": `--verify=none` verifies nothing and is not a failure.
var hasMismatches: Bool { !mismatches.isEmpty }

/// Writes abctl could not reach a verdict on — verified and mismatched are the two
/// things it DID decide, so anything left over was never established either way.
var unchecked: Int { max(0, written - verified - mismatches.count) }

struct Mismatch: Decodable, Equatable {
let name: String
let detail: String
/// true → abctl READ the config and the bytes differ (the write did not land).
/// false → abctl could not compare it, which says nothing about the write.
/// abctl keeps these apart deliberately; collapsing them would report a network
/// failure as a lost write.
let observed: Bool
}

enum CodingKeys: String, CodingKey { case mode, written, verified, mismatches }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
mode = try c.decodeIfPresent(String.self, forKey: .mode) ?? "unknown"
written = try c.decodeIfPresent(Int.self, forKey: .written) ?? 0
verified = try c.decodeIfPresent(Int.self, forKey: .verified) ?? 0
mismatches = try c.decodeIfPresent([Mismatch].self, forKey: .mismatches) ?? []
}

/// The one-line verdict, in abgui's own words rather than scraped from abctl's.
var headline: String {
if hasMismatches {
let lost = mismatches.filter(\.observed).count
if lost > 0 {
return "\(lost) of \(written) written configuration(s) did not land on Apple Business."
}
return "\(mismatches.count) of \(written) written configuration(s) could not be checked."
}
if mode == "none" { return "Writes were not verified (--verify=none)." }
return "\(verified) written configuration(s) confirmed on Apple Business."
}
}

/// One reconcile phase (reconcile.Result / reconcile.BlueprintResult share this shape).
struct Phase: Decodable, Equatable {
let outcomes: [OutcomeRow]
Expand Down
9 changes: 6 additions & 3 deletions abgui/Sources/abgui/Models/ReadOnlyKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@ enum ReadOnlyKind: String, CaseIterable, Identifiable, Hashable {
return [
ColumnSpec(header: "Serial") { $0.attr("serialNumber") ?? $0.id },
ColumnSpec(header: "Model") { $0.attr("deviceModel") ?? "—" },
ColumnSpec(header: "OS") { $0.attr("osVersion") ?? "—" },
// No "OS" column: `osVersion` is BUILT-IN-MDM posture, not an orgDevices
// attribute — every Go read of it comes off mdmDetails, and abctl's own
// orgDevices table prints serial/family/model only. It was permanently "—",
// in the table and in the CSV export. Enrolled Devices is where OS lives.
ColumnSpec(header: "Family") { $0.attr("productFamily") ?? "—" },
]
case .mdmDevices:
Expand All @@ -87,9 +90,9 @@ enum ReadOnlyKind: String, CaseIterable, Identifiable, Hashable {
return [
ColumnSpec(header: "Name") {
let n = [$0.attr("firstName"), $0.attr("lastName")].compactMap { $0 }.joined(separator: " ")
return n.isEmpty ? ($0.attr("managedAppleId") ?? $0.id) : n
return n.isEmpty ? ($0.attr("managedAppleAccount") ?? $0.id) : n
},
ColumnSpec(header: "Managed Apple ID") { $0.attr("managedAppleId") ?? $0.attr("email") ?? "—" },
ColumnSpec(header: "Managed Apple ID") { $0.attr("managedAppleAccount") ?? $0.attr("email") ?? "—" },
ColumnSpec(header: "Roles") { let r = $0.roleNames(); return r.isEmpty ? "—" : r },
ColumnSpec(header: "Status") { $0.attr("status") ?? "—" },
]
Expand Down
19 changes: 17 additions & 2 deletions abgui/Sources/abgui/Views/ApplySheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,15 +146,21 @@ struct ApplySheet: View {
// Either signal is enough to withhold the green verdict: the phase counters and the
// per-item rows are two different numbers out of abctl, and the honest reading of a
// disagreement between them is "something failed", never "all clear".
return result.totalErrors > 0 || model.syncFailure != nil ? .partial(result) : .succeeded(result)
// abctl's own STRUCTURED verdict is consulted first: a run can report every item
// `done` and still have failed, because Apple answers 2xx to a write it then
// declines to store. `verification.mismatches` is where abctl says so, and it is
// the reason the green verdict is not simply "no errors".
let notPersisted = result.verification?.mismatches.contains { $0.observed } ?? false
return result.totalErrors > 0 || model.syncFailure != nil || notPersisted
? .partial(result) : .succeeded(result)
}
if model.syncFailure != nil { return .failed }
// `lastWriteError` is SHARED with every other gated write in the app (a failed config
// delete leaves it set), so it only counts as a sync failure once this sheet has actually
// run something — `apply()` clears the progress log and writes its first line before any
// of this can be true. Without that guard, opening Apply after an unrelated write failure
// would greet the user with "Sync FAILED" for a sync that never happened.
if !model.applyProgressLog.isEmpty, model.lastWriteError != nil { return .failed }
if !model.applyProgressLog.isEmpty, model.writeError(.apply) != nil { return .failed }
return nil
}

Expand Down Expand Up @@ -528,6 +534,15 @@ struct ApplySheet: View {
VStack(alignment: .leading, spacing: 6) {
Text("\(result.totalWrites) write(s) - \(result.totalErrors) error(s) - \(result.totalSkipped) skipped")
.foregroundStyle(result.totalErrors > 0 ? .red : .green)
if let verification = result.verification {
// Read back from abctl's structured verdict rather than its prose. A write
// Apple acknowledged and dropped shows up HERE and nowhere in the counters
// above, which is exactly why it gets its own line.
Text(verification.headline)
.font(.caption)
.foregroundStyle(verification.mismatches.contains { $0.observed } ? .red : .secondary)
.fixedSize(horizontal: false, vertical: true)
}
if !result.rows.isEmpty {
Divider()
ScrollView {
Expand Down
35 changes: 23 additions & 12 deletions abgui/Sources/abgui/Views/ArchiveView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,30 @@ struct ArchiveView: View {
Text("Choose a workspace in Diff / Drift to browse its archive.")
}
} else {
Table(model.archiveEntries, selection: $selection) {
TableColumn("Configuration") { Text($0.configName) }
TableColumn("Archived") { Text($0.archivedAt) }
TableColumn("Reason") { Text($0.reason) }
}
.overlay {
if model.archiveEntries.isEmpty {
ContentUnavailableView("No archived versions", systemImage: "clock.arrow.circlepath",
description: Text("abctl archives a live profile before each overwrite or delete."))
// A VStack, not two bare siblings. This branch emitted the Table and the error Text
// as an unwrapped pair into a slot that takes ONE view, so the error composited over
// the middle of the table instead of sitting under it — the same class of layout
// fault as the safe-area insets, and it only ever showed up when a write had failed.
VStack(spacing: 0) {
Table(model.archiveEntries, selection: $selection) {
TableColumn("Configuration") { Text($0.configName) }
TableColumn("Archived") { Text($0.archivedAt) }
TableColumn("Reason") { Text($0.reason) }
}
.overlay {
if model.archiveEntries.isEmpty {
ContentUnavailableView("No archived versions", systemImage: "clock.arrow.circlepath",
description: Text("abctl archives a live profile before each overwrite or delete."))
}
}
if let error = model.writeError(.archive) {
Text(error)
.foregroundStyle(.red)
.font(.caption)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.padding([.horizontal, .vertical], 8)
}
}
if let error = model.lastWriteError {
Text(error).foregroundStyle(.red).font(.caption).padding(.horizontal)
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion abgui/Sources/abgui/Views/ConfigEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ struct ConfigEditorView: View {

editor

if let error = model.lastWriteError {
if let error = model.writeError(.configuration) {
Text(error).foregroundStyle(.red).font(.caption)
.frame(maxWidth: .infinity, alignment: .leading)
.padding([.horizontal, .bottom])
Expand Down
3 changes: 0 additions & 3 deletions abgui/Sources/abgui/Views/ConsoleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,6 @@ struct ConsoleView: View {
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let error = model.lastWriteError, model.consoleEntries.isEmpty {
Text(error).font(.caption).foregroundStyle(.red)
}
}
.padding()
.background(.quaternary.opacity(0.35))
Expand Down
12 changes: 7 additions & 5 deletions abgui/Sources/abgui/Views/DiffView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ struct DiffView: View {
.disabled((model.plan?.actionableChangeCount ?? 0) == 0)
Button { model.refreshPlan() } label: { Label("Refresh", systemImage: "arrow.clockwise") }
.toolbarLabel("Recompute the plan: re-read gitops/ and re-fetch the live tenant.")
.disabled(model.isLoading || model.isSeeding)
.disabled(model.isPlanning || model.isSeeding)
}
Button { showWorkspacePicker = true } label: { Label("Workspace", systemImage: "folder") }
.toolbarLabel("Choose the folder that contains your gitops/ tree. Every command on this screen runs there.")
Expand Down Expand Up @@ -114,8 +114,10 @@ struct DiffView: View {
}
} else if model.isSeeding {
workingView("Initializing workspace from the tenant...")
} else if model.isLoading {
// Check isLoading BEFORE the plan branch, so a refresh from an already-computed
} else if model.isPlanning {
// `isPlanning`, not the shared `isLoading`: a Devices or Configurations fetch must
// not put THIS screen into its computing state, and finishing one must not take it
// out of it. Checked BEFORE the plan branch, so a refresh from an already-computed
// state visibly shows progress instead of silently redisplaying the old result.
workingView("Computing plan...")
} else if model.needsSeed {
Expand Down Expand Up @@ -229,7 +231,7 @@ struct DiffView: View {
// nothing rendering it, a row-button click that abctl rejected (unmanaged
// collection, no manifest for the blueprint, member not actually attached)
// looked like a click that did nothing at all.
if let error = model.lastWriteError {
if let error = model.writeError(.adopt) {
Label(error, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.red)
Expand Down Expand Up @@ -350,7 +352,7 @@ private struct PlanDetailRow: View {
.buttonStyle(.bordered)
.controlSize(.small)
.help(adopt.help)
.disabled(model.isWriting || model.isLoading)
.disabled(model.isWriting || model.isPlanning)
}
}
.padding(.vertical, 8)
Expand Down
2 changes: 1 addition & 1 deletion abgui/Sources/abgui/Views/MembershipSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ struct MembershipSheet: View {
}
}

if let error = model.lastWriteError {
if let error = model.writeError(.membership) {
Text(error).foregroundStyle(.red).font(.caption)
}

Expand Down
Loading