native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469import ComposableArchitectureimport Foundationimport ProwlCLISharedimport Sharing
/// Shared workflow Settings domain for the global Built-in/personal index and one repository's/// direct Workflows section (docs-ai 063.014). The root exposes compact navigation rows; pushed/// details own enablement, Run Setup, role preferences, validation, Run, and source-file actions./// Filesystem access stays in `WorkflowSettingsClient`; row derivation is `WorkflowSettingsCatalog`.@Reducerstruct WorkflowsSettingsFeature { @ObservableState struct State: Equatable { var settingsScope: WorkflowSettingsScope /// The last scan, kept so a settings write can rebuild the rows without touching the disk. var scan: WorkflowSettingsScan? var catalog: WorkflowSettingsCatalog /// Why the sources could not be read (an unreadable folder); the lists are then stale or empty. var loadError: String? var cliInstallStatus: CLIInstallStatus = .notInstalled /// Whether a shell can run `prowl` from the install path (the gate); `cliInstallStatus` /// only names what occupies it (the banner's button and copy). var cliUsable = false var cliServiceStatus: CLIServiceStatus = .stopped var runTargets: [WorkflowSettingsRunTarget] = [] var path = StackState<WorkflowSettingsDetailFeature.State>() var isAuthoringPromptPresented = false /// The New Workflow form while it is open. var newWorkflow: NewWorkflowDraft? @Presents var alert: AlertState<Alert>?
init( scope: WorkflowSettingsScope = .global, userDirectory: URL = WorkflowSources.userDirectory( home: FileManager.default.homeDirectoryForCurrentUser) ) { settingsScope = scope catalog = .empty(userDirectory: userDirectory) }
/// The banner's reason, in the same precedence as the start sheet: an app that is not /// listening cannot be fixed by installing the CLI. var cliBlocker: CLIBlocker? { if let reason = cliServiceStatus.unreachableDescription { return .socketUnavailable(reason) } guard cliUsable else { return .cliUnusable(cliInstallStatus) } return nil }
var displayedRows: [WorkflowSettingsRow] { switch settingsScope { case .global: return catalog.bundle + catalog.user case .repository(let repository): return catalog.repositories.first { $0.repositoryID == repository.repositoryID }?.rows ?? [] } }
var hasNoWorkflows: Bool { displayedRows.isEmpty }
/// Why the form's Create button is disabled, or nil when the draft can be written. var newWorkflowProblem: String? { guard let draft = newWorkflow else { return nil } if draft.name.trimmingCharacters(in: .whitespaces).isEmpty { return String(localized: "Enter a name.") } if draft.id.isEmpty { return String(localized: "Enter an ID.") } guard WorkflowSchema.isWorkflowID(draft.id) else { return String( localized: "IDs use lowercase letters, digits, dots, dashes, and underscores, and start with a letter or digit." ) } if draft.id.hasPrefix(WorkflowSchema.reservedIDPrefix) { return String( localized: "IDs starting with “\(WorkflowSchema.reservedIDPrefix)” are reserved for built-in workflows." ) } let fileName = draft.request.fileName if displayedRows.contains(where: { $0.workflowID == draft.id || $0.fileName == fileName }) || FileManager.default.fileExists( atPath: workflowDirectory.appending(path: fileName).path(percentEncoded: false)) { return String(localized: "A workflow with this ID already exists here.") } return nil }
var allRows: [WorkflowSettingsRow] { displayedRows }
var workflowDirectory: URL { switch settingsScope { case .global: catalog.userDirectory case .repository(let repository): repository.directory } }
/// Everything whose change must re-derive the rows: the source directories (files appear, /// disappear, get renamed) and every discovered file (an in-place save touches only the /// file's vnode). A directory that does not exist yet is watched through its parent. func detailState(for row: WorkflowSettingsRow) -> WorkflowSettingsDetailFeature.State { WorkflowSettingsDetailFeature.State(row: row, runTargets: runTargets) }
var watchedPaths: [URL] { guard let scan else { return [workflowDirectory] } let directories = [scan.userDirectory] + scan.repositories.map(\.directory) let files = (scan.entries + scan.repositories.flatMap(\.entries)).flatMap { entry in [entry.file.url] + (entry.file.snapshot?.files.keys.flatMap { path -> [URL] in var urls: [URL] = [] var relative = path while !relative.isEmpty { urls.append(entry.file.url.appending(path: relative)) relative = relative.split(separator: "/").dropLast().joined(separator: "/") } return urls } ?? []) } var seen: Set<String> = [] return (directories + files).filter { seen.insert($0.path(percentEncoded: false)).inserted } } }
enum CLIBlocker: Equatable { /// No runnable `prowl` at the install path; the status says what is there instead. case cliUnusable(CLIInstallStatus) case socketUnavailable(String) }
enum Action: Equatable { case task /// The page went away (the Settings window closed on it): stop watching until it appears again. case teardown case reload /// A watched directory changed; coalesced into one `reload` after a short quiet period. case directoriesChanged case showDetails(rowID: String) case setEnabled(settingsKey: String, isEnabled: Bool) case setBindMode(settingsKey: String, mode: WorkflowBindModeOverride.Mode?) /// nil forgets the remembered profile ("Ask at start"). case setRememberedBinding(WorkflowBindingMemoryKey, profileID: UUID?) case revealTapped(rowID: String) case revealUserFolderTapped case newWorkflowTapped case newWorkflowNameChanged(String) case newWorkflowIDChanged(String) case newWorkflowIconChanged(String) case newWorkflowKindChanged(WorkflowStarterTemplate.Kind) case createWorkflowTapped case dismissNewWorkflow case askAgentTapped case setAuthoringPromptPresented(Bool) case installCLITapped case cliInstallCompleted(Result<String, CLIInstallError>) case manageProfilesTapped case path(StackActionOf<WorkflowSettingsDetailFeature>) case alert(PresentationAction<Alert>) case delegate(Delegate) }
enum Alert: Equatable { case dismiss }
@CasePathable enum Delegate: Equatable { case notice(WorkflowsSettingsNotice) case openProfiles case runWorkflow(workflowKey: String, worktreeID: String, forceSheet: Bool) }
private nonisolated enum CancelID { case watch case debounce }
static let reloadDebounce: Duration = .milliseconds(300)
@Dependency(WorkflowSettingsClient.self) private var client @Dependency(OpenURLClient.self) private var openURLClient @Dependency(CLIInstallClient.self) private var cliInstallClient @Dependency(CLIServiceStatusClient.self) private var cliServiceStatusClient @Dependency(\.continuousClock) private var clock
var body: some Reducer<State, Action> { Reduce { state, action in switch action { case .task, .reload: reload(&state) return watch(state)
case .teardown: return .merge(.cancel(id: CancelID.watch), .cancel(id: CancelID.debounce))
case .directoriesChanged: return .run { send in try await clock.sleep(for: Self.reloadDebounce) await send(.reload) } .cancellable(id: CancelID.debounce, cancelInFlight: true)
case .showDetails(let rowID): guard let row = state.allRows.first(where: { $0.id == rowID }) else { return .none } state.path.removeAll() state.path.append(state.detailState(for: row)) return .none
case .setEnabled(let settingsKey, let isEnabled): persist(&state) { settings in var keys = Set(settings.disabledWorkflowIDs) if isEnabled { keys.remove(settingsKey) } else { keys.insert(settingsKey) } settings.disabledWorkflowIDs = UserGlobalSettings.normalizedWorkflowIDs(Array(keys)) } return .none
case .setBindMode(let settingsKey, let mode): persist(&state) { $0.setWorkflowBindMode(mode, for: settingsKey) } return .none
case .setRememberedBinding(let key, let profileID): persist(&state) { settings in if let profileID { settings.remember(workflowBinding: key, profileID: profileID) } else { settings.forget(workflowBinding: key) } } return .none
case .revealTapped(let rowID): guard let row = state.allRows.first(where: { $0.id == rowID }) else { return .none } return .run { [client] _ in client.reveal(row.url) }
case .revealUserFolderTapped: let directory = state.workflowDirectory return .run { [client] _ in // Finder cannot select a folder that does not exist; create it so the drop target is real. try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) client.reveal(directory) }
case .newWorkflowTapped: state.newWorkflow = NewWorkflowDraft() return .none
case .newWorkflowNameChanged(let name): guard var draft = state.newWorkflow else { return .none } draft.name = name if !draft.idEdited { draft.id = WorkflowStarterTemplate.suggestedID(for: name) } state.newWorkflow = draft return .none
case .newWorkflowIDChanged(let id): guard var draft = state.newWorkflow else { return .none } draft.id = id.trimmingCharacters(in: .whitespaces) // An emptied field goes back to following the name. draft.idEdited = !draft.id.isEmpty if !draft.idEdited { draft.id = WorkflowStarterTemplate.suggestedID(for: draft.name) } state.newWorkflow = draft return .none
case .newWorkflowIconChanged(let icon): state.newWorkflow?.icon = icon.trimmingCharacters(in: .whitespaces) return .none
case .newWorkflowKindChanged(let kind): state.newWorkflow?.kind = kind return .none
case .createWorkflowTapped: guard let draft = state.newWorkflow, state.newWorkflowProblem == nil else { return .none } do { let url = try client.createWorkflow(state.workflowDirectory, draft.request) state.newWorkflow = nil reload(&state) openURLClient.open(url.appending(path: "workflow.yaml")) return .merge( watch(state), .send(.delegate(.notice(.workflowCreated(path: url.path(percentEncoded: false))))) ) } catch let error as WorkflowSettingsError { state.alert = Self.errorAlert(error.message) return .send(.delegate(.notice(.failed(message: error.message)))) } catch { state.alert = Self.errorAlert(error.localizedDescription) return .send(.delegate(.notice(.failed(message: error.localizedDescription)))) }
case .dismissNewWorkflow: state.newWorkflow = nil return .none
case .askAgentTapped: // From the form the prompt opens on top of it; from the index it stands alone. if state.newWorkflow != nil { state.newWorkflow?.showsAuthoringPrompt = true } else { state.isAuthoringPromptPresented = true } return .none
case .setAuthoringPromptPresented(let isPresented): if state.newWorkflow != nil { state.newWorkflow?.showsAuthoringPrompt = isPresented } else { state.isAuthoringPromptPresented = isPresented } return .none
case .installCLITapped: let installPath = cliDefaultInstallPath return .run { [cliInstallClient] send in do { try await cliInstallClient.install(installPath) await send(.cliInstallCompleted(.success(installPath.path(percentEncoded: false)))) } catch let error as CLIInstallError { await send(.cliInstallCompleted(.failure(error))) } catch { await send( .cliInstallCompleted(.failure(CLIInstallError(message: error.localizedDescription)))) } }
case .cliInstallCompleted(.success(let path)): state.cliInstallStatus = cliInstallClient.installationStatus(cliDefaultInstallPath) state.cliUsable = cliInstallClient.isUsable(cliDefaultInstallPath) return .send(.delegate(.notice(.cliInstalled(path: path))))
case .cliInstallCompleted(.failure(let error)): state.cliInstallStatus = cliInstallClient.installationStatus(cliDefaultInstallPath) state.cliUsable = cliInstallClient.isUsable(cliDefaultInstallPath) state.alert = Self.errorAlert(error.message) return .send(.delegate(.notice(.failed(message: error.message))))
case .manageProfilesTapped: return .send(.delegate(.openProfiles))
case .path(.element(id: _, action: .appeared)): state.runTargets = client.runTargets(state.settingsScope) synchronizePath(&state) return .none
case .path(.element(id: _, action: .delegate(.setEnabled(let rowID, let enabled)))): return .send(.setEnabled(settingsKey: rowID, isEnabled: enabled))
case .path(.element(id: _, action: .delegate(.setRunSetup(let rowID, let mode)))): return .send(.setBindMode(settingsKey: rowID, mode: mode))
case .path(.element(id: _, action: .delegate(.setPreferredProfile(let key, let profileID)))): return .send(.setRememberedBinding(key, profileID: profileID))
case .path( .element(id: _, action: .delegate(.runWorkflow(let key, let worktreeID, let forceSheet)))): return .send( .delegate(.runWorkflow(workflowKey: key, worktreeID: worktreeID, forceSheet: forceSheet)))
case .path(.element(id: _, action: .delegate(.manageProfiles))): return .send(.delegate(.openProfiles))
case .path(.element(id: let id, action: .delegate(.deleted))): state.path.pop(from: id) return .send(.reload)
case .path: return .none
case .alert: return .none
case .delegate: return .none } } .ifLet(\.$alert, action: \.alert) .forEach(\.path, action: \.path) { WorkflowSettingsDetailFeature() } }
private func reload(_ state: inout State) { state.cliInstallStatus = cliInstallClient.installationStatus(cliDefaultInstallPath) state.cliUsable = cliInstallClient.isUsable(cliDefaultInstallPath) state.cliServiceStatus = cliServiceStatusClient.current() state.runTargets = client.runTargets(state.settingsScope) do { let scan = try client.scan(state.settingsScope) state.scan = scan state.loadError = nil rebuild(&state) } catch let error as WorkflowSettingsError { state.loadError = error.message } catch { state.loadError = error.localizedDescription } }
/// Rows follow the stored settings, not a local copy: the write lands first, then the rows are /// derived from what is on disk, so the page can never show a toggle the file does not hold. private func persist(_ state: inout State, _ update: (inout UserGlobalSettings) -> Void) { @Shared(.userGlobalSettings) var settings $settings.withLock(update) rebuild(&state) }
private func rebuild(_ state: inout State) { guard let scan = state.scan else { return } @Shared(.userGlobalSettings) var settings state.catalog = WorkflowSettingsCatalog.build(scan: scan, settings: settings) synchronizePath(&state) }
private func synchronizePath(_ state: inout State) { let rows = state.allRows let runTargets = state.runTargets for id in state.path.ids { guard let rowID = state.path[id: id]?.rowID else { continue } state.path[id: id]?.row = rows.first { $0.id == rowID } state.path[id: id]?.runTargets = runTargets } }
private func watch(_ state: State) -> Effect<Action> { let paths = state.watchedPaths return .run { [client] send in for await _ in client.watch(paths) { await send(.directoriesChanged) } } .cancellable(id: CancelID.watch, cancelInFlight: true) }
private static func errorAlert(_ message: String) -> AlertState<Alert> { AlertState { TextState(String(localized: "Workflows Error")) } actions: { ButtonState(action: .dismiss) { TextState(String(localized: "OK")) } } message: { TextState(message) } }}
/// What the New Workflow form collects before a starter bundle is written.struct NewWorkflowDraft: Equatable { var name = "" var id = "" var icon = "" var kind: WorkflowStarterTemplate.Kind = .singleAgent /// Once the user typed an ID it stops following the name. var idEdited = false /// "Create with Agent…" inside the form: the prompt sheet opens over it. var showsAuthoringPrompt = false
var request: WorkflowStarterTemplate.Request { WorkflowStarterTemplate.Request( name: name.trimmingCharacters(in: .whitespaces), id: id, icon: icon.isEmpty ? nil : icon, kind: kind) }}
enum WorkflowsSettingsNotice: Equatable { case workflowCreated(path: String) case cliInstalled(path: String) case failed(message: String)}