native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342// App/Sources/Domain/Workflow/WorkflowSettingsCatalog.swift// Rows of Settings › Agents › Workflows (docs-ai 063 D1): a pure derivation over one filesystem// scan and UserGlobalSettings, so the page shows exactly what the enabled set, the bind-mode// overrides, and the binding memory hold — and can be tested without the disk.
import Foundationimport ProwlCLIShared
nonisolated struct WorkflowSettingsRepositoryContext: Equatable, Sendable { let repositoryID: String let name: String let rootURL: URL
var rootPath: String { rootURL.path(percentEncoded: false) } var directory: URL { WorkflowSources.repoDirectory(root: rootURL) }}
nonisolated enum WorkflowSettingsScope: Equatable, Sendable { case global case repository(WorkflowSettingsRepositoryContext)}
/// One repository's `.prowl/workflows` as the scan found it.nonisolated struct WorkflowSettingsRepositoryScan: Equatable, Sendable { let repositoryID: String let name: String /// `repositoryRootURL.path(percentEncoded: false)` — the `repo:` binding-memory scope /// (`WorkflowRunAdmission.runScope`). let rootPath: String let directory: URL /// The discovery catalog over (bundle, user, this repository). Repo rows come from its /// `.repo` entries; a `.user` entry marked shadowed here is overridden by this repository. let entries: [WorkflowCatalogEntry]}
/// Everything one Settings scan read from disk, before settings are applied.nonisolated struct WorkflowSettingsScan: Equatable, Sendable { let bundleDirectory: URL? let userDirectory: URL /// The discovery catalog over (bundle, user) alone. let entries: [WorkflowCatalogEntry] let repositories: [WorkflowSettingsRepositoryScan]
static func empty(userDirectory: URL) -> Self { Self(bundleDirectory: nil, userDirectory: userDirectory, entries: [], repositories: []) }}
nonisolated struct WorkflowSettingsRow: Equatable, Sendable, Identifiable { enum Status: Equatable, Sendable { case invalid(errors: Int) case disabled case superseded case readyWithWarnings(Int) case ready }
struct Candidate: Equatable, Sendable, Identifiable { let profileID: UUID let name: String let agentToken: String /// nil = selectable; otherwise why the profile cannot serve the role (same rule as the sheet). let unavailableReason: String?
var id: UUID { profileID } }
struct LaunchRole: Equatable, Sendable, Identifiable { let name: String let memoryKey: WorkflowBindingMemoryKey let rememberedProfileID: UUID? let candidates: [Candidate] let placement: WorkflowPlacement let direction: WorkflowSplitDirection let background: Bool
var id: String { name } }
struct Role: Equatable, Sendable, Identifiable { let name: String let source: WorkflowRoleSource let launch: LaunchRole?
var id: String { name }
var behaviorDescription: String { switch source { case .current: return String(localized: "Uses the pane that starts this workflow.") case .pick: return String(localized: "Choose an existing agent when starting.") case .launch: guard let launch else { return String(localized: "Prowl launches a new agent.") } switch launch.placement { case .tab: return launch.background ? String(localized: "Prowl launches a new agent in a background tab.") : String(localized: "Prowl launches a new agent in a new tab.") case .split: let side = switch launch.direction { case .right: String(localized: "right", comment: "The side of a split: 'a split on the right'.") case .left: String(localized: "left", comment: "The side of a split: 'a split on the left'.") case .top: String(localized: "top", comment: "The side of a split: 'a split on the top'.") case .down: String(localized: "bottom", comment: "The side of a split: 'a split on the bottom'.") } return launch.background ? String(localized: "Prowl launches a new agent in a split on the \(side) without changing focus.") : String(localized: "Prowl launches a new agent in a split on the \(side).") } } } }
let scope: WorkflowScope let url: URL /// nil when the file did not parse. let workflowID: String? let name: String let description: String? let icon: String? let diagnostics: [WorkflowDiagnostic] let isValid: Bool /// `<scope>/<id>` — the key of `disabledWorkflowIDs` and the bind-mode override; nil /// without an id (nothing to toggle or bind then). let settingsKey: String? let isEnabled: Bool let bindModeOverride: WorkflowBindModeOverride.Mode? /// The `bind` every launch role declares when they agree; nil without launch roles or when mixed. let declaredBind: WorkflowBindMode? let roles: [Role] /// Set when another file wins this id: "Overridden by demo.yaml" (same source) or /// "Overridden in <repository>". let shadowNote: String? /// Set on an effective repository definition when it overrides a personal definition. let precedenceNote: String? var scriptActionCount: Int = 0
/// Files are unique per path even when two of them declare the same id. var id: String { url.path(percentEncoded: false) } var fileName: String { url.lastPathComponent } var errorCount: Int { diagnostics.errorCount } var warningCount: Int { diagnostics.warningCount } var launchRoles: [LaunchRole] { roles.compactMap(\.launch) }
var status: Status { if !isValid { return .invalid(errors: errorCount) } if shadowNote != nil { return .superseded } if !isEnabled { return .disabled } if warningCount > 0 { return .readyWithWarnings(warningCount) } return .ready }}
nonisolated struct WorkflowSettingsRepositoryGroup: Equatable, Sendable, Identifiable { let repositoryID: String let name: String let directory: URL let rows: [WorkflowSettingsRow]
var id: String { repositoryID }}
nonisolated struct WorkflowSettingsCatalog: Equatable, Sendable { let bundleDirectory: URL? let userDirectory: URL let bundle: [WorkflowSettingsRow] let user: [WorkflowSettingsRow] /// Only repositories that hold at least one workflow file. let repositories: [WorkflowSettingsRepositoryGroup]
static func empty(userDirectory: URL) -> Self { Self(bundleDirectory: nil, userDirectory: userDirectory, bundle: [], user: [], repositories: []) }
static func build(scan: WorkflowSettingsScan, settings: UserGlobalSettings) -> WorkflowSettingsCatalog { let builder = Builder(scan: scan, settings: settings) return WorkflowSettingsCatalog( bundleDirectory: scan.bundleDirectory, userDirectory: scan.userDirectory, bundle: scan.entries.filter { $0.file.scope == .bundle }.map { builder.row($0, siblings: scan.entries, repository: nil) }, user: scan.entries.filter { $0.file.scope == .user }.map { builder.row($0, siblings: scan.entries, repository: nil) }, repositories: scan.repositories.compactMap { repository in let entries = repository.entries.filter { $0.file.scope == .repo } guard !entries.isEmpty else { return nil } return WorkflowSettingsRepositoryGroup( repositoryID: repository.repositoryID, name: repository.name, directory: repository.directory, rows: entries.map { builder.row($0, siblings: repository.entries, repository: repository) }) }) }
static func settingsKey( scope: WorkflowScope, id: String, repositoryRootPath: String? = nil ) -> String? { WorkflowPreferenceKey.make( scope: scope, workflowID: id, repositoryRootPath: repositoryRootPath) }
private struct Builder { let scan: WorkflowSettingsScan let settings: UserGlobalSettings
func row( _ entry: WorkflowCatalogEntry, siblings: [WorkflowCatalogEntry], repository: WorkflowSettingsRepositoryScan? ) -> WorkflowSettingsRow { let file = entry.file let definition = file.definition let runScope: WorkflowRunScope = switch file.scope { case .bundle: .bundle case .user: .user case .repo: .repo(repositoryID: repository?.rootPath ?? "") } let settingsKey = file.id.flatMap { WorkflowSettingsCatalog.settingsKey( scope: file.scope, id: $0, repositoryRootPath: repository?.rootPath) } let launchRoles = (definition?.roles ?? []).compactMap { role -> WorkflowSettingsRow.LaunchRole? in guard role.source == .launch, let requirements = role.launch, let definition else { return nil } let key = WorkflowBindingResolver.memoryKey( scope: runScope, workflowID: definition.id, role: role) let context = WorkflowBindingResolverContext(profiles: settings.agentProfiles) let remembered = settings.rememberedWorkflowBinding(for: key) // Enabled profiles, each with the resolver's own rejection (the sheet's rule), plus the // remembered profile when it was disabled since — so the picker can say why instead of // presenting it as deleted. let listed = settings.agentProfiles.filter { $0.isEnabled || $0.id == remembered } return WorkflowSettingsRow.LaunchRole( name: role.name, memoryKey: key, rememberedProfileID: remembered, candidates: listed.map { profile in WorkflowSettingsRow.Candidate( profileID: profile.id, name: profile.name, agentToken: profile.runtime.agent.rawValue, unavailableReason: WorkflowBindingResolver.rejection( of: profile, requirements: requirements, context: context )?.userFacingText(requirements: requirements)) }, placement: requirements.placement, direction: requirements.direction, background: requirements.background) } let roles = (definition?.roles ?? []).map { role in WorkflowSettingsRow.Role( name: role.name, source: role.source, launch: launchRoles.first { $0.name == role.name }) } let binds = Set((definition?.roles ?? []).compactMap { $0.launch?.bind }) return WorkflowSettingsRow( scope: file.scope, url: file.url, workflowID: file.id, name: definition?.name ?? file.url.lastPathComponent, description: definition?.description, icon: definition?.icon, diagnostics: file.diagnostics, isValid: file.isValid, settingsKey: settingsKey, isEnabled: settingsKey.map { !settings.disabledWorkflowIDs.contains($0) } ?? false, bindModeOverride: settingsKey.flatMap { settings.workflowBindMode(for: $0) }, declaredBind: binds.count == 1 ? binds.first : nil, roles: roles, shadowNote: shadowNote(for: entry, siblings: siblings), precedenceNote: precedenceNote(for: entry, siblings: siblings), scriptActionCount: file.actions.count) }
private func precedenceNote( for entry: WorkflowCatalogEntry, siblings: [WorkflowCatalogEntry] ) -> String? { guard entry.file.scope == .repo, !entry.shadowed, let id = entry.file.id else { return nil } guard siblings.contains(where: { $0.file.scope == .user && $0.file.id == id }) else { return nil } return String(localized: "Overrides your personal workflow in this repository.") }
/// Discovery marks the loser; the note names the winner: an earlier file in the same /// source, or the repositories whose catalogs shadow this user file. private func shadowNote(for entry: WorkflowCatalogEntry, siblings: [WorkflowCatalogEntry]) -> String? { guard let id = entry.file.id else { return nil } if entry.shadowed, let winner = siblings.first(where: { $0.file.id == id && $0.file.isValid && !$0.shadowed }) { return String(localized: "Overridden by \(winner.file.url.lastPathComponent)") } guard entry.file.scope != .repo else { return nil } let overriding = scan.repositories.filter { repository in repository.entries.contains { $0.file.url == entry.file.url && $0.shadowed } } guard !overriding.isEmpty else { return nil } return String(localized: "Overridden in \(overriding.map(\.name).joined(separator: ", "))") } }}
extension WorkflowBindingRejection { /// The picker footnote for a profile that cannot serve a role (start sheet and Settings). nonisolated func userFacingText(requirements: WorkflowLaunchRequirements) -> String { switch self { case .missing: return String(localized: "The profile no longer exists.") case .disabled: return String(localized: "Disabled in Settings.") case .agentNotAllowed: let allowed = (requirements.agents ?? []).joined(separator: ", ") if allowed.isEmpty { return String(localized: "This role needs another agent.") } return String(localized: "This role needs \(allowed).") case .promptUnsupported: return String(localized: "This profile's runtime cannot take a launch prompt.") } }}