From f797fbe61b6f847b0bed6bd6fd09b818e5334e3d Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 28 Oct 2026 08:17:33 -0400 Subject: [PATCH] Jobs tray: every change run since launch, from any screen, with Stop The executor reports each run as it starts, as each step ends and as it finishes; a bounded board (GamGUI's registry's caps) lists them in every window's toolbar, with Stop after the step in flight. A stopped run parks nothing for a retry, so Stop also ends a wait for Google. Retry stays on the screen that ran it. Co-Authored-By: Claude Opus 5.5 --- App/AppServices.swift | 12 +- App/GamGUIApp.swift | 36 ++++ App/JobsView.swift | 142 +++++++++++++++ Packages/GamKit/Package.swift | 4 +- .../Sources/ChangeCore/ChangeCore.swift | 60 ++++++- .../Sources/Directory/OffboardChanges.swift | 3 +- .../Sources/Directory/OnboardChanges.swift | 3 +- Packages/GamKit/Sources/Jobs/Jobs.swift | 169 +++++++++++++++++- .../Tests/ChangeCoreTests/JobsTests.swift | 162 +++++++++++++++++ .../GamEngineTests/WriteRouteTests.swift | 11 ++ docs/domains/README.md | 1 + docs/domains/jobs.md | 49 +++++ docs/plans/2026-10-08-native-gamgui.md | 4 +- docs/screenshots.md | 7 + scripts/screenshots.sh | 3 +- 15 files changed, 648 insertions(+), 18 deletions(-) create mode 100644 App/JobsView.swift create mode 100644 Packages/GamKit/Tests/ChangeCoreTests/JobsTests.swift create mode 100644 docs/domains/jobs.md diff --git a/App/AppServices.swift b/App/AppServices.swift index 79affdf2..759b12ab 100644 --- a/App/AppServices.swift +++ b/App/AppServices.swift @@ -2,6 +2,7 @@ import ChangeCore import Directory import Foundation import GamEngine +import Jobs import Setup import Stores import Vault @@ -16,6 +17,8 @@ struct AppServices { let gam: (runner: GamRunner, runtimeDirectory: URL)? /// The only path to a write (ChangeCore); none without gam. let executor: Executor? + /// Every write run since launch, as the executor reports it: the jobs tray. + let jobs: JobBoard /// The user page's writes, through the executor. let userChanges: UserChanges /// A selected person's groups, delegates, auto-reply and signature, read live. @@ -97,13 +100,16 @@ struct AppServices { extraEnvironment: [String: String] = [:]) -> AppServices { let runner = gam.map { AuthenticatedRunner(runner: $0.runner, vault: vault, runtimeDirectory: $0.runtimeDirectory) } let setup = SetupModel(vault: vault, runner: runner, gamgui: gamgui, setupFolderURL: setupFolderURL, lastDomain: lastDomain) - // The executor runs on the domain Setup connected, at its generation: a switch refuses old previews. + // The executor runs on the domain Setup connected, at its generation: a switch refuses old previews. Each run it + // makes is reported to the jobs tray, whose Stop asks it to stop after the step in flight. + let jobs = JobBoard() let executor = runner.map { runner in Executor(runner: runner, audit: AuditLog(url: auditURL), currentActor: { @MainActor [weak setup] in setup?.connectedAdmin }, tenant: { @MainActor [weak setup] in setup?.active.map { ($0, setup!.generation) } - }, extraEnvironment: extraEnvironment) + }, extraEnvironment: extraEnvironment, jobs: jobs.report) } + if let executor { jobs.stopRun = { @Sendable id in await executor.stopAfterCurrentStep(id) } } let directory = DirectoryStore(setup: setup, runner: runner) let groupMembers = GroupMembers(setup: setup, runner: runner) let groups = GroupStore(setup: setup, runner: runner) @@ -113,7 +119,7 @@ struct AppServices { phishingChanges.keepNobodyHas = { [weak setup, weak nobodyHas] domain, messageIDs, at in nobodyHas?.record(domain: domain, messageIDs: messageIDs, by: setup?.connectedAdmin ?? "", at: at) } - return AppServices(setup: setup, directory: directory, gam: gam, executor: executor, + return AppServices(setup: setup, directory: directory, gam: gam, executor: executor, jobs: jobs, userChanges: UserChanges(executor: executor, directory: directory), userAccess: UserAccess(setup: setup, runner: runner), signatureChanges: SignatureChanges(executor: executor, setup: setup, directory: directory), diff --git a/App/GamGUIApp.swift b/App/GamGUIApp.swift index a77d5bb4..00b52ceb 100644 --- a/App/GamGUIApp.swift +++ b/App/GamGUIApp.swift @@ -1,6 +1,7 @@ import AppKit import Directory import GamEngine +import Jobs import Setup import SwiftUI @@ -97,6 +98,28 @@ enum ColumnWidths { struct ContentView: View { let services: AppServices @State private var screen: Screen? = Screen.initial + #if DEBUG + @State private var demoJobs = false + + /// Two made-up runs for the tray's snapshot: an offboarding part done, and a pull from every mailbox that ended with two + /// mailboxes failed. Events as the executor reports them; nothing runs. + static func showDemoJobs(_ board: JobBoard) { + let offboarding = UUID(), pull = UUID() + board.handle(.started(job: pull, preview: UUID(), title: "Trash message for 9 addresses", total: 9)) + for name in ["alice", "bob", "dana", "erin", "frank", "gwen", "hank"] { + board.handle(.step(job: pull, item: "Trash message: \(name)@example.com", outcome: .succeeded, detail: "")) + } + for name in ["ivy", "jack"] { + board.handle(.step(job: pull, item: "Trash message: \(name)@example.com", outcome: .failed, + detail: "Mail service not enabled for \(name)@example.com.")) + } + board.handle(.finished(job: pull)) + board.handle(.started(job: offboarding, preview: UUID(), title: "Offboarding carol@example.com", total: 12)) + for step in ["Sign out", "Reset password", "Hide from directory", "Set vacation", "Remove group member"] { + board.handle(.step(job: offboarding, item: "\(step): carol@example.com", outcome: .succeeded, detail: "")) + } + } + #endif /// The person Signatures opens on when another screen sends it one. @State private var signaturesPerson: String? /// The person Calendars opens on when Users sends it one. @@ -205,6 +228,19 @@ struct ContentView: View { .navigationTitle("Setup") } } + // The jobs tray, on every screen: each run the executor makes, and Stop. + .toolbar { + ToolbarItem(placement: .primaryAction) { JobsButton(board: services.jobs) } + } + #if DEBUG + // Snapshots (`SWIFTGAMGUI_TAB=jobs`): the tray over the screen, with made-up runs: nothing runs. + .sheet(isPresented: $demoJobs) { JobsTray(board: services.jobs).frame(width: 440, height: 520) } + .task { + guard ProcessInfo.processInfo.environment["SWIFTGAMGUI_TAB"] == "jobs", Spikes.isRequested else { return } + Self.showDemoJobs(services.jobs) + demoJobs = true + } + #endif // Siri's request opens Users in the window in front, where it's drafted. .onChange(of: services.siriDrafts.titleChange != nil && appearsActive, initial: true) { _, wanted in if wanted { screen = .users } diff --git a/App/JobsView.swift b/App/JobsView.swift new file mode 100644 index 00000000..c407c723 --- /dev/null +++ b/App/JobsView.swift @@ -0,0 +1,142 @@ +import Jobs +import SwiftUI + +/// The jobs tray (GamGUI's plan U5) in every window's toolbar: how many runs are going, and on a click every run since launch, +/// running first, each with where it stands, its newest steps and its failures in words, and Stop. Reads the board only; Stop +/// asks the executor to stop after the step in flight, which always finishes and is audited (`JobBoard`). +struct JobsButton: View { + let board: JobBoard + @State private var open = false + @Environment(\.appearsActive) private var appearsActive + + var body: some View { + let running = board.running + Button { open.toggle() } label: { + Label(running == 0 ? "Jobs" : "\(running) Running", systemImage: running == 0 ? "checklist" : "arrow.triangle.2.circlepath") + .labelStyle(.titleAndIcon) + } + .help(running == 0 ? "The changes run since GAM GUI started" : "\(running) change\(running == 1 ? " is" : "s are") running: see where each stands") + .accessibilityLabel(running == 0 ? "Jobs" : "Jobs: \(running) running") + .popover(isPresented: $open, arrowEdge: .bottom) { + JobsTray(board: board).frame(width: 440, height: 480) + } + // A run that ends is said from the window in front: by the board's counter, so a step redraws nothing here. + .onChange(of: board.finishedCount) { + if appearsActive, let job = board.lastFinished { + AccessibilityNotification.Announcement("Finished: \(job.title). \(job.status)").post() + } + } + } +} + +/// The tray's list: at most `JobBoard.trayRows`, running first, each opening to its steps. +struct JobsTray: View { + let board: JobBoard + @State private var stopping: Job? + + var body: some View { + let tray = board.tray + VStack(alignment: .leading, spacing: 0) { + Text("Jobs").font(.headline).accessibilityAddTraits(.isHeader).padding([.horizontal, .top]) + if tray.rows.isEmpty { + ContentUnavailableView("Nothing Has Run", systemImage: "checklist", + description: Text("A change you confirm is listed here while it runs and after it ends.")) + } else { + List(tray.rows) { job in + JobRow(job: job) { stopping = job } + } + .listStyle(.inset) + if tray.more > 0 { + Text("And \(tray.more) older not listed.").font(.callout).foregroundStyle(.secondary).padding(.horizontal) + } + } + Text("Every change is in Audit too. To try again what failed or didn't run, use the screen that ran it: its preview " + + "shows what's left.") + .font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true).padding() + } + .confirmationDialog(stopping.map { "Stop \($0.title)?" } ?? "", isPresented: Binding(get: { stopping != nil }, set: { if !$0 { stopping = nil } }), + presenting: stopping) { job in + Button("Stop After This Step", role: .destructive) { Task { await board.stop(job.id) } } + Button("Keep Running", role: .cancel) {} + } message: { _ in + Text("The step running now finishes. The steps after it won't run: the run may be left part done, so check its screen.") + } + } +} + +/// One run: its name, where it stands in words and a bar (never color alone), Stop while it runs, and its steps. +private struct JobRow: View { + let job: Job + let stop: () -> Void + + private var symbol: String { + guard job.finished else { return "arrow.triangle.2.circlepath" } + return job.failedTotal > 0 ? "exclamationmark.triangle.fill" : job.notRun + job.waiting > 0 ? "pause.circle.fill" : "checkmark.circle.fill" + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text(job.title).fontWeight(.semibold) + Text(job.status).font(.callout).foregroundStyle(.secondary) + } + } icon: { + Image(systemName: symbol).symbolRenderingMode(.multicolor) + } + .accessibilityElement(children: .combine) + Spacer() + if !job.finished { + Button("Stop…", action: stop) + .disabled(job.stopping) + .accessibilityLabel("Stop \(job.title)") + .accessibilityHint("Asks first. The step running finishes; the rest don't run.") + } + } + if !job.finished, job.total > 0 { + ProgressView(value: Double(job.done), total: Double(job.total)) + .accessibilityLabel("\(job.done) of \(job.total) steps done") + } + if !job.recent.isEmpty || !job.failed.isEmpty { + DisclosureGroup("Steps") { + VStack(alignment: .leading, spacing: 4) { + if !job.failed.isEmpty { + Text("Failed").fontWeight(.semibold).accessibilityAddTraits(.isHeader) + ForEach(Array(job.failed.enumerated()), id: \.offset) { _, step in StepLine(step: step) } + if job.more > 0 { Text("And \(job.more) more.").foregroundStyle(.secondary) } + } + Text(job.done > job.recent.count ? "The newest \(job.recent.count)" : "Each step").fontWeight(.semibold) + .accessibilityAddTraits(.isHeader) + ForEach(Array(job.recent.enumerated()), id: \.offset) { _, step in StepLine(step: step) } + } + .font(.callout) + .textSelection(.enabled) + } + } + } + .padding(.vertical, 4) + } +} + +/// A step: what it was, how it ended in words, and why when it didn't succeed. +private struct StepLine: View { + let step: Job.Step + + private var ended: String { + switch step.outcome { + case .succeeded: "Done" + case .failed: "Failed" + case .notRun: "Not run" + case .waiting: "Waiting for Google" + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 1) { + Text("\(ended): \(step.item)") + if !step.detail.isEmpty { Text(step.detail).foregroundStyle(.secondary) } + } + .accessibilityElement(children: .combine) + } +} diff --git a/Packages/GamKit/Package.swift b/Packages/GamKit/Package.swift index 0a640e5d..56db1849 100644 --- a/Packages/GamKit/Package.swift +++ b/Packages/GamKit/Package.swift @@ -21,7 +21,7 @@ let package = Package( ], targets: [ .target(name: "GamEngine", dependencies: ["Vault"]), - .target(name: "ChangeCore", dependencies: ["GamEngine", "Vault"]), + .target(name: "ChangeCore", dependencies: ["GamEngine", "Vault", "Jobs"]), .target(name: "Vault"), .target(name: "Catalog"), .target(name: "Jobs"), @@ -32,7 +32,7 @@ let package = Package( .target(name: "TestSupport", dependencies: ["GamEngine", "Vault"]), .testTarget(name: "GamEngineTests", dependencies: ["GamEngine", "Vault", "TestSupport"]), .testTarget(name: "VaultTests", dependencies: ["Vault"]), - .testTarget(name: "ChangeCoreTests", dependencies: ["ChangeCore", "GamEngine", "Vault", "TestSupport"]), + .testTarget(name: "ChangeCoreTests", dependencies: ["ChangeCore", "GamEngine", "Vault", "Jobs", "TestSupport"]), .testTarget(name: "SetupTests", dependencies: ["Setup", "GamEngine", "Vault", "TestSupport"]), .testTarget(name: "DirectoryStoreTests", dependencies: ["Directory", "Setup", "GamEngine", "Vault", "ChangeCore", "Stores", "TestSupport"]), .testTarget(name: "StoresTests", dependencies: ["Stores", "GamEngine", "TestSupport"]), diff --git a/Packages/GamKit/Sources/ChangeCore/ChangeCore.swift b/Packages/GamKit/Sources/ChangeCore/ChangeCore.swift index 76e34a8a..489e0c38 100644 --- a/Packages/GamKit/Sources/ChangeCore/ChangeCore.swift +++ b/Packages/GamKit/Sources/ChangeCore/ChangeCore.swift @@ -1,6 +1,7 @@ import CryptoKit import Foundation import GamEngine +import Jobs import os import Vault @@ -173,6 +174,8 @@ extension WriteAction { public struct HeldPreview: Sendable, Identifiable { public let id: UUID public let steps: [WriteStep] + /// What the jobs tray calls its run ("Offboarding carol@example.com"): the screen's, else worked out from its steps. + public let title: String /// SHA-256 of every step's argv bytes, in the audit: what ran is provably what was shown. public let digest: String public let domain: Domain @@ -289,6 +292,8 @@ public actor Executor { /// Who is making the change, asked at each run: the admin the connected domain signs in as. private let currentActor: (@Sendable () async -> String?)? private let extraEnvironment: [String: String] + /// Where each run is reported as it starts, as each step ends and as it finishes: the jobs tray (`JobBoard.report`). + private let jobs: (@Sendable (JobEvent) -> Void)? private var held: [UUID: HeldPreview] = [:] private var order: [UUID] = [] @@ -309,7 +314,8 @@ public actor Executor { public init(runner: AuthenticatedRunner, audit: AuditLog, actor: String? = nil, currentActor: (@Sendable () async -> String?)? = nil, tenant: @escaping Tenant, now: @escaping @Sendable () -> ContinuousClock.Instant = { ContinuousClock.now }, - extraEnvironment: [String: String] = [:]) { + extraEnvironment: [String: String] = [:], jobs: (@Sendable (JobEvent) -> Void)? = nil) { + self.jobs = jobs self.runner = runner self.audit = audit self.actor = actor @@ -332,7 +338,7 @@ public actor Executor { /// `stopsAtAccountWideFailure` and `typedAddress` are for a routine (offboarding): see `HeldPreview`. public func preview(_ steps: [WriteStep], origin: Origin = .form, confirmStep: Bool = false, typedCountAbove: Int? = nil, expecting: (domain: Domain, generation: Int)? = nil, - stopsAtAccountWideFailure: Bool = true, typedAddress: String? = nil, + stopsAtAccountWideFailure: Bool = true, typedAddress: String? = nil, title: String = "", precondition: (@Sendable () async throws -> Bool)? = nil) async -> Result { guard !steps.isEmpty else { return .failure(.nothingToRun) } for (index, step) in steps.enumerated() @@ -356,7 +362,7 @@ public actor Executor { return .failure(.tenantChanged) } let preview = HeldPreview( - id: UUID(), steps: steps, digest: HeldPreview.digest(of: steps), domain: current.domain, + id: UUID(), steps: steps, title: title.isEmpty ? Self.title(of: steps) : title, digest: HeldPreview.digest(of: steps), domain: current.domain, generation: current.generation, origin: origin, decision: Guard.evaluate(steps.map(\.change), typedCountAbove: typedCountAbove), confirmStep: confirmStep, typedCountAbove: typedCountAbove, stopsAtAccountWideFailure: stopsAtAccountWideFailure, @@ -368,6 +374,23 @@ public actor Executor { return .success(preview) } + /// A run's name for the jobs tray when its screen gives none: its one kind of change and whom it's for ("Trash message for 12 + /// addresses"), else how many changes and the address most of them are for. + static func title(of steps: [WriteStep]) -> String { + var actions: [WriteAction] = [], counts: [String: Int] = [:], targets: [String] = [] + for step in steps { + if !actions.contains(step.action) { actions.append(step.action) } + if counts[step.target] == nil { targets.append(step.target) } + counts[step.target, default: 0] += 1 + } + let whom = targets.count == 1 ? targets[0] : "\(targets.count) addresses" + if actions.count == 1 { return "\(actions[0].displayName) for \(whom)" } + // The one most steps are for, the first named on a tie: linear, for a run of thousands (review 2026-10-28). + var most = targets[0] + for target in targets.dropFirst() where counts[target]! > counts[most]! { most = target } + return "\(steps.count) changes for \(most)" + (targets.count > 1 ? " and \(targets.count - 1) more" : "") + } + public enum PreviewRefusal: Error, Equatable, Sendable { case nothingToRun, notConnected, tenantChanged, badPrerequisite(step: Int), badTolerance(step: Int), typedAddressNotATarget @@ -487,9 +510,12 @@ public actor Executor { if let why = await aliasRefusal(taken) { return .refused(why) } guard await isCurrent(taken) else { return .refused(Self.switched) } let ran = await execute(taken, progress: progress) - // A cancelled run leaves nothing to retry: its caller is stopping (review of O1, 2026-10-13). + // A cancelled run leaves nothing to retry: its caller is stopping (review of O1, 2026-10-13). Nor does a stopped one: a + // Stop (the jobs tray's, on any run) means nothing more of it runs, a wait for Google included (review 2026-10-28). Read + // before the `defer` above clears the request. return RunOutcome(refusal: nil, steps: ran.steps, auditProblem: ran.auditProblem, - retry: Task.isCancelled ? nil : park(taken, ran.steps, ran.listOutputs, round: 0, ranAt: now())) + retry: Task.isCancelled || stopRequests.contains(taken.id) ? nil + : park(taken, ran.steps, ran.listOutputs, round: 0, ranAt: now())) } // MARK: retrying what waits for a new account (D3d) @@ -562,7 +588,8 @@ public actor Executor { let ran = await execute(state.preview, resuming: state.results, listOutputs: state.listOutputs, attempt: round + 1, progress: progress) return RunOutcome(refusal: nil, steps: ran.steps, auditProblem: ran.auditProblem, - retry: Task.isCancelled ? nil : park(state.preview, ran.steps, ran.listOutputs, round: round, ranAt: state.ranAt)) + retry: Task.isCancelled || stopRequests.contains(state.preview.id) ? nil + : park(state.preview, ran.steps, ran.listOutputs, round: round, ranAt: state.ranAt)) } /// Stop, between rounds: the waiting steps are dropped, and the handle runs nothing. A round already under @@ -586,6 +613,17 @@ public actor Executor { /// A step after a Stop: never begun. public static let stopped = "Not run: stopped." + /// A step's result as the jobs tray hears it: what it was in words, how it ended, and why (masked, as the audit's). + static func jobStep(_ job: UUID, _ step: WriteStep, _ result: RunOutcome.Step) -> JobEvent { + let item = "\(step.action.displayName): \(step.target)" + return switch result { + case .succeeded: .step(job: job, item: item, outcome: .succeeded, detail: "") + case .failed(let message, _): .step(job: job, item: item, outcome: .failed, detail: message) + case .skipped(let reason): .step(job: job, item: item, outcome: .notRun, detail: reason) + case .waiting(let message): .step(job: job, item: item, outcome: .waiting, detail: message) + } + } + /// Tests: how many runs' waiting steps are held for a retry. var parkedRuns: Int { parked.count } @@ -630,6 +668,11 @@ public actor Executor { attempt: Int? = nil, progress: AsyncStream<(Int, RunOutcome.Step)>.Continuation?) async -> (steps: [RunOutcome.Step], listOutputs: [Int: String], auditProblem: String?) { let who = await currentActor?() ?? actor + // Reported to the jobs tray: a job each time steps run (a retry round is its own, of the steps still waiting). + let job = UUID(), deciding = prior.map { $0.count(where: \.isWaiting) } ?? preview.steps.count + jobs?(.started(job: job, preview: preview.id, title: preview.title + (attempt.map { " (waiting for Google, try \($0))" } ?? ""), + total: deciding)) + defer { jobs?(.finished(job: job)) } var results: [RunOutcome.Step] = [] var stoppedBy: String? var auditProblem: String? @@ -641,7 +684,10 @@ public actor Executor { continue } // Each result as it's decided: for a step that ran, after its end record. - defer { progress?.yield((index, results[index])) } + defer { + progress?.yield((index, results[index])) + jobs?(Self.jobStep(job, planned, results[index])) + } if let stoppedBy { results.append(.skipped(reason: stoppedBy)) continue diff --git a/Packages/GamKit/Sources/Directory/OffboardChanges.swift b/Packages/GamKit/Sources/Directory/OffboardChanges.swift index 71eec606..86499368 100644 --- a/Packages/GamKit/Sources/Directory/OffboardChanges.swift +++ b/Packages/GamKit/Sources/Directory/OffboardChanges.swift @@ -428,7 +428,8 @@ public final class OffboardChanges { // reminder alone (low risk), with nothing of the leaver's left to confirm. let typed = steps.contains { Guard.normalized($0.target) == Guard.normalized(user) } ? user : nil switch await executor.preview(steps, confirmStep: true, expecting: (domain, tenant.generation), - stopsAtAccountWideFailure: false, typedAddress: typed, precondition: precondition) { + stopsAtAccountWideFailure: false, typedAddress: typed, title: "Offboarding \(user)", + precondition: precondition) { case .success(let held): preview = held case .failure(let refusal): return refuse(refusal.message) } diff --git a/Packages/GamKit/Sources/Directory/OnboardChanges.swift b/Packages/GamKit/Sources/Directory/OnboardChanges.swift index 85ce7e5e..d995cd2f 100644 --- a/Packages/GamKit/Sources/Directory/OnboardChanges.swift +++ b/Packages/GamKit/Sources/Directory/OnboardChanges.swift @@ -416,7 +416,8 @@ public final class OnboardChanges { problem = "This build has no GAM. Build the app again after running scripts/fetch_gam.sh." return } - switch await executor.preview(steps, confirmStep: true, expecting: expecting) { + switch await executor.preview(steps, confirmStep: true, expecting: expecting, + title: "Onboarding \(plan.email.isEmpty ? plan.assignee : plan.email)" + (isRetry ? ", the rest" : "")) { case .success(let preview): let count = steps.count pending = Pending(preview: preview, plan: plan, isRetry: isRetry, diff --git a/Packages/GamKit/Sources/Jobs/Jobs.swift b/Packages/GamKit/Sources/Jobs/Jobs.swift index 5c49df76..af06c793 100644 --- a/Packages/GamKit/Sources/Jobs/Jobs.swift +++ b/Packages/GamKit/Sources/Jobs/Jobs.swift @@ -1,2 +1,167 @@ -/// Bounded long runs with Stop between targets (invariant 9). Phase 3. -public enum Jobs {} +import Foundation +import Observation + +/// Every write run, listed from any screen (GamGUI's jobs tray, plan U5): what ChangeCore's executor reports as a run starts, +/// as each step ends and as it finishes (`JobEvent`), kept here so it can be watched and stopped from anywhere. Bounded +/// (invariant 9): the full counts, but only a sample of the failures and the newest steps, and only the newest finished runs. +/// It runs nothing: Stop asks the executor to stop after the step in flight, which always finishes and is audited. +public enum JobEvent: Sendable, Equatable { + /// A run of `preview`'s steps began: `total` steps to decide (a retry round's, only the steps still waiting). + case started(job: UUID, preview: UUID, title: String, total: Int) + /// One step decided: what it was, in words, and how it ended. + case step(job: UUID, item: String, outcome: JobOutcome, detail: String) + case finished(job: UUID) +} + +/// How a step ended, as the tray counts it. +public enum JobOutcome: Sendable, Equatable { + case succeeded + case failed + /// Not begun: stopped, or a step it needs didn't succeed. + case notRun + /// Waiting for Google to finish a new account; a later round runs it. + case waiting +} + +/// One run, as the tray shows it. +public struct Job: Sendable, Identifiable, Equatable { + public struct Step: Sendable, Equatable { + public let item: String + public let outcome: JobOutcome + /// Why it failed or didn't run (GAM's words, masked), at most `detailCap` characters. + public let detail: String + } + + public let id: UUID + /// The preview it runs: what Stop names to the executor. + public let preview: UUID + public let title: String + public let total: Int + public internal(set) var done = 0 + public internal(set) var succeeded = 0 + public internal(set) var failedTotal = 0 + public internal(set) var notRun = 0 + public internal(set) var waiting = 0 + /// The newest steps, newest last, at most `JobBoard.window`. + public internal(set) var recent: [Step] = [] + /// The first failures, at most `JobBoard.failedCap`: `failedTotal` counts them all. + public internal(set) var failed: [Step] = [] + public internal(set) var finished = false + /// Stop was asked; the step in flight finishes, nothing after it begins. + public internal(set) var stopping = false + public let started: Date + public internal(set) var ended: Date? + + /// Failures counted past the sample: "and N more". + public var more: Int { failedTotal - failed.count } + + /// Where it stands, in words: "3 of 12 steps", then how it ended. + public var status: String { + guard finished else { + return (stopping ? "Stopping after the step running: " : "Running: ") + "\(done) of \(total) step\(total == 1 ? "" : "s") done" + + (failedTotal > 0 ? ", \(failedTotal) failed" : "") + "." + } + var parts = ["\(succeeded) of \(total) succeeded"] + if failedTotal > 0 { parts.append("\(failedTotal) failed") } + if notRun > 0 { parts.append("\(notRun) not run") } + if waiting > 0 { parts.append("\(waiting) waiting for Google") } + return (failedTotal + notRun + waiting == 0 ? "Done: " : "Ended: ") + parts.joined(separator: ", ") + "." + } +} + +/// The runs since the app started: running first, then finished, newest first in each. +@MainActor +@Observable +public final class JobBoard { + /// Steps a job's live feed keeps (GamGUI's `RECENT_WINDOW`). + public static let window = 12 + /// Failures a job keeps beside its count (GamGUI's `FAILED_SAMPLE_CAP`). + public static let failedCap = 200 + /// Characters kept of a step's reason (GamGUI's `DETAIL_CAP`: GAM can echo a whole body on a usage error). + public static let detailCap = 300 + /// Finished jobs kept (GamGUI's `register_job`'s `keep`). + public static let finishedKept = 10 + /// Jobs the tray lists (GamGUI's `TRAY_ROWS`). + public static let trayRows = 8 + + /// Oldest first. + public private(set) var jobs: [Job] = [] + /// Runs going now, and runs ended since launch: counters, so a view that shows only these isn't redrawn at every step. + public private(set) var running = 0 + public private(set) var finishedCount = 0 + /// The run that ended last, for the line spoken when one does. + public private(set) var lastFinished: Job? + /// Asks the executor to stop a preview's run after the step in flight (`Executor.stopAfterCurrentStep`); set once the + /// executor exists, which is made with `report`. + @ObservationIgnored public var stopRun: (@Sendable (UUID) async -> Bool)? + @ObservationIgnored private let continuation: AsyncStream.Continuation + @ObservationIgnored private var reader: Task? + + /// What the executor reports into: in order, from any isolation. + public nonisolated var report: @Sendable (JobEvent) -> Void { + let continuation = continuation + return { continuation.yield($0) } + } + + public init() { + let (events, continuation) = AsyncStream.makeStream() + self.continuation = continuation + reader = Task { [weak self] in + for await event in events { self?.handle(event) } + } + } + + /// One event, as the executor reported it. + public func handle(_ event: JobEvent) { + switch event { + case .started(let id, let preview, let title, let total): + jobs.append(Job(id: id, preview: preview, title: title, total: total, started: Date())) + running += 1 + case .step(let id, let item, let outcome, let detail): + guard let index = jobs.firstIndex(where: { $0.id == id }) else { return } + let step = Job.Step(item: item, outcome: outcome, detail: String(detail.prefix(Self.detailCap))) + jobs[index].done += 1 + switch outcome { + case .succeeded: jobs[index].succeeded += 1 + case .failed: + jobs[index].failedTotal += 1 + if jobs[index].failed.count < Self.failedCap { jobs[index].failed.append(step) } + case .notRun: jobs[index].notRun += 1 + case .waiting: jobs[index].waiting += 1 + } + jobs[index].recent.append(step) + if jobs[index].recent.count > Self.window { jobs[index].recent.removeFirst() } + case .finished(let id): + guard let index = jobs.firstIndex(where: { $0.id == id }), !jobs[index].finished else { return } + jobs[index].finished = true + jobs[index].stopping = false + jobs[index].ended = Date() + running -= 1 + finishedCount += 1 + lastFinished = jobs[index] + // Only the newest finished runs are kept; a running one never goes. + let finished = jobs.filter(\.finished) + if finished.count > Self.finishedKept { + let dropped = Set(finished.prefix(finished.count - Self.finishedKept).map(\.id)) + jobs.removeAll { dropped.contains($0.id) } + } + } + } + + /// Asks the executor to stop `job` after the step in flight. False when it had already ended. + @discardableResult + public func stop(_ job: Job.ID) async -> Bool { + guard let index = jobs.firstIndex(where: { $0.id == job }), !jobs[index].finished else { return false } + let preview = jobs[index].preview + guard let stopRun, await stopRun(preview) else { return false } + if let index = jobs.firstIndex(where: { $0.id == job }), !jobs[index].finished { jobs[index].stopping = true } + return true + } + + /// The tray's rows (running first, then finished, newest first in each, at most `trayRows`) and how many more there are. + public var tray: (rows: [Job], more: Int) { + let newest = Array(jobs.reversed()) + let rows = Array((newest.filter { !$0.finished } + newest.filter(\.finished)).prefix(Self.trayRows)) + return (rows, jobs.count - rows.count) + } +} diff --git a/Packages/GamKit/Tests/ChangeCoreTests/JobsTests.swift b/Packages/GamKit/Tests/ChangeCoreTests/JobsTests.swift new file mode 100644 index 00000000..254a60a3 --- /dev/null +++ b/Packages/GamKit/Tests/ChangeCoreTests/JobsTests.swift @@ -0,0 +1,162 @@ +@testable import ChangeCore +import Foundation +import GamEngine +@testable import Jobs +import Synchronization +import TestSupport +import Testing +import Vault + +/// The jobs tray (GamGUI's plan U5, `test_jobs_tray.py`): every run the executor makes reported as it starts, as each step ends +/// and as it finishes, in order; a run refused before it starts is no job; Stop from the tray stops it after the step in flight; +/// and the board bounded as GamGUI's registry is (invariant 9). +@MainActor +@Suite("Jobs", .serialized) +final class JobsTests { + let example = Domain("example.com")! + let base: URL + let runner: AuthenticatedRunner + let events = Shared<[JobEvent]>([]) + let confirmed = OperatorConfirmation(confirmed: true) + + init() throws { + let store = MemoryStore() + try store.write(Data(#"{"client_email": "gam@p.iam.gserviceaccount.com"}"#.utf8), as: .oauth2Service, for: example) + try store.write(Data(#"{"decoded_id_token": {"email": "admin@example.com"}}"#.utf8), as: .oauth2, for: example) + base = try RuntimeDirectory.prepare(FileManager.default.temporaryDirectory.appending(path: "swiftgamgui-jobs-\(UUID().uuidString)/run")) + runner = AuthenticatedRunner(runner: GamRunner(binary: Fixtures.mockGam), vault: Vault(store: store), runtimeDirectory: base) + } + + deinit { + try? FileManager.default.removeItem(at: base.deletingLastPathComponent()) + } + + /// `lag`: the mock's new accounts aren't ready for that many calls (`GAM_MOCK_STATE`'s `new_account_lag`). + private func executor(report: (@Sendable (JobEvent) -> Void)? = nil, lag: Int? = nil) throws -> Executor { + let events = events, example = example + var environment = Fixtures.mockEnvironment + if let lag { + let state = base.deletingLastPathComponent().appending(path: "state-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: state, withIntermediateDirectories: true) + try Data("\(lag)\n".utf8).write(to: state.appending(path: "new_account_lag")) + environment["GAM_MOCK_STATE"] = state.path + } + return Executor(runner: runner, audit: AuditLog(url: base.deletingLastPathComponent().appending(path: "audit/audit.jsonl")), + actor: "admin@example.com", tenant: { (example, 1) }, extraEnvironment: environment, + jobs: report ?? { event in events.value.withLock { $0.append(event) } }) + } + + private func suspend(_ email: String) -> WriteStep { + WriteStep(GamCommands.setSuspended(email: email, suspended: true), target: email, summary: "Suspend \(email)") + } + + /// A run, reported in order: started (its preview, its title, how many steps), each step as it ends in words, finished. A + /// preview's title is its screen's, else its one kind of change and whom it's for. + @Test func aRunIsReportedAsItGoes() async throws { + let executor = try executor() + let preview = try await executor.preview([suspend("alice@example.com"), suspend("bob@example.com")]).get() + #expect(preview.title == "Suspend user for 2 addresses") + #expect(await executor.run(preview, confirmation: confirmed).succeeded) + let reported = events.value.withLock { $0 } + guard case .started(let job, preview.id, "Suspend user for 2 addresses", 2)? = reported.first else { Issue.record("\(reported)"); return } + #expect(Array(reported.dropFirst()) == [ + .step(job: job, item: "Suspend user: alice@example.com", outcome: .succeeded, detail: ""), + .step(job: job, item: "Suspend user: bob@example.com", outcome: .succeeded, detail: ""), + .finished(job: job), + ]) + let titled = try await executor.preview([suspend("carol@example.com")], title: "Offboarding carol@example.com").get() + #expect(titled.title == "Offboarding carol@example.com") + #expect(Executor.title(of: [suspend("a@example.com"), WriteStep(GamCommands.signOutUser(email: "a@example.com"), target: "a@example.com", + summary: ""), suspend("b@example.com")]) + == "3 changes for a@example.com and 1 more") + } + + /// A run refused before any step (unconfirmed, expired, another tenant) is no job: nothing ran. + @Test func aRefusedRunIsNoJob() async throws { + let executor = try executor() + let preview = try await executor.preview([suspend("alice@example.com")]).get() + let refused = await executor.run(preview, confirmation: OperatorConfirmation(confirmed: false)) + #expect(refused.refusal != nil && events.value.withLock { $0.isEmpty }) + } + + /// Stop from the tray: the step in flight finishes, the rest are "not run", the job says it ended so, and a stop after the + /// end does nothing. + @Test func stopFromTheTrayStopsAfterTheStepInFlight() async throws { + let board = JobBoard() + let executor = try executor(report: board.report) + board.stopRun = { @Sendable id in await executor.stopAfterCurrentStep(id) } + let preview = try await executor.preview([suspend("alice@example.com"), suspend("bob@example.com"), suspend("carol@example.com")]).get() + let calls = Shared(0) + await executor.setBeforeEachStep { @Sendable in + guard calls.value.withLock({ $0 += 1; return $0 }) == 2 else { return } + // The tray's Stop, while step 2 is about to run: its job has been reported by now. + for _ in 0..<100 where await MainActor.run(body: { board.jobs.isEmpty }) { try? await Task.sleep(for: .milliseconds(10)) } + let job = await MainActor.run { board.jobs.first?.id } + #expect(await board.stop(job!)) + } + let outcome = await executor.run(preview, confirmation: confirmed) + #expect(outcome.steps[2] == .skipped(reason: Executor.stopped)) + for _ in 0..<100 where board.jobs.first?.finished != true { try await Task.sleep(for: .milliseconds(10)) } + let job = try #require(board.jobs.first) + #expect(job.finished && job.succeeded == 2 && job.notRun == 1 && job.done == 3 && !job.stopping) + #expect(job.status == "Ended: 2 of 3 succeeded, 1 not run.") + #expect(job.recent.last == Job.Step(item: "Suspend user: carol@example.com", outcome: .notRun, detail: Executor.stopped)) + #expect(await board.stop(job.id) == false, "after the end") + } + + /// A Stop from the tray stops the whole run, its wait for Google too: a new account's signature waiting when the tray's Stop + /// comes is never retried, so nothing more of the run reaches GAM (review 2026-10-28: the retry ran it 20 seconds later). + @Test func aStoppedRunLeavesNothingToRetry() async throws { + let board = JobBoard() + let executor = try executor(report: board.report, lag: 5) + board.stopRun = { @Sendable id in await executor.stopAfterCurrentStep(id) } + let email = "new@example.com", password = "Xk7m-Qp9r-2Tzv" + let create = WriteStep(GamCommands.createUser(email: email, firstName: "J", lastName: "L", password: password), target: email, + summary: "Create", secrets: [Secret(Data(password.utf8))]) + let signature = WriteStep(GamCommands.setSignature(email: email, signature: "J"), target: email, summary: "Signature", + requires: [0], newAccountFrom: 0) + let group = WriteStep(GamCommands.addGroupMember(group: "sales@example.com", member: email), target: email, summary: "Group", + requires: [0]) + let preview = try await executor.preview([create, signature, group, suspend("bob@example.com")], title: "Onboarding \(email)").get() + let calls = Shared(0) + await executor.setBeforeEachStep { @Sendable in + guard calls.value.withLock({ $0 += 1; return $0 }) == 3 else { return } + for _ in 0..<100 where await MainActor.run(body: { board.jobs.isEmpty }) { try? await Task.sleep(for: .milliseconds(10)) } + let job = await MainActor.run { board.jobs.first?.id } + #expect(await board.stop(job!)) + } + let outcome = await executor.run(preview, confirmation: confirmed) + #expect(outcome.steps[1].isWaiting && outcome.steps[3] == .skipped(reason: Executor.stopped)) + #expect(outcome.retry == nil, "stopped: nothing parked for a retry") + } + + /// Bounded as GamGUI's registry: the newest steps, the first failures with every one counted, each reason cut short, the + /// newest finished runs; the tray running first, then finished, newest first in each, at most its rows. + @Test func theBoardIsBounded() { + let board = JobBoard() + let big = UUID() + board.handle(.started(job: big, preview: UUID(), title: "Big", total: 500)) + for n in 0..<300 { + board.handle(.step(job: big, item: "Step \(n)", outcome: n.isMultiple(of: 2) ? .succeeded : .failed, detail: String(repeating: "x", count: 1_000))) + } + let job = board.jobs[0] + #expect(job.done == 300 && job.failedTotal == 150 && job.failed.count == 150 && job.recent.count == JobBoard.window) + #expect(job.recent.last?.item == "Step 299" && job.failed.first?.detail.count == JobBoard.detailCap) + for n in 0..<250 { board.handle(.step(job: big, item: "More \(n)", outcome: .failed, detail: "")) } + #expect(board.jobs[0].failed.count == JobBoard.failedCap && board.jobs[0].more == 400 - JobBoard.failedCap) + #expect(board.jobs[0].status == "Running: 550 of 500 steps done, 400 failed.") + + let runs = (0..<12).map { _ in UUID() } + for (n, id) in runs.enumerated() { + board.handle(.started(job: id, preview: UUID(), title: "Run \(n)", total: 1)) + board.handle(.finished(job: id)) + } + #expect(board.jobs.filter(\.finished).count == JobBoard.finishedKept && !board.jobs.contains { $0.title == "Run 0" }) + let tray = board.tray + #expect(tray.rows.count == JobBoard.trayRows && tray.rows.first?.title == "Big" && tray.rows[1].title == "Run 11") + #expect(tray.more == board.jobs.count - JobBoard.trayRows && board.running == 1) + #expect(board.finishedCount == 12 && board.lastFinished?.title == "Run 11", "counted though only ten are kept") + board.handle(.finished(job: runs[11])) + #expect(board.finishedCount == 12 && board.running == 1, "a second end of one run counts once") + } +} diff --git a/Packages/GamKit/Tests/GamEngineTests/WriteRouteTests.swift b/Packages/GamKit/Tests/GamEngineTests/WriteRouteTests.swift index 920aff88..e5c56917 100644 --- a/Packages/GamKit/Tests/GamEngineTests/WriteRouteTests.swift +++ b/Packages/GamKit/Tests/GamEngineTests/WriteRouteTests.swift @@ -54,6 +54,17 @@ struct WriteRouteTests { #expect(try Self.files(in: "App", matching: ["GamRead(", "GamWrite("]).isEmpty) } + /// The jobs tray reaches no write: the Jobs module depends on nothing in GamKit, its screen imports only it and SwiftUI, and + /// only the app's wiring gives it a Stop (the executor's `stopAfterCurrentStep`, which never runs anything). + @Test func theJobsTrayReachesNoWrite() throws { + let manifest = try String(contentsOf: Self.root.appending(path: "Packages/GamKit/Package.swift"), encoding: .utf8) + #expect(manifest.contains(#".target(name: "Jobs"),"#), "Jobs gains no dependency") + let imports = try Self.swiftFiles(in: "App").first { $0.path == "App/JobsView.swift" }?.lines.filter { $0.hasPrefix("import ") } + #expect(imports.map(Set.init) == ["import Jobs", "import SwiftUI"]) + #expect(try Self.files(in: "App", matching: ["stopRun ="]) == ["App/AppServices.swift"]) + #expect(try Self.files(in: "Packages/GamKit/Sources", matching: ["stopRun ="]).isEmpty) + } + /// `GamRunner.runRaw` takes any argv, and without a config directory GAM falls back to `~/.gam` /// (the operator's own GAM, if any). Only the authenticated runner (credentials) and the version probe /// (an empty config) call it, and only they make a config directory. diff --git a/docs/domains/README.md b/docs/domains/README.md index dcdf9398..d17f9b6f 100644 --- a/docs/domains/README.md +++ b/docs/domains/README.md @@ -19,6 +19,7 @@ the Swift design inherits; read it too when this one is still a stub. | Phishing: one email out of every mailbox, by its Message-ID (GAM's dry run, then a trash per mailbox) | [phishing.md](phishing.md) | built; live check not yet | | Catalog and the Builder | [catalog-builder.md](catalog-builder.md) | stub (phase 4) | | ChangeCore: preview, guard, audit | [guard-audit.md](guard-audit.md) | phase 2: guard and audit log built | +| The jobs tray: every write run, from any screen, with Stop | [jobs.md](jobs.md) | phase 3: the tray built (2026-10-28); bulk runs on it later | | The Audit and Reports screens: the log paged and exported, the directory reports, the usage report | [audit-reports.md](audit-reports.md) | built; the usage report's live read pending | Template for a filled runbook: **One line** · **Owns invariant(s)** · **Enforcement home** · Files · diff --git a/docs/domains/jobs.md b/docs/domains/jobs.md new file mode 100644 index 00000000..e217d513 --- /dev/null +++ b/docs/domains/jobs.md @@ -0,0 +1,49 @@ +# The jobs tray + +**One line:** every write run since launch, listed from any screen as it starts, as each step ends and as it finishes, with +Stop: GamGUI's jobs tray (plan U5). Plan §12 phase 3's Jobs, slice 1. + +**Touches** invariant 2 (it adds no write path: Stop only asks the executor to stop after the step in flight) and 9 (bounded). + +## Files +- `Packages/GamKit/Sources/Jobs/Jobs.swift`: `JobEvent` (what the executor reports), `Job` (one run: counts, the newest + steps, the first failures, where it stands in words), `JobBoard` (the runs since launch, `@MainActor @Observable`, fed in + order through an `AsyncStream`; `stop`; the tray's rows). +- `ChangeCore.swift`: `Executor(jobs:)` reports each `execute`: `started` (a fresh job ID, the preview's ID, its title, the + steps it will decide), a `step` beside the existing `progress` yield (`Executor.jobStep`), `finished` on the way out. + `HeldPreview.title`: the screen's (`Executor.preview(title:)`: Offboarding and Onboarding name theirs), else + `Executor.title(of:)` from the steps (linear, for a run of thousands). One change to what the executor does: a stopped run, + like a cancelled one, parks nothing for a retry, so a Stop also ends Onboarding's wait for Google (review 2026-10-28: the + tray gave every run a Stop, and a stopped onboarding's waiting signature was still retried 20 seconds later). +- `App/JobsView.swift` (the toolbar button, the tray, a row, a step), `App/AppServices.swift` (the board made before the + executor, which reports into `board.report`; `stopRun` set after), `App/GamGUIApp.swift` (the toolbar item, a spoken line + when a run ends, the snapshot demo). +- Tests: `ChangeCoreTests/JobsTests.swift`. + +## How it works +- **Every run is a job**, a one-step change too: the tray is "what ran since launch", and Audit stays the full record. A run + refused before its first step (unconfirmed, expired, another tenant, a target in flight) is no job: nothing ran. A retry + round (Onboarding's wait for Google) is a job of its own, of the steps still waiting, titled "(waiting for Google, try N)". +- **Bounded as GamGUI's registry** (`web/jobs.py`): the full counts, the newest 12 steps (`window`), the first 200 failures + (`failedCap`, "and N more" past it), each reason cut to 300 characters (`detailCap`: GAM can echo a whole body), the newest 10 + finished runs (`finishedKept`, pruned as each ends; a running one never goes), 8 rows in the tray, running first. +- **Stop** asks first ("Stop After This Step"), then `Executor.stopAfterCurrentStep` for the job's preview: the step in flight + finishes, with its end record, and the rest read "Not run: stopped." It's every job's (GamGUI asked first only for + offboarding; here every Stop can leave a run part done, and the dialog says so). The row says "Stopping…" until it ends. +- **Retry stays on the screen that ran it** (a deliberate difference from GamGUI's per-kind retry from the job): Offboard's + Preview the Rest and Onboarding's retry already make a new preview of what's left, with the checks their screen wraps around + it (the precondition, the typed address). A generic retry from the tray would run steps without those. The tray says where. +- Ordered: the executor's events go through one stream to the main actor, never a Task each (which could land out of order). +- A run's end is spoken from the window in front ("Finished: …") by the toolbar button, watching the board's counters + (`running`, `finishedCount`, `lastFinished`): a step redraws only the tray, never the window (review 2026-10-28). +- **Reaches no write** (`WriteRouteTests.theJobsTrayReachesNoWrite`): the Jobs module has no dependency, its screen imports only + it and SwiftUI, and only `AppServices` sets `stopRun`. + +## Not built yet +- The bulk runs GamGUI has (onboarding from a CSV, a department for many, a signature for a scope): each will run through the + executor and so appear here, with its own retry of what failed. Build each only when the operator asks. +- A job's page of its own: the tray's row opens to its steps; the screen that ran it has the whole panel. + +## Testing +`swift test --package-path Packages/GamKit --filter JobsTests`: a run's events in order against the strict mock, a refused run +no job, Stop from the board after the step in flight, the bounds and the tray's order. The tray's look: the `jobs` snapshot. diff --git a/docs/plans/2026-10-08-native-gamgui.md b/docs/plans/2026-10-08-native-gamgui.md index 1c6bda96..20435d48 100644 --- a/docs/plans/2026-10-08-native-gamgui.md +++ b/docs/plans/2026-10-08-native-gamgui.md @@ -5,7 +5,9 @@ on 2026-10-09: the operator's build copied GamGUI's credentials, Touch ID worked domain and listed the directory. The model and Siri spikes still wait on the operator. Phase 2 has begun with ChangeCore's guard and audit log (#9, #10), which wait for the ChangeCore review. The write path's design is decided (`docs/plans/2026-10-09-write-path-options.md`: typed `GamRead`/`GamWrite`), -and its refactor is built: the app can run only reads. GamGUI's guided setup (fresh-setup commands, the setup folder and its wipe, the delegation step) is ported. ChangeCore's executor and write ticket are built and reviewed (slice 3, #19). The app reconnects at launch and loads the directory (#20). The Users writes are built (title and department, confirmed live; suspend, groups, delegates, auto-reply, sign-out argv-identical); signature moves to the Signatures screen and delete to offboarding. The Siri slice's title intent is built (2026-10-10): "Change a title in GamGUI" drafts the change on Users and the operator saves it; confirmed live through Shortcuts the same day (saved with a click, audited `origin: siri`). Spoken, Siri couldn't find the app: it hears the operator's "Gam Gooey" as "GAM GUI", ignored `INAlternativeAppNames` on the Mac, and four indexed apps were named GamGUI (the original and three builds). The operator chose **GAM GUI** as the display name (localized, so the bundle stays GamGUI), command-line builds moved to a `.noindex` folder, and the spoken check is open again on that build. The Groups screen's reads are built (slice A, 2026-10-10: the group list and a group's members, read-only, mock-tested; `docs/domains/groups.md`); read live on the operator's tenant the same day (the list and a group's members). Groups' writes are built (slice B, 2026-10-12): from a group's page, people added with a role (several at once, one GamGUI step each, the count typed above 25) and a member removed, through ChangeCore; argv-identical to GamGUI's, and the mock's membership writes now fail as GAM 7.48.22's do. Signatures for one person are built (2026-10-10; plan for signatures S1a–S1c, `docs/domains/signatures.md`): GamGUI's render and reader held to a fixture, GAM's stored form and keyword reading read from the vendored build, the template store (atomic, private, an unreadable file moved aside), and the Signatures screen with its write through ChangeCore (a confirm sheet showing the held body, the current signature beside the new one, Put Back Previous, and a "Gmail signature" card on Users' Mail tab); the set is argv-identical to GamGUI's and waits on its live check (S1d). Onboarding's roles are this app's own (O2, 2026-10-13): created and edited on Onboard, by GamGUI's rules plus native bounds, with a one-time copy from GamGUI. The welcome email is built (W, 2026-10-13): named emails edited beside the roles, rendered and held at preview, sent last; the body keeps its line breaks (a listed difference from GamGUI). Onboarding O1 is built (2026-10-12; `docs/domains/onboarding.md`): one hire through one ChangeCore preview (create with a one-time sign-in card, the role's signature, groups, calendars, and the checklist on a task list whose id the run takes from its own list step), the wait for Google with Stop, and a retry of what failed; roles are read from GamGUI's file. Its writes are argv-identical; live stages B and C are next. Calendars C1 and C2a are built (2026-10-12, `docs/domains/calendars.md`): rooms, a person's calendars and who has access, and sharing one with a person or removing access through ChangeCore, argv-identical and mock-tested; the live checks C1c and C2b wait on the operator. Offboarding's read-only plan is built (Off1, 2026-10-13; `docs/domains/offboarding.md`): the Offboard screen shows exactly what GamGUI's eight steps would run for a leaver and a manager, each `gam` line argv-identical to GamGUI's and held to a fixture, with GamGUI's checks and warnings, and it can't run anything (no executor; a source scan holds it). Offboarding runs (Off2, 2026-10-13): the plan held as one ChangeCore preview and run with the leaver's address typed, each step's result as it ends, Stop between steps, and Preview the Rest; its adversarial review comes before the first live run. Offboarding templates fill its form (2026-10-13, native): the steps a leaver's offboarding runs, the auto-reply, the reminder and its invitee, with the operator's routine, Hand-over, as the default. Offboarding's added steps are begun (2026-10-13, `docs/plans/2026-10-13-offboarding-options.md`, decided with the operator): clear recovery info and hide from the directory, warnings for the leaver's secondary calendars (Google now deletes them with the account) and Vault holds, and the typed `confirm` a ten-step routine needs; groups, org unit and suspend wait for a Close Out step with the delete. Reports and Audit are built (2026-10-13, `docs/domains/audit-reports.md`): GamGUI's directory reports as lists of people, its usage report (a read; live check pending), and its Audit screen over this app's log, paged, filtered and exported to CSV as GamGUI does; GamGUI's log beside it (§11) is not built yet. Phishing is built (2026-10-19, after a compromised account's lure, `docs/domains/phishing.md`): one email out of every mailbox by its Message-ID, GAM's dry run to find the copies, then one trash per mailbox through ChangeCore; Pull All takes several emails into one preview (2026-10-23, phishing.md D12); its live check is not yet. Next: onboarding's live stages B and C, Off2's adversarial review, the read-only L1 check of offboarding beside GamGUI on the next leaver, then Off3 (the delete): L2, the routine, ran live on 2026-10-16 (twelve steps with the added ones, each checked in Google); the signature, group-write, calendar and usage-report live checks (the group writes on a scratch group the operator owns), and a signature for a scope as a job (S2) when the operator asks. Start from `docs/plans/2026-10-09-session-handoff.md`; "Phase 1, slice 2 +and its refactor is built: the app can run only reads. GamGUI's guided setup (fresh-setup commands, the setup folder and its wipe, the delegation step) is ported. ChangeCore's executor and write ticket are built and reviewed (slice 3, #19). The app reconnects at launch and loads the directory (#20). The Users writes are built (title and department, confirmed live; suspend, groups, delegates, auto-reply, sign-out argv-identical); signature moves to the Signatures screen and delete to offboarding. The Siri slice's title intent is built (2026-10-10): "Change a title in GamGUI" drafts the change on Users and the operator saves it; confirmed live through Shortcuts the same day (saved with a click, audited `origin: siri`). Spoken, Siri couldn't find the app: it hears the operator's "Gam Gooey" as "GAM GUI", ignored `INAlternativeAppNames` on the Mac, and four indexed apps were named GamGUI (the original and three builds). The operator chose **GAM GUI** as the display name (localized, so the bundle stays GamGUI), command-line builds moved to a `.noindex` folder, and the spoken check is open again on that build. The Groups screen's reads are built (slice A, 2026-10-10: the group list and a group's members, read-only, mock-tested; `docs/domains/groups.md`); read live on the operator's tenant the same day (the list and a group's members). Groups' writes are built (slice B, 2026-10-12): from a group's page, people added with a role (several at once, one GamGUI step each, the count typed above 25) and a member removed, through ChangeCore; argv-identical to GamGUI's, and the mock's membership writes now fail as GAM 7.48.22's do. Signatures for one person are built (2026-10-10; plan for signatures S1a–S1c, `docs/domains/signatures.md`): GamGUI's render and reader held to a fixture, GAM's stored form and keyword reading read from the vendored build, the template store (atomic, private, an unreadable file moved aside), and the Signatures screen with its write through ChangeCore (a confirm sheet showing the held body, the current signature beside the new one, Put Back Previous, and a "Gmail signature" card on Users' Mail tab); the set is argv-identical to GamGUI's and waits on its live check (S1d). Onboarding's roles are this app's own (O2, 2026-10-13): created and edited on Onboard, by GamGUI's rules plus native bounds, with a one-time copy from GamGUI. The welcome email is built (W, 2026-10-13): named emails edited beside the roles, rendered and held at preview, sent last; the body keeps its line breaks (a listed difference from GamGUI). Onboarding O1 is built (2026-10-12; `docs/domains/onboarding.md`): one hire through one ChangeCore preview (create with a one-time sign-in card, the role's signature, groups, calendars, and the checklist on a task list whose id the run takes from its own list step), the wait for Google with Stop, and a retry of what failed; roles are read from GamGUI's file. Its writes are argv-identical; live stages B and C are next. Calendars C1 and C2a are built (2026-10-12, `docs/domains/calendars.md`): rooms, a person's calendars and who has access, and sharing one with a person or removing access through ChangeCore, argv-identical and mock-tested; the live checks C1c and C2b wait on the operator. Offboarding's read-only plan is built (Off1, 2026-10-13; `docs/domains/offboarding.md`): the Offboard screen shows exactly what GamGUI's eight steps would run for a leaver and a manager, each `gam` line argv-identical to GamGUI's and held to a fixture, with GamGUI's checks and warnings, and it can't run anything (no executor; a source scan holds it). Offboarding runs (Off2, 2026-10-13): the plan held as one ChangeCore preview and run with the leaver's address typed, each step's result as it ends, Stop between steps, and Preview the Rest; its adversarial review comes before the first live run. Offboarding templates fill its form (2026-10-13, native): the steps a leaver's offboarding runs, the auto-reply, the reminder and its invitee, with the operator's routine, Hand-over, as the default. Offboarding's added steps are begun (2026-10-13, `docs/plans/2026-10-13-offboarding-options.md`, decided with the operator): clear recovery info and hide from the directory, warnings for the leaver's secondary calendars (Google now deletes them with the account) and Vault holds, and the typed `confirm` a ten-step routine needs; groups, org unit and suspend wait for a Close Out step with the delete. Reports and Audit are built (2026-10-13, `docs/domains/audit-reports.md`): GamGUI's directory reports as lists of people, its usage report (a read; live check pending), and its Audit screen over this app's log, paged, filtered and exported to CSV as GamGUI does; GamGUI's log beside it (§11) is not built yet. Phishing is built (2026-10-19, after a compromised account's lure, `docs/domains/phishing.md`): one email out of every mailbox by its Message-ID, GAM's dry run to find the copies, then one trash per mailbox through ChangeCore; Pull All takes several emails into one preview (2026-10-23, phishing.md D12); its live check is not yet. Next: onboarding's live stages B and C, Off2's adversarial review, the read-only L1 check of offboarding beside GamGUI on the next leaver, then Off3 (the delete): L2, the routine, ran live on 2026-10-16 (twelve steps with the added ones, each checked in Google); the signature, group-write, calendar and usage-report live checks (the group writes on a scratch group the operator owns), and a signature for a scope as a job (S2) when the operator asks. The jobs tray is built (2026-10-28, phase 3's Jobs, slice 1): +every run the executor makes listed from any screen with Stop, bounded as GamGUI's registry, retries staying on their screens +(`docs/domains/jobs.md`). Start from `docs/plans/2026-10-09-session-handoff.md`; "Phase 1, slice 2 progress" before the Appendix has the detail.** This copy in SwiftGamGui is the one that tracks progress; GamGUI's copy is the frozen original. Written 2026-10-08 at GamGUI `834493c` diff --git a/docs/screenshots.md b/docs/screenshots.md index 675e4efd..20e4c4b7 100644 --- a/docs/screenshots.md +++ b/docs/screenshots.md @@ -42,6 +42,13 @@ saved template (Hand-over by default), and every step's exact command before it Reports: the people behind each of the directory's counts, the storage and mail usage, and every account's connected apps and AI agents. Audit: this app's audit log, searched, paged and exported to CSV. +The jobs tray, in every window's toolbar: each change run since launch, running first, with where it stands, its newest +steps and failures in words, and Stop. + +| Jobs | +|---| +| ![The jobs tray: an offboarding running with its progress and Stop, and a pull from every mailbox ended with two failed](screenshots/jobs.png) | + | Reports | Usage | Audit | |---|---|---| | ![Reports: each directory report with its count, and the people without 2-step verification](screenshots/reports.png) | ![The storage and mail usage: the top people by storage, with the day the counts are for](screenshots/reports-usage.png) | ![Audit: the log's records, each with its outcome in words, and a page at a time](screenshots/audit.png) | diff --git a/scripts/screenshots.sh b/scripts/screenshots.sh index b642417e..2b7c1ea6 100755 --- a/scripts/screenshots.sh +++ b/scripts/screenshots.sh @@ -53,6 +53,7 @@ status=0 # list, what was done and what shows it, the attacker's emails, and a review written); compromised-incident, her logs saved to a demo incident # (in the demo's temporary folder) with a note, opened on Report. Incident opens on Investigate; a plan, the confirm sheet # or a run is on Respond. +# The jobs tray (`jobs`, tab `jobs`) is drawn over Home as a sheet, with two made-up runs (`ContentView.showDemoJobs`): nothing runs. # Reports opens on its first report, then on its # usage row (`usage`, the mock's report), then on every account's connected apps and agents (`apps`, read at once, with its # summary; CI has no on-device model, so Explain says why; in a window tall enough to show its row in the list); Audit on a @@ -60,7 +61,7 @@ status=0 # Phishing opens empty, then with the mock's phishing email found by its Message-ID, then (tab `recipients`) who its # sender sent it to, (`confirm`) the confirm sheet over the copies, and (`result`) a finished run (made up: nothing runs); # then Pull All: three emails at once (commas between them), two that mailboxes have and one none has, and their confirm. -for spec in home:home users:users groups:groups setup:setup \ +for spec in home:home users:users groups:groups setup:setup jobs:home::jobs \ person-profile:users:alice@example.com:profile person-groups:users:alice@example.com:groups \ person-mail:users:alice@example.com:mail person-security:users:alice@example.com:security \ group-sales:groups:sales@example.com group-sales-add:groups:sales@example.com:add \