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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 200 additions & 29 deletions ProwlCLI/Commands/WorkflowCommand.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// ProwlCLI/Commands/WorkflowCommand.swift
// `prowl workflow`: definitions discovery and authoring support (docs-ai 063 B1).
// `list` asks the running app; `validate` and `schema` run locally and never open the socket.
// `prowl workflow`: workflow definition discovery, authoring, and execution.

import ArgumentParser
import Foundation
Expand All @@ -9,13 +8,18 @@ import ProwlCLIShared
struct WorkflowCommand: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "workflow",
abstract: "Discover, validate, and describe Agent Workflow definitions.",
abstract: "Discover, validate, and run Agent Workflow definitions.",
discussion: """
Definitions are YAML files (`prowl.workflow/v1`) found in the app bundle, ~/.prowl/workflows, \
and <repo>/.prowl/workflows. `validate` and `schema` work with Prowl closed; `list` needs the app.
and <repo>/.prowl/workflows. `validate` and `schema` work with Prowl closed; every other \
subcommand needs the running app.
""",
subcommands: [
WorkflowListCommand.self,
WorkflowRunCommand.self,
WorkflowStatusCommand.self,
WorkflowDoneCommand.self,
WorkflowCancelCommand.self,
WorkflowValidateCommand.self,
WorkflowSchemaCommand.self,
]
Expand All @@ -31,18 +35,180 @@ struct WorkflowListCommand: ParsableCommand {
@OptionGroup var selector: SelectorOptions
@OptionGroup var options: GlobalOptions

@Argument(help: "Worktree id/name/path or a pane/tab handle (auto-resolved). Defaults to the caller's pane.")
@Argument(help: "Worktree id/name/path or a pane/tab handle. Defaults to the caller's pane.")
var target: String?

mutating func run() throws {
try CLIExecution.run(
command: WorkflowCommandPayload.commandName, output: options.outputMode, colorEnabled: options.colorEnabled
) {
let envelope = CommandEnvelope(
try WorkflowSocketCommand.execute(options: options) {
CommandEnvelope(
output: options.outputMode,
command: .workflow(
WorkflowInput(action: .list, target: try selector.resolve(positionalTarget: target)))
)
}
}
}

struct WorkflowRunCommand: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "run",
abstract: "Start a validated workflow in a source pane or worktree."
)

@Argument(help: "Workflow id or unique workflow name.") var workflow: String
@Argument(
help: "Optional source pane, tab, or worktree. Defaults to the caller pane when required.")
var source: String?
@OptionGroup var selector: SelectorOptions
@Option(
name: .long, help: "Role binding: launch=<profile name|UUID|auto>, pick=<pane handle|UUID>.")
var role: [String] = []
@Option(name: .long, help: "Workflow input as name=value. Repeat for multiple inputs.") var input: [String] = []
@Option(name: .long, help: "Skip an awaited step at start. Repeat for multiple steps.") var skip: [String] = []
@OptionGroup var options: GlobalOptions

mutating func run() throws {
try WorkflowSocketCommand.execute(options: options) {
CommandEnvelope(
output: options.outputMode,
command: .workflow(
WorkflowInput(
action: .run,
target: try selector.resolve(positionalTarget: source),
workflow: workflow,
roleBindings: role,
inputValues: input,
skippedSteps: skip
))
)
}
}
}

struct WorkflowStatusCommand: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "status",
abstract: "Show an active run, or the run awaiting delivery from this pane."
)

@Argument(help: "Workflow run UUID. Omit to inspect the calling pane's run.") var runID: String?
@OptionGroup var options: GlobalOptions

mutating func run() throws {
try WorkflowSocketCommand.execute(options: options) {
CommandEnvelope(
output: options.outputMode, command: .workflow(WorkflowInput(action: .status, runID: runID))
)
}
}
}

struct WorkflowDoneCommand: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "done",
abstract: "Deliver one workflow step's output from stdin or a UTF-8 file."
)

/// The body always travels with the request; the hard cap of dsl-spec §5 (`OUTPUT_TOO_LARGE`).
static let maximumBodyBytes = 4 * 1024 * 1024

@Argument(help: "'-' reads the output body from piped stdin (or use --file).") var input: String?
@Option(name: .long, help: "Read the UTF-8 output body from this file instead of stdin.")
var file: String?
@Option(name: .long, help: "Declared verdict value, when this step requires one.") var verdict: String?
@Option(name: .long, help: "Delivery token; defaults to $PROWL_WORKFLOW_TOKEN.") var token: String?
@Option(name: .customLong("run"), help: "Run UUID of a manual delivery (with --step).") var runID: String?
@Option(name: .long, help: "Step id of a manual delivery (with --run).") var step: String?
@Flag(
name: .long,
help: "Deliver to the explicit --run/--step even when this pane belongs to another step.")
var force = false
@OptionGroup var options: GlobalOptions

mutating func run() throws {
let body = try bodyValue()
try WorkflowSocketCommand.execute(options: options) {
CommandEnvelope(
output: options.outputMode,
command: .workflow(WorkflowInput(action: .list, target: try selector.resolve(positionalTarget: target)))
command: .workflow(
WorkflowInput(
action: .done,
runID: runID,
stepID: step,
body: body,
verdict: verdict,
token: token ?? ProcessInfo.processInfo.environment[WorkflowSchema.tokenEnvironmentKey],
force: force
))
)
}
}

func validate() throws {
try Self.validate(input: input, file: file, runID: runID, step: step, force: force)
}

/// Argument rules, shared with the parser tests.
static func validate(input: String?, file: String?, runID: String?, step: String?, force: Bool)
throws
{
guard input != nil || file != nil else {
throw ValidationError("Pass the output body through stdin ('-') or --file <path>.")
}
guard !(input != nil && file != nil) else {
throw ValidationError("Pass the body through stdin ('-') or --file, not both.")
}
guard input == nil || input == "-" else {
throw ValidationError("The only positional output source is '-'.")
}
guard (runID == nil) == (step == nil) else {
throw ValidationError("--run and --step must be passed together.")
}
guard !force || runID != nil else {
throw ValidationError("--force applies to an explicit --run/--step target.")
}
}

private func bodyValue() throws -> String {
let data: Data
if let file {
guard let contents = FileManager.default.contents(atPath: file) else {
throw ExitError(code: CLIErrorCode.pathNotFound, message: "Could not read --file \(file).")
}
data = contents
} else {
guard isatty(fileno(stdin)) == 0 else {
throw ExitError(
code: CLIErrorCode.emptyInput,
message: "workflow done - reads the output body from piped stdin.")
}
data = (try? FileHandle.standardInput.readToEnd()) ?? Data()
}
guard data.count <= Self.maximumBodyBytes else {
throw ExitError(
code: CLIErrorCode.outputTooLarge,
message: "The output body is \(data.count) bytes; the maximum is \(Self.maximumBodyBytes).")
}
guard let text = String(data: data, encoding: .utf8) else {
throw ExitError(
code: CLIErrorCode.invalidArgument, message: "The output body is not valid UTF-8.")
}
return text
}
}

struct WorkflowCancelCommand: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "cancel", abstract: "Cancel an active workflow run.")

@Argument(help: "Workflow run UUID.") var runID: String
@OptionGroup var options: GlobalOptions

mutating func run() throws {
try WorkflowSocketCommand.execute(options: options) {
CommandEnvelope(
output: options.outputMode, command: .workflow(WorkflowInput(action: .cancel, runID: runID))
)
try CLIRunner.execute(envelope)
}
}
}
Expand All @@ -67,42 +233,34 @@ struct WorkflowValidateCommand: ParsableCommand {
}
}

@Argument(help: "Path to a workflow YAML file.")
var file: String

@Option(
name: .long,
help: "Source the file belongs to (bundle, user, repo); inferred from its directory when omitted."
)
var scope: Scope?

@Argument(help: "Path to a workflow YAML file.") var file: String
@Option(name: .long, help: "Source scope (bundle, user, repo); inferred when omitted.") var scope: Scope?
@OptionGroup var options: GlobalOptions

mutating func run() throws {
try CLIExecution.run(
command: WorkflowCommandPayload.commandName, output: options.outputMode, colorEnabled: options.colorEnabled
command: WorkflowCommandPayload.commandName, output: options.outputMode,
colorEnabled: options.colorEnabled
) {
let payload = try WorkflowCommandExecutor.current().validate(path: file, scope: scope?.value)
if payload.valid {
try WorkflowCommandRunner.render(.validate(payload), options: options)
return
}
// An invalid file is an error outcome whose details carry the full validate payload.
let response = CommandResponse(
ok: false,
command: WorkflowCommandPayload.commandName,
schemaVersion: WorkflowCommandPayload.schemaVersion,
error: CommandError(
code: CLIErrorCode.workflowInvalid,
message: "\(payload.path) has \(payload.diagnostics.filter { $0.severity == .error }.count) error(s).",
message:
"\(payload.path) has \(payload.diagnostics.filter { $0.severity == .error }.count) error(s).",
details: try RawJSON(encoding: payload)
)
)
switch options.outputMode {
case .json:
OutputRenderer.render(response, mode: .json)
case .text:
print(OutputRenderer.workflowValidateText(payload))
case .json: OutputRenderer.render(response, mode: .json)
case .text: print(OutputRenderer.workflowValidateText(payload))
}
throw ExitCode.failure
}
Expand All @@ -119,9 +277,22 @@ struct WorkflowSchemaCommand: ParsableCommand {

mutating func run() throws {
try CLIExecution.run(
command: WorkflowCommandPayload.commandName, output: options.outputMode, colorEnabled: options.colorEnabled
command: WorkflowCommandPayload.commandName, output: options.outputMode,
colorEnabled: options.colorEnabled
) {
try WorkflowCommandRunner.render(
.schema(try WorkflowCommandExecutor.current().schema()), options: options)
}
}
}

enum WorkflowSocketCommand {
static func execute(options: GlobalOptions, makeEnvelope: () throws -> CommandEnvelope) throws {
try CLIExecution.run(
command: WorkflowCommandPayload.commandName, output: options.outputMode,
colorEnabled: options.colorEnabled
) {
try WorkflowCommandRunner.render(.schema(try WorkflowCommandExecutor.current().schema()), options: options)
try CLIRunner.execute(try makeEnvelope())
}
}
}
Expand Down
Loading
Loading