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
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,11 @@ format-lint: # Check Swift formatting without rewriting files
lint: # Lint code with swiftlint
mise exec -- swiftlint lint --quiet --config .swiftlint.yml

check: format-changed format-lint lint test-scripts # Format changed Swift files, then run swift-format lint, SwiftLint, and the script tests
.PHONY: check-workflow-naming
check-workflow-naming: # Check maintained workflow source and references for retired names
python3 scripts/check_workflow_naming.py

check: format-changed format-lint lint test-scripts check-workflow-naming # Format changed Swift files, then run linters and checks

log-stream: # Stream logs from the app via log stream
log stream --predicate 'subsystem == "com.onevcat.prowl"' --style compact --color always
Expand Down
14 changes: 7 additions & 7 deletions ProwlCLI/Commands/WorkflowCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ struct WorkflowCommand: ParsableCommand {
WorkflowRunCommand.self,
WorkflowTestActionCommand.self,
WorkflowStatusCommand.self,
WorkflowDoneCommand.self,
WorkflowDeliverCommand.self,
WorkflowCancelCommand.self,
WorkflowValidateCommand.self,
WorkflowSchemaCommand.self,
Expand Down Expand Up @@ -105,9 +105,9 @@ struct WorkflowStatusCommand: ParsableCommand {
}
}

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

Expand All @@ -134,7 +134,7 @@ struct WorkflowDoneCommand: ParsableCommand {
output: options.outputMode,
command: .workflow(
WorkflowInput(
action: .done,
action: .deliver,
runID: runID,
stepID: step,
body: body,
Expand Down Expand Up @@ -182,7 +182,7 @@ struct WorkflowDoneCommand: ParsableCommand {
guard isatty(fileno(stdin)) == 0 else {
throw ExitError(
code: CLIErrorCode.emptyInput,
message: "workflow done - reads the output body from piped stdin.")
message: "workflow deliver - reads the output body from piped stdin.")
}
data = (try? FileHandle.standardInput.readToEnd()) ?? Data()
}
Expand Down Expand Up @@ -235,7 +235,7 @@ struct WorkflowValidateCommand: ParsableCommand {
}
}

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

Expand Down Expand Up @@ -318,7 +318,7 @@ struct WorkflowTestActionCommand: ParsableCommand {
abstract: "Run one action from an installed workflow bundle with the same native approval policy.")

@Argument(help: "Workflow id or unique name.") var workflow: String
@Argument(help: "builtin:git.context or local:<action-id>.") var action: String
@Argument(help: "builtin:collect-worktree-context or local:<action-id>.") var action: String
@Argument(help: "Source worktree or pane.") var source: String?
@Option(name: .long, help: "JSON object supplied to the action.") var inputJSON = "{}"
@OptionGroup var selector: SelectorOptions
Expand Down
14 changes: 7 additions & 7 deletions ProwlCLI/Output/OutputRenderer+Workflow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ extension OutputRenderer {
print(workflowListText(list))
case .run(let run), .status(let run), .cancel(let run):
print(workflowRunText(run))
case .done(let done):
print(workflowDoneText(done))
case .deliver(let deliver):
print(workflowDeliverText(deliver))
case .validate(let validate):
print(workflowValidateText(validate))
case .schema(let schema):
Expand Down Expand Up @@ -95,9 +95,9 @@ extension OutputRenderer {
lines.append(parts.joined(separator: " "))
}
}
if !payload.outputs.isEmpty {
lines.append("Outputs:")
for (name, output) in payload.outputs.sorted(by: { $0.key < $1.key }) {
if !payload.deliveries.isEmpty {
lines.append("Deliveries:")
for (name, output) in payload.deliveries.sorted(by: { $0.key < $1.key }) {
let verdict = output.verdict.map { " verdict \($0)" } ?? ""
lines.append(" \(name.bold) \(output.latestPath.dim)\(verdict)")
}
Expand All @@ -109,7 +109,7 @@ extension OutputRenderer {
return lines.joined(separator: "\n")
}

static func workflowDoneText(_ payload: WorkflowDonePayload) -> String {
static func workflowDeliverText(_ payload: WorkflowDeliverPayload) -> String {
let delivery = payload.delivery
var lines: [String] = []
switch delivery.state {
Expand Down Expand Up @@ -140,7 +140,7 @@ extension OutputRenderer {
case "skipped":
let detail = [status.step, status.dependent].compactMap { $0 }.joined(separator: " → ")
return "skipped".yellow + (detail.isEmpty ? "" : " (\(detail))")
case "cancelled", "interrupted", "max_rounds_reached":
case "cancelled", "interrupted", "iteration_limit_reached":
return status.state.replacing("_", with: " ").red
default: return status.state
}
Expand Down
2 changes: 1 addition & 1 deletion ProwlCLIContracts/Resources/action-definition-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
"type": "string"
}
},
"environment": {
"inherit_env": {
"type": "array",
"items": {
"type": "string",
Expand Down
26 changes: 13 additions & 13 deletions ProwlCLIContracts/Resources/cli-output-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3830,7 +3830,7 @@
"$ref": "#/$defs/workflowStatusData"
},
{
"$ref": "#/$defs/workflowDoneData"
"$ref": "#/$defs/workflowDeliverData"
},
{
"$ref": "#/$defs/workflowCancelData"
Expand Down Expand Up @@ -3981,7 +3981,7 @@
"worktree",
"run_directory",
"bindings",
"outputs",
"deliveries",
"started_at",
"updated_at"
],
Expand Down Expand Up @@ -4032,7 +4032,7 @@
"activation": {
"$ref": "#/$defs/workflowActivation"
},
"outputs": {
"deliveries": {
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/workflowOutput"
Expand Down Expand Up @@ -4068,7 +4068,7 @@
"worktree",
"run_directory",
"bindings",
"outputs",
"deliveries",
"started_at",
"updated_at"
],
Expand Down Expand Up @@ -4119,7 +4119,7 @@
"activation": {
"$ref": "#/$defs/workflowActivation"
},
"outputs": {
"deliveries": {
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/workflowOutput"
Expand Down Expand Up @@ -4155,7 +4155,7 @@
"worktree",
"run_directory",
"bindings",
"outputs",
"deliveries",
"started_at",
"updated_at"
],
Expand Down Expand Up @@ -4206,7 +4206,7 @@
"activation": {
"$ref": "#/$defs/workflowActivation"
},
"outputs": {
"deliveries": {
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/workflowOutput"
Expand All @@ -4229,7 +4229,7 @@
}
}
},
"workflowDoneData": {
"workflowDeliverData": {
"type": "object",
"additionalProperties": false,
"required": [
Expand All @@ -4239,7 +4239,7 @@
],
"properties": {
"action": {
"const": "done"
"const": "deliver"
},
"run": {
"$ref": "#/$defs/workflowRun"
Expand All @@ -4261,7 +4261,7 @@
"worktree",
"run_directory",
"bindings",
"outputs",
"deliveries",
"started_at",
"updated_at"
],
Expand Down Expand Up @@ -4309,7 +4309,7 @@
"activation": {
"$ref": "#/$defs/workflowActivation"
},
"outputs": {
"deliveries": {
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/workflowOutput"
Expand Down Expand Up @@ -4346,7 +4346,7 @@
"completed",
"cancelled",
"skipped",
"max_rounds_reached",
"iteration_limit_reached",
"interrupted"
]
},
Expand Down Expand Up @@ -4577,7 +4577,7 @@
"type": "string"
}
},
"verdict": {
"verdicts": {
"type": "array",
"items": {
"type": "string"
Expand Down
6 changes: 3 additions & 3 deletions ProwlCLIContracts/Resources/workflow-definition-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@
},
"action": {
"type": "string",
"pattern": "^(builtin:git\\.context|local:[a-z0-9][a-z0-9_-]{0,63})$"
"pattern": "^(builtin:collect-worktree-context|local:(?=[a-z0-9-]{1,64}$)[a-z][a-z0-9]*(?:-[a-z0-9]+)*)$"
},
"with": {
"type": "object",
Expand Down Expand Up @@ -446,7 +446,7 @@
"type": "object",
"additionalProperties": false,
"properties": {
"output": {
"delivery": {
"$ref": "#/$defs/slug"
},
"format": {
Expand All @@ -463,7 +463,7 @@
"minLength": 1
}
},
"verdict": {
"verdicts": {
"type": "array",
"minItems": 2,
"maxItems": 4,
Expand Down
34 changes: 17 additions & 17 deletions ProwlCLITests/ProwlCLIIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1285,7 +1285,7 @@ final class ProwlCLIIntegrationTests: XCTestCase {
XCTAssertTrue(text.stdout.contains("1 warning(s)"), text.stdout)
}

func testWorkflowDoneAcceptsSixteenMiBWithJSONEscaping() throws {
func testWorkflowDeliverAcceptsSixteenMiBWithJSONEscaping() throws {
let file = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: file) }
let body = Data(repeating: 10, count: WorkflowSizeLimits.payload)
Expand All @@ -1295,7 +1295,7 @@ final class ProwlCLIIntegrationTests: XCTestCase {
error: .init(code: "STEP_NOT_EXPECTING", message: "No assigned task."))
let (request, result) = try runWithMockServer(
socketPath: temporarySocketPath(suffix: "workflow-large-done"), response: response,
args: ["workflow", "done", "--file", file.path, "--json"])
args: ["workflow", "deliver", "--file", file.path, "--json"])
XCTAssertEqual(result.exitCode, 1)
XCTAssertGreaterThan(request.count, 32 * 1024 * 1024)
let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: request)
Expand All @@ -1307,7 +1307,7 @@ final class ProwlCLIIntegrationTests: XCTestCase {
let runID = UUID().uuidString
let payload = WorkflowCommandPayload.read(WorkflowContentPayload(
run: runID, invocation: 3, role: "author", step: "brief", resource: "resource-1",
body: "AA==", encoding: "base64", resources: [.init(id: "resource-1", name: "outputs/brief.md")],
body: "AA==", encoding: "base64", resources: [.init(id: "resource-1", name: "deliveries/brief.md")],
offset: 4, nextOffset: 5, totalBytes: 8))
let response = try CommandResponse(
ok: true, command: "workflow", schemaVersion: "prowl.cli.workflow.v1", data: RawJSON(encoding: payload))
Expand All @@ -1332,9 +1332,9 @@ final class ProwlCLIIntegrationTests: XCTestCase {
}

func testWorkflowRunAndDoneRoundTripThroughTheSocket() throws {
let output = WorkflowOutputPayload(
name: "brief", ordinal: 1, path: "/Projects/App/.prowl/workflow-runs/R/outputs/brief.1.md",
latestPath: "/Projects/App/.prowl/workflow-runs/R/outputs/brief.md", verdict: nil,
let output = WorkflowDeliveryRecordPayload(
name: "brief", ordinal: 1, path: "/Projects/App/.prowl/workflow-runs/R/deliveries/brief.1.md",
latestPath: "/Projects/App/.prowl/workflow-runs/R/deliveries/brief.md", verdict: nil,
deliveredAt: "2026-08-30T01:02:03.000Z")
let run = WorkflowRunPayload(
id: "0BADCAFE-0000-4000-8000-000000000042",
Expand All @@ -1357,17 +1357,17 @@ final class ProwlCLIIntegrationTests: XCTestCase {
activation: WorkflowActivationPayload(
ordinal: 1, step: "brief", role: "author", state: "waiting", dispatchID: "d-1", output: "brief",
expect: WorkflowExpectationPayload(
format: .markdown, sections: ["## Scope"], verdict: nil, strict: false,
completion: ["PROWL_WORKFLOW_TOKEN=T prowl workflow done -"]),
format: .markdown, sections: ["## Scope"], verdicts: nil, strict: false,
completion: ["PROWL_WORKFLOW_TOKEN=T prowl workflow deliver -"]),
deadline: nil),
outputs: [:],
deliveries: [:],
startedAt: "2026-08-30T01:00:00.000Z",
updatedAt: "2026-08-30T01:00:00.000Z",
finishedAt: nil,
selfInitiated: WorkflowSelfInitiatedPayload(
line: "[Prowl] Read /Projects/App/.prowl/workflow-runs/R/instructions/brief.1.md and follow it — finish with: PROWL_WORKFLOW_TOKEN=T prowl workflow done -",
line: "[Prowl] Read /Projects/App/.prowl/workflow-runs/R/instructions/brief.1.md and follow it — finish with: PROWL_WORKFLOW_TOKEN=T prowl workflow deliver -",
instructionPath: "/Projects/App/.prowl/workflow-runs/R/instructions/brief.1.md",
completion: ["PROWL_WORKFLOW_TOKEN=T prowl workflow done -"]))
completion: ["PROWL_WORKFLOW_TOKEN=T prowl workflow deliver -"]))
let runResponse = try CommandResponse(
ok: true, command: "workflow", schemaVersion: "prowl.cli.workflow.v1",
data: RawJSON(encoding: WorkflowCommandPayload.run(run)))
Expand Down Expand Up @@ -1405,35 +1405,35 @@ final class ProwlCLIIntegrationTests: XCTestCase {
let doneResponse = try CommandResponse(
ok: true, command: "workflow", schemaVersion: "prowl.cli.workflow.v1",
data: RawJSON(
encoding: WorkflowCommandPayload.done(
WorkflowDonePayload(
encoding: WorkflowCommandPayload.deliver(
WorkflowDeliverPayload(
run: run,
delivery: WorkflowDeliveryPayload(
state: .provisional, ordinal: 1, step: "brief", role: "author", output: output,
warnings: [WorkflowDeliveryWarningPayload(code: "missing_sections", message: "missing section(s) ## Claims")])
))))
let (doneRequest, doneResult) = try runWithMockServer(
socketPath: temporarySocketPath(suffix: "workflow-done"), response: doneResponse,
args: ["workflow", "done", "-", "--verdict", "clean", "--json"],
args: ["workflow", "deliver", "-", "--verdict", "clean", "--json"],
stdinData: Data("## Scope\nOnly the scope.\n".utf8),
environment: [WorkflowSchema.tokenEnvironmentKey: "T"])
XCTAssertEqual(doneResult.exitCode, 0, doneResult.stderr)
let doneEnvelope = try JSONDecoder().decode(CommandEnvelope.self, from: doneRequest)
guard case .workflow(let doneInput) = doneEnvelope.command else { return XCTFail("Expected a workflow envelope") }
XCTAssertEqual(doneInput.action, .done)
XCTAssertEqual(doneInput.action, .deliver)
XCTAssertEqual(doneInput.body, "## Scope\nOnly the scope.\n")
XCTAssertEqual(doneInput.verdict, "clean")
XCTAssertEqual(doneInput.token, "T", "the token comes from the environment the step handed out")
XCTAssertNil(doneInput.runID)
XCTAssertFalse(doneInput.force)
let doneText = try runWithMockServer(
socketPath: temporarySocketPath(suffix: "workflow-done-text"), response: doneResponse,
args: ["workflow", "done", "-", "--no-color"], stdinData: Data("x".utf8)).1
args: ["workflow", "deliver", "-", "--no-color"], stdinData: Data("x".utf8)).1
XCTAssertEqual(doneText.exitCode, 0, doneText.stderr)
XCTAssertTrue(doneText.stdout.contains("Provisional"), doneText.stdout)
XCTAssertTrue(doneText.stdout.contains("missing_sections"), doneText.stdout)

let noStdin = try runProwl(args: ["workflow", "done", "-"], environment: [ProwlSocket.environmentKey: "/nonexistent.sock"])
let noStdin = try runProwl(args: ["workflow", "deliver", "-"], environment: [ProwlSocket.environmentKey: "/nonexistent.sock"])
XCTAssertNotEqual(noStdin.exitCode, 0)
}

Expand Down
2 changes: 1 addition & 1 deletion ProwlCLITests/WorkflowActionContractTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ struct WorkflowActionContractTests {

@Test func scriptEnvironmentDisablesBytecodeAndKeepsOnlyAllowedValues() throws {
let source = yaml.replacing("entrypoint: main.py", with:
"entrypoint: main.py\n environment: [SELECTED_VALUE, PYTHONDONTWRITEBYTECODE]")
"entrypoint: main.py\n inherit_env: [SELECTED_VALUE, PYTHONDONTWRITEBYTECODE]")
let action = try WorkflowScriptAction.parse(source, id: "count")
let environment = WorkflowPreparedBundle.environment(for: action, inherited: [
"PATH": "/usr/bin:/bin", "SELECTED_VALUE": "included", "UNSELECTED_VALUE": "excluded",
Expand Down
Loading
Loading