diff --git a/ProwlCLI/Commands/WorkflowCommand.swift b/ProwlCLI/Commands/WorkflowCommand.swift index 7d6d844bf..ba63ce62c 100644 --- a/ProwlCLI/Commands/WorkflowCommand.swift +++ b/ProwlCLI/Commands/WorkflowCommand.swift @@ -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 @@ -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 /.prowl/workflows. `validate` and `schema` work with Prowl closed; `list` needs the app. + and /.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, ] @@ -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=, pick=.") + 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 .") + } + 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) } } } @@ -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 } @@ -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()) } } } diff --git a/ProwlCLI/Output/OutputRenderer+Workflow.swift b/ProwlCLI/Output/OutputRenderer+Workflow.swift index 657bf7cee..97c79711a 100644 --- a/ProwlCLI/Output/OutputRenderer+Workflow.swift +++ b/ProwlCLI/Output/OutputRenderer+Workflow.swift @@ -10,6 +10,10 @@ extension OutputRenderer { switch payload { case .list(let list): print(workflowListText(list)) + case .run(let run), .status(let run), .cancel(let run): + print(workflowRunText(run)) + case .done(let done): + print(workflowDoneText(done)) case .validate(let validate): print(workflowValidateText(validate)) case .schema(let schema): @@ -22,7 +26,9 @@ extension OutputRenderer { if let worktree = payload.worktree { lines.append("Worktree: \(worktree.name.bold) \(worktree.path.dim)") } - lines.append("Sources: bundle \(payload.sources.bundle ?? "—") user \(payload.sources.user) repo \(payload.sources.repo ?? "—")".dim) + lines.append( + "Sources: bundle \(payload.sources.bundle ?? "—") user \(payload.sources.user) repo \(payload.sources.repo ?? "—")" + .dim) guard !payload.workflows.isEmpty else { lines.append("No workflow definitions found.") return lines.joined(separator: "\n") @@ -43,11 +49,98 @@ extension OutputRenderer { return lines.joined(separator: "\n") } + static func workflowRunText(_ payload: WorkflowRunPayload) -> String { + var lines = [ + "Run: \(payload.id.bold) \(payload.workflow.id) (\(payload.workflow.name)) [\(payload.source.rawValue)]" + ] + lines.append("Status: \(workflowStatusText(payload.status))") + if let step = payload.step { + var line = "Step: \(step)" + if let activation = payload.activation { + line += + " waiting for '\(activation.role)' → output '\(activation.output)' (\(activation.state))" + } + lines.append(line) + } + if let role = payload.role { + lines.append("Your role: \(role.bold)") + } + if let activation = payload.activation, !activation.expect.completion.isEmpty { + lines.append("Finish with: \(activation.expect.completion.joined(separator: " or "))") + } + lines.append("Worktree: \(payload.worktree.name) \(payload.worktree.path.dim)") + lines.append("Run directory: \(payload.runDirectory.dim)") + if !payload.bindings.isEmpty { + lines.append("Bindings:") + for (role, binding) in payload.bindings.sorted(by: { $0.key < $1.key }) { + var parts = [" \(role.bold) \(binding.source.rawValue)"] + if let profile = binding.profile { + parts.append("\(profile.name) (\(profile.agent))") + } + if let pane = binding.pane { + let agent = pane.agent.map { " (\($0))" } ?? "" + parts.append("\(pane.handle) \(pane.displayName)\(agent)") + } + lines.append(parts.joined(separator: " ")) + } + } + if !payload.outputs.isEmpty { + lines.append("Outputs:") + for (name, output) in payload.outputs.sorted(by: { $0.key < $1.key }) { + let verdict = output.verdict.map { " verdict \($0)" } ?? "" + lines.append(" \(name.bold) \(output.latestPath.dim)\(verdict)") + } + } + if let selfInitiated = payload.selfInitiated { + lines.append("This pane is the current role; nothing was typed. Follow this line yourself:") + lines.append(" \(selfInitiated.line)") + } + return lines.joined(separator: "\n") + } + + static func workflowDoneText(_ payload: WorkflowDonePayload) -> String { + let delivery = payload.delivery + var lines: [String] = [] + switch delivery.state { + case .delivered: + lines.append( + "\("Delivered".green) output '\(delivery.output.name)' for step '\(delivery.step)' (invocation \(delivery.ordinal))" + ) + case .provisional: + lines.append( + "\("Provisional".yellow) output '\(delivery.output.name)' for step '\(delivery.step)' is on disk but needs a decision in Prowl:" + ) + for warning in delivery.warnings { + lines.append(" - \(warning.message) [\(warning.code)]") + } + } + lines.append(" \(delivery.output.path.dim)") + lines.append("Run: \(payload.run.id) \(workflowStatusText(payload.run.status))") + return lines.joined(separator: "\n") + } + + private static func workflowStatusText(_ status: WorkflowRunStatusPayload) -> String { + switch status.state { + case "running": return "running".green + case "needs_attention": + let detail = status.attention.map { " — \($0.message)" } ?? "" + return "needs attention".yellow + detail + case "completed": return "completed".green + case "skipped": + let detail = [status.step, status.dependent].compactMap { $0 }.joined(separator: " → ") + return "skipped".yellow + (detail.isEmpty ? "" : " (\(detail))") + case "cancelled", "interrupted", "max_rounds_reached": + return status.state.replacing("_", with: " ").red + default: return status.state + } + } + static func workflowValidateText(_ payload: WorkflowValidatePayload) -> String { var lines = payload.diagnostics.map { diagnostic in - let position = diagnostic.line.map { line in - ":\(line)" + (diagnostic.column.map { ":\($0)" } ?? "") - } ?? "" + let position = + diagnostic.line.map { line in + ":\(line)" + (diagnostic.column.map { ":\($0)" } ?? "") + } ?? "" let severity = diagnostic.severity == .error ? "error".red : "warning".yellow return "\(payload.path)\(position): \(severity)[\(diagnostic.code)]: \(diagnostic.message)" } @@ -55,7 +148,8 @@ extension OutputRenderer { let warnings = payload.diagnostics.count - errors let identity = payload.workflow.map { "\($0.id) (\($0.name))" } ?? payload.path if payload.valid { - lines.append("\("OK".green) \(identity)\(warnings > 0 ? " \(warnings) warning(s)".yellow : "")") + lines.append( + "\("OK".green) \(identity)\(warnings > 0 ? " \(warnings) warning(s)".yellow : "")") } else { lines.append("\("INVALID".red) \(identity) \(errors) error(s), \(warnings) warning(s)") } @@ -64,7 +158,8 @@ extension OutputRenderer { private static func renderWorkflowSchema(_ payload: WorkflowSchemaPayload) { if let object = try? JSONSerialization.jsonObject(with: payload.schema.bytes), - let pretty = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) + let pretty = try? JSONSerialization.data( + withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) { FileHandle.standardOutput.write(pretty) FileHandle.standardOutput.write(Data([UInt8(ascii: "\n")])) diff --git a/ProwlCLIContracts/Resources/cli-output-schema.json b/ProwlCLIContracts/Resources/cli-output-schema.json index 4ddc43d0c..c08232e2f 100644 --- a/ProwlCLIContracts/Resources/cli-output-schema.json +++ b/ProwlCLIContracts/Resources/cli-output-schema.json @@ -3823,6 +3823,18 @@ { "$ref": "#/$defs/workflowListData" }, + { + "$ref": "#/$defs/workflowRunData" + }, + { + "$ref": "#/$defs/workflowStatusData" + }, + { + "$ref": "#/$defs/workflowDoneData" + }, + { + "$ref": "#/$defs/workflowCancelData" + }, { "$ref": "#/$defs/workflowValidateData" }, @@ -3953,6 +3965,742 @@ } } }, + "workflowRunData": { + "type": "object", + "additionalProperties": false, + "required": [ + "action", + "id", + "workflow", + "scope", + "source", + "status", + "worktree", + "run_directory", + "bindings", + "outputs", + "started_at", + "updated_at" + ], + "properties": { + "action": { + "const": "run" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "workflow": { + "$ref": "#/$defs/workflowIdentity" + }, + "scope": { + "$ref": "#/$defs/workflowScope" + }, + "definition_path": { + "type": "string" + }, + "source": { + "enum": [ + "live", + "record" + ] + }, + "status": { + "$ref": "#/$defs/workflowRunStatus" + }, + "step": { + "type": "string" + }, + "role": { + "type": "string" + }, + "worktree": { + "$ref": "#/$defs/workflowRunWorktree" + }, + "run_directory": { + "type": "string" + }, + "bindings": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/workflowBinding" + } + }, + "activation": { + "$ref": "#/$defs/workflowActivation" + }, + "outputs": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/workflowOutput" + } + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "finished_at": { + "type": "string", + "format": "date-time" + }, + "self_initiated": { + "$ref": "#/$defs/workflowSelfInitiated" + } + } + }, + "workflowStatusData": { + "type": "object", + "additionalProperties": false, + "required": [ + "action", + "id", + "workflow", + "scope", + "source", + "status", + "worktree", + "run_directory", + "bindings", + "outputs", + "started_at", + "updated_at" + ], + "properties": { + "action": { + "const": "status" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "workflow": { + "$ref": "#/$defs/workflowIdentity" + }, + "scope": { + "$ref": "#/$defs/workflowScope" + }, + "definition_path": { + "type": "string" + }, + "source": { + "enum": [ + "live", + "record" + ] + }, + "status": { + "$ref": "#/$defs/workflowRunStatus" + }, + "step": { + "type": "string" + }, + "role": { + "type": "string" + }, + "worktree": { + "$ref": "#/$defs/workflowRunWorktree" + }, + "run_directory": { + "type": "string" + }, + "bindings": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/workflowBinding" + } + }, + "activation": { + "$ref": "#/$defs/workflowActivation" + }, + "outputs": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/workflowOutput" + } + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "finished_at": { + "type": "string", + "format": "date-time" + }, + "self_initiated": { + "$ref": "#/$defs/workflowSelfInitiated" + } + } + }, + "workflowCancelData": { + "type": "object", + "additionalProperties": false, + "required": [ + "action", + "id", + "workflow", + "scope", + "source", + "status", + "worktree", + "run_directory", + "bindings", + "outputs", + "started_at", + "updated_at" + ], + "properties": { + "action": { + "const": "cancel" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "workflow": { + "$ref": "#/$defs/workflowIdentity" + }, + "scope": { + "$ref": "#/$defs/workflowScope" + }, + "definition_path": { + "type": "string" + }, + "source": { + "enum": [ + "live", + "record" + ] + }, + "status": { + "$ref": "#/$defs/workflowRunStatus" + }, + "step": { + "type": "string" + }, + "role": { + "type": "string" + }, + "worktree": { + "$ref": "#/$defs/workflowRunWorktree" + }, + "run_directory": { + "type": "string" + }, + "bindings": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/workflowBinding" + } + }, + "activation": { + "$ref": "#/$defs/workflowActivation" + }, + "outputs": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/workflowOutput" + } + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "finished_at": { + "type": "string", + "format": "date-time" + }, + "self_initiated": { + "$ref": "#/$defs/workflowSelfInitiated" + } + } + }, + "workflowDoneData": { + "type": "object", + "additionalProperties": false, + "required": [ + "action", + "run", + "delivery" + ], + "properties": { + "action": { + "const": "done" + }, + "run": { + "$ref": "#/$defs/workflowRun" + }, + "delivery": { + "$ref": "#/$defs/workflowDelivery" + } + } + }, + "workflowRun": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "workflow", + "scope", + "source", + "status", + "worktree", + "run_directory", + "bindings", + "outputs", + "started_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "workflow": { + "$ref": "#/$defs/workflowIdentity" + }, + "scope": { + "$ref": "#/$defs/workflowScope" + }, + "definition_path": { + "type": "string" + }, + "source": { + "enum": [ + "live", + "record" + ] + }, + "status": { + "$ref": "#/$defs/workflowRunStatus" + }, + "step": { + "type": "string" + }, + "role": { + "type": "string" + }, + "worktree": { + "$ref": "#/$defs/workflowRunWorktree" + }, + "run_directory": { + "type": "string" + }, + "bindings": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/workflowBinding" + } + }, + "activation": { + "$ref": "#/$defs/workflowActivation" + }, + "outputs": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/workflowOutput" + } + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "finished_at": { + "type": "string", + "format": "date-time" + }, + "self_initiated": { + "$ref": "#/$defs/workflowSelfInitiated" + } + } + }, + "workflowRunStatus": { + "type": "object", + "additionalProperties": false, + "required": [ + "state" + ], + "properties": { + "state": { + "enum": [ + "running", + "needs_attention", + "completed", + "cancelled", + "skipped", + "max_rounds_reached", + "interrupted" + ] + }, + "step": { + "type": "string" + }, + "dependent": { + "type": "string" + }, + "attention": { + "$ref": "#/$defs/workflowAttention" + } + } + }, + "workflowAttention": { + "type": "object", + "additionalProperties": false, + "required": [ + "reason", + "message", + "step", + "actions" + ], + "properties": { + "reason": { + "type": "string" + }, + "message": { + "type": "string" + }, + "step": { + "type": "string" + }, + "role": { + "type": "string" + }, + "ordinal": { + "type": "integer", + "minimum": 1 + }, + "actions": { + "type": "array", + "items": { + "enum": [ + "focus_pane", + "nudge", + "keep_waiting", + "retry", + "relaunch", + "accept_delivery", + "accept_with_verdict", + "ask_again", + "skip", + "cancel" + ] + } + }, + "issues": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "workflowRunWorktree": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "branch", + "path" + ], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "workflowBinding": { + "type": "object", + "additionalProperties": false, + "required": [ + "source" + ], + "properties": { + "source": { + "enum": [ + "current", + "launch", + "pick" + ] + }, + "profile": { + "$ref": "#/$defs/workflowProfileBinding" + }, + "pane": { + "$ref": "#/$defs/workflowPaneBinding" + } + } + }, + "workflowProfileBinding": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "agent" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "agent": { + "type": "string" + } + } + }, + "workflowPaneBinding": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "handle", + "display_name" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "tab_id": { + "type": "string", + "format": "uuid" + }, + "handle": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "agent": { + "type": "string" + } + } + }, + "workflowActivation": { + "type": "object", + "additionalProperties": false, + "required": [ + "ordinal", + "step", + "role", + "state", + "output", + "expect" + ], + "properties": { + "ordinal": { + "type": "integer", + "minimum": 1 + }, + "step": { + "type": "string" + }, + "role": { + "type": "string" + }, + "state": { + "enum": [ + "waiting", + "persisting", + "provisional", + "delivered", + "skipped", + "revoked" + ] + }, + "dispatch_id": { + "type": "string" + }, + "output": { + "type": "string" + }, + "expect": { + "$ref": "#/$defs/workflowExpectation" + }, + "deadline": { + "type": "string", + "format": "date-time" + } + } + }, + "workflowExpectation": { + "type": "object", + "additionalProperties": false, + "required": [ + "format", + "sections", + "strict", + "completion" + ], + "properties": { + "format": { + "enum": [ + "markdown", + "text", + "json" + ] + }, + "sections": { + "type": "array", + "items": { + "type": "string" + } + }, + "verdict": { + "type": "array", + "items": { + "type": "string" + } + }, + "strict": { + "type": "boolean" + }, + "completion": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "workflowOutput": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "ordinal", + "path", + "latest_path", + "delivered_at" + ], + "properties": { + "name": { + "type": "string" + }, + "ordinal": { + "type": "integer", + "minimum": 1 + }, + "path": { + "type": "string" + }, + "latest_path": { + "type": "string" + }, + "verdict": { + "type": "string" + }, + "delivered_at": { + "type": "string", + "format": "date-time" + } + } + }, + "workflowSelfInitiated": { + "type": "object", + "additionalProperties": false, + "required": [ + "line", + "completion" + ], + "properties": { + "line": { + "type": "string" + }, + "instruction_path": { + "type": "string" + }, + "completion": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "workflowDelivery": { + "type": "object", + "additionalProperties": false, + "required": [ + "state", + "ordinal", + "step", + "role", + "output", + "warnings" + ], + "properties": { + "state": { + "enum": [ + "delivered", + "provisional" + ] + }, + "ordinal": { + "type": "integer", + "minimum": 1 + }, + "step": { + "type": "string" + }, + "role": { + "type": "string" + }, + "output": { + "$ref": "#/$defs/workflowOutput" + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/$defs/workflowDeliveryWarning" + } + } + } + }, + "workflowDeliveryWarning": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, "workflowValidateData": { "type": "object", "additionalProperties": false, diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index 999fed5e1..8c95e4dd5 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -1284,6 +1284,103 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertTrue(text.stdout.contains("1 warning(s)"), text.stdout) } + 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, + deliveredAt: "2026-08-30T01:02:03.000Z") + let run = WorkflowRunPayload( + id: "0BADCAFE-0000-4000-8000-000000000042", + workflow: WorkflowIdentity(id: "review", name: "Review"), + scope: .repo, + definitionPath: "/Projects/App/.prowl/workflows/review.yaml", + source: .live, + status: WorkflowRunStatusPayload(state: "running"), + step: "brief", + role: "author", + worktree: WorkflowRunWorktreePayload(id: "wt", name: "feature", branch: "feat/x", path: "/Projects/App"), + runDirectory: "/Projects/App/.prowl/workflow-runs/R", + bindings: [ + "author": WorkflowBindingPayload( + source: .current, + pane: WorkflowPaneBindingPayload( + id: "00000000-0000-0000-0000-000000000001", tabID: nil, handle: "p1", displayName: "Claude Code", + agent: "claude")) + ], + 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 -"]), + deadline: nil), + outputs: [:], + 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 -", + instructionPath: "/Projects/App/.prowl/workflow-runs/R/instructions/brief.1.md", + completion: ["PROWL_WORKFLOW_TOKEN=T prowl workflow done -"])) + let runResponse = try CommandResponse( + ok: true, command: "workflow", schemaVersion: "prowl.cli.workflow.v1", + data: RawJSON(encoding: WorkflowCommandPayload.run(run))) + let (runRequest, runResult) = try runWithMockServer( + socketPath: temporarySocketPath(suffix: "workflow-run"), response: runResponse, + args: ["workflow", "run", "review", "p3", "--role", "reviewer=Codex", "--input", "rounds=2", "--skip", "x", "--json"]) + XCTAssertEqual(runResult.exitCode, 0, runResult.stderr) + let runEnvelope = try JSONDecoder().decode(CommandEnvelope.self, from: runRequest) + guard case .workflow(let runInput) = runEnvelope.command else { return XCTFail("Expected a workflow envelope") } + XCTAssertEqual(runInput.action, .run) + XCTAssertEqual(runInput.workflow, "review") + XCTAssertEqual(runInput.target, .auto("p3")) + XCTAssertEqual(runInput.roleBindings, ["reviewer=Codex"]) + XCTAssertEqual(runInput.inputValues, ["rounds=2"]) + XCTAssertEqual(runInput.skippedSteps, ["x"]) + let runOutput = try jsonObject(from: runResult.stdout) + XCTAssertEqual(((runOutput["data"] as? [String: Any])?["self_initiated"] as? [String: Any])?["instruction_path"] as? String, + "/Projects/App/.prowl/workflow-runs/R/instructions/brief.1.md") + let runText = try runWithMockServer( + socketPath: temporarySocketPath(suffix: "workflow-run-text"), response: runResponse, + args: ["workflow", "run", "review", "--no-color"]).1 + XCTAssertEqual(runText.exitCode, 0, runText.stderr) + XCTAssertTrue(runText.stdout.contains("Run: 0BADCAFE-0000-4000-8000-000000000042"), runText.stdout) + XCTAssertTrue(runText.stdout.contains("Follow this line yourself"), runText.stdout) + + let doneResponse = try CommandResponse( + ok: true, command: "workflow", schemaVersion: "prowl.cli.workflow.v1", + data: RawJSON( + encoding: WorkflowCommandPayload.done( + WorkflowDonePayload( + 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"], + 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.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 + 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"]) + XCTAssertNotEqual(noStdin.exitCode, 0) + } + // MARK: - skills (local-only) func testSkillsListIsLocalOnlyAndValidatesAgainstSchema() throws { diff --git a/ProwlCLITests/WorkflowCommandParsingTests.swift b/ProwlCLITests/WorkflowCommandParsingTests.swift index 120a45537..281d4ee28 100644 --- a/ProwlCLITests/WorkflowCommandParsingTests.swift +++ b/ProwlCLITests/WorkflowCommandParsingTests.swift @@ -6,7 +6,15 @@ import XCTest final class WorkflowCommandParsingTests: XCTestCase { func testRootRoutesWorkflowSubcommands() throws { XCTAssertTrue(try ProwlCommand.parseAsRoot(["workflow", "list"]) is WorkflowListCommand) - XCTAssertTrue(try ProwlCommand.parseAsRoot(["workflow", "validate", "flow.yaml"]) is WorkflowValidateCommand) + XCTAssertTrue(try ProwlCommand.parseAsRoot(["workflow", "run", "demo"]) is WorkflowRunCommand) + XCTAssertTrue(try ProwlCommand.parseAsRoot(["workflow", "status"]) is WorkflowStatusCommand) + XCTAssertTrue(try ProwlCommand.parseAsRoot(["workflow", "done", "-"]) is WorkflowDoneCommand) + XCTAssertTrue( + try ProwlCommand.parseAsRoot(["workflow", "cancel", "00000000-0000-0000-0000-000000000000"]) + is WorkflowCancelCommand) + XCTAssertTrue( + try ProwlCommand.parseAsRoot(["workflow", "validate", "flow.yaml"]) is WorkflowValidateCommand + ) XCTAssertTrue(try ProwlCommand.parseAsRoot(["workflow", "schema"]) is WorkflowSchemaCommand) } @@ -15,7 +23,8 @@ final class WorkflowCommandParsingTests: XCTestCase { XCTAssertEqual(try bare.selector.resolve(positionalTarget: bare.target), .none) let positional = try WorkflowListCommand.parse(["p3"]) - XCTAssertEqual(try positional.selector.resolve(positionalTarget: positional.target), .auto("p3")) + XCTAssertEqual( + try positional.selector.resolve(positionalTarget: positional.target), .auto("p3")) let flag = try WorkflowListCommand.parse(["--worktree", "main"]) XCTAssertEqual(try flag.selector.resolve(positionalTarget: flag.target), .worktree("main")) @@ -27,7 +36,9 @@ final class WorkflowCommandParsingTests: XCTestCase { } func testValidateRequiresAFileAndAcceptsAScope() throws { - let command = try WorkflowValidateCommand.parse(["flows/review.yaml", "--scope", "repo", "--json"]) + let command = try WorkflowValidateCommand.parse([ + "flows/review.yaml", "--scope", "repo", "--json", + ]) XCTAssertEqual(command.file, "flows/review.yaml") XCTAssertEqual(command.scope, .repo) XCTAssertTrue(command.options.json) @@ -36,13 +47,91 @@ final class WorkflowCommandParsingTests: XCTestCase { XCTAssertThrowsError(try WorkflowValidateCommand.parse(["x.yaml", "--scope", "global"])) } + func testDoneParsesItsDeliveryOptions() throws { + let done = try XCTUnwrap( + ProwlCommand.parseAsRoot([ + "workflow", "done", "-", "--verdict", "clean", "--token", "T", "--run", + "0BADCAFE-0000-4000-8000-000000000042", "--step", "review", "--force", + ]) as? WorkflowDoneCommand) + XCTAssertEqual(done.input, "-") + XCTAssertEqual(done.verdict, "clean") + XCTAssertEqual(done.token, "T") + XCTAssertEqual(done.runID, "0BADCAFE-0000-4000-8000-000000000042") + XCTAssertEqual(done.step, "review") + XCTAssertTrue(done.force) + let file = try XCTUnwrap( + ProwlCommand.parseAsRoot(["workflow", "done", "--file", "/tmp/out.md"]) + as? WorkflowDoneCommand) + XCTAssertEqual(file.file, "/tmp/out.md") + XCTAssertNil(file.input) + } + + func testDoneRejectsMissingOrDoubledBodiesAndHalfManualTargets() { + XCTAssertThrowsError(try ProwlCommand.parseAsRoot(["workflow", "done"])) + XCTAssertThrowsError( + try ProwlCommand.parseAsRoot(["workflow", "done", "-", "--file", "/tmp/out.md"])) + XCTAssertThrowsError(try ProwlCommand.parseAsRoot(["workflow", "done", "out.md"])) + XCTAssertThrowsError(try ProwlCommand.parseAsRoot(["workflow", "done", "-", "--run", "id"])) + XCTAssertThrowsError(try ProwlCommand.parseAsRoot(["workflow", "done", "-", "--step", "s"])) + XCTAssertThrowsError(try ProwlCommand.parseAsRoot(["workflow", "done", "-", "--force"])) + XCTAssertNoThrow( + try ProwlCommand.parseAsRoot([ + "workflow", "done", "-", "--run", "id", "--step", "s", "--force", + ])) + } + + func testRunParsesRepeatableBindingsInputsAndSkips() throws { + let run = try XCTUnwrap( + ProwlCommand.parseAsRoot([ + "workflow", "run", "prowl.adversarial-review", "p3", "--role", "reviewer=Codex", "--role", + "partner=p5", + "--input", "max_rounds=3", "--skip", "brief", + ]) as? WorkflowRunCommand) + XCTAssertEqual(run.workflow, "prowl.adversarial-review") + XCTAssertEqual(run.source, "p3") + XCTAssertEqual(run.role, ["reviewer=Codex", "partner=p5"]) + XCTAssertEqual(run.input, ["max_rounds=3"]) + XCTAssertEqual(run.skip, ["brief"]) + } + + func testWorkflowDoneEnvelopeCarriesTheBodyAndToken() throws { + let envelope = CommandEnvelope( + output: .json, + command: .workflow( + WorkflowInput( + action: .done, runID: "r", stepID: "s", body: "# Out\n", verdict: "clean", token: "T", + force: true))) + let decoded = try JSONDecoder().decode( + CommandEnvelope.self, from: try JSONEncoder().encode(envelope)) + guard case .workflow(let input) = decoded.command else { + return XCTFail("Expected a workflow envelope") + } + XCTAssertEqual(input.action, .done) + XCTAssertEqual(input.body, "# Out\n") + XCTAssertEqual(input.token, "T") + XCTAssertEqual(input.runID, "r") + XCTAssertEqual(input.stepID, "s") + XCTAssertTrue(input.force) + } + func testWorkflowInputEnvelopeEncodesTheTarget() throws { - let envelope = CommandEnvelope(output: .json, command: .workflow(WorkflowInput(action: .list, target: .auto("p3")))) + let envelope = CommandEnvelope( + output: .json, + command: .workflow( + WorkflowInput( + action: .run, target: .auto("p3"), workflow: "demo", roleBindings: ["reviewer=Codex"], + inputValues: ["rounds=3"], skippedSteps: ["brief"]))) let data = try JSONEncoder().encode(envelope) let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) - guard case .workflow(let input) = decoded.command else { return XCTFail("Expected a workflow envelope") } - XCTAssertEqual(input.action, .list) + guard case .workflow(let input) = decoded.command else { + return XCTFail("Expected a workflow envelope") + } + XCTAssertEqual(input.action, .run) XCTAssertEqual(input.target, .auto("p3")) + XCTAssertEqual(input.workflow, "demo") + XCTAssertEqual(input.roleBindings, ["reviewer=Codex"]) + XCTAssertEqual(input.inputValues, ["rounds=3"]) + XCTAssertEqual(input.skippedSteps, ["brief"]) XCTAssertEqual(decoded.command.name, "workflow") } } diff --git a/ProwlCLITests/WorkflowSchemaTests.swift b/ProwlCLITests/WorkflowSchemaTests.swift index c2f270a7c..c3748ada7 100644 --- a/ProwlCLITests/WorkflowSchemaTests.swift +++ b/ProwlCLITests/WorkflowSchemaTests.swift @@ -23,11 +23,103 @@ final class WorkflowSchemaTests: XCTestCase { #"{"ok":true,"command":"workflow","schema_version":"prowl.cli.workflow.v1","data":{"action":"schema","schema":{"$id":"x","type":"object"}}}"# let error = #"{"ok":false,"command":"workflow","schema_version":"prowl.cli.workflow.v1","error":{"code":"WORKFLOW_INVALID","message":"2 error(s).","details":{"action":"validate","path":"/x.yaml","valid":false,"diagnostics":[]}}}"# - for instance in [list, listWithoutWorktree, validate, schema, error] { + let run = + ###"{"ok":true,"command":"workflow","schema_version":"prowl.cli.workflow.v1","data":{"action":"run",\###(Self.runFields)}}"### + let status = + ###"{"ok":true,"command":"workflow","schema_version":"prowl.cli.workflow.v1","data":{"action":"status",\###(Self.recordFields)}}"### + let cancel = + ###"{"ok":true,"command":"workflow","schema_version":"prowl.cli.workflow.v1","data":{"action":"cancel",\###(Self.runFields)}}"### + let done = + ###"{"ok":true,"command":"workflow","schema_version":"prowl.cli.workflow.v1","data":{"action":"done","run":{\###(Self.runFields)},"delivery":{"state":"provisional","ordinal":1,"step":"brief","role":"author","output":\###(Self.output),"warnings":[{"code":"missing_sections","message":"missing ## Claims"}]}}}"### + for instance in [list, listWithoutWorktree, validate, schema, error, run, status, cancel, done] { try assertValidity(instance, expected: true) } } + func testOutputSchemaRejectsMalformedRuntimePayloads() throws { + let badDeliveryState = + ###"{"ok":true,"command":"workflow","schema_version":"prowl.cli.workflow.v1","data":{"action":"done","run":{\###(Self.runFields)},"delivery":{"state":"accepted","ordinal":1,"step":"brief","role":"author","output":\###(Self.output),"warnings":[]}}}"### + let badBindingSource = + ###"{"ok":true,"command":"workflow","schema_version":"prowl.cli.workflow.v1","data":{"action":"run",\###(Self.runFields.replacingOccurrences(of: #""source":"current""#, with: #""source":"remote""#))}}"### + let badState = + ###"{"ok":true,"command":"workflow","schema_version":"prowl.cli.workflow.v1","data":{"action":"status",\###(Self.recordFields.replacingOccurrences(of: #""state":"interrupted""#, with: #""state":"paused""#))}}"### + let missingRunDirectory = + ###"{"ok":true,"command":"workflow","schema_version":"prowl.cli.workflow.v1","data":{"action":"cancel",\###(Self.runFields.replacingOccurrences(of: #""run_directory":"/Projects/App/.prowl/workflow-runs/0BADCAFE-0000-4000-8000-000000000042","#, with: ""))}}"### + for instance in [badDeliveryState, badBindingSource, badState, missingRunDirectory] { + try assertValidity(instance, expected: false) + } + } + + func testRuntimePayloadsRoundTripThroughCodable() throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let output = WorkflowOutputPayload( + name: "brief", ordinal: 1, path: "/r/outputs/brief.1.md", latestPath: "/r/outputs/brief.md", verdict: nil, + deliveredAt: "2026-08-30T01:02:03Z") + let run = WorkflowRunPayload( + id: "0BADCAFE-0000-4000-8000-000000000042", + workflow: WorkflowIdentity(id: "prowl.adversarial-review", name: "Adversarial Review"), + scope: .repo, + definitionPath: "/Projects/App/.prowl/workflows/review.yaml", + source: .live, + status: WorkflowRunStatusPayload( + state: "needs_attention", step: "brief", + attention: WorkflowAttentionPayload( + reason: "delivery_issues", message: "m", step: "brief", role: "author", ordinal: 1, + actions: ["accept_delivery", "ask_again", "skip", "cancel"], issues: ["missing_sections"])), + step: "brief", + role: "author", + worktree: WorkflowRunWorktreePayload(id: "wt", name: "feature", branch: "feat/x", path: "/Projects/App"), + runDirectory: "/r", + bindings: [ + "author": WorkflowBindingPayload( + source: .current, + pane: WorkflowPaneBindingPayload( + id: "00000000-0000-0000-0000-000000000001", tabID: nil, handle: "p1", displayName: "Claude Code", + agent: "claude")), + "reviewer": WorkflowBindingPayload( + source: .launch, + profile: WorkflowProfileBindingPayload(id: "00000000-0000-0000-0000-000000000009", name: "Pi", agent: "pi")), + ], + activation: WorkflowActivationPayload( + ordinal: 1, step: "brief", role: "author", state: "provisional", dispatchID: "dispatch-1", output: "brief", + expect: WorkflowExpectationPayload( + format: .markdown, sections: ["## Scope"], verdict: nil, strict: false, + completion: ["PROWL_WORKFLOW_TOKEN=T prowl workflow done -"]), + deadline: nil), + outputs: ["brief": output], + startedAt: "2026-08-30T01:00:00Z", + updatedAt: "2026-08-30T01:02:03Z", + finishedAt: nil, + selfInitiated: WorkflowSelfInitiatedPayload( + line: "[Prowl] Read /r/instructions/brief.1.md and follow it — finish with: PROWL_WORKFLOW_TOKEN=T prowl workflow done -", + instructionPath: "/r/instructions/brief.1.md", + completion: ["PROWL_WORKFLOW_TOKEN=T prowl workflow done -"])) + let done = WorkflowCommandPayload.done( + WorkflowDonePayload( + run: run, + delivery: WorkflowDeliveryPayload( + state: .provisional, ordinal: 1, step: "brief", role: "author", output: output, + warnings: [WorkflowDeliveryWarningPayload(code: "missing_sections", message: "missing ## Claims")]))) + for payload in [WorkflowCommandPayload.run(run), .status(run), .cancel(run), done] { + let data = try encoder.encode(payload) + XCTAssertTrue(String(decoding: data, as: UTF8.self).hasPrefix(#"{"action":"\#(payload.action.rawValue)""#)) + XCTAssertEqual(try JSONDecoder().decode(WorkflowCommandPayload.self, from: data), payload) + try assertValidity( + #"{"ok":true,"command":"workflow","schema_version":"prowl.cli.workflow.v1","data":\#(String(decoding: data, as: UTF8.self))}"#, + expected: true) + } + } + + private static let output = + #"{"name":"brief","ordinal":1,"path":"/r/outputs/brief.1.md","latest_path":"/r/outputs/brief.md","delivered_at":"2026-08-30T01:02:03Z"}"# + + private static let runFields = + ###""id":"0BADCAFE-0000-4000-8000-000000000042","workflow":{"id":"prowl.adversarial-review","name":"Adversarial Review"},"scope":"repo","definition_path":"/Projects/App/.prowl/workflows/review.yaml","source":"live","status":{"state":"running"},"step":"brief","role":"author","worktree":{"id":"wt","name":"feature","branch":"feat/x","path":"/Projects/App"},"run_directory":"/Projects/App/.prowl/workflow-runs/0BADCAFE-0000-4000-8000-000000000042","bindings":{"author":{"source":"current","pane":{"id":"00000000-0000-0000-0000-000000000001","tab_id":"00000000-0000-0000-0000-000000000011","handle":"p1","display_name":"Claude Code","agent":"claude"}},"reviewer":{"source":"launch","profile":{"id":"00000000-0000-0000-0000-000000000009","name":"Pi Reviewer","agent":"pi"}}},"activation":{"ordinal":1,"step":"brief","role":"author","state":"waiting","dispatch_id":"dispatch-1","output":"brief","expect":{"format":"markdown","sections":["## Scope","## Claims"],"strict":false,"completion":["PROWL_WORKFLOW_TOKEN=T prowl workflow done -"]},"deadline":"2026-08-30T01:10:00Z"},"outputs":{},"started_at":"2026-08-30T01:00:00Z","updated_at":"2026-08-30T01:00:00Z","self_initiated":{"line":"[Prowl] Read /r/instructions/brief.1.md and follow it — finish with: PROWL_WORKFLOW_TOKEN=T prowl workflow done -","instruction_path":"/r/instructions/brief.1.md","completion":["PROWL_WORKFLOW_TOKEN=T prowl workflow done -"]}"### + + private static let recordFields = + ###""id":"0BADCAFE-0000-4000-8000-000000000042","workflow":{"id":"prowl.handoff","name":"Hand Off"},"scope":"bundle","source":"record","status":{"state":"interrupted"},"worktree":{"id":"wt","name":"feature","branch":"feat/x","path":"/Projects/App"},"run_directory":"/Projects/App/.prowl/workflow-runs/0BADCAFE-0000-4000-8000-000000000042","bindings":{"source":{"source":"current","pane":{"id":"00000000-0000-0000-0000-000000000001","handle":"p1","display_name":"shell"}}},"outputs":{"brief":\###(WorkflowSchemaTests.output)},"started_at":"2026-08-30T01:00:00Z","updated_at":"2026-08-30T01:05:00Z","finished_at":"2026-08-30T01:05:00Z""### + func testOutputSchemaRejectsUnknownFieldsBadScopesAndCrossActionFields() throws { let unknownField = #"{"ok":true,"command":"workflow","schema_version":"prowl.cli.workflow.v1","data":{"action":"list","sources":{"user":"/u"},"workflows":[],"extra":1}}"# diff --git a/docs-ai/013-prowl-cli/contracts/schema.md b/docs-ai/013-prowl-cli/contracts/schema.md index 846c40d8c..31b6f549e 100644 --- a/docs-ai/013-prowl-cli/contracts/schema.md +++ b/docs-ai/013-prowl-cli/contracts/schema.md @@ -22,7 +22,7 @@ The bundle has one versioned success-or-error response schema for every wire com | `agents.wait` | `#/$defs/agentsWaitResponse` (errors may carry `#/$defs/agentsWaitErrorDetails`) | | `profiles` | `#/$defs/profilesResponse` | | `skills` (local-only) | `#/$defs/skillsResponse` | -| `workflow` (`list` over the socket; `validate`/`schema` local-only) | `#/$defs/workflowResponse` | +| `workflow` (`list`, `run`, `status`, `done`, `cancel` over the socket; `validate`/`schema` local-only) | `#/$defs/workflowResponse` | | `focus` | `#/$defs/focusResponse` | | `send` | `#/$defs/sendResponse` | | `key` | `#/$defs/keyResponse` | diff --git a/docs-ai/013-prowl-cli/contracts/workflow.md b/docs-ai/013-prowl-cli/contracts/workflow.md index f5943bb94..66c42d82a 100644 --- a/docs-ai/013-prowl-cli/contracts/workflow.md +++ b/docs-ai/013-prowl-cli/contracts/workflow.md @@ -2,23 +2,36 @@ ## Status -Current version: `prowl.cli.workflow.v1` (docs-ai 063 B1). +Current version: `prowl.cli.workflow.v1` (docs-ai 063 B1 for `list` / `validate` / `schema`, +063 B3 for `run` / `status` / `done` / `cancel`). -`workflow` is the definitions surface of Agent Workflows: it discovers, validates, and -describes `prowl.workflow/v1` YAML files. `list` crosses the socket (the app resolves the -worktree and reads the enabled set); `validate` and `schema` are **local-only** and work with -Prowl closed. Every response uses `command: "workflow"` and one closed `data` object -discriminated by `action`. Running workflows (`run`, `status`, `done`, `cancel`) is a later -slice and is not part of this contract. +`workflow` is the surface of Agent Workflows: it discovers, validates, and describes +`prowl.workflow/v1` YAML files, and runs them. `list`, `run`, `status`, `done`, and `cancel` +cross the socket; `validate` and `schema` are **local-only** and work with Prowl closed. Every +response uses `command: "workflow"` and one closed `data` object discriminated by `action`. +The run protocol itself (roles, activations, tokens, the two-phase delivery) is specified in +[063 dsl-spec §9](../../063-agent-workflows/dsl-spec.md#9-cli-participant-protocol); this page +is the wire contract. ## Input ```bash prowl workflow list [target] [--target|--worktree|--tab|--pane ] [--json] +prowl workflow run [source] [--role =]... [--input =]... [--skip ]... [--json] +prowl workflow status [run-id] [--json] +prowl workflow done (-|--file ) [--verdict ] [--token ] [--run --step ] [--force] [--json] +prowl workflow cancel [--json] prowl workflow validate [--scope bundle|user|repo] [--json] prowl workflow schema [--json] ``` +Wire request: `command: "workflow"` with `action` (`list` | `run` | `status` | `done` | +`cancel`), `target` (060 selector), and the action's fields — `workflow`, `roleBindings[]`, +`inputValues[]`, `skippedSteps[]` (`run`); `runID` (`status`, `cancel`, `done`); `stepID`, +`body`, `verdict`, `token`, `force` (`done`). The CLI reads the `done` body itself (stdin or +`--file`, UTF-8, at most 4 MiB → `OUTPUT_TOO_LARGE` client-side) and fills `token` from +`--token` or `$PROWL_WORKFLOW_TOKEN`. + ### Sources and precedence | Scope | Directory | Notes | @@ -41,6 +54,72 @@ and are never shadowed; a file that does not parse is listed without an `id`. - Any selector follows the 060 targeting rules (`TARGET_NOT_FOUND` / `TARGET_NOT_UNIQUE`); a pane or tab selector resolves to its worktree. +### `run` source and preflight (063 B3, decisions W2/W4) + +The definition is the unshadowed entry with that `id`, or the unique one with that `name` +(`WORKFLOW_NOT_FOUND`; `INVALID_ARGUMENT` names the ids when several share a name). It must +be valid (`WORKFLOW_INVALID`, `details` = the validate payload) and enabled +(`WORKFLOW_DISABLED`). + +- **Source.** A workflow with a `current` role binds it to a pane: the caller's own pane when + no source is given (`SOURCE_REQUIRED` outside a pane), or an explicit pane / tab target + (`pN`, `tN`, UUID, `--pane`, `--tab`). A workflow without one takes a worktree: the caller's, + then the focused one, or any worktree target. A worktree target for a workflow with a + `current` role is `SOURCE_REQUIRED`. +- **Bindings** are frozen before any side effect, in role declaration order. `current`: the + source pane, which must not belong to another active run (`PANE_BUSY`), must not hold a + pending dispatch record (`DISPATCH_PENDING`; an activation is a dispatch record and a pane + holds one at a time — #733 D4), and must host a detected agent when a `message` step to it + survives the skips (`AGENT_NOT_FOUND`); a bare shell is a valid source otherwise. `pick`: + `--role =` is required (`INVALID_ARGUMENT`), inside the source worktree + (`TARGET_NOT_FOUND`), hosting a detected agent (`AGENT_NOT_FOUND`), not the source pane or + another bound pane (`INVALID_ARGUMENT`), not in another run (`PANE_BUSY`), not holding a + pending dispatch (`DISPATCH_PENDING`). `launch`: `--role =` + (`PROFILE_NOT_FOUND`, `PROFILE_NOT_UNIQUE`), else the remembered binding for the role's + requirements digest, else a profile matching `suggest`, else the repository's Recommended + profile; every candidate is re-validated (exists, enabled, satisfies `agents`, its runtime + accepts a seeded prompt) — a rejected override or remembered binding falls through and is + noted in the run log; nothing left is `PROFILE_NOT_FOUND`. The profile's launch plan is + frozen in memory (`WORKFLOW_FAILED` when it cannot be planned); only its id, name, and agent + reach the record. A `current` role takes no override, unknown roles and duplicate overrides + are `INVALID_ARGUMENT`. +- **Inputs / skips.** `--input` values are checked against the declared inputs (unknown, + missing without default, range, enum, single line → `INVALID_ARGUMENT`); `--skip` must name + a step with an `expect` whose output nothing that can still run requires + (`INVALID_ARGUMENT` names the dependent step). A worktree path that cannot be rendered on + one line is `UNSAFE_PATH`. +- **Reply point.** The run directory exists and `run.json` is written before the response; + the first step is already in progress. A self-initiated first step is answered only once its + activation record exists (or its opening failed and the run sits in attention), so the + returned completion command is attributable the moment the caller runs it; nothing was typed. + +### `done` attribution (decision W3) + +1. The caller pane (socket peer ancestry) and its pending dispatch record identify the + activation; the machine then checks the token (`TOKEN_REQUIRED`, `TOKEN_INVALID`) and the + body (`OUTPUT_INVALID`, `OUTPUT_TOO_LARGE`, `VERDICT_REQUIRED`). A pane whose record is not + a waiting workflow activation is `STEP_NOT_EXPECTING`. +2. `--run --step ` is the manual path: from outside any pane (else + `SOURCE_REQUIRED` without it), or from a pane that holds no workflow activation. It targets + the step's *current* activation without a token; the run must be live (`RUN_NOT_FOUND`), the + step must be the one waiting (`STEP_NOT_EXPECTING`). The run log records `source=manual`. +3. Both present and disagreeing (the caller pane waits for another step or run) is + `ROLE_MISMATCH` unless `--force`, which takes the manual path (`source=manual --force`). + +The response is sent only after the output reached the run directory (decision W1): a cancel +or skip that lands while it is being written answers `STEP_NOT_EXPECTING`, a write failure +`WORKFLOW_FAILED`; a client that disconnects first sees `REQUEST_CANCELLED` while the run +continues. `agents dispatch-complete` from a pane that owes a workflow delivery is refused with +`WORKFLOW_DELIVERY_REQUIRED` whose message carries the replacement `done` command. + +### `status` (decision W5) + +Without a run id the calling pane must belong to an active run (`SOURCE_REQUIRED` outside a +pane, `RUN_NOT_FOUND` otherwise). With one, a live run is reported from the app; a run the app +no longer holds is read from its `run.json` in any known worktree (`source: "record"`); neither +is `RUN_NOT_FOUND`. Runs an earlier app instance left `running` / `needs_attention` are marked +`interrupted` when their worktree loads; nothing is resumed. + ### `validate` scope `--scope` decides whether a `prowl.*` id is allowed. When omitted it is inferred from the @@ -97,6 +176,95 @@ valid; the app-side `list` always has the bundle. - `enabled` is the user's per-definition switch keyed by `/` (all enabled by default; the Settings toggle arrives with 063 D1). A file without an id is never enabled. +### `run` / `status` / `cancel` — the run object + +```json +{ + "ok": true, + "command": "workflow", + "schema_version": "prowl.cli.workflow.v1", + "data": { + "action": "run", + "id": "0BADCAFE-0000-4000-8000-000000000042", + "workflow": { "id": "review", "name": "Review" }, + "scope": "repo", + "definition_path": "/Projects/App/.prowl/workflows/review.yaml", + "source": "live", + "status": { "state": "running" }, + "step": "brief", + "role": "author", + "worktree": { "id": "…", "name": "feature", "branch": "feat/x", "path": "/Projects/App" }, + "run_directory": "/Projects/App/.prowl/workflow-runs/0BADCAFE-0000-4000-8000-000000000042", + "bindings": { + "author": { "source": "current", "pane": { "id": "…", "tab_id": "…", "handle": "p1", "display_name": "Claude Code", "agent": "claude" } }, + "reviewer": { "source": "launch", "profile": { "id": "…", "name": "Codex", "agent": "codex" } } + }, + "activation": { + "ordinal": 1, "step": "brief", "role": "author", "state": "waiting", "dispatch_id": "…", "output": "brief", + "expect": { "format": "markdown", "sections": ["## Scope", "## Claims"], "strict": false, + "completion": ["PROWL_WORKFLOW_TOKEN=… prowl workflow done -"] }, + "deadline": "2026-08-30T01:10:00.000Z" + }, + "outputs": {}, + "started_at": "2026-08-30T01:00:00.000Z", + "updated_at": "2026-08-30T01:00:00.000Z", + "self_initiated": { + "line": "[Prowl] Read …/instructions/brief.1.md and follow it — finish with: PROWL_WORKFLOW_TOKEN=… prowl workflow done -", + "instruction_path": "…/instructions/brief.1.md", + "completion": ["PROWL_WORKFLOW_TOKEN=… prowl workflow done -"] + } + } +} +``` + +- `source` is `live` (the app holds the run) or `record` (read from `run.json`; then + `activation` and `self_initiated` are absent and no token is spelled anywhere). +- `status.state` is `running` | `needs_attention` | `completed` | `cancelled` | `skipped` + (with `step` and `dependent`) | `max_rounds_reached` | `interrupted`; `status.attention` + carries `reason` (`needs_input`, `idle_without_delivery`, `blocked`, `agent_gone:`, + `injection_failed:`, `launch_failed`, `rendered_text_invalid`, `action_failed`, + `persist_failed`, `delivery_issues`, `timeout`), `message`, `step`, `role`, `ordinal`, + `actions[]` (the controls C1 will offer), and `issues[]` for a provisional delivery. +- `step` is the step in progress; absent once the run ended. `role` is the *verified* calling + pane's role when it is bound in the run; only when that pane owns the current activation does + `activation.expect.completion` spell the completion commands (they carry the token) — a + worktree-started run, a manual or forced `done`, and any other role's pane get an empty list. `activation` is the activation + waiting for, persisting, or holding a provisional delivery — the one `done` can address; a + step stuck in an injection or launch attention reports none. +- `bindings..profile` is the frozen profile (id, name, agent) of a `launch` role; + `bindings..pane` is the role's pane (`launch` roles gain it once launched). +- `outputs` is the latest delivered output per name (`name`, `ordinal`, `path`, + `latest_path`, `verdict`, `delivered_at`). +- `self_initiated` appears on `run` only, when the run started from the pane that is its + `current` role and the first step messages that role: the runner typed nothing. +- `cancel` returns the run after cancellation (`status.state: "cancelled"`). + +### `done` + +```json +{ + "ok": true, + "command": "workflow", + "schema_version": "prowl.cli.workflow.v1", + "data": { + "action": "done", + "run": { "…": "the run object above, after the delivery" }, + "delivery": { + "state": "provisional", + "ordinal": 1, "step": "brief", "role": "author", + "output": { "name": "brief", "ordinal": 1, "path": "…/outputs/brief.1.md", "latest_path": "…/outputs/brief.md", "delivered_at": "…" }, + "warnings": [{ "code": "missing_sections", "message": "missing section(s) ## Claims" }] + } + } +} +``` + +`delivery.state` is `delivered` (the output is the step's output and the run advanced) or +`provisional` (the body had issues a non-strict step tolerates — codes `missing_sections`, +`unparsable_json`, `verdict_missing`, `verdict_undeclared`, `verdict_unexpected` — it is on +disk and the run is in `needs_attention` until the user accepts it, asks again, or skips; +B3 offers no CLI control for that decision, C1 does). + ### `validate` ```json @@ -142,18 +310,28 @@ schema is printed alone, pretty-printed. | Code | When | | --- | --- | -| `WORKFLOW_INVALID` | `validate` found at least one error. `details` carries the full validate payload (`action`, `path`, `valid: false`, `workflow` when parsed, `diagnostics`). Exit status 1. | -| `WORKFLOW_NOT_FOUND` | Reserved for id-addressed actions of later slices. | -| `WORKFLOW_FAILED` | `list` could not read a source directory or encode the payload. | -| `TARGET_NOT_FOUND` / `TARGET_NOT_UNIQUE` | `list` selector resolution. | -| `PATH_NOT_FOUND` / `INVALID_ARGUMENT` | `validate` path is missing or a directory; conflicting selectors on `list`. | -| `APP_NOT_RUNNING` | `list` without a reachable app. Never raised by `validate` or `schema`. | +| `WORKFLOW_INVALID` | `validate` found at least one error, or `run` named a definition with errors. `details` carries the full validate payload. Exit status 1. | +| `WORKFLOW_NOT_FOUND` / `WORKFLOW_DISABLED` | `run`: no unshadowed definition with that id or unique name; or it is switched off. | +| `WORKFLOW_FAILED` | A source directory could not be read, the run directory could not be created, a profile could not be planned, an accepted output could not be saved, or a payload could not be encoded. | +| `SOURCE_REQUIRED` | `run` of a workflow with a `current` role outside a pane (or with a worktree target); `status` without a run id and `done` without `--run --step` outside a pane. | +| `PANE_BUSY` / `DISPATCH_PENDING` / `AGENT_NOT_FOUND` / `PROFILE_NOT_FOUND` / `PROFILE_NOT_UNIQUE` / `UNSAFE_PATH` | `run` preflight, see above. | +| `RUN_NOT_FOUND` | `cancel` / manual `done` of a run that is not live; `status ` of a run neither live nor recorded; `status` from a pane outside any active run. | +| `STEP_NOT_EXPECTING` / `TOKEN_REQUIRED` / `TOKEN_INVALID` / `ROLE_MISMATCH` | `done` attribution, see above. | +| `OUTPUT_INVALID` / `OUTPUT_TOO_LARGE` / `VERDICT_REQUIRED` | `done` body validation (dsl-spec §5): empty body, above the cap, or a `strict` step's requirements. | +| `WORKFLOW_DELIVERY_REQUIRED` | `agents dispatch-complete` from a pane whose pending record is a workflow activation. | +| `REQUEST_CANCELLED` / `REQUEST_CONFLICT` | The socket peer disconnected while `done` waited for persistence; an in-app request id collision (never expected). | +| `TARGET_NOT_FOUND` / `TARGET_NOT_UNIQUE` | Selector resolution (`list`, `run`). | +| `PATH_NOT_FOUND` / `INVALID_ARGUMENT` | `validate` path is missing or a directory; malformed `--role` / `--input` / `--skip`, conflicting selectors, a non-UUID run id, half a manual target. | +| `APP_NOT_RUNNING` | Any socket action without a reachable app. Never raised by `validate` or `schema`. | ## Verification `ProwlCLITests/WorkflowDocumentParserTests`, `WorkflowValidatorTests`, -`WorkflowDiscoveryTests`, `WorkflowSchemaTests` (output contract + definition schema pinned -to `WorkflowJSONSchema.definitionSchemaJSON`), `WorkflowCommandParsingTests`, +`WorkflowDiscoveryTests`, `WorkflowSchemaTests` (output contract for every action + definition +schema pinned to `WorkflowJSONSchema.definitionSchemaJSON`), `WorkflowCommandParsingTests`, `WorkflowCommandExecutorTests`, and the `workflow` cases in `ProwlCLIIntegrationTests` -(real `prowl` process for `validate`/`schema`, mock socket for `list`); -`supacodeTests/WorkflowCommandHandlerTests` for worktree resolution and the enabled set. +(real `prowl` process for `validate`/`schema`, mock socket for `list` and `done`); +`supacodeTests/WorkflowCommandHandlerTests` (worktree / source resolution, enabled set), +`WorkflowRunAdmissionTests` (preflight), `WorkflowRuntimeCoordinatorTests` (`done` +attribution, `status`, `cancel`), `WorkflowCLIRendezvousTests`, and `WorkflowRunsFeatureTests` +(the reducer: ordered effects, the two-phase `done` answer, late launches, restart scan). diff --git a/docs-ai/063-agent-workflows/000-plan.md b/docs-ai/063-agent-workflows/000-plan.md index 286c4b2c7..2b861bb67 100644 --- a/docs-ai/063-agent-workflows/000-plan.md +++ b/docs-ai/063-agent-workflows/000-plan.md @@ -2,9 +2,9 @@ | | | | --- | --- | -| **Status** | In progress — R1 shipped in v2026.8.29; R2a under way: B1 (#740, [006](006-b1-definitions.md)), #733 (#741), and #726 T0 (#739) merged; B2 runner core implemented ([007](007-b2-runner-core.md)); B3 next | +| **Status** | In progress — R1 shipped in v2026.8.29; R2a under way: B1 (#740, [006](006-b1-definitions.md)), #733 (#741), #726 T0 (#739), and B2 (#743, [007](007-b2-runner-core.md)) merged; B3 runner wiring = #744 (review; record [008](008-b3-runner-wiring.md)) | | **Anchor date** | 2026-08-21 | -| **Primary PRs** | R1: #709 (C0), #710 (A1), #713 (A1b), #714 (A2) — shipped in v2026.8.29; R2a: #740 (B1), #743 (B2, [007](007-b2-runner-core.md)); B3–D3 TBD | +| **Primary PRs** | R1: #709 (C0), #710 (A1), #713 (A1b), #714 (A2) — shipped in v2026.8.29; R2a: #740 (B1), #743 (B2, [007](007-b2-runner-core.md)); #744 (B3, [008](008-b3-runner-wiring.md)); C1–D3 TBD | | **Related** | [047 cross-agent-handoff](../047-cross-agent-handoff/000-plan.md), [049 agents-toolbar-entry](../049-agents-toolbar-entry/000-plan.md), [053 agent-profiles](../053-agent-profiles/000-plan.md), [055 agent-profile-runtimes](../055-agent-profile-runtimes/000-plan.md), [059 agent-transcript-snapshots](../059-agent-transcript-snapshots/000-plan.md), [060 cli-targeting-and-contract-governance](../060-prowl-cli-targeting-and-contract-governance/000-plan.md), [061 native-toolbar-controls](../061-native-toolbar-controls/toolbar-controls.md), [064 agent-completion-signals](../064-agent-completion-signals/000-plan.md) (signal bus, `agents signal` / `agents wait`), [#699 `prowl create pane`](https://github.com/onevcat/Prowl/issues/699), [PR #651 (direction reference, not merged)](https://github.com/onevcat/Prowl/pull/651), [DSL spec (living)](dsl-spec.md), [release plan (living)](release-plan.md), `docs/components/handoff.md`, `docs/components/agent-profiles.md`, `docs/components/cli.md` | ## Background @@ -422,7 +422,7 @@ attaches hooks through A2's launch boundary. | **A2** | A | A1 | Profile launch boundary (`.prompt`, placement override, anchor, background, synchronous `LaunchedSurface` result) + `prowl create tab/pane --profile --prompt -` + `prowl profiles list`; exposes the seam 064-S3 uses for launch-scoped hooks. Unlocks the CLI-driven route; the runner's `launch` boundary. | | **B1** | B | — | Definitions: Yams, `AgentWorkflow` model + validator + JSON Schema, three-source discovery, `prowl workflow list/validate/schema`. Makes the DSL concrete and authorable (no user-facing surface until R2). Lives in `ProwlCLIShared` so `validate`/`schema` run without the app; `list` goes through the socket and reads a hidden enabled set (`@Shared`, all enabled until D1's page). Record: [006](006-b1-definitions.md). | | **B2** | B | B1 | Runner core (pure): run state machine incl. `repeat`, run store, template renderer, action registry, watchdog with injected clock that consumes exact signals first (064-S5's watchdog part, moved here 2026-08-29) — tested against fake boundaries. Activations live in the shared dispatch store; there is no separate `WorkflowRequestRegistry` (decision 2026-08-29). | -| **B3** | B | A2, 064-S1, B2, #733 | Runner wiring: `WorkflowRunsFeature` effects, observer consumption via `AppFeature`, CLI preflight, `prowl workflow run/status/done/cancel` + contracts. Engine first powered on. | +| **B3** | B | A2, 064-S1, B2, #733 | Runner wiring: reducer-owned `WorkflowRunsFeature` effects, per-activation `observeAgentDispatch` + `observeAgentState` watchdog streams, CLI admission preflight, `prowl workflow run/status/done/cancel` + contracts. Engine first powered on. | | **C1** | C | B3 | Status center fifth state + run panel + attention triggers + notifications (061 visual verification). Runs become visible. | | **C2** | C | B3 | Start sheet (bindings, suggestion-based profile creation, don't-ask-again, `--skip` equivalent) + entry points (capsule popover, palette, Active Agents context menu). GUI-initiated runs. | | **D1** | D | B1, C2, 065-K1 | `prowl-workflows` authoring skill (registered by adding it to `skills/`; embedding and the registry come from [065](../065-bundled-agent-skills/000-plan.md)), `docs/components/workflows.md`, Settings › Workflows page (enable/validate/Reveal/New/Ask-agent/per-workflow auto) added to the Agents group. Distribution and docs. | diff --git a/docs-ai/063-agent-workflows/007-b2-runner-core.md b/docs-ai/063-agent-workflows/007-b2-runner-core.md index 6cf0b39ad..047f4a053 100644 --- a/docs-ai/063-agent-workflows/007-b2-runner-core.md +++ b/docs-ai/063-agent-workflows/007-b2-runner-core.md @@ -209,7 +209,10 @@ Everything lives in `supacode/Domain/Workflow/` (app target) plus three Shared t a fresh nudge; `run.json` records the issue codes under `status.attention.issues`. - After the automatic nudge is spent (or after "Keep waiting"), a later `turn-ended` still earns `idle_grace` before the run asks for attention; only an `idle_grace` that expires idle - escalates. `needs-input` and heuristic `blocked` raise attention but keep watching, so a + escalates. A grace expiry that saw activity (`working`, `session-start`, `progress`) re-arms + the same grace (amended by B3, [008](008-b3-runner-wiring.md): the original policy only + cleared the flag, and a freshly launched agent whose first detector `working` arrived after + its hook `turn-ended` left the watchdog silent for good). `needs-input` and heuristic `blocked` raise attention but keep watching, so a later `turn-ended` without delivery can still nudge. - Skip of an `action` step is not offered (Retry / Cancel); Skip of a `launch` step leaves the role without a pane, and a later `message` to it raises `agent_gone:not_launched` with diff --git a/docs-ai/063-agent-workflows/008-b3-runner-wiring.md b/docs-ai/063-agent-workflows/008-b3-runner-wiring.md new file mode 100644 index 000000000..f1c31d296 --- /dev/null +++ b/docs-ai/063-agent-workflows/008-b3-runner-wiring.md @@ -0,0 +1,318 @@ +# 063.008 — Workflow Runner Wiring (B3): Plan + +## Status + +Implemented on `feat/workflow-runner-wiring-b3` (2026-08-29/30), after B2 merged as +[#743](https://github.com/onevcat/Prowl/pull/743); PR +[#744](https://github.com/onevcat/Prowl/pull/744). B3 is the R2a slice that powers the +engine for CLI callers. C1 owns workflow presentation and user attention controls; C2 owns the +start sheet and interactive binding picker. + +## Confirmed inputs + +- B1 (#740) owns workflow discovery, parsing, validation, and the local `validate` / `schema` + commands. #733 (#741) owns re-dispatch into an existing idle pane. B2 (#743) owns the pure + machine, store, renderer, watchdog, native actions, and binding resolver. +- The normative CLI protocol is [dsl-spec §9](dsl-spec.md#9-cli-participant-protocol). B3 must + not reopen B2 decisions H1–H14. +- B2's `WorkflowRunHarness` is the interpreter reference. In particular, delivery is + validate → persist output → `.outputPersisted` → complete dispatch / advance; a CLI `done` + response must not report success before persistence. + +## Scope + +1. Add a reducer-owned `WorkflowRunsFeature` below `AppFeature`. It holds active in-memory runs + and interprets every `WorkflowRunEffect` against the terminal, dispatch, launch, store, + native-action, and watchdog boundaries. `WorkflowRun` remains the persisted state; the feature + reconstructs the pure machine for each transition with injected date/UUID values rather than + introducing a stateful runner object. +2. Implement the `WorkflowActivationBridge` over `WorktreeTerminalManager`, and wire: + - message activation issue/bind → committed text → submit, with issuance rollback on failure; + - prompted profile launch with workflow-only child environment values and the launch dispatch; + - per-activation `WorkflowWatchdog` streams (`observeAgentDispatch` and + `observeAgentState`); + - output persistence, logging, native actions, close/notify, and terminal-run cleanup. +3. Extend the workflow CLI across all four governed layers: parser/input envelope, app handler, + versioned payload/output renderer plus executable schema, contracts/manual/skill. Add + `run`, `status`, `done`, and `cancel` while preserving B1's `list`, `validate`, and `schema`. +4. Resolve a run request from the discovered effective definition and freeze bindings. CLI + binding resolution uses explicit `--role` overrides, remembered bindings, suggestion, and + Recommended. A `pick` role without an explicit pane, or a launch role whose resolver reaches + `.ask`, fails before any side effect; C2 owns the picker. Persist successful launch bindings + in `@Shared` memory keyed by B2's requirements digest. +5. At app start, scan each known worktree root through `WorkflowRunStore.markInterruptedRuns`. + V1 does not resume runs; `status ` can still read the materialized record after a + restart. +6. Update the 063/release ledgers: #743 is merged, B3 is in progress, and this record is the B3 + plan. + +## Boundary decisions + +| # | Decision | Reason | +| --- | --- | --- | +| W1 | CLI commands enter the reducer through a request/response rendezvous that owns continuations only, never runner state. `run` replies after preflight, layout, and the initial record persist; `done` replies when its activation leaves `persisting` — delivered/provisional succeeds, persist-failed/revoked/terminal fails — not merely when an `.outputPersisted` event arrives. | The reducer remains the single owner of active runs; a socket handler must nevertheless await `done` persistence before replying. Cancel and store-failure can race a queued output-persist event, whose machine guard intentionally ignores terminal transitions; resolving only on that event would leak a CLI continuation. | +| W2 | B3's preflight means workflow discovery/validity/enabled state, source/worktree resolution, binding legality, pane ownership, and run-directory setup. It does **not** add CLI installation or socket-health UI. | A reachable socket is already a prerequisite of any CLI command; the observable installation/socket status belongs to D1. This resolves the ambiguous “CLI preflight” wording in the slice table. | +| W3 | `workflow done` identifies an activation from the caller pane's pending dispatch first, then checks the machine token. Explicit `--run --step` is the documented manual path; mismatched caller and explicit target is `ROLE_MISMATCH` unless `--force`. `agents dispatch-complete` is intercepted before the normal handler can complete a workflow activation. | Preserves the B1/B2 trust boundary: tokens correlate but do not authenticate. | +| W4 | A non-strict delivery with validation issues persists and becomes `needsAttention`; B3 reports `delivery.state = provisional`, warnings, and the attention vocabulary through `status`, but does not silently accept it. | H14 requires a user decision. C1 supplies Accept / Ask again / Skip / Retry / Relaunch controls; B3 intentionally has only `status` and `cancel` as public lifecycle controls. Before C1, a provisional delivery (and every other attention state) is cancel-only; it cannot be re-delivered because the activation is no longer waiting. R2a is not released with B3 but without C1. | +| W5 | `status` reads an active run from reducer state when available and otherwise reads a v1 record from its indexed worktree root. No run is reconstructed from disk. | Status and `agents wait --dispatch` remain useful after an app restart without accidentally implementing V2 resume. | +| W6 | CLI launch roles use only frozen profile launch plans. `PROWL_WORKFLOW_TOKEN`, `PROWL_WORKFLOW_RUN`, and `PROWL_WORKFLOW_ROLE` are child-only surface environment values, not `run.json` or response data. | Retains B2's privacy rule and prevents a workflow token from leaking to unrelated processes or persisted metadata. | +| W7 | A `close` step closes the pane the run launched without a confirmation (`closeSurface(confirmation: .skip)`); the effect is revocable (a cancel that beats it keeps the pane), and the boundary closes only when the run is still the pane's *most recent* binder (`WorkflowRunsFeature.State.paneOwners`, recorded at admission and at launch take-up — never from a clock — whatever that binder's status: a later run that ended keeps the pane). The plan's "protected close" wording is superseded. | Ghostty's protected close asks whenever the pane's process is alive, which an idle agent's process always is — every workflow cleanup would pop a modal from the executor. The author asked for the close explicitly and the run owns the pane; the two real hazards (a cancel racing the close, a pane re-bound after the run ended) are what the guards cover. | + +## Tests and verification + +Follow test-first development for the deterministic routing and contract layers. Add reducer/handler +coverage for source resolution, every binding source and override, one-run-per-pane rejection, +`done` attribution/token/force mismatch, two-phase persistence (including Cancel or persistence +failure while a `done` rendezvous waits), dispatch-complete interception, late launch cleanup, +watchdog lifecycle, restart interruption, and structured payload/schema validation. Keep B2's pure suites unchanged except for seams required by real wiring. + +Run the CLI unit, smoke, and socket integration targets as required for CLI work; run `make check` +and `make build-app`. Then use an isolated Debug app and matching CLI to drive a real workflow: + +- message into an idle existing Claude Code or Codex pane, then a second #733 re-dispatch; +- launch with the protocol block and workflow token visible only to the child; +- `done -` resolved by caller ancestry and followed by `agents wait --dispatch`; +- refused `agents dispatch-complete` with `WORKFLOW_DELIVERY_REQUIRED`; +- watchdog nudge/attention behavior on both hooked and unhooked runtimes; +- a deliberately provisional delivery: verify `done` reports `provisional`, `status` exposes + its attention, and document that C1 is required to resolve it rather than treating it as a + happy-path completion; +- output/run-directory inspection and restart interruption. + +## Delivered + +Everything B3 adds sits between B2's pure domain and the app's boundaries; B2's suites are +unchanged except for one seam. + +- `WorkflowRunsFeature` (`supacode/Features/Workflow/Reducer/`) — the reducer-owned run + table (`sessions`, terminal runs included so `status` keeps answering), `started` / + `event` / `deliver` / `userAction` / `markInterruptedRuns`. Effects are performed in the + machine's order through one FIFO per run (`WorkflowEffectQueue`, enqueued synchronously while + reducing, drained by a single long-lived executor effect per run), so an instruction file exists + before the pointer line that names it is typed and `run.json` writes never overtake each other; + idle waits and watchdogs are separate cancellable effects (`cancelInFlight` per ordinal, a + run-wide id torn down on `.finished`). A transition that revokes the in-flight invocation or + ends the run *fences* the queue: the executor drops what was enqueued before, `.inject` + re-checks the fence between opening its record and typing, and an ignored + `.injectionSucceeded` abandons the record it opened. A materialize failure stops the rest of + its batch and raises the injection / launch attention. Late (`run` ended) and stale (machine no longer + expects it) `.launched` events abandon their dispatch record and close the pane. A successful + launch remembers its profile under B2's digest key (`UserGlobalSettings.workflowBindings`). +- The `done` rendezvous (`WorkflowCLIRendezvous`, `WorkflowCLIResponderClient`): the reducer + keeps `pendingDeliveries[requestID]` and answers when the addressed activation leaves + `persisting` — `delivered` / `provisional` succeed, `persist_failed` answers + `WORKFLOW_FAILED`, a revoked / skipped activation or an ended run answers + `STEP_NOT_EXPECTING`; a client that disconnects gets `REQUEST_CANCELLED` and the run + continues. An answer that arrives before the handler awaits is buffered on the slot. +- Admission (`WorkflowRunAdmission`): effective definition (id, then unique name; + `WORKFLOW_NOT_FOUND` / `WORKFLOW_INVALID` with the validate payload / `WORKFLOW_DISABLED`), + source rules (W2), bindings in role order (`current`: pane, `PANE_BUSY`, `DISPATCH_PENDING` + when the pane still holds a pending record — found live: a launched author that never ran + `dispatch-complete` made the self-initiated activation fail `roleBusy` and the run loop into + the idle wait —, `AGENT_NOT_FOUND` only when a message to it survives the skips; `pick`: + explicit `pN` / UUID in the source worktree, same pending-record rule; `launch`: override → remembered → suggestion → Recommended through B2's resolver, + `.ask` → `PROFILE_NOT_FOUND`, a rejected override or memory is logged into the run and falls + through), inputs / skips through `WorkflowRunMachine.start`, the frozen launch plan + (`AgentProfileLaunchPlanner.plan` with a placeholder prompt), layout + initial `run.json` + before the reply. +- The coordinator (`WorkflowRuntimeCoordinator`): `run` (a self-initiated first step is + answered through the rendezvous once its activation record exists) / `status` (W5: live + session, else a `run.json` from any known worktree root, else `RUN_NOT_FOUND`) / `done` (W3: + caller pane's pending dispatch → activation; explicit `--run --step` manual; disagreement + `ROLE_MISMATCH` unless `--force`) / `cancel`. Completion commands are spelled only for the + verified caller pane's own activation. `agents dispatch-complete` is intercepted before the + store through `AgentDispatchCompleteCommandHandler.intercept` → `WORKFLOW_DELIVERY_REQUIRED`, + for live and just-ended runs alike. +- Live boundaries (`WorkflowRuntimeComposition`): `waitForRole` = the #733 evidence rules + (`AgentConditionEvidence.idleVerdict`, shared with `agents dispatch`) without the 5 s cap — + exact idle at once, a detector-only idle after 2 s of stability, `working` keeps waiting, + an exact `needs-input` or 30 s of heuristic `blocked` ends the wait as blocked, 10 s without + a detected agent as `noAgent`, a closed surface as `gone`, a foreign pending dispatch record + as `dispatchPending` (the machine seam `.roleUnavailable` maps these to the injection-failed + attentions, so a pane nobody completes never spins the run between `roleIdle` and + `roleBusy`); `launch` = issue the + dispatch → A2 prepare with the placeholder prompt → `attachingWorkflow` (the rendered kickoff + prompt replaces the placeholder, `PROWL_WORKFLOW_TOKEN` / `_RUN` / `_ROLE` ride + `PROWL_LAUNCH_WORKFLOW_` carriers the `env` line unsets for the child, exactly like + `PROWL_DISPATCH_ID`) → launch → bind → focus unless `background`; every later failure cancels + the issuance and closes the pane. `notify` logs and, when system notifications are enabled, + posts a banner titled `Workflow · `. +- CLI (`prowl workflow run/status/done/cancel`), payload `prowl.cli.workflow.v1` actions + `run` / `status` / `cancel` (run object) and `done` (`run` + `delivery`), executable schema, + text renderers, `docs/components/cli.md`, the contract page, and the `prowl-cli` skill + recipe. `done` reads its body client-side (stdin or `--file`, 4 MiB cap). + +## Verification + +- Red first for the routing and contract layers: the reducer suite (`WorkflowRunsFeatureTests`: + ordered execution with the instruction file on disk before the pointer is typed, the `done` + rendezvous through delivered / provisional / persist-failed / cancelled-while-persisting, late + and stale launches, the idle-wait outcomes, the restart scan), `WorkflowRunAdmissionTests` + (definition selection, source rules, every binding source and override, one run per pane, the + pending-dispatch refusal, start-time validation), `WorkflowRuntimeCoordinatorTests` (`done` + attribution incl. `ROLE_MISMATCH` / `--force`, `status` live / record / who-am-I, `cancel`), + `WorkflowCLIRendezvousTests` (buffered early answers, cancellation), the launch-plan carrier + test, the dispatch-complete interception test, the `.roleUnavailable` machine tests; CLI parser, + schema (`WorkflowSchemaTests` for every action + Codable round trip), and the mock-socket + `run` / `done` round trip. `make check`, `make build-cli`, `make test-cli-unit` (233), + `make test-cli-smoke`, `make test-cli-integration` (110), `make build-app` (0 warnings in the + changed files), the workflow app suites (121 at PR time; 135 with the review rounds' tests), + and `make test` — 2892 passed, 0 failed at the final head. Note for the next slice: run the + *full* `make test` before every push, not the workflow subset — the AppFeature suites caught + a dependency the subset never touched (review round 4), and CI failed four times on it. +- Live, in an isolated Debug instance (`CFFIXED_USER_HOME` scratch home, `PROWL_CLI_SOCKET= + /tmp/prowl-b3.sock`, `Claude Code` / `Codex` profiles whose `PATH` override puts the bundled + debug CLI first, a scratch Git repository with `b3-review` and `b3-idle` under + `.prowl/workflows/`): + - `b3-review` started from a launched Claude Code pane (`prowl workflow run b3-review --json` + typed by the agent itself): the response carried `self_initiated.line` with the instruction + path and the token-bearing completion command, nothing was typed into the caller; the brief + was delivered with `done -` resolved by caller ancestry (`delivered`); the reviewer profile + launched in a split with the protocol block in its prompt and — checked from inside the + child with a masked `env` — only `PROWL_WORKFLOW_TOKEN` / `_RUN` / `_ROLE` in its + environment (no `PROWL_DISPATCH_*` / `PROWL_LAUNCH_*`); `agents wait --dispatch` on the + activation's dispatch id returned the succeeded receipt "Delivered output 'findings' … + with verdict 'issues'"; round 1 messaged the idle author and then the idle reviewer through + #733 re-dispatch (log: "waiting for role … to be idle" → delivered), `until` exited on + `clean`, `notify` fired, `close` removed the reviewer pane; `run.json` and `log.md` carry + dispatch ids and paths but no token. + - `agents dispatch-complete` from the author while it owed the `fix` delivery was refused + with `WORKFLOW_DELIVERY_REQUIRED` and the replacement `done` command. + - A second run delivered a brief without `## Claims`: `done --json` answered + `delivery.state: provisional` with `missing_sections`, `status` showed `needs_attention` / + `delivery_issues` with the H14 actions, and `cancel` ended it (activation `revoked`, + `outputs/brief.md` kept on disk, `outputs` in the record empty). B3 offers no accept / + ask-again control; that is C1 (decision W4). + - Found and fixed live: a launched author that never ran `dispatch-complete` still held its + launch record, so the self-initiated activation failed `roleBusy`, the machine fell back to + the idle wait, and the run ended in an injection attention while `status` still advertised a + `waiting` activation `done` could not address. Admission now refuses such panes with + `DISPATCH_PENDING`, the idle wait ends as `dispatchPending` attention instead of spinning, + and `status` reports only the activation `done` can address (`WorkflowRun.currentActivation`). + - `b3-idle` (no `current` role, started from a worktree target) launched its worker, which + answered "OK" within three seconds; the exact `turn-ended` reached the dispatch record + (`agents wait --dispatch` → `DISPATCH_INCOMPLETE`) but no nudge followed. Cause, in B2's + policy: a `turn_grace` expiry that had seen "activity" cleared the flag and scheduled + nothing, and the detector's first `working` for a freshly launched agent arrives *after* + the hook's `turn-ended`, so the watchdog went silent for good. Fixed in the policy: an + expiry that sees activity re-arms the same grace (`turn_grace` / `idle_grace`) instead of + waiting for an event that may never come; the B2 tests that pinned the silent `[]` now pin + the re-arm, plus a regression test for the late first detection. Re-verified live after the + fix: the worker answered "OK" within seconds of its launch, the nudge (`[Prowl] When your + work for this step is fully complete, finish with: PROWL_WORKFLOW_TOKEN=… prowl workflow + done -`) was typed 41 s after the run started, the worker answered "OK" again, and 3 min + later the run entered `needs_attention` / `idle_without_delivery` with the H7 copy + ("… has been idle without delivering report; Prowl nudged it once"). The heuristic + (unhooked) watchdog path was not exercised live; B2's policy tests cover it. + - Restart: the isolated app was killed while `b3-idle` was `running`; after relaunch + `status ` answered from `run.json` (`source: record`, `interrupted`, no activation) + and `log.md` gained "Run marked interrupted at app launch (no resume in V1)". + - Re-run after each review round that touched the live path (round 2: fenced bookkeeping and + the in-`deliverLine` liveness guard; round 3: revocable `close`; rounds 4–5: pane + ownership at the close boundary): `b3-review` from a launched author pane completed each + time (230 s, 260 s, 100 s, 180 s) — brief delivered by caller ancestry, reviewer launched, `fix` + and `rereview` re-dispatched through the #733 idle wait, `until` exited on `clean` after + one round, `notify` fired, `close` removed the reviewer pane; no dispatch record was left + pending on either pane. + +## Review + +Adversarial review with the neighboring `pi` pane (the installed Prowl is the v2026.8.29 release, +whose app does not accept `agents.dispatch`; briefs were sent with `prowl send` and awaited with +`agents wait --until idle`, findings under `/tmp/prowl-b3-review/`). + +- **Round 1 — 8 findings (1 P0, 6 P1, 1 P2), all accepted and fixed.** P0: `run` spelled the + current activation's completion command whenever it included the self-initiated line — a + workflow whose first awaited step is a `launch` handed the launcher the reviewer's token — and a + manual or forced `done` was answered as if the caller were the delivering role (it could learn + the next activation's token); completion commands are now spelled only for the *verified* + caller pane's own activation (`callerRole` travels with the request; `includeSelfInitiated` + only adds the self-initiated line). P1: work queued before a cancel / skip / retry still ran + (an `.inject` could open a record and type into a pane the run had left) — the reducer now + fences the run's queue whenever a transition revokes the in-flight invocation or ends the + run, the executor drops fenced effects one by one, `.inject` re-checks the fence between + opening the record and typing (cancelling the issuance), and an `.injectionSucceeded` the + machine ignores abandons the record it opened; `agents dispatch-complete` searched only + active sessions, so a cancelled run's not-yet-abandoned record could be completed normally + (all sessions are searched now, an ended run answers `WORKFLOW_DELIVERY_REQUIRED` with its + status); the idle wait rebuilt the #733 baseline on every poll, so a fresh exact `turn-ended` + never counted while the screen still showed `working` (`WorkflowRoleWaitPolicy` now keeps the + arm-time baseline, as `agents wait` does); an exact `needs-input` could be outrun by the + detector-idle stabilizer (it is checked first now); a launch without `expect` left its new + pane unreserved until `.launched` reached the reducer (`WorkflowPaneReservations` now holds + it, admission counts it busy); a self-initiated `run` replied before its activation record + existed, so an immediate `done` could be `STEP_NOT_EXPECTING` (the reply now waits for the + record through the same rendezvous as `done`). P2: a duplicate request id was registered + twice (refused with `REQUEST_CONFLICT` now). +- **Round 2 — 5 findings (0 P0, 4 P1, 1 P2), all accepted and fixed; round-1 fixes + verified.** P1: the batch-wide fence also dropped the *bookkeeping* of transitions the + machine had already made — cancelling step B before the executor reached + `completeActivation(A)` of the delivered step A left A's record pending forever — so a fenced + batch now skips only pane- and worktree-facing effects (`WorkflowRunEffect.isRevocable`: + `openActivation`, `inject`, `typeLine`, `launch`, `runAction`) and still performs records, + logs, completions, abandonments, close, notify; the separate stale check before typing left a + check-to-use gap (and the no-`expect` and nudge lines had none) — the liveness guard is now + evaluated inside `deliverLine` on the same main-actor turn as the insertion, for every typed + line, answering `stale` (the issuance is returned, nothing typed); a native action already + running is not cancelled (its writes are the handoff store's own atomic operations) but its + result is dropped when the run left meanwhile — documented as a limitation, with the + liveness re-check after `execute`; reservations were pruned only against *active* bindings, + so a pane a finished run kept stayed reserved forever (pruned against every run that ever + bound it now). P2: a waiter the socket cancelled left its verified role behind and its id + reusable before the reducer answered (`inFlight` ids stay claimed until `resolve`). +- **Round 3 (verification) — round-2 fixes confirmed; 4 findings (0 P0, 3 P1, 1 P2), all + accepted and fixed.** P1: `close` was non-revocable, so a close queued when a cancel landed + still force-closed the pane — possibly one another run had bound since, because a terminal + run no longer counts as busy at admission (`close` is revocable now, re-checks the fence on + its own turn, and the boundary refuses to close a pane another *active* run has bound; the + `.skip` confirmation became decision W7); a native action could start after a cancel that + landed between the batch check and `execute` (the fence is now consulted as the last + main-actor operation before the action starts; the cancel that lands during the hop to the + action's executor cannot stop it — the machine's cancel log names the action as still running + and the result is discarded — documented as the remaining limitation); a stale injection + could, in theory, issue a record nobody owns (the chain issuance → typed line → returned + issuance is one main-actor turn, so the window does not exist; the executor now also checks + the fence right before issuance). P2: the stale-line test forced the fake terminal's answer + and polled with `Task.sleep` — `FencingQueue` now raises the real queue's fence on the n-th + staleness check and resumes the test through a continuation, and the fake evaluates the guard + the reducer supplied. Also cleaned up while there: `store.send { $0.x == y }` closures that + asserted nothing (assignments or `#expect` now) and the spurious `await`s on synchronous + main-actor closures in the executor. +- **Round 4 (verification) — round-3 fixes confirmed (incl. the fence-before-enqueue ordering + that keeps a completing batch's `close` live); 2 findings (0 P0, 1 P1, 1 P2), both accepted + and fixed.** P1: the ownership check consulted only *active* runs, so once a later run that + had taken the pane ended (keeping it, as cancel promises), the earlier run's still-queued close + saw no owner and closed it — the boundary now closes only when the run is the pane's most + recent binder, whatever that binder's status (`latestBinder(of:)`; W7 amended). P2: the + round-3 cancel log claimed the native action "keeps running" merely because the phase was + `runningAction`, which is also the state when the pre-start guard stopped it — the machine no + longer guesses; the executor writes the definitive line itself ("not started; the run had + moved on" from the guard, "finished / failed after the run moved on; result discarded" after + a late return), through an injected `workflowActionExecutor` so a test can hold an action open + across a cancel. Also fixed here: `markInterruptedRuns` read the `date` dependency for every + scan, which failed `AppFeatureTerminalLayoutRestoreTests` (no clock override) — the clock is + read only for a record that is marked. +- **Round 5 (verification) — round-4 fixes confirmed; 2 findings (0 P0, 1 P1, 1 P2), both + accepted and fixed.** P1: the round-4 owner rule ordered bindings by `run.startedAt`, which + is neither monotonic nor total (an equal reading or a clock step backwards could hand the + pane back to the earlier run) — the reducer now records the owner itself, in `paneOwners`, + when a run is admitted and when a launch is taken up, and the boundary compares run ids + only. P2: an action the executor skipped at the *batch* check (the fence rose before its + batch was reached) left no "not started" line in `log.md`, only an app log — the batch + check writes the same definitive line now. +- **Round 6 (verification) — round-5 fixes confirmed; 1 finding (0 P0, 1 P1), accepted and + fixed.** P1: launch reservations were pruned against the runs' *current* bindings, and a + relaunch drops the old pane from the binding before the replacement is taken up — so a pane + a relaunch left behind stayed reserved for as long as it lived and was refused to later runs + as `PANE_BUSY`. Admission now prunes against `paneOwners.keys` (every pane a run ever bound, + including one a relaunch dropped). Test: `aRelaunchKeepsTheOldPaneAmongTheOwnedOnes`. +- **Round 7 (verification) — round-6 fix confirmed, no P0/P1 remaining; 1 P2, fixed.** The + relaunch test asserted only `paneOwners` and would have passed with the old pruning rule — + the rule now lives in `WorkflowPaneReservations.pending(for:isLive:)` and the same test + reserves the first pane and asserts the reservation is gone after the relaunch. + +## Non-goals + +No status-center UI, run panel, notifications, start sheet, workflow picker, built-in definition, +workflow authoring skill, Settings page, CLI-install/socket-health status, handoff migration, or +V2 resume/fan-out/observe mode. Those remain C1, C2, D1–D3, and V2. diff --git a/docs-ai/063-agent-workflows/dsl-spec.md b/docs-ai/063-agent-workflows/dsl-spec.md index d3a5820ec..9de71f55c 100644 --- a/docs-ai/063-agent-workflows/dsl-spec.md +++ b/docs-ai/063-agent-workflows/dsl-spec.md @@ -139,7 +139,7 @@ steps: notify: "Adversarial review: {{ outputs.findings.verdict }} after {{ loop.count }} round(s)" # ⑤ - id: cleanup - close: reviewer # ⑥ only launch roles; protected close (confirms if the agent is still running) + close: reviewer # ⑥ only launch roles; closes the pane the run launched, no confirmation ``` | Verb | Payload keys | Allowed target | Notes | @@ -148,7 +148,7 @@ steps: | `launch: ` | `prompt` (kickoff, templated), optional `skill` (an id from the embedded skill registry — same pattern as a workflow id, e.g. `prowl.adversarial-reviewer`; it must resolve to a bundled skill, unknown ids are validation errors; custom skills are V2) | `launch` role, at most once per run (V1) | Profile plan with `AgentStartIntent.prompt`. The rendered prompt is passed whole through the launch boundary's prompt carrier (A2's `PROWL_LAUNCH_PROMPT`; no PTY line limit): multi-line prompts are allowed, NUL is rejected, and a rendered prompt above 32 KiB is `PROMPT_TOO_LARGE`. Materialization applies to `message` only. When the step has an `expect`, the runner appends the workflow completion protocol block (below) in place of S2's plain dispatch protocol. | | `action: ` | `with` (templated map) | — | V1 registry: `handoff.transition` (inputs `briefing?`, `from`, `to`; performs archive-first `.prowl/handoff/` transition; outputs `kickoff_prompt`, `artifact_path`, `has_briefing`), `handoff.checkpoint`, `git.context`. Every registered action declares a typed schema for its `with` inputs (required/optional) and its output keys; `prowl workflow schema` prints them. | | `notify: ` | — | — | Bell pipeline; click focuses the `current` role's pane, or — when the workflow has no `current` role — the source worktree (status panel). | -| `close: ` | — | `launch` roles | Never implicit; cancel never closes panes. | +| `close: ` | — | `launch` roles | Never implicit. Closes the pane this run launched without a confirmation — the step is the author's explicit ask and the run owns the pane (B3 decision W7). Cancel never closes panes: a close still queued when the run is cancelled is dropped, and a pane another run has bound since is left alone. | | `repeat` | `max` (required), `until` (optional), `steps` | — | While-loop semantics: `until` is evaluated **before entering** and **after every iteration**, so a verdict already satisfied by an earlier step skips the loop. `until` compares a declared verdict only: `outputs..verdict == ` or `in [..]`, every literal must belong to that output's declared `verdict` set. `max` is either a positive integer literal or a template that references exactly one `integer` input (nothing else); it is resolved at start, must lie in `1…20` (the V1 ceiling), and an invalid value is `WORKFLOW_INVALID` (literal) or a start-time `INVALID_ARGUMENT` (input). Reaching `max` with `until` still unsatisfied (or absent) ends the run as `max_rounds_reached`. | **Typed line formats and the completion-command renderer.** Every line Prowl types into a diff --git a/docs-ai/063-agent-workflows/release-plan.md b/docs-ai/063-agent-workflows/release-plan.md index 8a31fe506..fc12f6432 100644 --- a/docs-ai/063-agent-workflows/release-plan.md +++ b/docs-ai/063-agent-workflows/release-plan.md @@ -34,7 +34,7 @@ say when each is cut: | Release | State | Next action | | --- | --- | --- | | R1 | **Shipped** — v2026.8.29 (2026-08-29) | — | -| R2a | In progress | B1 #740, #726 T0 #739, #733 #741 merged; B2 = #743 (review; record [063.007](007-b2-runner-core.md)); B3 next | +| R2a | In progress | B1 #740, #726 T0 #739, #733 #741, and B2 #743 merged; B3 = #744 (review; record [063.008](008-b3-runner-wiring.md)) | | R2b | Planned | after R2a ships | | R3 | Planned | after R2b ships | @@ -110,7 +110,9 @@ User-visible result: onevcat's daily CLI-driven orchestration is first-class User-visible result: a workflow file runs from the CLI (`prowl workflow run`), its steps and attention states show in the status center, and a coordinating agent can re-dispatch into a -reviewer it already launched. Parallelism: B1 ∥ #733 ∥ #726 T0 (the two 064 slices do not +reviewer it already launched. **R2a is released only after both B3 and C1 merge**: B3 intentionally +leaves attention controls to C1, so a non-strict provisional delivery must never ship as a +cancel-only production workflow state. Parallelism: B1 ∥ #733 ∥ #726 T0 (the two 064 slices do not touch B1's files); #733 must merge before B3 starts. Docs: `workflows.md` (CLI part), `cli.md`, `prowl-cli` skill. ### R2b — Workflow GUI, docs, and the first built-in diff --git a/docs/components/cli.md b/docs/components/cli.md index bd6b878c3..f60f73baa 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -467,15 +467,20 @@ from the GUI (Install / Remove / Repair / Replace per skill × detected target) the same status as `prowl skills list` — see [settings](settings.md#agent-skills). ### `prowl workflow` -Discover, validate, and describe Agent Workflow definitions — YAML files -(`schema: prowl.workflow/v1`) that declare a multi-agent flow Prowl runs. Definitions come -from three sources, later ones winning for the same id: the app bundle +Discover, validate, and **run** Agent Workflow definitions — YAML files +(`schema: prowl.workflow/v1`) that declare a multi-agent flow Prowl runs (roles, `message` / +`launch` steps with expected outputs, `repeat … until`, native actions). Definitions come from +three sources, later ones winning for the same id: the app bundle (`Prowl.app/Contents/Resources/workflows/`, ids `prowl.*` are reserved for it) < `~/.prowl/workflows/*.yaml` < `/.prowl/workflows/*.yaml`. `validate` and `schema` run -**locally** and work with Prowl closed; `list` needs the app. +**locally** and work with Prowl closed; every other subcommand needs the app. ```bash prowl workflow list [target] [--json] # every definition visible to a worktree, with status +prowl workflow run [source] [--role r=]... [--input k=v]... [--skip ]... [--json] +prowl workflow status [run-id] [--json] # no args: the calling pane's run, role, awaited step +prowl workflow done [-|--file ] [--verdict ] [--token ] [--run --step ] [--force] [--json] +prowl workflow cancel [--json] prowl workflow validate [--scope bundle|user|repo] [--json] # parse + validate one file; exit 1 on errors prowl workflow schema [--json] # JSON Schema (Draft 2020-12) of a workflow file ``` @@ -488,6 +493,53 @@ prowl workflow schema [--json] # JSON Schema (Draft 202 `shadowed` (a higher-precedence source defines the same id, so this file is not the one that runs). A file that does not parse is listed with `valid: false` and no `id`. Invalid files never shadow valid ones. +- `run` starts the effective (unshadowed, valid, enabled) definition with that id or unique + name. A workflow with a `current` role runs **from a pane**: the caller's own pane by + default, or an explicit `pN` / pane UUID; outside a pane it fails with `SOURCE_REQUIRED`. A + workflow without one runs in a worktree (the caller's, the focused one, or an explicit + worktree target). Bindings are frozen before anything happens: `--role =` (else the remembered profile for that role, then a profile matching the + role's `suggest`, then the repository's Recommended profile — `PROFILE_NOT_FOUND` when nothing + qualifies, `PROFILE_NOT_UNIQUE` for an ambiguous name; an override the role rejects is logged + and falls through), `--role =` (required; an agent pane of the same + worktree that is not the source pane), `--input name=value` for declared inputs, `--skip + ` for steps whose output nothing else needs. A pane belongs to at most one active run + (`PANE_BUSY`) and must not still hold a pending dispatch (`DISPATCH_PENDING`: an activation is + a dispatch record and a pane holds one at a time — complete or abandon it first). The + response is the run (`.data.id`, `.data.status`, `.data.step`, frozen + `.data.bindings`, `.data.run_directory`); when the run was started from the pane that is + its `current` role and the first step messages that role, `.data.self_initiated` carries the + line the runner would have typed (instruction path and completion command included) and + nothing is typed into the caller — read it and follow it yourself. The run directory is + `/.prowl/workflow-runs//` (`run.json`, `log.md`, `instructions/`, + `outputs/`, `skills/`), self-ignored by Git. +- `done` delivers the output of the step this pane is working on: the body comes from piped + stdin (`-`) or `--file`; `--verdict` supplies the declared verdict when the step requires + one. Prowl attributes the delivery by the **caller pane** (its pending workflow activation) and + checks the token the step handed out (`PROWL_WORKFLOW_TOKEN=… prowl workflow done -` for a + typed step, the child environment of a launched role, or `--token`): a stale or wrong token + is `TOKEN_INVALID`, a missing one `TOKEN_REQUIRED`, a pane whose step moved on + `STEP_NOT_EXPECTING`. `--run --step ` is the manual path from outside the role's + pane (no token needed); when the calling pane is itself waiting for a different step, the + explicit target needs `--force` (`ROLE_MISMATCH` otherwise). The command answers only after + the output is in the run directory: `.data.delivery.state` is `delivered` (the run advanced) + or `provisional` — the body had issues a non-strict step tolerates (missing sections, an + undeclared verdict, …), listed in `.data.delivery.warnings[]`; it is on disk but the run waits + for a decision in Prowl (accept, ask again, skip). Empty bodies are `OUTPUT_INVALID`, bodies + above the step's cap `OUTPUT_TOO_LARGE`, and `strict: true` steps reject issues outright. + `agents dispatch-complete` from a pane that owes a workflow delivery is refused with + `WORKFLOW_DELIVERY_REQUIRED` and the exact `done` command to run instead. +- `status` without an argument answers "who am I": the calling pane's active run, its role, + the step in progress, and — for the role that owes it — the awaited output with its + requirements and completion commands (`.data.activation`). With a run UUID it reports that + run, live or, after an app restart, from its `run.json` (`.data.source` is `live` or + `record`; a record has no activation and no tokens). Runs an earlier app instance left + unfinished are marked `interrupted` at launch; V1 does not resume them. +- `cancel ` stops a live run: it stops advancing and injecting, abandons the pending + activation, keeps every pane and output, and reports the ended run. Attention states a run + reaches (an agent that went idle without delivering, a blocked or vanished pane, a + provisional delivery, a failed launch) are visible through `status` and, until the workflow + panel ships, resolvable only by `cancel`. - `validate` prints every diagnostic as `path:line:column: error[code]: message` (warnings likewise) and ends with `OK ()` or `INVALID …`. Errors make the command fail with `WORKFLOW_INVALID`; in JSON the full validate payload (`path`, `valid`, `workflow`, @@ -506,11 +558,19 @@ prowl workflow schema [--json] # JSON Schema (Draft 202 ```bash prowl workflow validate .prowl/workflows/review.yaml prowl workflow list --json | jq '.data.workflows[] | select(.valid) | .id' -prowl workflow schema > /tmp/workflow.schema.json +run="$(prowl workflow run review --role reviewer=Codex --input max_rounds=3 --json)" +printf '%s\n' "$run" | jq -r '.data.self_initiated.line' # what to do now, when this pane is the current role +prowl workflow status --json | jq '.data.activation' # what this pane owes, and how to deliver it +PROWL_WORKFLOW_TOKEN=… prowl workflow done - <<'EOF' +## Scope +… +EOF +prowl workflow cancel "$(printf '%s\n' "$run" | jq -r '.data.id')" ``` -JSON is `prowl.cli.workflow.v1` with `data.action` = `list` | `validate` | `schema`. -Running a workflow (`prowl workflow run`, `status`, `done`, `cancel`) is not available yet. +JSON is `prowl.cli.workflow.v1` with `data.action` = `list` | `run` | `status` | `done` | +`cancel` | `validate` | `schema`; `run`, `status`, and `cancel` share the run shape, `done` +nests it under `.data.run` beside `.data.delivery`. ### `prowl read [target]` Read a pane's content. @@ -805,7 +865,7 @@ artifacts and terminal excerpts do not appear in `git status`. | `DISPATCH_TARGET_BUSY` | `agents dispatch` refused: the pane's agent is working or blocked (`.error.details.observation`, `.signals`). Wait for `--until idle`, then retry. | | `DISPATCH_ALREADY_TERMINAL` | `dispatch-abandon` targeted a record that already completed, was abandoned, or is gone. | | `DISPATCH_FAILED` / `DISPATCH_ABANDONED` / `DISPATCH_NEEDS_INPUT` / `DISPATCH_INCOMPLETE` | `agents wait --dispatch` structured outcomes; `.error.details` retains the record, target, and evidence (see **Dispatch completion and waiting**). | -| `SOURCE_REQUIRED` | A caller-owned command such as `agents signal` or selector-free `handoff` could not map the socket peer ancestry to a Prowl pane. Run it inside the source pane without tmux/detached wrappers, or use an explicit selector where that command permits one. | +| `SOURCE_REQUIRED` | A caller-owned command such as `agents signal`, selector-free `handoff`, `workflow run` of a workflow with a `current` role, `workflow status` without a run id, or `workflow done` without `--run --step` could not map the socket peer ancestry to a Prowl pane. Run it inside the source pane without tmux/detached wrappers, or use an explicit selector where that command permits one. | | `AGENT_GONE` | The meaning is mode-specific: a signal caller disappeared, a dispatch worker became terminal, or a generic condition target closed. Inspect `.error.details.mode`; dispatch details retain a record, while condition details retain the requested condition and exact surface observation. | | `BLOCKER_UNREADABLE` | A blocked screen was detected but Prowl could not safely extract its current interaction text. Re-run `agents read` or inspect with `read`. | | `SESSION_UNRESOLVED` / `RESULT_NOT_FOUND` / `RESULT_INCOMPLETE` / `RESULT_TOO_LARGE` | `agents read --result-only` could not provide one trustworthy complete result. Drop `--result-only` to retain the live snapshot and inspect `.data.result`. | @@ -814,8 +874,16 @@ artifacts and terminal excerpts do not appear in `git status`. | `INSTALL_CONFLICT` | A real file or directory occupies a skill link slot, or a project-scope target folder is a symlink leading outside the repository; nothing was changed. Remove or fix it manually, or choose other targets. | | `BUNDLE_NOT_FOUND` | The `prowl` binary is not inside a Prowl app bundle and `PROWL_SKILLS_DIR` is unset or invalid — run the installed `prowl` or set the override. | | `INVALID_SKILL_FRONTMATTER` | A bundled (or `PROWL_SKILLS_DIR`) skill's `SKILL.md` frontmatter is malformed — fix the override skill, or reinstall Prowl if the bundle itself is damaged. | -| `WORKFLOW_INVALID` | `workflow validate` found errors; the full diagnostics are in `.error.details` (JSON) or on stdout (text). Fix the file and re-run. | -| `WORKFLOW_NOT_FOUND` | No workflow definition with that id is visible to the worktree — re-run `workflow list`. | +| `WORKFLOW_INVALID` | `workflow validate` found errors, or `workflow run` named a definition with errors; the full diagnostics are in `.error.details` (JSON) or on stdout (text). Fix the file and re-run. | +| `WORKFLOW_NOT_FOUND` / `WORKFLOW_DISABLED` | No workflow definition with that id or unique name is visible to the worktree, or it is switched off — re-run `workflow list`. | +| `RUN_NOT_FOUND` | No live run with that UUID (`cancel`, manual `done`), no record of it in any known worktree (`status`), or the calling pane is not part of an active run (`status` without arguments). | +| `PANE_BUSY` / `DISPATCH_PENDING` (`workflow run`) | The source pane or a `--role` pane already belongs to another active run, or still holds a pending dispatch record that must be completed or abandoned first. | +| `PROFILE_NOT_FOUND` / `PROFILE_NOT_UNIQUE` (`workflow run`) | No enabled Profile satisfies a `launch` role (pass `--role =`), or the given name matches several. | +| `STEP_NOT_EXPECTING` / `TOKEN_REQUIRED` / `TOKEN_INVALID` | `workflow done`: the calling pane holds no waiting activation (the step moved on, was skipped, or the run ended before the output was saved), the completion command was run without its token, or the token belongs to an earlier step. Re-run the latest completion command Prowl typed. | +| `ROLE_MISMATCH` | `workflow done --run --step` named a step other than the one the calling pane is waiting for; pass `--force` to deliver there anyway. | +| `OUTPUT_INVALID` / `OUTPUT_TOO_LARGE` / `VERDICT_REQUIRED` | `workflow done`: empty body (or, for a `strict` step, missing sections / bad format / bad verdict), body above the step's size cap, or a strict step that declares verdicts got none. Non-strict issues are accepted as `delivery.state = provisional` instead. | +| `WORKFLOW_DELIVERY_REQUIRED` | `agents dispatch-complete` ran in a pane whose pending dispatch is a workflow activation; the message carries the exact `prowl workflow done` command to run instead. | +| `REQUEST_CANCELLED` | The CLI disconnected before an in-app workflow request completed; the run itself was not affected. | | `NO_ACTIVE_PANE` | No pane for focused-target; pass an explicit `--pane`. | | `EMPTY_INPUT` | `send` got neither argv nor stdin (or both). | | `INVALID_ARGUMENT` | Bad flag/combo (e.g. `--capture --no-wait`) or out-of-range value. | diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index 8d9f99047..1b129c2f6 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -150,6 +150,39 @@ record is in `.error.details.record`) until you wait for or `dispatch-abandon` t one. The prompt is piped stdin (multi-line is fine; it arrives as one message), and the reviewer completes with the usual `agents dispatch-complete` — from its own pane, no id needed. +Run a workflow file instead of scripting the rounds yourself (`workflow list` shows what is +visible to your worktree; `workflow validate ` checks a new one locally): + +```bash +run="$(prowl workflow run review --role reviewer=Codex --input max_rounds=3 --json)" +printf '%s\n' "$run" | jq -r '.data.id, .data.status.state, .data.self_initiated.line' +``` + +A workflow with a `current` role runs from the pane you call it in (that pane becomes the +role; `SOURCE_REQUIRED` outside a pane, `PANE_BUSY` when it already belongs to a run). Launch +roles freeze a Profile before anything happens: `--role =`, else +the remembered, suggested, or Recommended one (`PROFILE_NOT_FOUND` when nothing qualifies); +`pick` roles need `--role =pN`. When the first step messages your own pane, nothing is +typed: `.data.self_initiated.line` is the instruction to follow now, and its completion command +delivers your output. Every step Prowl types into a pane ends with the exact command that +completes it — run it with the output on stdin when your work for that step is fully done: + +```bash +PROWL_WORKFLOW_TOKEN=… prowl workflow done - <<'EOF' # the token Prowl handed you; launched roles have it in $PROWL_WORKFLOW_TOKEN +## Findings +… +EOF +prowl workflow status --json | jq '.data.activation' # what this pane still owes, with its requirements +``` + +`done` answers after the output is saved: `.data.delivery.state` is `delivered`, or +`provisional` when the body had issues a non-strict step tolerates (`.data.delivery.warnings[]`) +— then the run waits for the user, not for another `done`. A pane whose step moved on gets +`STEP_NOT_EXPECTING`; a wrong token `TOKEN_INVALID`; `agents dispatch-complete` in a workflow +pane is refused with `WORKFLOW_DELIVERY_REQUIRED` and the command to run instead. `prowl +workflow cancel ` ends a run and keeps every pane and output; `status ` reads a +run even after an app restart (`.data.source: record`). + Create a fresh tab in a listed worktree: ```bash @@ -295,4 +328,4 @@ Required sections are `## Objective`, `## Current State`, and `## Next Steps`; o ## Command Set -`list`, `agents`, `agents read`, `agents signal`, `agents dispatch`, `agents dispatch-complete`, `agents dispatch-abandon`, `agents wait`, `profiles list`, `skills list|install|uninstall|path` (local-only), `read`, `send`, `key`, `focus`, `create tab`, `create pane`, `close`, `handoff to`, `handoff save`, and `open` (default). There is no CLI `quit`; close temporary tabs or panes with an explicit `close`. `tab create`, `tab close`, and `pane close` remain deprecated aliases for one release. +`list`, `agents`, `agents read`, `agents signal`, `agents dispatch`, `agents dispatch-complete`, `agents dispatch-abandon`, `agents wait`, `profiles list`, `skills list|install|uninstall|path` (local-only), `workflow list|run|status|done|cancel` (`workflow validate|schema` local-only), `read`, `send`, `key`, `focus`, `create tab`, `create pane`, `close`, `handoff to`, `handoff save`, and `open` (default). There is no CLI `quit`; close temporary tabs or panes with an explicit `close`. `tab create`, `tab close`, and `pane close` remain deprecated aliases for one release. diff --git a/supacode/App/WorkflowRuntimeComposition.swift b/supacode/App/WorkflowRuntimeComposition.swift new file mode 100644 index 000000000..62a1a4289 --- /dev/null +++ b/supacode/App/WorkflowRuntimeComposition.swift @@ -0,0 +1,552 @@ +// supacode/App/WorkflowRuntimeComposition.swift +// The live boundaries of the workflow runner (docs-ai 063 B3): the activation bridge over the +// dispatch store, the idle wait built on the #733 evidence rules, the profile launch with +// child-only workflow environment, the per-activation watchdog sources, admission facts, and the +// CLI coordinator. Nothing here holds run state; the reducer does. + +import ComposableArchitecture +import Foundation + +/// Lets the responder dependency (installed before the store exists) reach the coordinator that +/// is built with the CLI router. +@MainActor +final class WorkflowCoordinatorBox { + var coordinator: WorkflowRuntimeCoordinator? +} + +/// Panes a workflow launch created but the reducer has not bound yet (dsl-spec §10: one run per +/// pane). A launch without `expect` opens no dispatch record, so between surface creation and +/// the reducer's `.launched` nothing else would keep admission from binding that pane. +@MainActor +final class WorkflowPaneReservations { + private var surfaceIDs: Set = [] + + func reserve(_ surfaceID: UUID) { + surfaceIDs.insert(surfaceID) + } + + func release(_ surfaceID: UUID) { + surfaceIDs.remove(surfaceID) + } + + /// Reservations still worth honoring: the pane exists and no run — live, ended, or relaunched + /// away from it — has ever bound it (a pane a finished run kept is free again). + func pending(everBound: Set, isLive: (UUID) -> Bool) -> Set { + surfaceIDs = surfaceIDs.filter { !everBound.contains($0) && isLive($0) } + return surfaceIDs + } + + /// The reservations admission still honors, pruned against every pane a run ever owned — + /// a relaunch's old pane included, so a reservation ends the moment its launch was taken up. + func pending(for workflowRuns: WorkflowRunsFeature.State, isLive: (UUID) -> Bool) -> Set { + pending(everBound: Set(workflowRuns.paneOwners.keys), isLive: isLive) + } +} + +/// Everything the app installs for the workflow runner at its composition root. +struct WorkflowRuntimeInstallation { + let coordinatorBox: WorkflowCoordinatorBox + let reservations: WorkflowPaneReservations + let activation: WorkflowActivationClient + let runtime: WorkflowRuntimeClient + let watchdog: WorkflowWatchdogClient + let queue: WorkflowEffectQueueClient + let responder: WorkflowCLIResponderClient + + func install(into values: inout DependencyValues) { + values.workflowActivationClient = activation + values.workflowRuntimeClient = runtime + values.workflowWatchdogClient = watchdog + values.workflowEffectQueue = queue + values.workflowCLIResponder = responder + } +} + +extension SupacodeApp { + private static let workflowLogger = SupaLogger("Workflow") + + @MainActor + static func makeWorkflowRuntime( + terminalManager: WorktreeTerminalManager, + storeBox: SupacodeAppStoreBox + ) -> WorkflowRuntimeInstallation { + let bridge = makeWorkflowActivationBridge(terminalManager: terminalManager, storeBox: storeBox) + let coordinatorBox = WorkflowCoordinatorBox() + let reservations = WorkflowPaneReservations() + return WorkflowRuntimeInstallation( + coordinatorBox: coordinatorBox, + reservations: reservations, + activation: makeWorkflowActivationClient(bridge: bridge), + runtime: makeWorkflowRuntimeClient( + terminalManager: terminalManager, storeBox: storeBox, reservations: reservations), + watchdog: makeWorkflowWatchdogClient( + terminalManager: terminalManager, activationBridge: bridge, storeBox: storeBox), + queue: WorkflowEffectQueue().client, + responder: WorkflowCLIResponderClient(respond: { requestID, resolution in + coordinatorBox.coordinator?.resolve(requestID, resolution) + }) + ) + } + + // MARK: - Activation bridge + + @MainActor + static func makeWorkflowActivationBridge( + terminalManager: WorktreeTerminalManager, + storeBox: SupacodeAppStoreBox + ) -> LiveWorkflowActivationBridge { + LiveWorkflowActivationBridge(terminalManager: terminalManager) { surfaceID in + guard let appStore = storeBox.store else { return nil } + let resolver = makeTargetResolver(appStore: appStore, terminalManager: terminalManager) + guard case .success(let target) = resolver.resolve(.pane(surfaceID.uuidString)) else { + return nil + } + return TabResolvedTarget(from: target) + } + } + + @MainActor + static func makeWorkflowActivationClient(bridge: LiveWorkflowActivationBridge) + -> WorkflowActivationClient + { + WorkflowActivationClient( + openMessage: { bridge.openMessageActivation(surfaceID: $0) }, + cancel: { bridge.cancelActivation(dispatchID: $0) }, + abandon: { bridge.abandonActivation(dispatchID: $0, reason: $1) }, + complete: { bridge.completeActivation(dispatchID: $0, summary: $1) }, + observe: { bridge.observeActivation(dispatchID: $0) } + ) + } + + // MARK: - Evidence + + /// The same view of a pane `agents wait` and `agents dispatch` use (docs-ai 064.012/014). + @MainActor + static func makeWorkflowConditionSnapshot( + surfaceID: UUID, + terminalManager: WorktreeTerminalManager, + storeBox: SupacodeAppStoreBox + ) -> AgentConditionSnapshot { + let observed = terminalManager.agentObservationSnapshot(surfaceID: surfaceID) + let agent = storeBox.store?.state.repositories.activeAgents.entries.first { + $0.surfaceID == surfaceID + } + let evidence = terminalManager.currentAgentSignalEvidence(surfaceID: surfaceID) + return AgentConditionSnapshot( + agent: agent, + signal: evidence.activeTerminal, + changedSignal: evidence.latest, + revision: observed?.revision ?? 0, + isLive: terminalManager.isSurfaceLive(surfaceID), + signals: terminalManager.agentSignalsPayload(surfaceID: surfaceID) + ) + } + + // MARK: - Watchdog + + @MainActor + static func makeWorkflowWatchdogClient( + terminalManager: WorktreeTerminalManager, + activationBridge: LiveWorkflowActivationBridge, + storeBox: SupacodeAppStoreBox + ) -> WorkflowWatchdogClient { + WorkflowWatchdogClient(arm: { _, request in + let sources = WorkflowWatchdog.Sources( + observeAgent: { terminalManager.observeAgentState(surfaceID: request.surfaceID) }, + observeDispatch: { + request.dispatchID.flatMap { activationBridge.observeActivation(dispatchID: $0) } + }, + snapshot: { + WorkflowWatchdog.snapshot( + from: makeWorkflowConditionSnapshot( + surfaceID: request.surfaceID, terminalManager: terminalManager, storeBox: storeBox)) + } + ) + let watchdog = WorkflowWatchdog( + request: request, settings: WorkflowWatchdogSettings(), sources: sources) + return WorkflowWatchdogHandle(verdicts: watchdog.start(), cancel: { watchdog.cancel() }) + }) + } + + // MARK: - Runtime + + /// How long a pane without a detected agent may take to show one before the idle wait gives up + /// (the CLI wait's appearance grace), and how long a heuristic `blocked` must persist (the + /// watchdog's blocked grace). + nonisolated static let workflowRoleWaitPollMilliseconds = 250 + nonisolated static let workflowRoleWaitAppearanceMilliseconds = 10_000 + nonisolated static let workflowRoleWaitBlockedMilliseconds = 30_000 + + @MainActor + static func makeWorkflowRuntimeClient( + terminalManager: WorktreeTerminalManager, + storeBox: SupacodeAppStoreBox, + reservations: WorkflowPaneReservations = WorkflowPaneReservations() + ) -> WorkflowRuntimeClient { + WorkflowRuntimeClient( + waitForRole: { surfaceID in + await waitForWorkflowRole( + surfaceID: surfaceID, terminalManager: terminalManager, storeBox: storeBox) + }, + deliverLine: { worktree, surfaceID, line, isLive in + guard let state = terminalManager.stateIfExists(for: worktree.id) else { + return .insertFailed + } + // Same main-actor turn as the insertion: a fence raised by a cancel cannot slip in between. + guard isLive() else { return .stale } + guard state.insertCommittedText(line, in: surfaceID) else { return .insertFailed } + return state.submitLine(in: surfaceID) ? .delivered : .submitFailed + }, + launch: { worktree, frozenPlan, request in + await launchWorkflowRole( + worktree: worktree, frozenPlan: frozenPlan, request: request, + boundary: WorkflowLaunchBoundary( + terminalManager: terminalManager, storeBox: storeBox, reservations: reservations)) + }, + close: { worktree, surfaceID, runID in + // Same main-actor turn as the close: a run that ended no longer counts as busy at + // admission, so a later run may have bound the pane — and kept it when it ended. + if let owner = storeBox.store?.state.workflowRuns.paneOwners[surfaceID], owner != runID { + workflowLogger.warning( + "[Workflow] Run \(runID) left pane \(surfaceID) open: workflow run \(owner) bound it since.") + return false + } + return terminalManager.stateIfExists(for: worktree.id)?.closeSurface( + id: surfaceID, confirmation: .skip) ?? false + }, + notify: { worktree, text in + workflowLogger.notice("[\(worktree.name)] \(text)") + guard let appStore = storeBox.store, appStore.state.settings.systemNotificationsEnabled + else { return } + @Dependency(SystemNotificationClient.self) var notifications + Task { @MainActor in + await notifications.send("Workflow · \(worktree.name)", text, worktree.id, nil) + } + } + ) + } + + /// The #733 idle precondition without its five-second cap (docs-ai 063.007, "What B3 must do + /// with each effect"), decided by `WorkflowRoleWaitPolicy` against the baseline captured when + /// the wait started: a fresh exact `turn-ended` ends it at once, a detector-only idle view must + /// stay stable for two seconds, `working` keeps waiting, an exact `needs-input` or a heuristic + /// `blocked` that persists for the blocked grace ends the wait as blocked, and a pane that holds + /// someone else's pending dispatch record ends it as `dispatchPending`. + @MainActor + private static func waitForWorkflowRole( + surfaceID: UUID, + terminalManager: WorktreeTerminalManager, + storeBox: SupacodeAppStoreBox + ) async -> WorkflowRoleWaitOutcome { + let clock = ContinuousClock() + var elapsed = 0 + var policy = WorkflowRoleWaitPolicy( + blockedGraceMilliseconds: workflowRoleWaitBlockedMilliseconds, + appearanceGraceMilliseconds: workflowRoleWaitAppearanceMilliseconds) + while !Task.isCancelled { + let snapshot = makeWorkflowConditionSnapshot( + surfaceID: surfaceID, terminalManager: terminalManager, storeBox: storeBox) + let pending = terminalManager.pendingAgentDispatchSnapshot(surfaceID: surfaceID)?.record.id + if let outcome = policy.observe(snapshot, pendingDispatchID: pending, elapsedMilliseconds: elapsed) { + return outcome + } + do { + try await clock.sleep(for: .milliseconds(workflowRoleWaitPollMilliseconds)) + } catch { + return .cancelled + } + elapsed += workflowRoleWaitPollMilliseconds + } + return .cancelled + } + + /// A2's plan → prepare → launch sequence for a `launch` role: the dispatch record is issued + /// first (when the step expects a delivery), the frozen plan is prepared with its placeholder + /// prompt, the rendered kickoff prompt and the child-only `PROWL_WORKFLOW_*` values are attached + /// after preflight (like `attachingDispatch`), and the record is bound to the new pane. Every + /// failure after a step rolls the earlier ones back: the issuance is cancelled and the pane closed. + @MainActor + private static func launchWorkflowRole( + worktree: Worktree, + frozenPlan: AgentProfileLaunchPlan, + request: WorkflowLaunchRequest, + boundary: WorkflowLaunchBoundary + ) async -> Result { + let (terminalManager, storeBox, reservations) = (boundary.terminalManager, boundary.storeBox, boundary.reservations) + var dispatchID: String? + if request.expectsDelivery { + do { + dispatchID = try terminalManager.issueAgentDispatch().record.id + } catch { + return .failure(.failed("the launch activation could not be issued: \(error)")) + } + } + func rollback(closing surfaceID: UUID?) { + if let dispatchID { terminalManager.cancelAgentDispatchIssuance(dispatchID: dispatchID) } + if let surfaceID { + reservations.release(surfaceID) + _ = terminalManager.stateIfExists(for: worktree.id)?.closeSurface( + id: surfaceID, confirmation: .skip) + } + } + let attached: PreparedAgentProfileLaunch + let prepared = await prepareWorkflowLaunch( + worktree: worktree, frozenPlan: frozenPlan, request: request, terminalManager: terminalManager) + switch prepared { + case .failure(let error): + rollback(closing: nil) + return .failure(error) + case .success(let value): + attached = value + } + let launched: LaunchedSurface + switch terminalManager.launchPreparedAgentProfile(attached, in: worktree) { + case .failure(let error): + rollback(closing: nil) + return .failure(.failed("the profile could not be launched: \(error)")) + case .success(let value): + launched = value + } + // Reserved until the reducer binds the pane (or a later admission finds it bound / gone). + reservations.reserve(launched.surfaceID) + guard let appStore = storeBox.store else { + rollback(closing: launched.surfaceID) + return .failure(.failed("the app store is unavailable")) + } + let resolver = makeTargetResolver(appStore: appStore, terminalManager: terminalManager) + guard case .success(let target) = resolver.resolve(.pane(launched.surfaceID.uuidString)) else { + rollback(closing: launched.surfaceID) + return .failure(.failed("the launched pane could not be resolved")) + } + if let dispatchID { + do { + try terminalManager.bindAgentDispatch( + dispatchID: dispatchID, target: TabResolvedTarget(from: target)) + } catch { + rollback(closing: launched.surfaceID) + return .failure(.failed("the launch activation could not be bound: \(error)")) + } + } + if !request.background { + selectCLIWorktreeContext( + worktreeID: worktree.id, appStore: appStore, terminalManager: terminalManager) + terminalManager.state(for: worktree).selectTab(launched.tabID) + } + let snapshot = TargetResolutionSnapshotBuilder.makeSnapshot( + repositoriesState: appStore.state.repositories, + terminalManager: terminalManager + ) + let handle = snapshot.worktrees.flatMap(\.tabs).flatMap(\.panes).first { + $0.id == launched.surfaceID + }?.handle + return .success( + WorkflowLaunchResult( + pane: WorkflowPaneIdentity( + surfaceID: launched.surfaceID, + tabID: launched.tabID.rawValue, + handle: handle.map { "p\($0)" } ?? launched.surfaceID.uuidString, + displayName: request.profile.name, + agent: request.profile.agent), + dispatchID: dispatchID)) + } + + /// Placement, A2 preflight with the placeholder prompt, then the kickoff prompt and the + /// `PROWL_WORKFLOW_*` carriers attached to the prepared plan. + @MainActor + private static func prepareWorkflowLaunch( + worktree: Worktree, + frozenPlan: AgentProfileLaunchPlan, + request: WorkflowLaunchRequest, + terminalManager: WorktreeTerminalManager + ) async -> Result { + let placement: AgentProfileLaunchRequest.Placement + switch request.placement { + case .tab: + placement = .tab(background: request.background) + case .split: + placement = .split( + anchor: request.anchorSurfaceID, + direction: splitDirection(request.direction), + background: request.background + ) + } + let launchRequest = AgentProfileLaunchRequest( + plan: frozenPlan, + placement: placement, + workingDirectoryOverride: worktree.workingDirectory, + inheritanceAnchor: request.anchorSurfaceID, + title: request.profile.name + ) + let preparation: PreparedAgentProfileLaunch + switch await terminalManager.prepareAgentProfileLaunch(launchRequest, in: worktree) { + case .failure(let error): + return .failure(.failed("the profile launch could not be prepared: \(error)")) + case .success(let value): + preparation = value + } + let attachedPlan: AgentProfileLaunchPlan + do { + attachedPlan = try preparation.context.request.plan.attachingWorkflow( + prompt: request.prompt, environment: request.environment) + } catch { + return .failure(.failed("the kickoff prompt could not be attached: \(error)")) + } + let attached = PreparedAgentProfileLaunch( + context: FrozenAgentProfileLaunchContext( + request: AgentProfileLaunchRequest( + plan: attachedPlan, + placement: preparation.context.request.placement, + workingDirectoryOverride: preparation.context.request.workingDirectoryOverride, + inheritanceAnchor: preparation.context.request.inheritanceAnchor, + title: preparation.context.request.title), + inheritedCWD: preparation.context.inheritedCWD, + anchorSurfaceID: preparation.context.anchorSurfaceID, + tracksFocusedAnchor: preparation.context.tracksFocusedAnchor, + tracksInheritedCWD: preparation.context.tracksInheritedCWD), + warnings: preparation.warnings) + return .success(attached) + } + + nonisolated private static func splitDirection(_ direction: WorkflowSplitDirection) + -> UserCustomSplitDirection + { + switch direction { + case .right: .right + case .left: .left + case .top: .top + case .down: .down + } + } + + // MARK: - Admission and coordination + + @MainActor + static func makeWorkflowAdmissionEnvironment( + appStore: StoreOf, + terminalManager: WorktreeTerminalManager, + reservations: WorkflowPaneReservations + ) -> WorkflowAdmissionEnvironment { + @Shared(.userGlobalSettings) var settings + let profiles = settings.agentProfiles + let bundledSkills = + Bundle.main.resourceURL.flatMap { try? ProwlSkills.bundled(resourcesURL: $0) } ?? [] + return WorkflowAdmissionEnvironment( + profiles: profiles, + recommendation: { repositoryRootURL in + @Shared(.userRepositorySettings(repositoryRootURL)) var repositorySettings + return ( + repositorySettings.defaultAgentProfileID, repositorySettings.lastLaunchedAgentProfileID + ) + }, + rememberedBinding: { key in + @Shared(.userGlobalSettings) var settings + return settings.rememberedWorkflowBinding(for: key) + }, + detectedAgent: { surfaceID in + appStore.state.repositories.activeAgents.entries.first { $0.surfaceID == surfaceID }.map { + WorkflowDetectedAgent(token: $0.agent.rawValue, displayName: $0.agent.displayName) + } + }, + pendingDispatchID: { surfaceID in + terminalManager.pendingAgentDispatchSnapshot(surfaceID: surfaceID)?.record.id + }, + busySurfaceIDs: reservations.pending( + for: appStore.state.workflowRuns, isLive: { terminalManager.isSurfaceLive($0) }), + worktree: { id in + resolveCLITerminalWorktree( + id: id, repositories: Array(appStore.state.repositories.repositories)) + }, + branchName: { worktree in + WorktreeBranchReader.branchName(of: worktree.workingDirectory) ?? worktree.name + }, + makeLaunchPlan: { profile in + try AgentProfileLaunchPlanner.plan( + for: profile, + intent: .prompt(WorkflowRunAdmissionPlaceholder.prompt), + homeBaseDirectory: SupacodePaths.agentProfileHomesDirectory) + }, + bundledSkill: { id in bundledSkills.first { $0.id == id } } + ) + } + + @MainActor + static func makeWorkflowCoordinator( + appStore: StoreOf, + terminalManager: WorktreeTerminalManager, + rendezvous: WorkflowCLIRendezvous, + reservations: WorkflowPaneReservations + ) -> WorkflowRuntimeCoordinator { + WorkflowRuntimeCoordinator( + dependencies: WorkflowRuntimeCoordinator.Dependencies( + admissionEnvironment: { + makeWorkflowAdmissionEnvironment( + appStore: appStore, terminalManager: terminalManager, reservations: reservations) + }, + sessions: { Array(appStore.state.workflowRuns.sessions.values) }, + send: { appStore.send(.workflowRuns($0)) }, + pendingDispatchID: { + terminalManager.pendingAgentDispatchSnapshot(surfaceID: $0)?.record.id + }, + worktreeRoots: { + workflowRunRoots(of: Array(appStore.state.repositories.repositories)).map { + URL(filePath: $0, directoryHint: .isDirectory) + } + }, + rendezvous: rendezvous + )) + } + + /// The `WORKFLOW_DELIVERY_REQUIRED` refusal of `agents dispatch-complete` for a pane whose + /// pending record is a workflow activation (decision W3), live or already ended. + @MainActor + static func workflowDeliveryRefusal( + surfaceID: UUID, + appStore: StoreOf, + terminalManager: WorktreeTerminalManager + ) -> CommandError? { + guard let dispatchID = terminalManager.pendingAgentDispatchSnapshot(surfaceID: surfaceID)?.record.id else { + return nil + } + return WorkflowRuntimeCoordinator.deliveryRefusal( + dispatchID: dispatchID, sessions: Array(appStore.state.workflowRuns.sessions.values)) + } +} + +/// The app objects a workflow launch touches. +@MainActor +struct WorkflowLaunchBoundary { + let terminalManager: WorktreeTerminalManager + let storeBox: SupacodeAppStoreBox + let reservations: WorkflowPaneReservations +} + +/// The prompt a frozen `launch` plan is compiled with; `attachingWorkflow` replaces it at launch. +nonisolated enum WorkflowRunAdmissionPlaceholder { + static let prompt = "[Prowl workflow kickoff]" +} + +/// `git symbolic-ref --short HEAD` of a worktree, synchronously and cheaply; nil outside Git. +nonisolated enum WorktreeBranchReader { + static func branchName(of worktree: URL) -> String? { + let process = Process() + process.executableURL = URL(filePath: "/usr/bin/git") + process.arguments = [ + "-C", worktree.path(percentEncoded: false), "symbolic-ref", "--short", "-q", "HEAD", + ] + let output = Pipe() + process.standardOutput = output + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + return nil + } + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + guard let branch = String(bytes: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !branch.isEmpty + else { return nil } + return branch + } +} diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 72c746a3a..d77ed8e78 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -15,7 +15,7 @@ import Sharing import SwiftUI @MainActor -private final class SupacodeAppStoreBox { +final class SupacodeAppStoreBox { weak var store: StoreOf? } @@ -204,6 +204,7 @@ struct SupacodeApp: App { _worktreeInfoWatcher = State(initialValue: worktreeInfoWatcher) let storeBox = SupacodeAppStoreBox() let handoffRequestRegistry = HandoffRequestRegistry() + let workflowRuntime = Self.makeWorkflowRuntime(terminalManager: terminalManager, storeBox: storeBox) let coordinator = Self.makePullRequestRefreshCoordinator(storeBox: storeBox) _pullRequestRefreshCoordinator = State(initialValue: coordinator) @@ -242,6 +243,7 @@ struct SupacodeApp: App { handoffRequestRegistry.supersede(requestID) } ) + workflowRuntime.install(into: &values) values.outgoingChangesClient = Self.makeOutgoingChangesClient(storeBox: storeBox) } @@ -252,7 +254,9 @@ struct SupacodeApp: App { let cliServer = Self.makeCLISocketServer( appStore: appStore, terminalManager: terminalManager, - handoffRequestRegistry: handoffRequestRegistry + handoffRequestRegistry: handoffRequestRegistry, + workflowCoordinatorBox: workflowRuntime.coordinatorBox, + workflowReservations: workflowRuntime.reservations ) _cliSocketServer = State(initialValue: cliServer) @@ -526,7 +530,7 @@ struct SupacodeApp: App { ) } - private static func makeTargetResolver( + static func makeTargetResolver( appStore: StoreOf, terminalManager: WorktreeTerminalManager ) -> TargetResolver { @@ -684,6 +688,8 @@ struct SupacodeApp: App { appStore: StoreOf, terminalManager: WorktreeTerminalManager, handoffRequestRegistry: HandoffRequestRegistry = HandoffRequestRegistry(), + workflowCoordinatorBox: WorkflowCoordinatorBox = WorkflowCoordinatorBox(), + workflowReservations: WorkflowPaneReservations = WorkflowPaneReservations(), agentSignalCallerResolver: AgentSignalCommandHandler.ResolveCaller? = nil ) -> CLICommandRouter { @@ -768,6 +774,9 @@ struct SupacodeApp: App { } catch { return .failure(.notFound) } + }, + intercept: { surfaceID in + Self.workflowDeliveryRefusal(surfaceID: surfaceID, appStore: appStore, terminalManager: terminalManager) } ) let dispatchAbandonHandler = AgentDispatchAbandonCommandHandler( @@ -1187,36 +1196,17 @@ struct SupacodeApp: App { } ) - let workflowHandler = WorkflowCommandHandler { - @Shared(.userGlobalSettings) var settings - @Shared(.agentRuntimeAvailabilityProbeResults) var probeResults - let bundledSkills = Bundle.main.resourceURL.flatMap { try? ProwlSkills.bundled(resourcesURL: $0) } - let installedAgents = - probeResults.isEmpty - ? nil - : Set(probeResults.filter { $0.value.isAvailable }.keys.map { $0.agent.rawValue }) - return WorkflowRuntimeSnapshot( - resolution: TargetResolutionSnapshotBuilder.makeSnapshot( - repositoriesState: appStore.state.repositories, - terminalManager: terminalManager - ), - paneByShellPID: terminalManager.paneByShellPID(), - bundleWorkflowsURL: SupacodePaths.bundledWorkflowsURL, - userWorkflowsURL: WorkflowSources.userDirectory(home: FileManager.default.homeDirectoryForCurrentUser), - disabledWorkflowIDs: Set(settings.disabledWorkflowIDs), - bundledSkillIDs: bundledSkills.map { Set($0.map(\.id)) }, - knownAgents: Set(DetectedAgent.allCases.map(\.rawValue)), - installedAgents: installedAgents, - enabledProfiles: settings.agentProfiles.filter(\.isEnabled).map { profile in - WorkflowProfileSuggestion( - agent: profile.runtime.agent.rawValue, - model: profile.model, - reasoningEffort: profile.reasoningEffort, - executionMode: profile.executionMode.rawValue - ) - } - ) - } + let workflowCoordinator = Self.makeWorkflowCoordinator( + appStore: appStore, + terminalManager: terminalManager, + rendezvous: WorkflowCLIRendezvous(), + reservations: workflowReservations + ) + workflowCoordinatorBox.coordinator = workflowCoordinator + let workflowHandler = WorkflowCommandHandler( + snapshotProvider: { Self.makeWorkflowRuntimeSnapshot(appStore: appStore, terminalManager: terminalManager) }, + runtime: workflowCoordinator + ) return CLICommandRouter( openHandler: openHandler, listHandler: listHandler, @@ -1308,16 +1298,56 @@ struct SupacodeApp: App { ) } + @MainActor + static func makeWorkflowRuntimeSnapshot( + appStore: StoreOf, + terminalManager: WorktreeTerminalManager + ) -> WorkflowRuntimeSnapshot { + @Shared(.userGlobalSettings) var settings + @Shared(.agentRuntimeAvailabilityProbeResults) var probeResults + let bundledSkills = Bundle.main.resourceURL.flatMap { try? ProwlSkills.bundled(resourcesURL: $0) } + let installedAgents = + probeResults.isEmpty + ? nil + : Set(probeResults.filter { $0.value.isAvailable }.keys.map { $0.agent.rawValue }) + return WorkflowRuntimeSnapshot( + resolution: TargetResolutionSnapshotBuilder.makeSnapshot( + repositoriesState: appStore.state.repositories, + terminalManager: terminalManager + ), + paneByShellPID: terminalManager.paneByShellPID(), + bundleWorkflowsURL: SupacodePaths.bundledWorkflowsURL, + userWorkflowsURL: WorkflowSources.userDirectory(home: FileManager.default.homeDirectoryForCurrentUser), + disabledWorkflowIDs: Set(settings.disabledWorkflowIDs), + bundledSkillIDs: bundledSkills.map { Set($0.map(\.id)) }, + knownAgents: Set(DetectedAgent.allCases.map(\.rawValue)), + installedAgents: installedAgents, + enabledProfiles: settings.agentProfiles.filter(\.isEnabled).map { profile in + WorkflowProfileSuggestion( + agent: profile.runtime.agent.rawValue, + model: profile.model, + reasoningEffort: profile.reasoningEffort, + executionMode: profile.executionMode.rawValue + ) + } + ) + + } + private static func makeCLISocketServer( appStore: StoreOf, terminalManager: WorktreeTerminalManager, - handoffRequestRegistry: HandoffRequestRegistry + handoffRequestRegistry: HandoffRequestRegistry, + workflowCoordinatorBox: WorkflowCoordinatorBox, + workflowReservations: WorkflowPaneReservations ) -> CLISocketServer { let cliRouter = makeCLICommandRouter( appStore: appStore, terminalManager: terminalManager, - handoffRequestRegistry: handoffRequestRegistry + handoffRequestRegistry: handoffRequestRegistry, + workflowCoordinatorBox: workflowCoordinatorBox, + workflowReservations: workflowReservations ) let cliServer = CLISocketServer(router: cliRouter) let logger = SupaLogger("CLIService") @@ -1674,7 +1704,7 @@ struct SupacodeApp: App { } } - private static func selectCLIWorktreeContext( + static func selectCLIWorktreeContext( worktreeID: Worktree.ID, appStore: StoreOf, terminalManager: WorktreeTerminalManager diff --git a/supacode/CLIService/AgentConditionEvidence.swift b/supacode/CLIService/AgentConditionEvidence.swift index 90ef643ba..93edce90b 100644 --- a/supacode/CLIService/AgentConditionEvidence.swift +++ b/supacode/CLIService/AgentConditionEvidence.swift @@ -84,6 +84,43 @@ enum AgentConditionEvidence { } } + /// The arm-time verdict of `agents wait --until idle` under `auto`, shared by `agents dispatch` + /// and the workflow runner's idle wait (docs-ai 064.014 D5, 063 B3). + enum IdleVerdict: Equatable { + case idle + /// Idle by one source only; the caller keeps polling (and stabilizes a detector-only view). + case settling(String) + case busy(String) + } + + /// A pre-arm `turn-ended` counts only with detector corroboration, and the detector alone + /// counts only where the wait would fall back to it. Either source alone is not a refusal yet; + /// working or blocked without such evidence is. + static func idleVerdict( + for snapshot: AgentConditionSnapshot, baseline explicitBaseline: Baseline? = nil + ) -> IdleVerdict { + let state = normalizedState(snapshot) + // Without a baseline every signal the snapshot holds predates this call (the re-dispatch + // case); a wait that keeps polling passes the baseline it armed with, so a later exact + // `turn-ended` counts even while the screen still shows `working`. + let baseline = explicitBaseline ?? Baseline(snapshot: snapshot) + if exactMatch( + condition: .idle, snapshot: snapshot, normalizedState: state, baseline: baseline, minimumConfidence: .auto) + != nil + { + return .idle + } + if detectorReports(.idle, normalizedState: state), allowsHeuristic(.auto, condition: .idle, snapshot: snapshot) { + return .settling(state) + } + if let signal = snapshot.signal, signal.event == .turnEnded, accepts(signal.confidence, minimum: .auto), + !detectorReports(.blocked, normalizedState: state) + { + return .settling(state) + } + return .busy(state) + } + static func normalizedState(_ snapshot: AgentConditionSnapshot) -> String { guard snapshot.isLive else { return "gone" } return snapshot.agent.map { status(for: $0, fallback: .idle).rawValue } ?? "absent" diff --git a/supacode/CLIService/AgentDispatchCommandHandler.swift b/supacode/CLIService/AgentDispatchCommandHandler.swift index 607f9478a..2fae984d0 100644 --- a/supacode/CLIService/AgentDispatchCommandHandler.swift +++ b/supacode/CLIService/AgentDispatchCommandHandler.swift @@ -174,35 +174,15 @@ final class AgentDispatchCommandHandler: CommandHandler { } } - /// The arm-time evaluation of `agents wait --until idle` under `auto`: every signal the - /// snapshot holds predates this call, so a `turn-ended` counts only with detector - /// corroboration, and the detector alone counts only where the wait would fall back to it. - /// Either source alone is not a refusal yet — the wait itself would keep polling — so it - /// settles within the grace budget; working or blocked without such evidence is refused. + /// The shared arm-time evaluation (`AgentConditionEvidence.idleVerdict`): either source alone + /// is not a refusal yet — the wait itself would keep polling — so it settles within the grace + /// budget; working or blocked without such evidence is refused. private func verdict(for snapshot: AgentConditionSnapshot) -> IdleVerdict { - let state = AgentConditionEvidence.normalizedState(snapshot) - let baseline = AgentConditionEvidence.Baseline(snapshot: snapshot) - if AgentConditionEvidence.exactMatch( - condition: .idle, - snapshot: snapshot, - normalizedState: state, - baseline: baseline, - minimumConfidence: .auto - ) != nil { - return .idle + switch AgentConditionEvidence.idleVerdict(for: snapshot) { + case .idle: .idle + case .settling(let state): .settling(state) + case .busy(let state): .busy(heuristicObservation(snapshot, state: state)) } - let detectorIdle = AgentConditionEvidence.detectorReports(.idle, normalizedState: state) - if detectorIdle, AgentConditionEvidence.allowsHeuristic(.auto, condition: .idle, snapshot: snapshot) { - return .settling(state) - } - if let signal = snapshot.signal, - signal.event == .turnEnded, - AgentConditionEvidence.accepts(signal.confidence, minimum: .auto), - !AgentConditionEvidence.detectorReports(.blocked, normalizedState: state) - { - return .settling(state) - } - return .busy(heuristicObservation(snapshot, state: state)) } private func heuristicObservation(_ snapshot: AgentConditionSnapshot, state: String) -> AgentWaitObservation { @@ -273,18 +253,25 @@ final class AgentDispatchCompleteCommandHandler: CommandHandler { @MainActor ( UUID, DispatchCompletionOutcome, String ) -> Result + /// A refusal for a caller pane whose pending record belongs to someone else — a workflow + /// activation answers `WORKFLOW_DELIVERY_REQUIRED` here (docs-ai 063 B3, decision W3) so the + /// store never completes it through this path. + typealias Intercept = @MainActor (UUID) -> CommandError? private let resolveCaller: ResolveCaller private let complete: Complete + private let intercept: Intercept private let formatter: ISO8601DateFormatter init( resolveCaller: @escaping ResolveCaller, complete: @escaping Complete, + intercept: @escaping Intercept = { _ in nil }, now: @escaping @MainActor () -> Date = Date.init ) { self.resolveCaller = resolveCaller self.complete = complete + self.intercept = intercept self.formatter = Self.makeFormatter() _ = now } @@ -307,6 +294,14 @@ final class AgentDispatchCompleteCommandHandler: CommandHandler { message: "Run dispatch completion from the Prowl pane that owns this dispatch." ) } + if let refusal = intercept(caller.surfaceID) { + return CommandResponse( + ok: false, + command: "agents.dispatch-complete", + schemaVersion: "prowl.cli.agents.dispatch-complete.v1", + error: refusal + ) + } switch complete(caller.surfaceID, input.outcome, input.summary) { case .failure(let error): return map(error) diff --git a/supacode/CLIService/Shared/ErrorCodes.swift b/supacode/CLIService/Shared/ErrorCodes.swift index a5b466a5d..193d74ea1 100644 --- a/supacode/CLIService/Shared/ErrorCodes.swift +++ b/supacode/CLIService/Shared/ErrorCodes.swift @@ -102,6 +102,8 @@ nonisolated public enum CLIErrorCode { public static let workflowNotFound = "WORKFLOW_NOT_FOUND" /// The file parsed or validated with errors; `details` carries the validate payload. public static let workflowInvalid = "WORKFLOW_INVALID" + /// `workflow run` named a definition the user switched off. + public static let workflowDisabled = "WORKFLOW_DISABLED" // Run-time codes of the workflow runner (dsl-spec §9); emitted by `workflow run/done` from B3 on. public static let runNotFound = "RUN_NOT_FOUND" public static let paneBusy = "PANE_BUSY" @@ -119,6 +121,10 @@ nonisolated public enum CLIErrorCode { public static let promptTooLarge = "PROMPT_TOO_LARGE" /// `agents dispatch-complete` from a pane whose pending record is a workflow activation. public static let workflowDeliveryRequired = "WORKFLOW_DELIVERY_REQUIRED" + /// A socket client disconnected before its in-app workflow request completed. + public static let requestCancelled = "REQUEST_CANCELLED" + /// A duplicate in-app request UUID was registered; the original remains authoritative. + public static let requestConflict = "REQUEST_CONFLICT" // Transport public static let transportFailed = "TRANSPORT_FAILED" diff --git a/supacode/CLIService/Shared/InputModels.swift b/supacode/CLIService/Shared/InputModels.swift index 138e15138..b7acde3ac 100644 --- a/supacode/CLIService/Shared/InputModels.swift +++ b/supacode/CLIService/Shared/InputModels.swift @@ -689,18 +689,64 @@ public struct PaneInput: Codable, Sendable { // MARK: - Workflow nonisolated public enum WorkflowInputAction: String, Codable, Sendable { - /// Discover definitions for a worktree; the only workflow action that crosses the socket today. case list + case run + case status + case done + case cancel } +/// The wire request for the workflow command family (docs-ai 063 B1/B3). Fields are +/// action-specific; the app handler ignores the ones that do not belong to `action`. nonisolated public struct WorkflowInput: Codable, Sendable { public let action: WorkflowInputAction - /// Worktree whose repo source is searched: any 060 target; `.none` = the caller's pane, then - /// the focused worktree. + /// `list`: the worktree whose repo source is searched. `run`: the source pane (a workflow with a + /// `current` role) or worktree. `.none` = the caller's pane, then the focused worktree (`list`). public let target: TargetSelector + /// `run`: workflow id or unique name. + public let workflow: String? + /// `run`: `=` overrides (dsl-spec §9). + public let roleBindings: [String] + /// `run`: `=` inputs. + public let inputValues: [String] + /// `run`: step ids skipped at start. + public let skippedSteps: [String] + /// `status` / `cancel`: the run; `done`: the manual target together with `stepID`. + public let runID: String? + public let stepID: String? + /// `done`: the delivered output body (already read by the CLI). + public let body: String? + public let verdict: String? + /// `done`: `--token` or `$PROWL_WORKFLOW_TOKEN`; correlation only, never authentication. + public let token: String? + /// `done`: deliver to the explicit target even when the caller pane belongs to another step. + public let force: Bool - public init(action: WorkflowInputAction = .list, target: TargetSelector = .none) { + public init( + action: WorkflowInputAction = .list, + target: TargetSelector = .none, + workflow: String? = nil, + roleBindings: [String] = [], + inputValues: [String] = [], + skippedSteps: [String] = [], + runID: String? = nil, + stepID: String? = nil, + body: String? = nil, + verdict: String? = nil, + token: String? = nil, + force: Bool = false + ) { self.action = action self.target = target + self.workflow = workflow + self.roleBindings = roleBindings + self.inputValues = inputValues + self.skippedSteps = skippedSteps + self.runID = runID + self.stepID = stepID + self.body = body + self.verdict = verdict + self.token = token + self.force = force } } diff --git a/supacode/CLIService/Shared/WorkflowCommandPayload.swift b/supacode/CLIService/Shared/WorkflowCommandPayload.swift index d951ccf4e..30de9dc09 100644 --- a/supacode/CLIService/Shared/WorkflowCommandPayload.swift +++ b/supacode/CLIService/Shared/WorkflowCommandPayload.swift @@ -1,6 +1,7 @@ // ProwlShared/WorkflowCommandPayload.swift // `prowl workflow` response data (`prowl.cli.workflow.v1`), discriminated by `action`. -// `list` crosses the socket; `validate` and `schema` are produced locally by the CLI. +// `list`, `run`, `status`, `done`, and `cancel` cross the socket; `validate` and `schema` are +// produced locally by the CLI. import Foundation @@ -9,12 +10,20 @@ nonisolated public enum WorkflowCommandPayload: Codable, Equatable, Sendable { public static let commandName = "workflow" case list(WorkflowListPayload) + case run(WorkflowRunPayload) + case status(WorkflowRunPayload) + case done(WorkflowDonePayload) + case cancel(WorkflowRunPayload) case validate(WorkflowValidatePayload) case schema(WorkflowSchemaPayload) public var action: WorkflowCommandAction { switch self { case .list: .list + case .run: .run + case .status: .status + case .done: .done + case .cancel: .cancel case .validate: .validate case .schema: .schema } @@ -28,6 +37,10 @@ nonisolated public enum WorkflowCommandPayload: Codable, Equatable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) switch try container.decode(WorkflowCommandAction.self, forKey: .action) { case .list: self = .list(try WorkflowListPayload(from: decoder)) + case .run: self = .run(try WorkflowRunPayload(from: decoder)) + case .status: self = .status(try WorkflowRunPayload(from: decoder)) + case .done: self = .done(try WorkflowDonePayload(from: decoder)) + case .cancel: self = .cancel(try WorkflowRunPayload(from: decoder)) case .validate: self = .validate(try WorkflowValidatePayload(from: decoder)) case .schema: self = .schema(try WorkflowSchemaPayload(from: decoder)) } @@ -38,6 +51,9 @@ nonisolated public enum WorkflowCommandPayload: Codable, Equatable, Sendable { try container.encode(action, forKey: .action) switch self { case .list(let payload): try payload.encode(to: encoder) + case .run(let payload), .status(let payload), .cancel(let payload): + try payload.encode(to: encoder) + case .done(let payload): try payload.encode(to: encoder) case .validate(let payload): try payload.encode(to: encoder) case .schema(let payload): try payload.encode(to: encoder) } @@ -46,6 +62,10 @@ nonisolated public enum WorkflowCommandPayload: Codable, Equatable, Sendable { nonisolated public enum WorkflowCommandAction: String, Codable, Equatable, Sendable { case list + case run + case status + case done + case cancel case validate case schema } @@ -58,7 +78,9 @@ nonisolated public struct WorkflowListPayload: Codable, Equatable, Sendable { public let sources: WorkflowListSources public let workflows: [WorkflowListEntry] - public init(worktree: WorkflowListWorktree?, sources: WorkflowListSources, workflows: [WorkflowListEntry]) { + public init( + worktree: WorkflowListWorktree?, sources: WorkflowListSources, workflows: [WorkflowListEntry] + ) { self.worktree = worktree self.sources = sources self.workflows = workflows @@ -152,6 +174,391 @@ nonisolated public struct WorkflowListEntry: Codable, Equatable, Sendable { } } +// MARK: - run / status / cancel + +/// One workflow run as the CLI sees it (docs-ai 063 B3). `source` says whether the run is live in +/// the app (`live`) or was read back from its `run.json` after a restart (`record`); a record +/// carries no `activation` and no `self_initiated` block. +nonisolated public struct WorkflowRunPayload: Codable, Equatable, Sendable { + public let id: String + public let workflow: WorkflowIdentity + public let scope: WorkflowScope + public let definitionPath: String? + public let source: WorkflowRunPayloadSource + public let status: WorkflowRunStatusPayload + /// The step in progress; absent once the run has ended. + public let step: String? + /// The caller pane's role when the command was attributed to a pane bound in this run. + public let role: String? + public let worktree: WorkflowRunWorktreePayload + public let runDirectory: String + public let bindings: [String: WorkflowBindingPayload] + /// The activation currently waiting for (or persisting) a delivery. + public let activation: WorkflowActivationPayload? + /// Latest delivered output per name. + public let outputs: [String: WorkflowOutputPayload] + public let startedAt: String + public let updatedAt: String + public let finishedAt: String? + /// The first step's line when the run was started from the `current` role's own pane: the + /// caller already holds it, nothing was typed (dsl-spec §9). + public let selfInitiated: WorkflowSelfInitiatedPayload? + + enum CodingKeys: String, CodingKey { + case id + case workflow + case scope + case definitionPath = "definition_path" + case source + case status + case step + case role + case worktree + case runDirectory = "run_directory" + case bindings + case activation + case outputs + case startedAt = "started_at" + case updatedAt = "updated_at" + case finishedAt = "finished_at" + case selfInitiated = "self_initiated" + } + + public init( + id: String, + workflow: WorkflowIdentity, + scope: WorkflowScope, + definitionPath: String?, + source: WorkflowRunPayloadSource, + status: WorkflowRunStatusPayload, + step: String?, + role: String?, + worktree: WorkflowRunWorktreePayload, + runDirectory: String, + bindings: [String: WorkflowBindingPayload], + activation: WorkflowActivationPayload?, + outputs: [String: WorkflowOutputPayload], + startedAt: String, + updatedAt: String, + finishedAt: String?, + selfInitiated: WorkflowSelfInitiatedPayload? + ) { + self.id = id + self.workflow = workflow + self.scope = scope + self.definitionPath = definitionPath + self.source = source + self.status = status + self.step = step + self.role = role + self.worktree = worktree + self.runDirectory = runDirectory + self.bindings = bindings + self.activation = activation + self.outputs = outputs + self.startedAt = startedAt + self.updatedAt = updatedAt + self.finishedAt = finishedAt + self.selfInitiated = selfInitiated + } +} + +nonisolated public enum WorkflowRunPayloadSource: String, Codable, Equatable, Sendable { + case live + case record +} + +nonisolated public struct WorkflowRunStatusPayload: Codable, Equatable, Sendable { + /// `running`, `needs_attention`, `completed`, `cancelled`, `skipped`, `max_rounds_reached`, `interrupted`. + public let state: String + /// The step that ended a `skipped` run, or the step in attention. + public let step: String? + /// The step whose input made a `skipped` run end. + public let dependent: String? + public let attention: WorkflowAttentionPayload? + + public init( + state: String, step: String? = nil, dependent: String? = nil, + attention: WorkflowAttentionPayload? = nil + ) { + self.state = state + self.step = step + self.dependent = dependent + self.attention = attention + } +} + +nonisolated public struct WorkflowAttentionPayload: Codable, Equatable, Sendable { + public let reason: String + public let message: String + public let step: String + public let role: String? + public let ordinal: Int? + public let actions: [String] + /// Issue codes of a provisional delivery (`delivery_issues` only). + public let issues: [String]? + + public init( + reason: String, + message: String, + step: String, + role: String?, + ordinal: Int?, + actions: [String], + issues: [String]? = nil + ) { + self.reason = reason + self.message = message + self.step = step + self.role = role + self.ordinal = ordinal + self.actions = actions + self.issues = issues + } +} + +nonisolated public struct WorkflowRunWorktreePayload: Codable, Equatable, Sendable { + public let id: String + public let name: String + public let branch: String + public let path: String + + public init(id: String, name: String, branch: String, path: String) { + self.id = id + self.name = name + self.branch = branch + self.path = path + } +} + +nonisolated public struct WorkflowBindingPayload: Codable, Equatable, Sendable { + public let source: WorkflowRoleSource + /// Frozen for `launch` roles: identity and agent token only, never the launch plan. + public let profile: WorkflowProfileBindingPayload? + /// The role's pane; absent for a `launch` role until its pane exists. + public let pane: WorkflowPaneBindingPayload? + + public init( + source: WorkflowRoleSource, profile: WorkflowProfileBindingPayload? = nil, + pane: WorkflowPaneBindingPayload? = nil + ) { + self.source = source + self.profile = profile + self.pane = pane + } +} + +nonisolated public struct WorkflowProfileBindingPayload: Codable, Equatable, Sendable { + public let id: String + public let name: String + public let agent: String + + public init(id: String, name: String, agent: String) { + self.id = id + self.name = name + self.agent = agent + } +} + +nonisolated public struct WorkflowPaneBindingPayload: Codable, Equatable, Sendable { + public let id: String + public let tabID: String? + /// The short `pN` handle templates expose. + public let handle: String + public let displayName: String + /// The detected agent token; absent for a bare shell. + public let agent: String? + + enum CodingKeys: String, CodingKey { + case id + case tabID = "tab_id" + case handle + case displayName = "display_name" + case agent + } + + public init(id: String, tabID: String?, handle: String, displayName: String, agent: String?) { + self.id = id + self.tabID = tabID + self.handle = handle + self.displayName = displayName + self.agent = agent + } +} + +nonisolated public struct WorkflowActivationPayload: Codable, Equatable, Sendable { + public let ordinal: Int + public let step: String + public let role: String + /// `waiting`, `persisting`, `provisional`, `delivered`, `skipped`, `revoked`. + public let state: String + public let dispatchID: String? + public let output: String + public let expect: WorkflowExpectationPayload + /// The absolute `expect.timeout` deadline, when the step declares one. + public let deadline: String? + + enum CodingKeys: String, CodingKey { + case ordinal + case step + case role + case state + case dispatchID = "dispatch_id" + case output + case expect + case deadline + } + + public init( + ordinal: Int, + step: String, + role: String, + state: String, + dispatchID: String?, + output: String, + expect: WorkflowExpectationPayload, + deadline: String? + ) { + self.ordinal = ordinal + self.step = step + self.role = role + self.state = state + self.dispatchID = dispatchID + self.output = output + self.expect = expect + self.deadline = deadline + } +} + +/// What a waiting activation requires of its delivery (dsl-spec §5). +nonisolated public struct WorkflowExpectationPayload: Codable, Equatable, Sendable { + public let format: WorkflowOutputFormat + public let sections: [String] + public let verdict: [String]? + public let strict: Bool + /// The exact `prowl workflow done` commands that complete the step, one per allowed verdict. + public let completion: [String] + + public init( + format: WorkflowOutputFormat, sections: [String], verdict: [String]?, strict: Bool, + completion: [String] + ) { + self.format = format + self.sections = sections + self.verdict = verdict + self.strict = strict + self.completion = completion + } +} + +nonisolated public struct WorkflowOutputPayload: Codable, Equatable, Sendable { + public let name: String + public let ordinal: Int + /// `outputs/..md`. + public let path: String + /// `outputs/.md`, the atomically replaced latest view. + public let latestPath: String + public let verdict: String? + public let deliveredAt: String + + enum CodingKeys: String, CodingKey { + case name + case ordinal + case path + case latestPath = "latest_path" + case verdict + case deliveredAt = "delivered_at" + } + + public init( + name: String, ordinal: Int, path: String, latestPath: String, verdict: String?, + deliveredAt: String + ) { + self.name = name + self.ordinal = ordinal + self.path = path + self.latestPath = latestPath + self.verdict = verdict + self.deliveredAt = deliveredAt + } +} + +nonisolated public struct WorkflowSelfInitiatedPayload: Codable, Equatable, Sendable { + /// The line the runner would have typed, completion command included. + public let line: String + /// The materialized instruction file the line points at, for `instruction` steps. + public let instructionPath: String? + /// The `prowl workflow done` commands that complete the step, one per allowed verdict. + public let completion: [String] + + enum CodingKeys: String, CodingKey { + case line + case instructionPath = "instruction_path" + case completion + } + + public init(line: String, instructionPath: String?, completion: [String]) { + self.line = line + self.instructionPath = instructionPath + self.completion = completion + } +} + +// MARK: - done + +nonisolated public struct WorkflowDonePayload: Codable, Equatable, Sendable { + public let run: WorkflowRunPayload + public let delivery: WorkflowDeliveryPayload + + public init(run: WorkflowRunPayload, delivery: WorkflowDeliveryPayload) { + self.run = run + self.delivery = delivery + } +} + +/// The receipt of one `prowl workflow done`. `delivered` means the output is the step's output +/// and the run advanced; `provisional` means it is on disk with the listed `warnings` and the +/// run waits for the user to accept it, ask again, or skip (decision H14 of docs-ai 063.007). +nonisolated public struct WorkflowDeliveryPayload: Codable, Equatable, Sendable { + public let state: WorkflowDeliveryState + public let ordinal: Int + public let step: String + public let role: String + public let output: WorkflowOutputPayload + public let warnings: [WorkflowDeliveryWarningPayload] + + public init( + state: WorkflowDeliveryState, + ordinal: Int, + step: String, + role: String, + output: WorkflowOutputPayload, + warnings: [WorkflowDeliveryWarningPayload] + ) { + self.state = state + self.ordinal = ordinal + self.step = step + self.role = role + self.output = output + self.warnings = warnings + } +} + +nonisolated public enum WorkflowDeliveryState: String, Codable, Equatable, Sendable { + case delivered + case provisional +} + +nonisolated public struct WorkflowDeliveryWarningPayload: Codable, Equatable, Sendable { + public let code: String + public let message: String + + public init(code: String, message: String) { + self.code = code + self.message = message + } +} + // MARK: - validate nonisolated public struct WorkflowValidatePayload: Codable, Equatable, Sendable { @@ -161,7 +568,9 @@ nonisolated public struct WorkflowValidatePayload: Codable, Equatable, Sendable public let workflow: WorkflowIdentity? public let diagnostics: [WorkflowDiagnosticPayload] - public init(path: String, valid: Bool, workflow: WorkflowIdentity?, diagnostics: [WorkflowDiagnosticPayload]) { + public init( + path: String, valid: Bool, workflow: WorkflowIdentity?, diagnostics: [WorkflowDiagnosticPayload] + ) { self.path = path self.valid = valid self.workflow = workflow @@ -195,7 +604,9 @@ nonisolated public struct WorkflowDiagnosticPayload: Codable, Equatable, Sendabl public let line: Int? public let column: Int? - public init(severity: WorkflowDiagnosticSeverity, code: String, message: String, line: Int?, column: Int?) { + public init( + severity: WorkflowDiagnosticSeverity, code: String, message: String, line: Int?, column: Int? + ) { self.severity = severity self.code = code self.message = message diff --git a/supacode/CLIService/WorkflowCLIRendezvous.swift b/supacode/CLIService/WorkflowCLIRendezvous.swift new file mode 100644 index 000000000..2080dd779 --- /dev/null +++ b/supacode/CLIService/WorkflowCLIRendezvous.swift @@ -0,0 +1,101 @@ +// supacode/CLIService/WorkflowCLIRendezvous.swift +// The request/response seam between a socket handler and the workflow reducer (docs-ai 063 B3, +// decision W1). It owns continuations only, never run state: the handler registers a request, +// sends the reducer action, then awaits; the reducer resolves when the command's declared +// response point is reached. Everything is main-actor serialized, so a reducer that answers +// synchronously inside `store.send` cannot race past the waiter — the answer is buffered. + +import Foundation + +@MainActor +final class WorkflowCLIRendezvous { + private enum Slot { + case registered + case buffered(CommandResponse) + case waiting(CheckedContinuation) + } + + private var slots: [UUID: Slot] = [:] + + var pendingRequestIDs: Set { Set(slots.keys) } + + /// Claims a request id before the reducer action is sent. False when the id is already in use. + @discardableResult + func register(_ requestID: UUID) -> Bool { + guard slots[requestID] == nil else { return false } + slots[requestID] = .registered + return true + } + + /// Awaits the response of a registered request; an answer that arrived before the wait is + /// returned at once. Cancelling the waiting task (the socket peer disconnected) releases the + /// slot with `REQUEST_CANCELLED` without touching the run. + func wait(for requestID: UUID) async -> CommandResponse { + switch slots[requestID] { + case .none: + return Self.failure(code: CLIErrorCode.requestConflict, message: "Workflow request was not registered.") + case .waiting: + return Self.failure(code: CLIErrorCode.requestConflict, message: "Workflow request is already pending.") + case .buffered(let response): + slots.removeValue(forKey: requestID) + return response + case .registered: + break + } + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + switch slots[requestID] { + case .buffered(let response): + // Resolved (or cancelled) between the synchronous check above and this suspension. + slots.removeValue(forKey: requestID) + continuation.resume(returning: response) + case .registered: + slots[requestID] = .waiting(continuation) + case .none, .waiting: + continuation.resume( + returning: Self.failure(code: CLIErrorCode.requestConflict, message: "Workflow request is already pending.") + ) + } + } + } onCancel: { + Task { @MainActor [weak self] in + self?.cancel(requestID) + } + } + } + + /// Delivers the response; false when no request with this id is outstanding. + @discardableResult + func resolve(_ requestID: UUID, with response: CommandResponse) -> Bool { + switch slots[requestID] { + case .none, .buffered: + return false + case .registered: + slots[requestID] = .buffered(response) + return true + case .waiting(let continuation): + slots.removeValue(forKey: requestID) + continuation.resume(returning: response) + return true + } + } + + /// Socket routing cancellation never changes the run: it merely releases this caller's + /// continuation. The reducer continues the already-accepted transaction. + @discardableResult + func cancel(_ requestID: UUID) -> Bool { + resolve( + requestID, + with: Self.failure(code: CLIErrorCode.requestCancelled, message: "Workflow command request was cancelled.") + ) + } + + static func failure(code: String, message: String) -> CommandResponse { + CommandResponse( + ok: false, + command: WorkflowCommandPayload.commandName, + schemaVersion: WorkflowCommandPayload.schemaVersion, + error: CommandError(code: code, message: message) + ) + } +} diff --git a/supacode/CLIService/WorkflowCommandHandler.swift b/supacode/CLIService/WorkflowCommandHandler.swift index 4cea7c409..9c44dec0a 100644 --- a/supacode/CLIService/WorkflowCommandHandler.swift +++ b/supacode/CLIService/WorkflowCommandHandler.swift @@ -1,6 +1,8 @@ // supacode/CLIService/WorkflowCommandHandler.swift -// Handles `prowl workflow list`: resolves the worktree whose repo source is searched, runs -// three-source discovery, and applies the (hidden until D1) enabled set. +// Handles `prowl workflow` over the socket (docs-ai 063 B1/B3): `list` resolves the worktree +// whose repo source is searched and runs three-source discovery; `run` resolves the source pane +// or worktree and hands admission to the runtime; `status`, `done`, and `cancel` are attributed by +// the caller pane and routed to the runtime coordinator. import Foundation @@ -23,9 +25,11 @@ final class WorkflowCommandHandler: CommandHandler { typealias SnapshotProvider = @MainActor () -> WorkflowRuntimeSnapshot private let snapshotProvider: SnapshotProvider + private let runtime: WorkflowRuntimeCoordinator? - init(snapshotProvider: @escaping SnapshotProvider) { + init(snapshotProvider: @escaping SnapshotProvider, runtime: WorkflowRuntimeCoordinator? = nil) { self.snapshotProvider = snapshotProvider + self.runtime = runtime } static func disabledKey(scope: WorkflowScope, id: String) -> String { @@ -36,28 +40,124 @@ final class WorkflowCommandHandler: CommandHandler { await handle(envelope: envelope, context: CLICommandContext()) } - // swiftlint:disable:next async_without_await func handle(envelope: CommandEnvelope, context: CLICommandContext) async -> CommandResponse { guard case .workflow(let input) = envelope.command else { return failure(code: CLIErrorCode.invalidArgument, message: "Expected a workflow command.") } let snapshot = snapshotProvider() - switch resolveWorktree(input.target, snapshot: snapshot, context: context) { + let callerPane = callerPane(context: context, paneByShellPID: snapshot.paneByShellPID) + switch input.action { + case .list: + switch resolveWorktree(input.target, snapshot: snapshot, callerPane: callerPane) { + case .failure(.notFound(let message)): + return failure(code: CLIErrorCode.targetNotFound, message: message) + case .failure(.notUnique(let message)): + return failure(code: CLIErrorCode.targetNotUnique, message: message) + case .success(let worktree): + do { + let payload = try listPayload(worktree: worktree, snapshot: snapshot) + return try CommandResponse( + ok: true, + command: WorkflowCommandPayload.commandName, + schemaVersion: WorkflowCommandPayload.schemaVersion, + data: RawJSON(encoding: WorkflowCommandPayload.list(payload)) + ) + } catch { + return failure( + code: CLIErrorCode.workflowFailed, message: "Failed to list workflows: \(error)") + } + } + case .run, .status, .done, .cancel: + guard let runtime else { return notConfigured() } + return await handleRuntime(input, runtime: runtime, snapshot: snapshot, callerPane: callerPane) + } + } + + private func handleRuntime( + _ input: WorkflowInput, + runtime: WorkflowRuntimeCoordinator, + snapshot: WorkflowRuntimeSnapshot, + callerPane: CallerPane? + ) async -> CommandResponse { + switch input.action { + case .list: + return failure(code: CLIErrorCode.invalidArgument, message: "Expected a runtime workflow action.") + case .run: + switch resolveSource(input.target, snapshot: snapshot, callerPane: callerPane) { + case .failure(let refusal): + return refusal.response + case .success(let source): + return await runtime.run(input, source: source, snapshot: snapshot) + } + case .status: + return runtime.status(input, callerPane: callerPane) + case .done: + return await runtime.done(input, callerPane: callerPane) + case .cancel: + return runtime.cancel(input, callerPane: callerPane) + } + } + + // MARK: - Source resolution + + /// `run`: the caller's own pane, or an explicit target. A pane or tab target names the pane the + /// `current` role binds to; a worktree target names no pane (decision W2). + func resolveSource( + _ selector: TargetSelector, snapshot: WorkflowRuntimeSnapshot, callerPane: CallerPane? + ) -> Result { + let resolver = TargetResolver { snapshot.resolution } + if case .none = selector { + if let callerPane, + let worktree = snapshot.resolution.worktrees.first(where: { $0.id == callerPane.worktreeID } + ) + { + return .success( + WorkflowRunSource(worktree: worktree, paneID: callerPane.surfaceID, paneIsCaller: true)) + } + guard case .success(let focused) = resolver.resolve(.none), + let worktree = snapshot.resolution.worktrees.first(where: { $0.id == focused.worktreeID }) + else { + return .failure( + refusal( + code: CLIErrorCode.sourceRequired, + message: + "Run `prowl workflow run` inside a Prowl pane, or pass a pane or worktree target.")) + } + return .success(WorkflowRunSource(worktree: worktree, paneID: nil, paneIsCaller: false)) + } + switch resolver.resolve(selector) { case .failure(.notFound(let message)): - return failure(code: CLIErrorCode.targetNotFound, message: message) + return .failure(refusal(code: CLIErrorCode.targetNotFound, message: message)) case .failure(.notUnique(let message)): - return failure(code: CLIErrorCode.targetNotUnique, message: message) - case .success(let worktree): - do { - let payload = try listPayload(worktree: worktree, snapshot: snapshot) - return try CommandResponse( - ok: true, - command: WorkflowCommandPayload.commandName, - schemaVersion: WorkflowCommandPayload.schemaVersion, - data: RawJSON(encoding: WorkflowCommandPayload.list(payload)) - ) - } catch { - return failure(code: CLIErrorCode.workflowFailed, message: "Failed to list workflows: \(error)") + return .failure(refusal(code: CLIErrorCode.targetNotUnique, message: message)) + case .success(let resolved): + guard + let worktree = snapshot.resolution.worktrees.first(where: { $0.id == resolved.worktreeID }) + else { + return .failure( + refusal( + code: CLIErrorCode.targetNotFound, message: "The target's worktree was not found.")) + } + let paneID = Self.addressesPane(selector, snapshot: snapshot) ? resolved.paneID : nil + return .success( + WorkflowRunSource( + worktree: worktree, paneID: paneID, + paneIsCaller: paneID != nil && paneID == callerPane?.surfaceID)) + } + } + + /// Whether a selector named a pane or a tab (whose focused pane stands in), not a worktree. + static func addressesPane(_ selector: TargetSelector, snapshot: WorkflowRuntimeSnapshot) -> Bool { + switch selector { + case .pane, .tab: + return true + case .none, .worktree: + return false + case .auto(let value): + if value.hasPrefix("p") || value.hasPrefix("t"), Int(value.dropFirst()) != nil { return true } + guard let id = UUID(uuidString: value) else { return false } + return snapshot.resolution.worktrees.contains { worktree in + worktree.tabs.contains { $0.id == id || $0.panes.contains { $0.id == id } } } } } @@ -69,12 +169,13 @@ final class WorkflowCommandHandler: CommandHandler { private func resolveWorktree( _ selector: TargetSelector, snapshot: WorkflowRuntimeSnapshot, - context: CLICommandContext + callerPane: CallerPane? ) -> Result { let resolver = TargetResolver { snapshot.resolution } if case .none = selector { - if let callerPane = callerPane(context: context, paneByShellPID: snapshot.paneByShellPID), - let worktree = snapshot.resolution.worktrees.first(where: { $0.id == callerPane.worktreeID }) + if let callerPane, + let worktree = snapshot.resolution.worktrees.first(where: { $0.id == callerPane.worktreeID } + ) { return .success(worktree) } @@ -88,13 +189,16 @@ final class WorkflowCommandHandler: CommandHandler { } } - private func callerPane(context: CLICommandContext, paneByShellPID: [pid_t: CallerPane]) -> CallerPane? { + private func callerPane(context: CLICommandContext, paneByShellPID: [pid_t: CallerPane]) + -> CallerPane? + { if !context.callerProcessAncestry.isEmpty { return CallerPaneResolver.pane( forCallerProcessAncestry: context.callerProcessAncestry, paneByShellPID: paneByShellPID) } guard let callerProcessID = context.callerProcessID else { return nil } - return CallerPaneResolver.pane(forCallerProcess: callerProcessID, paneByShellPID: paneByShellPID) + return CallerPaneResolver.pane( + forCallerProcess: callerProcessID, paneByShellPID: paneByShellPID) } // MARK: - Listing @@ -105,7 +209,8 @@ final class WorkflowCommandHandler: CommandHandler { let repoURL = worktree.map { WorkflowSources.repoDirectory(root: URL(filePath: $0.rootPath, directoryHint: .isDirectory)) } - let sources = WorkflowSources(bundle: snapshot.bundleWorkflowsURL, user: snapshot.userWorkflowsURL, repo: repoURL) + let sources = WorkflowSources( + bundle: snapshot.bundleWorkflowsURL, user: snapshot.userWorkflowsURL, repo: repoURL) let catalog = try WorkflowDiscovery.catalog(sources: sources) { scope in WorkflowValidationContext( scope: scope, @@ -135,12 +240,15 @@ final class WorkflowCommandHandler: CommandHandler { ) } + private func notConfigured() -> CommandResponse { + failure(code: "NOT_IMPLEMENTED", message: "Workflow runtime is not configured.") + } + private func failure(code: String, message: String) -> CommandResponse { - CommandResponse( - ok: false, - command: WorkflowCommandPayload.commandName, - schemaVersion: WorkflowCommandPayload.schemaVersion, - error: CommandError(code: code, message: message) - ) + WorkflowCLIRendezvous.failure(code: code, message: message) + } + + private func refusal(code: String, message: String) -> WorkflowCommandRefusal { + WorkflowCommandRefusal(response: failure(code: code, message: message)) } } diff --git a/supacode/CLIService/WorkflowRoleWaitPolicy.swift b/supacode/CLIService/WorkflowRoleWaitPolicy.swift new file mode 100644 index 000000000..cced81b77 --- /dev/null +++ b/supacode/CLIService/WorkflowRoleWaitPolicy.swift @@ -0,0 +1,66 @@ +// supacode/CLIService/WorkflowRoleWaitPolicy.swift +// The pure decision core of a `message` step's idle wait (docs-ai 063 B3, dsl-spec §10): the +// #733 evidence rules without their five-second cap, evaluated against the baseline captured +// when the wait started so a fresh exact `turn-ended` ends the wait even while the screen +// detector still shows `working`. Exact `needs-input` wins over every idle path. + +import Foundation + +@MainActor +struct WorkflowRoleWaitPolicy { + /// How long a heuristic `blocked` must persist before the wait ends as blocked. + let blockedGraceMilliseconds: Int + /// How long a live pane may show no detected agent before the wait gives up. + let appearanceGraceMilliseconds: Int + + private var baseline: AgentConditionEvidence.Baseline? + private var stabilizer = AgentConditionEvidence.HeuristicStabilizer() + private var blockedSinceMilliseconds: Int? + + init(blockedGraceMilliseconds: Int = 30_000, appearanceGraceMilliseconds: Int = 10_000) { + self.blockedGraceMilliseconds = blockedGraceMilliseconds + self.appearanceGraceMilliseconds = appearanceGraceMilliseconds + } + + /// One poll; nil keeps waiting. + mutating func observe( + _ snapshot: AgentConditionSnapshot, pendingDispatchID: String?, elapsedMilliseconds: Int + ) -> WorkflowRoleWaitOutcome? { + guard snapshot.isLive else { return .gone } + if let pendingDispatchID { return .dispatchPending(pendingDispatchID) } + if baseline == nil { + baseline = AgentConditionEvidence.Baseline(snapshot: snapshot) + } + guard let baseline, snapshot.agent != nil else { + return elapsedMilliseconds >= appearanceGraceMilliseconds ? .noAgent : nil + } + let state = AgentConditionEvidence.normalizedState(snapshot) + // An exact `needs-input` outranks every idle path: the agent is asking, not listening. + if AgentConditionEvidence.exactMatch( + condition: .blocked, snapshot: snapshot, normalizedState: state, baseline: baseline, minimumConfidence: .auto) + != nil + { + return .blocked + } + switch AgentConditionEvidence.idleVerdict(for: snapshot, baseline: baseline) { + case .idle: + return .idle + case .settling(let state): + blockedSinceMilliseconds = nil + let detectorCandidate = + AgentConditionEvidence.detectorReports(.idle, normalizedState: state) + && AgentConditionEvidence.allowsHeuristic(.auto, condition: .idle, snapshot: snapshot) + return stabilizer.observe(candidate: detectorCandidate ? state : nil, elapsedMilliseconds: elapsedMilliseconds) + ? .idle : nil + case .busy(let state): + _ = stabilizer.observe(candidate: nil, elapsedMilliseconds: elapsedMilliseconds) + guard AgentConditionEvidence.detectorReports(.blocked, normalizedState: state) else { + blockedSinceMilliseconds = nil + return nil + } + let since = blockedSinceMilliseconds ?? elapsedMilliseconds + blockedSinceMilliseconds = since + return elapsedMilliseconds - since >= blockedGraceMilliseconds ? .blocked : nil + } + } +} diff --git a/supacode/CLIService/WorkflowRunAdmission.swift b/supacode/CLIService/WorkflowRunAdmission.swift new file mode 100644 index 000000000..63c325117 --- /dev/null +++ b/supacode/CLIService/WorkflowRunAdmission.swift @@ -0,0 +1,592 @@ +// supacode/CLIService/WorkflowRunAdmission.swift +// Preflight of `prowl workflow run` (docs-ai 063 B3, decisions W2/W4): the effective definition, +// source and worktree facts, binding legality (explicit `--role` overrides, remembered bindings, +// suggestion, Recommended), one run per pane, the frozen launch plans, and the run directory with +// its initial record. Nothing here touches a pane: a request that fails admission has no side +// effect beyond the run directory it may have created for a run that then started. + +import Foundation + +/// The pane (or worktree only) the run is started from, as the handler resolved it. +struct WorkflowRunSource: Sendable { + let worktree: TargetResolutionSnapshot.Worktree + /// The pane the `current` role binds to; nil when the target named a worktree. + let paneID: UUID? + /// Whether `paneID` is the caller's own pane (a self-initiated run, dsl-spec §9). + let paneIsCaller: Bool + + /// The repository root the source worktree belongs to (the 053 Recommended memory key). + var repositoryRootURL: URL { + URL(filePath: worktree.rootPath, directoryHint: .isDirectory) + } +} + +/// A detected agent in a pane, as admission needs it. +nonisolated struct WorkflowDetectedAgent: Equatable, Sendable { + let token: String + let displayName: String +} + +/// Main-actor facts admission reads through closures so it stays testable without the app. +struct WorkflowAdmissionEnvironment { + let profiles: [AgentProfile] + /// The 053 Recommended inputs of a repository (designated, last launched), by repository root. + let recommendation: @MainActor (URL) -> (designated: UUID?, lastLaunched: UUID?) + let rememberedBinding: @MainActor (WorkflowBindingMemoryKey) -> UUID? + let detectedAgent: @MainActor (UUID) -> WorkflowDetectedAgent? + /// The pending dispatch record a pane holds, if any (#733 D4: one per surface). + let pendingDispatchID: @MainActor (UUID) -> String? + /// Panes bound in active runs (dsl-spec §10: one run per pane). + let busySurfaceIDs: Set + let worktree: @MainActor (Worktree.ID) -> Worktree? + let branchName: @MainActor (Worktree) -> String + let makeLaunchPlan: @MainActor (AgentProfile) throws -> AgentProfileLaunchPlan + let bundledSkill: @MainActor (String) -> BundledSkill? + let now: Date + let makeRunID: @Sendable () -> UUID + let makeToken: @Sendable () -> String + let limits: WorkflowDeliveryLimits + + init( + profiles: [AgentProfile], + recommendation: @escaping @MainActor (URL) -> (designated: UUID?, lastLaunched: UUID?) = { _ in (nil, nil) }, + rememberedBinding: @escaping @MainActor (WorkflowBindingMemoryKey) -> UUID? = { _ in nil }, + detectedAgent: @escaping @MainActor (UUID) -> WorkflowDetectedAgent?, + pendingDispatchID: @escaping @MainActor (UUID) -> String? = { _ in nil }, + busySurfaceIDs: Set = [], + worktree: @escaping @MainActor (Worktree.ID) -> Worktree?, + branchName: @escaping @MainActor (Worktree) -> String = { $0.name }, + makeLaunchPlan: @escaping @MainActor (AgentProfile) throws -> AgentProfileLaunchPlan, + bundledSkill: @escaping @MainActor (String) -> BundledSkill? = { _ in nil }, + now: Date = Date(), + makeRunID: @escaping @Sendable () -> UUID = { UUID() }, + makeToken: @escaping @Sendable () -> String = { UUID().uuidString }, + limits: WorkflowDeliveryLimits = WorkflowDeliveryLimits() + ) { + self.profiles = profiles + self.recommendation = recommendation + self.rememberedBinding = rememberedBinding + self.detectedAgent = detectedAgent + self.pendingDispatchID = pendingDispatchID + self.busySurfaceIDs = busySurfaceIDs + self.worktree = worktree + self.branchName = branchName + self.makeLaunchPlan = makeLaunchPlan + self.bundledSkill = bundledSkill + self.now = now + self.makeRunID = makeRunID + self.makeToken = makeToken + self.limits = limits + } +} + +struct WorkflowAdmittedRun: Sendable { + let session: WorkflowRunSession + let effects: [WorkflowRunEffect] + /// The caller pane's role when the run was started from a bound pane. + let callerRole: String? +} + +nonisolated struct WorkflowAdmissionFailure: Error, Equatable, Sendable { + let code: String + let message: String + /// The validate payload of an invalid definition (`WORKFLOW_INVALID`). + var details: WorkflowValidatePayload? +} + +@MainActor +enum WorkflowRunAdmission { + private struct Arguments { + let inputs: [String: String] + let overrides: [String: String] + let skipped: Set + } + + /// The whole preflight; on success the run directory holds `run.json` and the reducer can own + /// the session. + static func admit( + _ input: WorkflowInput, + source: WorkflowRunSource, + snapshot: WorkflowRuntimeSnapshot, + environment: WorkflowAdmissionEnvironment + ) -> Result { + guard let name = input.workflow, !name.isEmpty else { + return .failure(.init(code: CLIErrorCode.invalidArgument, message: "A workflow id or name is required.")) + } + let entry: WorkflowCatalogEntry + switch effectiveEntry(named: name, worktree: source.worktree, snapshot: snapshot) { + case .failure(let failure): return .failure(failure) + case .success(let value): entry = value + } + guard let definition = entry.file.definition, entry.file.isValid else { + return .failure( + .init( + code: CLIErrorCode.workflowInvalid, + message: + "Workflow '\(name)' has \(entry.file.diagnostics.errorCount) validation error(s); fix the file first.", + details: WorkflowValidatePayload(file: entry.file))) + } + let disabledKey = WorkflowCommandHandler.disabledKey(scope: entry.file.scope, id: definition.id) + if snapshot.disabledWorkflowIDs.contains(disabledKey) { + return .failure( + .init(code: CLIErrorCode.workflowDisabled, message: "Workflow '\(definition.id)' is disabled in Settings.")) + } + guard let worktree = environment.worktree(source.worktree.id) else { + return .failure(.init(code: CLIErrorCode.targetNotFound, message: "The source worktree is no longer available.")) + } + let arguments: Arguments + switch parseArguments(input, definition: definition) { + case .failure(let failure): return .failure(failure) + case .success(let value): arguments = value + } + var binder = RoleBinder( + definition: definition, + source: source, + environment: environment, + overrides: arguments.overrides, + scope: runScope(entry.file.scope, worktree: worktree), + deliversToCurrent: deliversToCurrentRole(definition, skipped: arguments.skipped)) + for role in definition.roles { + if let failure = binder.bind(role) { + return .failure(failure) + } + } + return start( + Admission( + definition: definition, entry: entry, worktree: worktree, arguments: arguments, binder: binder, source: source, + environment: environment)) + } + + // MARK: - Start + + private struct Admission { + let definition: WorkflowDefinition + let entry: WorkflowCatalogEntry + let worktree: Worktree + let arguments: Arguments + let binder: RoleBinder + let source: WorkflowRunSource + let environment: WorkflowAdmissionEnvironment + } + + private static func start(_ admission: Admission) -> Result { + let (definition, entry, worktree, arguments) = ( + admission.definition, admission.entry, admission.worktree, admission.arguments + ) + let (binder, source, environment) = (admission.binder, admission.source, admission.environment) + let context = WorkflowRunContext( + scope: binder.scope, + definitionPath: entry.file.url.path(percentEncoded: false), + worktree: WorkflowRunWorktree( + id: worktree.id, name: worktree.name, branch: environment.branchName(worktree), + path: worktree.workingDirectory.path(percentEncoded: false))) + let runID = environment.makeRunID() + let now = environment.now + let started: (machine: WorkflowRunMachine, effects: [WorkflowRunEffect]) + do { + started = try WorkflowRunMachine.start( + WorkflowRunStartRequest( + definition: definition, + runID: runID, + context: context, + bindings: binder.bindings, + inputs: arguments.inputs, + skippedSteps: arguments.skipped, + selfInitiated: source.paneIsCaller && binder.callerRole != nil, + limits: environment.limits), + now: { now }, + makeToken: environment.makeToken) + } catch { + return .failure(describe(error)) + } + var skills: [String: BundledSkill] = [:] + for step in definition.flattenedSteps { + if case .launch(_, _, let skill?, _) = step.action, let bundled = environment.bundledSkill(skill) { + skills[skill] = bundled + } + } + let session = WorkflowRunSession( + run: started.machine.run, + worktree: worktree, + launchPlans: binder.launchPlans, + bindingMemoryKeys: binder.memoryKeys, + skills: skills, + limits: environment.limits) + // Layout and the initial record are part of the reply (decision W1). + do { + try session.store.ensureLayout(runID: runID) + try session.store.writeRecord(WorkflowRunRecord(run: session.run)) + } catch { + return .failure( + .init( + code: CLIErrorCode.workflowFailed, + message: "The run directory could not be created under \(context.worktree.path): \(error)")) + } + let effects = binder.startLog.map(WorkflowRunEffect.log) + started.effects + return .success(WorkflowAdmittedRun(session: session, effects: effects, callerRole: binder.callerRole)) + } + + // MARK: - Arguments + + private static func parseArguments( + _ input: WorkflowInput, definition: WorkflowDefinition + ) -> Result { + let inputs: [String: String] + switch parsePairs(input.inputValues, what: "--input") { + case .failure(let failure): return .failure(failure) + case .success(let value): inputs = value + } + let overrides: [String: String] + switch parsePairs(input.roleBindings, what: "--role") { + case .failure(let failure): return .failure(failure) + case .success(let value): overrides = value + } + for role in overrides.keys.sorted() where definition.role(named: role) == nil { + return .failure( + .init(code: CLIErrorCode.invalidArgument, message: "Workflow '\(definition.id)' declares no role '\(role)'.")) + } + return .success(Arguments(inputs: inputs, overrides: overrides, skipped: Set(input.skippedSteps))) + } + + /// `name=value` pairs; duplicates and malformed entries are `INVALID_ARGUMENT`. + static func parsePairs(_ values: [String], what: String) -> Result<[String: String], WorkflowAdmissionFailure> { + var pairs: [String: String] = [:] + for value in values { + guard let separator = value.firstIndex(of: "="), separator != value.startIndex else { + return .failure( + .init(code: CLIErrorCode.invalidArgument, message: "\(what) expects =, got '\(value)'.")) + } + let name = String(value[.. Result { + guard let value else { return .success(nil) } + if value == "auto" { return .success(.auto) } + if let id = UUID(uuidString: value) { return .success(.profileID(id)) } + guard !value.isEmpty else { + return .failure( + .init( + code: CLIErrorCode.invalidArgument, message: "--role = needs a profile name, UUID, or auto.")) + } + return .success(.profileName(value)) + } + + // MARK: - Bindings + + /// Freezes one binding per role, in declaration order, so the first failure names the first role. + private struct RoleBinder { + let definition: WorkflowDefinition + let source: WorkflowRunSource + let environment: WorkflowAdmissionEnvironment + let overrides: [String: String] + let scope: WorkflowRunScope + let deliversToCurrent: Bool + + private(set) var bindings: [String: WorkflowRoleBinding] = [:] + private(set) var launchPlans: [String: AgentProfileLaunchPlan] = [:] + private(set) var memoryKeys: [String: WorkflowBindingMemoryKey] = [:] + private(set) var startLog: [String] = [] + private(set) var callerRole: String? + private var boundSurfaceIDs: Set = [] + + init( + definition: WorkflowDefinition, + source: WorkflowRunSource, + environment: WorkflowAdmissionEnvironment, + overrides: [String: String], + scope: WorkflowRunScope, + deliversToCurrent: Bool + ) { + self.definition = definition + self.source = source + self.environment = environment + self.overrides = overrides + self.scope = scope + self.deliversToCurrent = deliversToCurrent + } + + mutating func bind(_ role: WorkflowRoleDefinition) -> WorkflowAdmissionFailure? { + switch role.source { + case .current: bindCurrent(role) + case .pick: bindPick(role) + case .launch: bindLaunch(role) + } + } + + private mutating func bindCurrent(_ role: WorkflowRoleDefinition) -> WorkflowAdmissionFailure? { + guard overrides[role.name] == nil else { + return .init( + code: CLIErrorCode.invalidArgument, + message: "Role '\(role.name)' is the current pane; it takes no --role override.") + } + guard let paneID = source.paneID else { + return .init( + code: CLIErrorCode.sourceRequired, + message: "Workflow '\(definition.id)' runs from a pane (its '\(role.name)' role is the current pane): " + + "run it inside the pane or pass a pane target (pN / pane UUID).") + } + guard !environment.busySurfaceIDs.contains(paneID) else { + return .init(code: CLIErrorCode.paneBusy, message: "The source pane already belongs to another workflow run.") + } + if let dispatchID = environment.pendingDispatchID(paneID) { + return pendingDispatch(dispatchID, pane: "The source pane") + } + let agent = environment.detectedAgent(paneID) + if deliversToCurrent, agent == nil { + return .init( + code: CLIErrorCode.agentNotFound, + message: "Workflow '\(definition.id)' delivers a message to its '\(role.name)' role, " + + "but the source pane hosts no detected agent.") + } + guard let identity = paneIdentity(paneID, agent: agent, worktree: source.worktree) else { + return .init(code: CLIErrorCode.targetNotFound, message: "The source pane is no longer available.") + } + bindings[role.name] = .current(identity) + boundSurfaceIDs.insert(paneID) + if source.paneIsCaller { callerRole = role.name } + return nil + } + + private mutating func bindPick(_ role: WorkflowRoleDefinition) -> WorkflowAdmissionFailure? { + guard let override = overrides[role.name] else { + return .init( + code: CLIErrorCode.invalidArgument, + message: "Role '\(role.name)' is picked from an existing agent pane: pass --role \(role.name)=." + ) + } + guard let paneID = resolvePane(override, worktree: source.worktree) else { + return .init( + code: CLIErrorCode.targetNotFound, + message: "No pane '\(override)' in worktree '\(source.worktree.name)' for role '\(role.name)'.") + } + guard paneID != source.paneID, !boundSurfaceIDs.contains(paneID) else { + return .init( + code: CLIErrorCode.invalidArgument, + message: "Role '\(role.name)' cannot use a pane already bound in this run.") + } + guard !environment.busySurfaceIDs.contains(paneID) else { + return .init( + code: CLIErrorCode.paneBusy, message: "Pane '\(override)' already belongs to another workflow run.") + } + if let dispatchID = environment.pendingDispatchID(paneID) { + return pendingDispatch(dispatchID, pane: "Pane '\(override)'") + } + guard let agent = environment.detectedAgent(paneID) else { + return .init( + code: CLIErrorCode.agentNotFound, + message: "Pane '\(override)' hosts no detected agent for role '\(role.name)'." + ) + } + guard let identity = paneIdentity(paneID, agent: agent, worktree: source.worktree) else { + return .init(code: CLIErrorCode.targetNotFound, message: "Pane '\(override)' is no longer available.") + } + bindings[role.name] = .pick(identity) + boundSurfaceIDs.insert(paneID) + return nil + } + + /// A pane with a pending record cannot open an activation (#733 D4) — the record's owner + /// must finish first; refusing here keeps the run from looping on `roleBusy`. + private func pendingDispatch(_ dispatchID: String, pane: String) -> WorkflowAdmissionFailure { + .init( + code: CLIErrorCode.dispatchPending, + message: "\(pane) still holds pending dispatch \(dispatchID); complete it " + + "(`prowl agents dispatch-complete`) or abandon it (`prowl agents dispatch-abandon`) before starting a run.") + } + + private mutating func bindLaunch(_ role: WorkflowRoleDefinition) -> WorkflowAdmissionFailure? { + let override: WorkflowBindingOverride? + switch parseLaunchOverride(overrides[role.name]) { + case .failure(let failure): return failure + case .success(let value): override = value + } + let key = WorkflowBindingResolver.memoryKey(scope: scope, workflowID: definition.id, role: role) + let recommendation = environment.recommendation(source.repositoryRootURL) + let resolution = WorkflowBindingResolver.resolve( + role: role, + remembered: environment.rememberedBinding(key), + override: override, + context: WorkflowBindingResolverContext( + profiles: environment.profiles, + designatedProfileID: recommendation.designated, + lastLaunchedProfileID: recommendation.lastLaunched)) + let profile: AgentProfile + switch resolution { + case .failure(let error): + return .init(code: error.code, message: describe(error, role: role.name)) + case .success(let resolved): + switch resolved.resolution { + case .ask: + return .init( + code: CLIErrorCode.profileNotFound, + message: "No enabled Agent Profile satisfies role '\(role.name)'" + + (role.launch?.agents.map { " (agents: \($0.joined(separator: ", ")))" } ?? "") + + "; pass --role \(role.name)= or create one in Settings.") + case .resolved(let value, let tier): + profile = value + for rejectedTier in [WorkflowBindingTier.override, .remembered] { + guard let rejection = resolved.rejected[rejectedTier] else { continue } + let what = rejectedTier == .override ? "requested" : "remembered" + startLog.append( + "Role '\(role.name)': the \(what) profile was not used (\(describe(rejection))); " + + "resolved '\(profile.name)' (\(describe(tier))).") + } + } + } + do { + launchPlans[role.name] = try environment.makeLaunchPlan(profile) + } catch { + return .init( + code: CLIErrorCode.workflowFailed, + message: "Profile '\(profile.name)' cannot be launched for role '\(role.name)': \(error)") + } + memoryKeys[role.name] = key + bindings[role.name] = .launch( + WorkflowProfileBinding(id: profile.id, name: profile.name, agent: profile.runtime.agent.rawValue), pane: nil) + return nil + } + } + + // MARK: - Definition + + /// The winning (unshadowed) definition with this id, or the unique one with this name. + static func effectiveEntry( + named name: String, worktree: TargetResolutionSnapshot.Worktree, snapshot: WorkflowRuntimeSnapshot + ) -> Result { + let sources = WorkflowSources( + bundle: snapshot.bundleWorkflowsURL, + user: snapshot.userWorkflowsURL, + repo: WorkflowSources.repoDirectory(root: URL(filePath: worktree.rootPath, directoryHint: .isDirectory))) + let catalog: [WorkflowCatalogEntry] + do { + catalog = try WorkflowDiscovery.catalog(sources: sources) { scope in + WorkflowValidationContext( + scope: scope, + bundledSkillIDs: snapshot.bundledSkillIDs, + knownAgents: snapshot.knownAgents, + installedAgents: snapshot.installedAgents, + enabledProfiles: snapshot.enabledProfiles) + } + } catch { + return .failure(.init(code: CLIErrorCode.workflowFailed, message: "Failed to discover workflows: \(error)")) + } + let visible = catalog.filter { !$0.shadowed } + if let byID = visible.first(where: { $0.file.id == name }) { + return .success(byID) + } + let byName = visible.filter { $0.file.definition?.name == name } + switch byName.count { + case 0: + return .failure( + .init( + code: CLIErrorCode.workflowNotFound, + message: "No workflow '\(name)' is visible to worktree '\(worktree.name)'; see `prowl workflow list`.")) + case 1: + return .success(byName[0]) + default: + let ids = byName.compactMap(\.file.id).sorted().joined(separator: ", ") + return .failure( + .init( + code: CLIErrorCode.invalidArgument, message: "Several workflows are named '\(name)' (\(ids)); use the id.")) + } + } + + // MARK: - Helpers + + static func runScope(_ scope: WorkflowScope, worktree: Worktree) -> WorkflowRunScope { + switch scope { + case .bundle: .bundle + case .user: .user + case .repo: .repo(repositoryID: worktree.repositoryRootURL.path(percentEncoded: false)) + } + } + + /// dsl-spec §3: a `current` role needs a detected agent only when a `message` to it survives the skips. + static func deliversToCurrentRole(_ definition: WorkflowDefinition, skipped: Set) -> Bool { + guard let current = definition.roles.first(where: { $0.source == .current }) else { return false } + return definition.flattenedSteps.contains { step in + if case .message(let role, _, _) = step.action, role == current.name { return !skipped.contains(step.id) } + return false + } + } + + /// `pN` or a pane UUID inside the source worktree only. + static func resolvePane(_ reference: String, worktree: TargetResolutionSnapshot.Worktree) -> UUID? { + let panes = worktree.tabs.flatMap(\.panes) + if let id = UUID(uuidString: reference) { + return panes.first { $0.id == id }?.id + } + guard reference.hasPrefix("p"), let handle = Int(reference.dropFirst()) else { return nil } + return panes.first { $0.handle == handle }?.id + } + + static func paneIdentity( + _ paneID: UUID, agent: WorkflowDetectedAgent?, worktree: TargetResolutionSnapshot.Worktree + ) -> WorkflowPaneIdentity? { + for tab in worktree.tabs { + guard let pane = tab.panes.first(where: { $0.id == paneID }) else { continue } + return WorkflowPaneIdentity( + surfaceID: paneID, + tabID: tab.id, + handle: pane.handle.map { "p\($0)" } ?? paneID.uuidString, + displayName: agent?.displayName ?? "shell", + agent: agent?.token) + } + return nil + } + + private static func describe(_ error: WorkflowBindingError, role: String) -> String { + switch error { + case .profileNotFound(let reference): + "No Agent Profile '\(reference)' for role '\(role)'; see `prowl profiles list`." + case .profileNotUnique(let reference): "Several Agent Profiles are named '\(reference)'; use the profile UUID." + case .roleNotLaunchable: "Role '\(role)' is not a launch role." + } + } + + private static func describe(_ rejection: WorkflowBindingRejection) -> String { + switch rejection { + case .missing: "it no longer exists" + case .disabled: "it is disabled" + case .agentNotAllowed(let agent): "its agent '\(agent)' is not allowed by the role" + case .promptUnsupported: "its runtime cannot start with a prompt" + } + } + + private static func describe(_ tier: WorkflowBindingTier) -> String { + switch tier { + case .override: "override" + case .remembered: "remembered" + case .suggestion: "suggested" + case .recommended: "recommended" + } + } + + private static func describe(_ error: WorkflowRunStartError) -> WorkflowAdmissionFailure { + switch error { + case .invalidInput(let name, let reason): + .init(code: CLIErrorCode.invalidArgument, message: "Input '\(name)': \(reason)") + case .unsafePath(let path): + .init(code: CLIErrorCode.unsafePath, message: "The worktree path cannot be rendered on one line: \(path)") + case .invalidRepeatBound(let step): + .init( + code: CLIErrorCode.invalidArgument, + message: "Step '\(step)': repeat.max must resolve to 1…\(WorkflowSchema.repeatMaximum).") + case .unknownSkipStep(let step): + .init(code: CLIErrorCode.invalidArgument, message: "--skip \(step): no such step.") + case .skipNotExpecting(let step): + .init( + code: CLIErrorCode.invalidArgument, message: "--skip \(step): only steps that await an output can be skipped.") + case .skipNotAllowed(let step, let dependent): + .init(code: CLIErrorCode.invalidArgument, message: "--skip \(step): step '\(dependent)' needs its output.") + case .missingBinding(let role): + .init(code: CLIErrorCode.invalidArgument, message: "Role '\(role)' has no binding.") + } + } +} diff --git a/supacode/CLIService/WorkflowRunPayload+App.swift b/supacode/CLIService/WorkflowRunPayload+App.swift new file mode 100644 index 000000000..5226dcbe8 --- /dev/null +++ b/supacode/CLIService/WorkflowRunPayload+App.swift @@ -0,0 +1,176 @@ +// supacode/CLIService/WorkflowRunPayload+App.swift +// Maps a live `WorkflowRun` or a persisted `WorkflowRunRecord` to the `prowl workflow` wire +// payload (docs-ai 063 B3, decision W5). Delivery tokens never appear: the completion commands of +// the current activation are the only place a token is spelled, and only to the caller that +// already holds it (the `role` of the payload). + +import Foundation + +extension WorkflowRunPayload { + nonisolated static func makeDateFormatter() -> ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + } + + /// - Parameter callerRole: the role of the *verified* caller pane (socket peer ancestry) when it + /// is bound in the run; only then, and only for its own activation, does the payload spell the + /// completion commands (they carry the token). A manual or forced delivery passes nil. + nonisolated init(run: WorkflowRun, callerRole: String?, includeSelfInitiated: Bool) { + let formatter = Self.makeDateFormatter() + let activation = run.activeActivation + let spellsCompletion = callerRole != nil && activation?.role == callerRole + let selfInitiated: WorkflowSelfInitiatedPayload? = + if includeSelfInitiated, let line = run.selfInitiatedLine { + WorkflowSelfInitiatedPayload( + line: line, + instructionPath: run.invocations.first?.instructionPath, + completion: run.invocations.first?.activation?.completion.messageCommands ?? []) + } else { + nil + } + self.init( + id: run.id.uuidString, + workflow: WorkflowIdentity(id: run.definition.id, name: run.definition.name), + scope: run.context.scope.workflowScope, + definitionPath: run.context.definitionPath, + source: .live, + status: WorkflowRunStatusPayload(WorkflowRunRecord.Status(run.status)), + step: run.status.isTerminal ? nil : run.currentStep?.id, + role: callerRole, + worktree: WorkflowRunWorktreePayload( + id: run.context.worktree.id, name: run.context.worktree.name, + branch: run.context.worktree.branch, + path: run.context.worktree.path), + runDirectory: WorkflowRunPaths.path(run.runDirectory), + bindings: run.bindings.mapValues { + WorkflowBindingPayload(source: $0.source, profile: $0.profile, pane: $0.pane) + }, + // Only the pane that owns the activation (its role) is told the completion command; a + // self-initiated line already carries the caller's own step and nothing else. + activation: activation.map { + WorkflowActivationPayload($0, spellCompletion: spellsCompletion, formatter: formatter) + }, + outputs: run.outputs.mapValues { WorkflowOutputPayload($0, formatter: formatter) }, + startedAt: formatter.string(from: run.startedAt), + updatedAt: formatter.string(from: run.updatedAt), + finishedAt: run.finishedAt.map(formatter.string(from:)), + selfInitiated: selfInitiated + ) + } + + /// A run read back from `run.json` after a restart: no tokens exist any more, so no completion + /// commands, no activation, and no self-initiated line. + nonisolated init(record: WorkflowRunRecord) { + let formatter = Self.makeDateFormatter() + self.init( + id: record.run.id.uuidString, + workflow: WorkflowIdentity(id: record.run.workflowID, name: record.run.workflowName), + scope: record.run.scope.workflowScope, + definitionPath: record.run.definitionPath, + source: .record, + status: WorkflowRunStatusPayload(record.run.status), + step: record.run.status.isTerminal ? nil : record.steps.last { $0.state == .active }?.id, + role: nil, + worktree: WorkflowRunWorktreePayload( + id: record.worktree.id, name: record.worktree.name, branch: record.worktree.branch, + path: record.worktree.path), + runDirectory: WorkflowRunPaths.path( + WorkflowRunPaths.runDirectory(root: record.worktree.rootURL, runID: record.run.id)), + bindings: record.bindings.mapValues { + WorkflowBindingPayload(source: $0.source, profile: $0.profile, pane: $0.pane) + }, + activation: nil, + outputs: record.outputs.mapValues { WorkflowOutputPayload($0, formatter: formatter) }, + startedAt: formatter.string(from: record.run.startedAt), + updatedAt: formatter.string(from: record.run.updatedAt), + finishedAt: record.run.finishedAt.map(formatter.string(from:)), + selfInitiated: nil + ) + } +} + +extension WorkflowRunStatusPayload { + nonisolated init(_ status: WorkflowRunRecord.Status) { + self.init( + state: status.state, + step: status.step, + dependent: status.dependent, + attention: status.attention.map { + WorkflowAttentionPayload( + reason: $0.reason, message: $0.message, step: $0.step, role: $0.role, ordinal: $0.ordinal, + actions: $0.actions.map(\.rawValue), issues: $0.issues) + } + ) + } +} + +extension WorkflowBindingPayload { + nonisolated init( + source: WorkflowRoleSource, profile: WorkflowProfileBinding?, pane: WorkflowPaneIdentity? + ) { + self.init( + source: source, + profile: profile.map { + WorkflowProfileBindingPayload(id: $0.id.uuidString, name: $0.name, agent: $0.agent) + }, + pane: pane.map { + WorkflowPaneBindingPayload( + id: $0.surfaceID.uuidString, tabID: $0.tabID?.uuidString, handle: $0.handle, + displayName: $0.displayName, + agent: $0.agent) + } + ) + } +} + +extension WorkflowActivationPayload { + nonisolated init( + _ activation: WorkflowActivation, spellCompletion: Bool, formatter: ISO8601DateFormatter + ) { + self.init( + ordinal: activation.ordinal, + step: activation.stepID, + role: activation.role, + state: activation.state.rawValue, + dispatchID: activation.dispatchID, + output: activation.outputName, + expect: WorkflowExpectationPayload( + format: activation.expect.format, + sections: activation.expect.sections, + verdict: activation.expect.verdict, + strict: activation.expect.strict, + completion: spellCompletion ? activation.completion.messageCommands : []), + deadline: activation.deadline.map(formatter.string(from:)) + ) + } +} + +extension WorkflowOutputPayload { + nonisolated init(_ output: WorkflowOutputRecord, formatter: ISO8601DateFormatter) { + self.init( + name: output.name, + ordinal: output.ordinal, + path: output.path, + latestPath: output.latestPath, + verdict: output.verdict, + deliveredAt: formatter.string(from: output.deliveredAt) + ) + } +} + +extension WorkflowDeliveryPayload { + nonisolated init(state: WorkflowDeliveryState, receipt: WorkflowDeliveryReceipt, role: String) { + self.init( + state: state, + ordinal: receipt.ordinal, + step: receipt.stepID, + role: role, + output: WorkflowOutputPayload(receipt.output, formatter: WorkflowRunPayload.makeDateFormatter()), + warnings: receipt.issues.map { + WorkflowDeliveryWarningPayload(code: $0.code, message: $0.message) + } + ) + } +} diff --git a/supacode/CLIService/WorkflowRuntimeCoordinator.swift b/supacode/CLIService/WorkflowRuntimeCoordinator.swift new file mode 100644 index 000000000..3e88a4f61 --- /dev/null +++ b/supacode/CLIService/WorkflowRuntimeCoordinator.swift @@ -0,0 +1,414 @@ +// supacode/CLIService/WorkflowRuntimeCoordinator.swift +// The socket side of `prowl workflow run / status / done / cancel` (docs-ai 063 B3). It reads the +// reducer's sessions, attributes a `done` to an activation (decision W3), enters the reducer +// through actions, and awaits the `done` rendezvous (decision W1). It owns no run state. + +import Foundation + +/// A wire response carried as a failure through `Result`. +nonisolated struct WorkflowCommandRefusal: Error { + let response: CommandResponse +} + +@MainActor +final class WorkflowRuntimeCoordinator { + struct Dependencies { + let admissionEnvironment: @MainActor () -> WorkflowAdmissionEnvironment + /// Every session the reducer holds, terminal ones included. + let sessions: @MainActor () -> [WorkflowRunSession] + let send: @MainActor (WorkflowRunsFeature.Action) -> Void + /// The pending dispatch record of a pane, if any (the activation address of decision W3). + let pendingDispatchID: @MainActor (UUID) -> String? + /// Working directories of every known worktree, for `status` of a run that is not live (W5). + let worktreeRoots: @MainActor () -> [URL] + let rendezvous: WorkflowCLIRendezvous + let makeRequestID: @Sendable () -> UUID + + init( + admissionEnvironment: @escaping @MainActor () -> WorkflowAdmissionEnvironment, + sessions: @escaping @MainActor () -> [WorkflowRunSession], + send: @escaping @MainActor (WorkflowRunsFeature.Action) -> Void, + pendingDispatchID: @escaping @MainActor (UUID) -> String?, + worktreeRoots: @escaping @MainActor () -> [URL], + rendezvous: WorkflowCLIRendezvous = WorkflowCLIRendezvous(), + makeRequestID: @escaping @Sendable () -> UUID = { UUID() } + ) { + self.admissionEnvironment = admissionEnvironment + self.sessions = sessions + self.send = send + self.pendingDispatchID = pendingDispatchID + self.worktreeRoots = worktreeRoots + self.rendezvous = rendezvous + self.makeRequestID = makeRequestID + } + } + + private let dependencies: Dependencies + /// The verified caller role of each outstanding `run` / `done`, so the answer spells completion + /// commands only to the pane that owns the activation (never to a manual or forced caller). + private var callerRoles: [UUID: String] = [:] + /// Request ids the reducer still owes an answer for; a cancelled waiter frees its rendezvous + /// slot, but its id stays unusable until that answer arrives so nothing crosses requests. + private var inFlight: Set = [] + + init(dependencies: Dependencies) { + self.dependencies = dependencies + } + + // MARK: - run + + func run( + _ input: WorkflowInput, source: WorkflowRunSource, snapshot: WorkflowRuntimeSnapshot + ) async -> CommandResponse { + var environment = dependencies.admissionEnvironment() + environment = environment.busy(dependencies.sessions().filter { !$0.run.status.isTerminal }) + let admission = WorkflowRunAdmission.admit(input, source: source, snapshot: snapshot, environment: environment) + switch admission { + case .failure(let failure): + return Self.failure(failure) + case .success(let admitted): + guard admitted.session.run.selfInitiatedLine != nil else { + dependencies.send(.started(admitted.session, effects: admitted.effects)) + let run = + dependencies.sessions().first { $0.run.id == admitted.session.run.id }?.run ?? admitted.session.run + return Self.success( + .run(WorkflowRunPayload(run: run, callerRole: admitted.callerRole, includeSelfInitiated: true))) + } + // A self-initiated first step hands the caller its completion command: answer only once + // the activation record that attributes that command exists (or its opening failed). + let requestID = dependencies.makeRequestID() + guard claim(requestID, callerRole: admitted.callerRole) else { + return Self.failure(code: CLIErrorCode.requestConflict, message: "Workflow request id is already in use.") + } + dependencies.send(.started(admitted.session, effects: admitted.effects, requestID: requestID)) + return await dependencies.rendezvous.wait(for: requestID) + } + } + + // MARK: - status + + func status(_ input: WorkflowInput, callerPane: CallerPane?) -> CommandResponse { + if let reference = input.runID { + guard let runID = UUID(uuidString: reference) else { + return Self.failure( + code: CLIErrorCode.invalidArgument, message: "'\(reference)' is not a run UUID.") + } + if let session = dependencies.sessions().first(where: { $0.run.id == runID }) { + let role = callerPane.flatMap { Self.role(of: $0.surfaceID, in: session) } + return Self.success( + .status( + WorkflowRunPayload(run: session.run, callerRole: role, includeSelfInitiated: false))) + } + guard let record = readRecord(runID: runID) else { + return Self.failure( + code: CLIErrorCode.runNotFound, + message: "No workflow run \(runID.uuidString) is live or recorded in a known worktree.") + } + return Self.success(.status(WorkflowRunPayload(record: record))) + } + guard let callerPane else { + return Self.failure( + code: CLIErrorCode.sourceRequired, + message: "Run `prowl workflow status` inside a Prowl pane, or pass a run UUID.") + } + guard + let session = dependencies.sessions().first(where: { + !$0.run.status.isTerminal && $0.boundSurfaceIDs.contains(callerPane.surfaceID) + }) + else { + return Self.failure( + code: CLIErrorCode.runNotFound, message: "This pane is not part of an active workflow run.") + } + let role = Self.role(of: callerPane.surfaceID, in: session) + return Self.success( + .status(WorkflowRunPayload(run: session.run, callerRole: role, includeSelfInitiated: false))) + } + + // MARK: - done + + func done(_ input: WorkflowInput, callerPane: CallerPane?) async -> CommandResponse { + guard let body = input.body else { + return Self.failure( + code: CLIErrorCode.invalidArgument, message: "The delivery has no output body.") + } + let explicit: (runID: UUID, stepID: String)? + switch input.runID { + case .none: + guard input.stepID == nil else { + return Self.failure( + code: CLIErrorCode.invalidArgument, message: "--run and --step must be passed together.") + } + explicit = nil + case .some(let reference): + guard let stepID = input.stepID else { + return Self.failure( + code: CLIErrorCode.invalidArgument, message: "--run and --step must be passed together.") + } + guard let runID = UUID(uuidString: reference) else { + return Self.failure( + code: CLIErrorCode.invalidArgument, message: "'\(reference)' is not a run UUID.") + } + explicit = (runID, stepID) + } + + let attribution: Attribution + switch attribute(explicit: explicit, callerPane: callerPane, token: input.token, force: input.force) { + case .failure(let refusal): + return refusal.response + case .success(let value): + attribution = value + } + let request = WorkflowDeliveryRequest( + requestID: dependencies.makeRequestID(), + runID: attribution.runID, + ordinal: attribution.ordinal, + selector: attribution.selector, + body: body, + verdict: input.verdict, + source: attribution.source) + guard claim(request.requestID, callerRole: attribution.callerRole) else { + return Self.failure(code: CLIErrorCode.requestConflict, message: "Workflow request id is already in use.") + } + dependencies.send(.deliver(request)) + return await dependencies.rendezvous.wait(for: request.requestID) + } + + /// Registers a request with the rendezvous and remembers its verified caller role; false when + /// the id is still in flight (or waiting) for another request. + private func claim(_ requestID: UUID, callerRole: String?) -> Bool { + guard !inFlight.contains(requestID), dependencies.rendezvous.register(requestID) else { return false } + inFlight.insert(requestID) + if let callerRole { + callerRoles[requestID] = callerRole + } + return true + } + + private struct Attribution { + let runID: UUID + let ordinal: Int? + let selector: WorkflowDeliverySelector + let source: String + /// The caller pane's role when the delivery was attributed by that pane; nil for manual. + let callerRole: String? + } + + /// Decision W3: the caller pane's pending dispatch identifies the activation first; explicit + /// `--run --step` is the manual path; both present and disagreeing needs `--force`. + private func attribute( + explicit: (runID: UUID, stepID: String)?, callerPane: CallerPane?, token: String?, force: Bool + ) -> Result { + let active = dependencies.sessions().filter { !$0.run.status.isTerminal } + var callerActivation: (session: WorkflowRunSession, activation: WorkflowActivation)? + if let callerPane, let dispatchID = dependencies.pendingDispatchID(callerPane.surfaceID) { + for session in active { + if let activation = session.run.activation(forDispatchID: dispatchID) { + callerActivation = (session, activation) + break + } + } + } + if let (session, activation) = callerActivation { + if let explicit, explicit.runID != session.run.id || explicit.stepID != activation.stepID { + guard force else { + return .failure( + Self.refusal( + code: CLIErrorCode.roleMismatch, + message: + "This pane is waiting for step '\(activation.stepID)' of run \(session.run.id.uuidString), " + + "not step '\(explicit.stepID)' of run \(explicit.runID.uuidString); " + + "pass --force to deliver there anyway." + )) + } + return manual(explicit, source: "manual --force", active: active) + } + return .success( + Attribution( + runID: session.run.id, ordinal: activation.ordinal, selector: .token(token), + source: "pane", callerRole: activation.role)) + } + guard let explicit else { + if callerPane == nil { + return .failure( + Self.refusal( + code: CLIErrorCode.sourceRequired, + message: "Run `prowl workflow done` inside the pane that received the step, " + + "or pass --run --step for a manual delivery.")) + } + return .failure( + Self.refusal( + code: CLIErrorCode.stepNotExpecting, + message: + "This pane holds no waiting workflow activation; the step may have moved on or been skipped." + )) + } + return manual(explicit, source: "manual", active: active) + } + + private func manual( + _ explicit: (runID: UUID, stepID: String), source: String, active: [WorkflowRunSession] + ) -> Result { + guard let session = active.first(where: { $0.run.id == explicit.runID }) else { + return .failure( + Self.refusal( + code: CLIErrorCode.runNotFound, + message: "No active workflow run \(explicit.runID.uuidString).")) + } + return .success( + Attribution( + runID: session.run.id, ordinal: nil, selector: .manual(stepID: explicit.stepID), + source: source, callerRole: nil)) + } + + /// The reducer's answer to a `run` or `done` request (through `WorkflowCLIResponderClient`). + func resolve(_ requestID: UUID, _ resolution: WorkflowRequestResolution) { + inFlight.remove(requestID) + let callerRole = callerRoles.removeValue(forKey: requestID) + let response: CommandResponse + switch resolution { + case .started(let run): + response = Self.success(.run(WorkflowRunPayload(run: run, callerRole: callerRole, includeSelfInitiated: true))) + case .delivered(let run, let receipt): + response = Self.success( + .done(Self.donePayload(run: run, receipt: receipt, state: .delivered, callerRole: callerRole))) + case .provisional(let run, let receipt): + response = Self.success( + .done(Self.donePayload(run: run, receipt: receipt, state: .provisional, callerRole: callerRole))) + case .failed(let code, let message): + response = Self.failure(code: code, message: message) + } + dependencies.rendezvous.resolve(requestID, with: response) + } + + private static func donePayload( + run: WorkflowRun, receipt: WorkflowDeliveryReceipt, state: WorkflowDeliveryState, callerRole: String? + ) -> WorkflowDonePayload { + let role = run.invocations.first { $0.ordinal == receipt.ordinal }?.role ?? "-" + return WorkflowDonePayload( + run: WorkflowRunPayload(run: run, callerRole: callerRole, includeSelfInitiated: false), + delivery: WorkflowDeliveryPayload(state: state, receipt: receipt, role: role)) + } + + /// The `WORKFLOW_DELIVERY_REQUIRED` refusal for `agents dispatch-complete` when the pane's + /// pending record is a workflow activation. Terminal sessions count too: their abandon is + /// queued behind earlier work, and a plain completion must not win that race. + static func deliveryRefusal(dispatchID: String, sessions: [WorkflowRunSession]) -> CommandError? { + for session in sessions { + guard let activation = session.run.activation(forDispatchID: dispatchID) else { continue } + if session.run.status.isTerminal { + return CommandError( + code: CLIErrorCode.workflowDeliveryRequired, + message: "This pane's pending dispatch belongs to workflow run \(session.run.id.uuidString), " + + "which already ended (\(WorkflowRunMachine.describe(session.run.status))); the record is being abandoned." + ) + } + return CommandError( + code: CLIErrorCode.workflowDeliveryRequired, + message: activation.completion.deliveryRequiredMessage( + runID: session.run.id.uuidString, stepID: activation.stepID)) + } + return nil + } + + // MARK: - cancel + + func cancel(_ input: WorkflowInput, callerPane: CallerPane?) -> CommandResponse { + guard let reference = input.runID, let runID = UUID(uuidString: reference) else { + return Self.failure(code: CLIErrorCode.invalidArgument, message: "cancel needs a run UUID.") + } + guard let session = dependencies.sessions().first(where: { $0.run.id == runID }) else { + return Self.failure( + code: CLIErrorCode.runNotFound, message: "No live workflow run \(runID.uuidString).") + } + guard !session.run.status.isTerminal else { + return Self.failure( + code: CLIErrorCode.runNotFound, + message: + "Workflow run \(runID.uuidString) already ended (\(WorkflowRunMachine.describe(session.run.status)))." + ) + } + dependencies.send(.userAction(runID: runID, .cancel)) + let cancelled = dependencies.sessions().first { $0.run.id == runID }?.run ?? session.run + let role = callerPane.flatMap { Self.role(of: $0.surfaceID, in: session) } + return Self.success( + .cancel(WorkflowRunPayload(run: cancelled, callerRole: role, includeSelfInitiated: false))) + } + + // MARK: - Helpers + + private static func role(of surfaceID: UUID, in session: WorkflowRunSession) -> String? { + session.run.bindings.first { $0.value.pane?.surfaceID == surfaceID }?.key + } + + /// A v1 record from any known worktree root; nothing is reconstructed from it (decision W5). + private func readRecord(runID: UUID) -> WorkflowRunRecord? { + for root in dependencies.worktreeRoots() { + let store = WorkflowRunStore(rootURL: root) + let recordURL = store.directory(for: runID).appending( + path: WorkflowRunRecord.fileName, directoryHint: .notDirectory) + guard FileManager.default.fileExists(atPath: recordURL.path(percentEncoded: false)) else { + continue + } + if let record = try? store.readRecord(runID: runID) { + return record + } + } + return nil + } + + static func success(_ payload: WorkflowCommandPayload) -> CommandResponse { + do { + return try CommandResponse( + ok: true, + command: WorkflowCommandPayload.commandName, + schemaVersion: WorkflowCommandPayload.schemaVersion, + data: RawJSON(encoding: payload)) + } catch { + return failure( + code: CLIErrorCode.workflowFailed, + message: "Failed to encode the workflow response: \(error)") + } + } + + static func failure(_ failure: WorkflowAdmissionFailure) -> CommandResponse { + CommandResponse( + ok: false, + command: WorkflowCommandPayload.commandName, + schemaVersion: WorkflowCommandPayload.schemaVersion, + error: CommandError( + code: failure.code, + message: failure.message, + details: failure.details.flatMap { + try? RawJSON(encoding: WorkflowCommandPayload.validate($0)) + })) + } + + static func failure(code: String, message: String) -> CommandResponse { + WorkflowCLIRendezvous.failure(code: code, message: message) + } + + static func refusal(code: String, message: String) -> WorkflowCommandRefusal { + WorkflowCommandRefusal(response: failure(code: code, message: message)) + } +} + +extension WorkflowAdmissionEnvironment { + /// The same environment with the panes of `sessions` marked busy. + func busy(_ sessions: [WorkflowRunSession]) -> WorkflowAdmissionEnvironment { + WorkflowAdmissionEnvironment( + profiles: profiles, + recommendation: recommendation, + rememberedBinding: rememberedBinding, + detectedAgent: detectedAgent, + pendingDispatchID: pendingDispatchID, + busySurfaceIDs: busySurfaceIDs.union(sessions.flatMap(\.boundSurfaceIDs)), + worktree: worktree, + branchName: branchName, + makeLaunchPlan: makeLaunchPlan, + bundledSkill: bundledSkill, + now: now, + makeRunID: makeRunID, + makeToken: makeToken, + limits: limits) + } +} diff --git a/supacode/Clients/Workflow/WorkflowActionExecutorClient.swift b/supacode/Clients/Workflow/WorkflowActionExecutorClient.swift new file mode 100644 index 000000000..5c96b7ec7 --- /dev/null +++ b/supacode/Clients/Workflow/WorkflowActionExecutorClient.swift @@ -0,0 +1,18 @@ +// supacode/Clients/Workflow/WorkflowActionExecutorClient.swift +// The boundary the reducer runs a native `action` step through (docs-ai 063 B3): the app uses +// the bundled runner, tests substitute one they can hold or fail at will. + +import ComposableArchitecture +import Foundation + +enum WorkflowActionExecutorKey: DependencyKey { + static let liveValue: any WorkflowActionExecuting = WorkflowNativeActionRunner() + static let testValue: any WorkflowActionExecuting = WorkflowNativeActionRunner() +} + +extension DependencyValues { + var workflowActionExecutor: any WorkflowActionExecuting { + get { self[WorkflowActionExecutorKey.self] } + set { self[WorkflowActionExecutorKey.self] = newValue } + } +} diff --git a/supacode/Clients/Workflow/WorkflowActivationClient.swift b/supacode/Clients/Workflow/WorkflowActivationClient.swift new file mode 100644 index 000000000..dae707eda --- /dev/null +++ b/supacode/Clients/Workflow/WorkflowActivationClient.swift @@ -0,0 +1,37 @@ +import ComposableArchitecture +import Foundation + +/// The live dispatch-store operations used only by the workflow reducer. The dispatch store +/// remains workflow-agnostic: this client translates B2's activation effects at the app boundary. +struct WorkflowActivationClient: Sendable { + var openMessage: @MainActor @Sendable (UUID) -> Result + var cancel: @MainActor @Sendable (String) -> Void + var abandon: @MainActor @Sendable (String, String) -> Void + var complete: @MainActor @Sendable (String, String) -> Void + var observe: @MainActor @Sendable (String) -> AgentDispatchObservationStream? +} + +extension WorkflowActivationClient: DependencyKey { + static let liveValue = WorkflowActivationClient( + openMessage: { _ in .failure(.failed("WorkflowActivationClient.openMessage not configured")) }, + cancel: { _ in }, + abandon: { _, _ in }, + complete: { _, _ in }, + observe: { _ in nil } + ) + + static let testValue = WorkflowActivationClient( + openMessage: { _ in .failure(.failed("No test activation bridge configured.")) }, + cancel: { _ in }, + abandon: { _, _ in }, + complete: { _, _ in }, + observe: { _ in nil } + ) +} + +extension DependencyValues { + var workflowActivationClient: WorkflowActivationClient { + get { self[WorkflowActivationClient.self] } + set { self[WorkflowActivationClient.self] = newValue } + } +} diff --git a/supacode/Clients/Workflow/WorkflowCLIResponderClient.swift b/supacode/Clients/Workflow/WorkflowCLIResponderClient.swift new file mode 100644 index 000000000..022d480f8 --- /dev/null +++ b/supacode/Clients/Workflow/WorkflowCLIResponderClient.swift @@ -0,0 +1,36 @@ +// supacode/Clients/Workflow/WorkflowCLIResponderClient.swift +// The reducer's side of the CLI rendezvous (docs-ai 063 B3, decision W1): a `done` request is +// answered when its activation leaves `persisting`, never on the `.outputPersisted` event alone; +// a self-initiated `run` is answered once its first activation is open. The composition root +// turns the resolution into a wire response and resumes the socket handler. + +import ComposableArchitecture +import Foundation + +/// How a CLI request that entered the reducer ended. +nonisolated enum WorkflowRequestResolution: Equatable, Sendable { + /// A self-initiated run whose first activation is now open (or whose opening failed and left + /// the run in attention): the caller can act on the returned line. + case started(run: WorkflowRun) + /// The output is the step's output and the run advanced. + case delivered(run: WorkflowRun, receipt: WorkflowDeliveryReceipt) + /// The output is on disk with issues; the run waits for the user (decision H14). + case provisional(run: WorkflowRun, receipt: WorkflowDeliveryReceipt) + case failed(code: String, message: String) +} + +struct WorkflowCLIResponderClient: Sendable { + var respond: @MainActor @Sendable (UUID, WorkflowRequestResolution) -> Void +} + +extension WorkflowCLIResponderClient: DependencyKey { + static let liveValue = WorkflowCLIResponderClient(respond: { _, _ in }) + static let testValue = liveValue +} + +extension DependencyValues { + var workflowCLIResponder: WorkflowCLIResponderClient { + get { self[WorkflowCLIResponderClient.self] } + set { self[WorkflowCLIResponderClient.self] = newValue } + } +} diff --git a/supacode/Clients/Workflow/WorkflowEffectQueueClient.swift b/supacode/Clients/Workflow/WorkflowEffectQueueClient.swift new file mode 100644 index 000000000..5b0705af2 --- /dev/null +++ b/supacode/Clients/Workflow/WorkflowEffectQueueClient.swift @@ -0,0 +1,109 @@ +// supacode/Clients/Workflow/WorkflowEffectQueueClient.swift +// One FIFO per run for the machine's ordered effects (docs-ai 063 B3). The reducer enqueues +// every batch synchronously as it reduces; a single long-lived effect per run performs them one +// after another, so an instruction file exists before the line that names it is typed and +// `run.json` writes never overtake each other. Long-running observers (idle waits, watchdogs) +// stay separate cancellable effects. A *fence* invalidates everything enqueued before it: the +// reducer raises one whenever a revoke or the run's end makes earlier queued work stale, and the +// executor drops such work effect by effect instead of typing into a pane a cancel already left. + +import ComposableArchitecture +import Foundation + +/// A batch of effects together with the run state they were emitted from (`persist` writes it). +struct WorkflowEffectBatch: Equatable, Sendable { + /// Enqueue order within the run; the queue assigns it. + var sequence: Int = 0 + let session: WorkflowRunSession + let effects: [WorkflowRunEffect] + + init(session: WorkflowRunSession, effects: [WorkflowRunEffect]) { + self.session = session + self.effects = effects + } +} + +struct WorkflowEffectQueueClient: Sendable { + /// Opens the run's queue; the stream ends after `finish`. + var start: @MainActor @Sendable (UUID) -> AsyncStream + var enqueue: @MainActor @Sendable (UUID, WorkflowEffectBatch) -> Void + /// Marks every batch enqueued so far as stale. + var fence: @MainActor @Sendable (UUID) -> Void + /// Whether a batch with this sequence was fenced after it was enqueued. + var isStale: @MainActor @Sendable (UUID, Int) -> Bool + var finish: @MainActor @Sendable (UUID) -> Void +} + +@MainActor +final class WorkflowEffectQueue { + private struct Lane { + var continuation: AsyncStream.Continuation + var nextSequence = 1 + /// Batches with a sequence at or below this were enqueued before the latest fence. + var fencedThrough = 0 + } + + private var lanes: [UUID: Lane] = [:] + + func start(_ runID: UUID) -> AsyncStream { + lanes[runID]?.continuation.finish() + let (stream, continuation) = AsyncStream.makeStream(of: WorkflowEffectBatch.self) + lanes[runID] = Lane(continuation: continuation) + return stream + } + + func enqueue(_ runID: UUID, _ batch: WorkflowEffectBatch) { + guard var lane = lanes[runID] else { return } + var sequenced = batch + sequenced.sequence = lane.nextSequence + lane.nextSequence += 1 + lanes[runID] = lane + lane.continuation.yield(sequenced) + } + + func fence(_ runID: UUID) { + guard var lane = lanes[runID] else { return } + lane.fencedThrough = lane.nextSequence - 1 + lanes[runID] = lane + } + + func isStale(_ runID: UUID, sequence: Int) -> Bool { + guard let lane = lanes[runID] else { return true } + return sequence <= lane.fencedThrough + } + + func finish(_ runID: UUID) { + lanes.removeValue(forKey: runID)?.continuation.finish() + } + + var client: WorkflowEffectQueueClient { + WorkflowEffectQueueClient( + start: { [self] runID in start(runID) }, + enqueue: { [self] runID, batch in enqueue(runID, batch) }, + fence: { [self] runID in fence(runID) }, + isStale: { [self] runID, sequence in isStale(runID, sequence: sequence) }, + finish: { [self] runID in finish(runID) } + ) + } +} + +extension WorkflowEffectQueueClient: DependencyKey { + /// The app installs `WorkflowEffectQueue().client` at its composition root; tests inject a + /// queue explicitly. The default performs nothing. + static let liveValue = WorkflowEffectQueueClient( + start: { _ in AsyncStream { $0.finish() } }, + enqueue: { _, _ in }, + fence: { _ in }, + isStale: { _, _ in false }, + finish: { _ in } + ) + + static let testValue = liveValue +} + +extension DependencyValues { + var workflowEffectQueue: WorkflowEffectQueueClient { + get { self[WorkflowEffectQueueClient.self] } + set { self[WorkflowEffectQueueClient.self] = newValue } + } +} diff --git a/supacode/Clients/Workflow/WorkflowRuntimeClient.swift b/supacode/Clients/Workflow/WorkflowRuntimeClient.swift new file mode 100644 index 000000000..058416365 --- /dev/null +++ b/supacode/Clients/Workflow/WorkflowRuntimeClient.swift @@ -0,0 +1,86 @@ +// supacode/Clients/Workflow/WorkflowRuntimeClient.swift +// Terminal-side operations the workflow reducer cannot own (docs-ai 063 B3): the idle wait, the +// typed line, the profile launch, closing a pane, and the user notification. The live values are +// composed in `WorkflowRuntimeComposition.swift`; neither the run machine nor the dispatch store +// learns UI semantics. + +import ComposableArchitecture +import Foundation + +nonisolated enum WorkflowTextDelivery: Equatable, Sendable { + case delivered + case insertFailed + case submitFailed + /// The liveness guard failed right before insertion: nothing was typed. + case stale +} + +/// How a `message` step's idle wait ended (dsl-spec §10: a `working` role is never injected into). +nonisolated enum WorkflowRoleWaitOutcome: Equatable, Sendable { + case idle + /// The role's runtime reported `needs-input`, or its screen stayed blocked for the blocked grace. + case blocked + /// The pane is gone. + case gone + /// The pane hosts no detected agent; typing into a bare shell would run the line as a command. + case noAgent + /// The pane already holds a pending dispatch record (#733 D4: one per surface) that is not + /// this run's; the record's owner must complete or abandon it first. + case dispatchPending(String) + case cancelled +} + +nonisolated struct WorkflowLaunchResult: Equatable, Sendable { + let pane: WorkflowPaneIdentity + let dispatchID: String? +} + +nonisolated enum WorkflowLaunchError: Error, Equatable, Sendable { + case failed(String) +} + +struct WorkflowRuntimeClient: Sendable { + /// The #733 idle precondition without its five-second cap: exact `turn-ended` evidence first, + /// a stabilized detector view otherwise; returns when the role can receive a line. + var waitForRole: @MainActor @Sendable (UUID) async -> WorkflowRoleWaitOutcome + /// `insertCommittedText` + `submitLine` as one operation, entered only if the guard still + /// holds at that moment (the run's queue fence, checked on the same main-actor turn as the + /// insertion so a cancel cannot slip in between). + var deliverLine: @MainActor @Sendable (Worktree, UUID, String, @MainActor () -> Bool) -> WorkflowTextDelivery + /// Launches the frozen profile plan with the rendered kickoff prompt and the child-only + /// workflow environment; issues and binds the launch activation when the step expects a delivery. + var launch: + @MainActor @Sendable (Worktree, AgentProfileLaunchPlan, WorkflowLaunchRequest) async -> Result< + WorkflowLaunchResult, WorkflowLaunchError + > + /// Closes a `launch` role's pane for the run that launched it, without a confirmation (the + /// author's `close` step is explicit and the run owns the pane); `false` when the pane is gone + /// or another active run has bound it since. + var close: @MainActor @Sendable (Worktree, UUID, UUID) -> Bool + var notify: @MainActor @Sendable (Worktree, String) -> Void +} + +extension WorkflowRuntimeClient: DependencyKey { + static let liveValue = WorkflowRuntimeClient( + waitForRole: { _ in .cancelled }, + deliverLine: { _, _, _, _ in .insertFailed }, + launch: { _, _, _ in .failure(.failed("WorkflowRuntimeClient.launch is not configured")) }, + close: { _, _, _ in false }, + notify: { _, _ in } + ) + + static let testValue = WorkflowRuntimeClient( + waitForRole: { _ in .cancelled }, + deliverLine: { _, _, _, _ in .insertFailed }, + launch: { _, _, _ in .failure(.failed("No test workflow runtime configured.")) }, + close: { _, _, _ in false }, + notify: { _, _ in } + ) +} + +extension DependencyValues { + var workflowRuntimeClient: WorkflowRuntimeClient { + get { self[WorkflowRuntimeClient.self] } + set { self[WorkflowRuntimeClient.self] = newValue } + } +} diff --git a/supacode/Clients/Workflow/WorkflowWatchdogClient.swift b/supacode/Clients/Workflow/WorkflowWatchdogClient.swift new file mode 100644 index 000000000..4f90cb87e --- /dev/null +++ b/supacode/Clients/Workflow/WorkflowWatchdogClient.swift @@ -0,0 +1,31 @@ +// supacode/Clients/Workflow/WorkflowWatchdogClient.swift +// Arms B2's `WorkflowWatchdog` driver for one waiting activation (docs-ai 063 B3, decision H6). +// The driver lives inside the reducer effect that consumes its verdicts, so cancelling that +// effect (`disarmWatchdog`, run teardown) tears the streams and deadlines down with it. + +import ComposableArchitecture +import Foundation + +struct WorkflowWatchdogHandle: Sendable { + let verdicts: AsyncStream + let cancel: @MainActor @Sendable () -> Void +} + +struct WorkflowWatchdogClient: Sendable { + var arm: @MainActor @Sendable (UUID, WorkflowWatchdogRequest) -> WorkflowWatchdogHandle +} + +extension WorkflowWatchdogClient: DependencyKey { + static let liveValue = WorkflowWatchdogClient( + arm: { _, _ in WorkflowWatchdogHandle(verdicts: AsyncStream { $0.finish() }, cancel: {}) } + ) + + static let testValue = liveValue +} + +extension DependencyValues { + var workflowWatchdogClient: WorkflowWatchdogClient { + get { self[WorkflowWatchdogClient.self] } + set { self[WorkflowWatchdogClient.self] = newValue } + } +} diff --git a/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift b/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift index 150fc6768..280b9b0bc 100644 --- a/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift +++ b/supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift @@ -199,6 +199,50 @@ nonisolated struct AgentProfileLaunchPlan: Equatable, Sendable { ) } + /// A workflow `launch` role (docs-ai 063 B3, decision W6): replaces the placeholder prompt the + /// frozen plan was compiled with by the rendered kickoff prompt (its own protocol block, not + /// S2's dispatch protocol) and attaches the `PROWL_WORKFLOW_*` values as child-only carriers, + /// exactly like `attachingDispatch` carries `PROWL_DISPATCH_ID`. Nothing here reaches the + /// typed command or the pane's shell by name. + func attachingWorkflow(prompt: String, environment values: [String: String]) throws -> AgentProfileLaunchPlan { + guard !invocation.arguments.isEmpty, surfaceEnvironment[AgentProfileLaunchPlanner.promptCarrierName] != nil + else { + throw AgentProfileLaunchPlanError.dispatchRequiresPrompt + } + guard !prompt.contains("\0"), !values.contains(where: { $0.key.contains("\0") || $0.value.contains("\0") }) + else { + throw AgentProfileLaunchPlanError.promptContainsNUL + } + var arguments = invocation.arguments + arguments[arguments.index(before: arguments.endIndex)] = prompt + var environment = surfaceEnvironment + environment[AgentProfileLaunchPlanner.promptCarrierName] = prompt + var commandTokens = commandEnvironmentTokens + var carriers = environmentCarriers + for (offset, entry) in values.sorted(by: { $0.key < $1.key }).enumerated() { + let carrier = "\(AgentProfileLaunchPlanner.workflowCarrierPrefix)\(offset)" + carriers.append(carrier) + environment[carrier] = entry.value + commandTokens.append("\(entry.key)=\"$\(carrier)\"") + } + return AgentProfileLaunchPlan( + profileID: profileID, + profileName: profileName, + runtime: runtime, + invocation: AgentInvocation(executable: invocation.executable, arguments: arguments), + argumentCarriers: argumentCarriers, + environmentCarriers: carriers, + hookRegistration: hookRegistration, + commandEnvironmentTokens: commandTokens, + placement: placement, + splitDirection: splitDirection, + surfaceEnvironment: environment, + profileEnvironmentOverrides: profileEnvironmentOverrides, + dedicatedHome: dedicatedHome, + sessionConfigRoot: sessionConfigRoot + ) + } + func attachingDispatch(id: String, userPrompt: String) throws -> AgentProfileLaunchPlan { guard !id.isEmpty, !id.contains("\0"), !userPrompt.contains("\0"), !invocation.arguments.isEmpty, @@ -459,6 +503,8 @@ nonisolated enum AgentProfileLaunchPlanner { static let hookTokenCarrierName = "PROWL_LAUNCH_HOOK_TOKEN" static let hookSocketCarrierName = "PROWL_LAUNCH_HOOK_SOCKET" static let hookForwardCarrierName = "PROWL_LAUNCH_HOOK_FORWARD" + /// `PROWL_LAUNCH_WORKFLOW_`: one carrier per workflow child-environment value (063 B3). + static let workflowCarrierPrefix = "PROWL_LAUNCH_WORKFLOW_" /// Resolves a profile into one launch plan. Pure: no filesystem access — /// home provisioning happens at the launch boundary, not here. diff --git a/supacode/Domain/Workflow/LiveWorkflowActivationBridge.swift b/supacode/Domain/Workflow/LiveWorkflowActivationBridge.swift new file mode 100644 index 000000000..2a4635ef3 --- /dev/null +++ b/supacode/Domain/Workflow/LiveWorkflowActivationBridge.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Production adapter for B2 activation effects. It resolves the exact live surface at issuance +/// time, then delegates all lifecycle ownership to the existing dispatch store. +@MainActor +final class LiveWorkflowActivationBridge: WorkflowActivationBridge { + typealias ResolveTarget = @MainActor (UUID) -> TabResolvedTarget? + + private let terminalManager: WorktreeTerminalManager + private let resolveTarget: ResolveTarget + + init(terminalManager: WorktreeTerminalManager, resolveTarget: @escaping ResolveTarget) { + self.terminalManager = terminalManager + self.resolveTarget = resolveTarget + } + + func openMessageActivation(surfaceID: UUID) -> Result { + guard let target = resolveTarget(surfaceID) else { return .failure(.surfaceMissing) } + do { + return .success(try terminalManager.issueAgentDispatch(boundTo: target).record.id) + } catch let error as AgentDispatchStoreError { + return .failure(Self.openFailure(for: error)) + } catch { + return .failure(.failed("\(error)")) + } + } + + func cancelActivation(dispatchID: String) { + terminalManager.cancelAgentDispatchIssuance(dispatchID: dispatchID) + } + + func abandonActivation(dispatchID: String, reason: String) { + do { + _ = try terminalManager.abandonAgentDispatch(dispatchID: dispatchID, reason: reason) + } catch { + appLogger.warning("[Workflow] Could not abandon activation \(dispatchID): \(error)") + } + } + + func completeActivation(dispatchID: String, summary: String) { + guard let snapshot = terminalManager.agentDispatchSnapshot(dispatchID: dispatchID), + let surfaceID = snapshot.binding?.surfaceID + else { + appLogger.warning("[Workflow] Could not complete unbound activation \(dispatchID).") + return + } + do { + _ = try terminalManager.completeAgentDispatch( + dispatchID: dispatchID, outcome: .succeeded, summary: summary, callerSurfaceID: surfaceID) + } catch { + appLogger.warning("[Workflow] Could not complete activation \(dispatchID): \(error)") + } + } + + func observeActivation(dispatchID: String) -> AgentDispatchObservationStream? { + try? terminalManager.observeAgentDispatch(dispatchID: dispatchID) + } + + private static func openFailure(for error: AgentDispatchStoreError) -> WorkflowActivationOpenFailure { + switch error { + case .surfacePending: .roleBusy + case .capacityExceeded: .capacityExceeded + case .bindingMissing, .notFound: .surfaceMissing + case .alreadyBound, .sourceMismatch, .alreadyCompleted, .alreadyTerminal: .failed("\(error)") + } + } +} diff --git a/supacode/Domain/Workflow/WorkflowRunMachine.swift b/supacode/Domain/Workflow/WorkflowRunMachine.swift index ae09d155f..3da3bb760 100644 --- a/supacode/Domain/Workflow/WorkflowRunMachine.swift +++ b/supacode/Domain/Workflow/WorkflowRunMachine.swift @@ -50,6 +50,9 @@ nonisolated enum WorkflowUserAction: Equatable, Sendable { nonisolated enum WorkflowRunEvent: Equatable, Sendable { case roleIdle(ordinal: Int) + /// The idle wait ended without an idle role: the pane is gone, its agent stays blocked, or it + /// hosts no agent to inject into. The step enters attention as a failed injection would. + case roleUnavailable(ordinal: Int, WorkflowInjectionFailure) case injectionSucceeded(ordinal: Int, dispatchID: String?) case injectionFailed(ordinal: Int, WorkflowInjectionFailure) case launched(ordinal: Int, pane: WorkflowPaneIdentity, dispatchID: String?) @@ -316,7 +319,7 @@ nonisolated struct WorkflowRunMachine { case .injectionSucceeded(let ordinal, let dispatchID): guard case .injecting(ordinal) = run.phase else { return [] } openWaiting(ordinal: ordinal, dispatchID: dispatchID, effects: &effects) - case .injectionFailed(let ordinal, let failure): + case .injectionFailed(let ordinal, let failure), .roleUnavailable(let ordinal, let failure): applyInjectionFailed(ordinal: ordinal, failure: failure, effects: &effects) case .launched(let ordinal, let pane, let dispatchID): applyLaunched(ordinal: ordinal, pane: pane, dispatchID: dispatchID, effects: &effects) @@ -341,7 +344,16 @@ nonisolated struct WorkflowRunMachine { private mutating func applyInjectionFailed( ordinal: Int, failure: WorkflowInjectionFailure, effects: inout [WorkflowRunEffect] ) { - guard case .injecting(ordinal) = run.phase, let invocation = invocation(ordinal) else { return } + switch run.phase { + case .injecting(ordinal): + break + case .waitingForRole(_, ordinal): + // `.roleUnavailable`: the idle wait ended without an idle role; the step fails as an injection would. + run.phase = .injecting(ordinal: ordinal) + default: + return + } + guard let invocation = invocation(ordinal) else { return } if failure == .roleBusy { guard let surfaceID = run.bindings[invocation.role]?.pane?.surfaceID else { return } run.phase = .waitingForRole(role: invocation.role, ordinal: ordinal) diff --git a/supacode/Domain/Workflow/WorkflowRunStore.swift b/supacode/Domain/Workflow/WorkflowRunStore.swift index a0d2e388d..567715046 100644 --- a/supacode/Domain/Workflow/WorkflowRunStore.swift +++ b/supacode/Domain/Workflow/WorkflowRunStore.swift @@ -495,7 +495,7 @@ nonisolated struct WorkflowRunStore: Sendable { /// `interrupted`. Only a small header (`version`, `run.status.state`) is read before a run is /// selected, so a record of another version is left alone; a v1 record that cannot be decoded /// or a run directory that fails the containment gate is reported and left untouched. - func markInterruptedRuns(now: Date) throws -> WorkflowInterruptedRuns { + func markInterruptedRuns(now: () -> Date) throws -> WorkflowInterruptedRuns { let fileManager = FileManager.default guard fileManager.fileExists(atPath: runsDirectory.path(percentEncoded: false)) else { return WorkflowInterruptedRuns(interrupted: [], unreadable: []) @@ -529,8 +529,9 @@ nonisolated struct WorkflowRunStore: Sendable { unreadable.append(recordPath) continue } - try writeRecord(record.interrupted(at: now)) - try appendLog(runID: runID, line: "Run marked interrupted at app launch (no resume in V1).", now: now) + let timestamp = now() + try writeRecord(record.interrupted(at: timestamp)) + try appendLog(runID: runID, line: "Run marked interrupted at app launch (no resume in V1).", now: timestamp) interrupted.append(runID) } return WorkflowInterruptedRuns(interrupted: interrupted, unreadable: unreadable) diff --git a/supacode/Domain/Workflow/WorkflowWatchdog.swift b/supacode/Domain/Workflow/WorkflowWatchdog.swift index f9f2b93cb..be967380f 100644 --- a/supacode/Domain/Workflow/WorkflowWatchdog.swift +++ b/supacode/Domain/Workflow/WorkflowWatchdog.swift @@ -202,15 +202,21 @@ nonisolated struct WorkflowWatchdogPolicy: Equatable, Sendable { let active = snapshot.state == "working" || sawActivity switch deadline { case .turnGrace: + // Activity re-arms the same grace: a freshly launched agent's first detector `working` + // can arrive after the hook's `turn-ended`, and a watchdog that only cleared the flag + // here would wait for a second `turn-ended` that never comes (found live, 063 B3). guard !active else { sawActivity = false + schedule(.turnGrace, settings.turnGrace, &commands) return } escalate(after: .turnGrace, &commands) case .idleGrace: guard !active else { sawActivity = false - if mode == .heuristic, snapshot.state == "idle" || snapshot.state == "done" { + // Heuristic mode re-arms from the detector's next idle level; exact mode has no such + // trigger, so the grace re-arms itself. + if mode != .heuristic || snapshot.state == "idle" || snapshot.state == "done" { schedule(.idleGrace, settings.idleGrace, &commands) } return diff --git a/supacode/Features/App/Reducer/AppFeature+Support.swift b/supacode/Features/App/Reducer/AppFeature+Support.swift index aec00cc5f..42c32b9bb 100644 --- a/supacode/Features/App/Reducer/AppFeature+Support.swift +++ b/supacode/Features/App/Reducer/AppFeature+Support.swift @@ -8,6 +8,19 @@ enum CancelID { static let periodicRefresh = "app.periodicRefresh" } +/// Every directory a workflow run directory can live under (docs-ai 063 B3): each worktree, and +/// the root of a plain repository, which the CLI resolves as its own worktree. +func workflowRunRoots(of repositories: [Repository]) -> [String] { + var roots: [String] = [] + for repository in repositories { + if repository.capabilities.supportsRunnableFolderActions, !repository.capabilities.supportsWorktrees { + roots.append(repository.rootURL.path(percentEncoded: false)) + } + roots += repository.worktrees.map { $0.workingDirectory.path(percentEncoded: false) } + } + return roots +} + func makeTerminalRestorableWorktrees(from repositories: [Repository]) -> [Worktree] { var worktrees: [Worktree] = [] worktrees.reserveCapacity(repositories.reduce(0) { $0 + max(1, $1.worktrees.count) }) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 3dd117e63..15e8b727f 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -12,6 +12,7 @@ struct AppFeature { var settings: SettingsFeature.State var updates = UpdatesFeature.State() var commandPalette = CommandPaletteFeature.State() + var workflowRuns = WorkflowRunsFeature.State() var openActionSelection: OpenWorktreeAction = .finder /// Whether the selected worktree's repository resolves its open action /// automatically (project-aware) rather than a user-pinned app. Drives the @@ -53,6 +54,7 @@ struct AppFeature { case settings(SettingsFeature.Action) case updates(UpdatesFeature.Action) case commandPalette(CommandPaletteFeature.Action) + case workflowRuns(WorkflowRunsFeature.Action) case openActionSelectionChanged(OpenWorktreeAction) case openActionResetToAutomatic case worktreeSettingsLoaded(RepositorySettings, worktreeID: Worktree.ID) @@ -297,7 +299,10 @@ struct AppFeature { state.runScriptStatusByWorktreeID = state.runScriptStatusByWorktreeID.filter { ids.contains($0.key) } let restorableWorktrees = makeTerminalRestorableWorktrees(from: Array(repositories)) appLogger.info("[LayoutRestore] restorableWorktrees count=\(restorableWorktrees.count)") - var allEffects: [Effect] = [] + var allEffects: [Effect] = [ + // Runs a previous app instance left behind are marked interrupted (dsl-spec §10 Restart). + .send(.workflowRuns(.markInterruptedRuns(worktreeRoots: workflowRunRoots(of: Array(repositories))))) + ] if !shouldDeferDefaultView { allEffects.append(applyDefaultViewMode(into: &state)) } @@ -1011,6 +1016,9 @@ struct AppFeature { case .commandPalette(let action): return reduceCommandPaletteAction(action, state: &state) + case .workflowRuns: + return .none + case .openHandoffHud: return openHandoffHud(state: &state) @@ -1062,5 +1070,8 @@ struct AppFeature { Scope(state: \.commandPalette, action: \.commandPalette) { CommandPaletteFeature() } + Scope(state: \.workflowRuns, action: \.workflowRuns) { + WorkflowRunsFeature() + } } } diff --git a/supacode/Features/Settings/Models/UserGlobalSettings.swift b/supacode/Features/Settings/Models/UserGlobalSettings.swift index e1d8d4f94..e942331f0 100644 --- a/supacode/Features/Settings/Models/UserGlobalSettings.swift +++ b/supacode/Features/Settings/Models/UserGlobalSettings.swift @@ -8,6 +8,8 @@ nonisolated struct UserGlobalSettings: Codable, Equatable, Sendable { var didSeedAgentProfiles: Bool /// `/` keys of workflow definitions switched off (docs-ai 063 B1; Settings UI in D1). var disabledWorkflowIDs: [String] + /// Remembered `launch` role bindings (dsl-spec §3): one profile per requirements-digest key. + var workflowBindings: [WorkflowRememberedBinding] static let `default` = UserGlobalSettings(customCommands: []) @@ -16,18 +18,21 @@ nonisolated struct UserGlobalSettings: Codable, Equatable, Sendable { case agentProfiles case didSeedAgentProfiles case disabledWorkflowIDs + case workflowBindings } init( customCommands: [UserCustomCommand], agentProfiles: [AgentProfile] = [], didSeedAgentProfiles: Bool = false, - disabledWorkflowIDs: [String] = [] + disabledWorkflowIDs: [String] = [], + workflowBindings: [WorkflowRememberedBinding] = [] ) { self.customCommands = UserCustomCommand.normalizedCommands(customCommands) self.agentProfiles = AgentProfile.normalizedProfiles(agentProfiles) self.didSeedAgentProfiles = didSeedAgentProfiles self.disabledWorkflowIDs = Self.normalizedWorkflowIDs(disabledWorkflowIDs) + self.workflowBindings = WorkflowRememberedBinding.normalized(workflowBindings) } init(from decoder: Decoder) throws { @@ -39,6 +44,8 @@ nonisolated struct UserGlobalSettings: Codable, Equatable, Sendable { didSeedAgentProfiles = try container.decodeIfPresent(Bool.self, forKey: .didSeedAgentProfiles) ?? false let disabled = try container.decodeIfPresent([String].self, forKey: .disabledWorkflowIDs) ?? [] disabledWorkflowIDs = Self.normalizedWorkflowIDs(disabled) + let bindings = try container.decodeIfPresent([WorkflowRememberedBinding].self, forKey: .workflowBindings) ?? [] + workflowBindings = WorkflowRememberedBinding.normalized(bindings) } func normalized() -> UserGlobalSettings { @@ -46,12 +53,46 @@ nonisolated struct UserGlobalSettings: Codable, Equatable, Sendable { customCommands: customCommands, agentProfiles: agentProfiles, didSeedAgentProfiles: didSeedAgentProfiles, - disabledWorkflowIDs: disabledWorkflowIDs + disabledWorkflowIDs: disabledWorkflowIDs, + workflowBindings: workflowBindings ) } + func rememberedWorkflowBinding(for key: WorkflowBindingMemoryKey) -> UUID? { + workflowBindings.first { $0.key == key }?.profileID + } + + mutating func remember(workflowBinding key: WorkflowBindingMemoryKey, profileID: UUID) { + workflowBindings = WorkflowRememberedBinding.normalized( + workflowBindings.filter { $0.key != key } + [WorkflowRememberedBinding(key: key, profileID: profileID)]) + } + /// Stable order, no duplicates: the set semantics of a persisted list. static func normalizedWorkflowIDs(_ ids: [String]) -> [String] { Array(Set(ids)).sorted() } } + +/// One remembered `launch` binding: the profile that satisfied a role's requirements last time. +nonisolated struct WorkflowRememberedBinding: Codable, Equatable, Sendable { + let key: WorkflowBindingMemoryKey + let profileID: UUID + + enum CodingKeys: String, CodingKey { + case key + case profileID = "profile_id" + } + + /// One entry per key, in a stable order. + static func normalized(_ bindings: [WorkflowRememberedBinding]) -> [WorkflowRememberedBinding] { + var seen: Set = [] + var unique: [WorkflowRememberedBinding] = [] + for binding in bindings.reversed() where seen.insert(binding.key).inserted { + unique.append(binding) + } + return unique.sorted { + ($0.key.scope, $0.key.workflowID, $0.key.role, $0.key.digest) + < ($1.key.scope, $1.key.workflowID, $1.key.role, $1.key.digest) + } + } +} diff --git a/supacode/Features/Workflow/Reducer/WorkflowRunsFeature.swift b/supacode/Features/Workflow/Reducer/WorkflowRunsFeature.swift new file mode 100644 index 000000000..5878ad35e --- /dev/null +++ b/supacode/Features/Workflow/Reducer/WorkflowRunsFeature.swift @@ -0,0 +1,764 @@ +// supacode/Features/Workflow/Reducer/WorkflowRunsFeature.swift +// The reducer that owns every live workflow run (docs-ai 063 B3, decision H2/W1). The pure +// `WorkflowRunMachine` is reconstructed per transition; this reducer performs its effects against +// the terminal, dispatch, launch, store, native-action, and watchdog boundaries, answers the CLI +// `done` rendezvous when an activation leaves `persisting`, and cleans up what arrives late. + +import ComposableArchitecture +import Foundation + +/// The in-memory part of a run that `run.json` deliberately excludes: the worktree object, the +/// frozen profile launch plans (their surface environment carries override values), the binding +/// memory keys, and the bundled skill locations. +nonisolated struct WorkflowRunSession: Equatable, Sendable { + var run: WorkflowRun + let worktree: Worktree + let launchPlans: [String: AgentProfileLaunchPlan] + /// Per `launch` role, the binding-memory key its profile is remembered under once launched. + let bindingMemoryKeys: [String: WorkflowBindingMemoryKey] + let skills: [String: BundledSkill] + let limits: WorkflowDeliveryLimits + + init( + run: WorkflowRun, + worktree: Worktree, + launchPlans: [String: AgentProfileLaunchPlan], + bindingMemoryKeys: [String: WorkflowBindingMemoryKey] = [:], + skills: [String: BundledSkill] = [:], + limits: WorkflowDeliveryLimits = WorkflowDeliveryLimits() + ) { + self.run = run + self.worktree = worktree + self.launchPlans = launchPlans + self.bindingMemoryKeys = bindingMemoryKeys + self.skills = skills + self.limits = limits + } + + var store: WorkflowRunStore { WorkflowRunStore(rootURL: run.context.worktree.rootURL) } + + /// Every pane the run currently occupies (dsl-spec §10: one run per pane). + var boundSurfaceIDs: Set { + Set(run.bindings.values.compactMap { $0.pane?.surfaceID }) + } + + func machine(now: @escaping @Sendable () -> Date, makeToken: @escaping @Sendable () -> String) + -> WorkflowRunMachine + { + WorkflowRunMachine(run: run, limits: limits, now: now, makeToken: makeToken) + } +} + +/// A CLI `done` accepted by the machine and waiting for its output to reach the run directory. +nonisolated struct WorkflowPendingDelivery: Equatable, Sendable { + let runID: UUID + let ordinal: Int + let receipt: WorkflowDeliveryReceipt +} + +/// `prowl workflow done` after the handler attributed it (decision W3). +nonisolated struct WorkflowDeliveryRequest: Equatable, Sendable { + let requestID: UUID + let runID: UUID + /// The activation the caller pane's pending dispatch resolved to; nil for a manual delivery. + let ordinal: Int? + let selector: WorkflowDeliverySelector + let body: String + let verdict: String? + /// `pane` or `manual` (`manual --force` when the caller pane disagreed), for the run log. + let source: String +} + +@Reducer +struct WorkflowRunsFeature { + @ObservableState + struct State: Equatable { + /// Every run started in this app instance, terminal ones included (`status` reads them). + var sessions: [UUID: WorkflowRunSession] = [:] + var pendingDeliveries: [UUID: WorkflowPendingDelivery] = [:] + /// Self-initiated `run` requests waiting for their first activation to open (request → run). + var pendingStarts: [UUID: UUID] = [:] + /// Worktree roots whose leftover runs were marked `interrupted` at load (dsl-spec §10 Restart). + var scannedWorktreeRoots: Set = [] + /// The run that bound each pane most recently, whatever that run's status — recorded when a + /// run is admitted (`current` / `pick` bind then) and when a launch is taken up, never from + /// a clock. A pane a later run took over is not an earlier run's to close, even after the + /// later run ended and kept it. + var paneOwners: [UUID: UUID] = [:] + + var activeSessions: [WorkflowRunSession] { + sessions.values.filter { !$0.run.status.isTerminal } + } + + /// The active run a pane belongs to, if any. + func activeSession(boundTo surfaceID: UUID) -> WorkflowRunSession? { + activeSessions.first { $0.boundSurfaceIDs.contains(surfaceID) } + } + } + + enum Action: Equatable { + /// Admission succeeded (preflight, layout, initial record): own the run and perform its effects. + /// A self-initiated run passes the CLI request to answer once its first activation is open. + case started(WorkflowRunSession, effects: [WorkflowRunEffect], requestID: UUID? = nil) + case event(runID: UUID, WorkflowRunEvent) + case deliver(WorkflowDeliveryRequest) + case userAction(runID: UUID, WorkflowUserAction) + case markInterruptedRuns(worktreeRoots: [String]) + } + + @Dependency(WorkflowRuntimeClient.self) var runtime + @Dependency(WorkflowActivationClient.self) var activation + @Dependency(WorkflowWatchdogClient.self) var watchdog + @Dependency(WorkflowEffectQueueClient.self) var queue + @Dependency(WorkflowCLIResponderClient.self) var responder + @Dependency(WorkflowActionExecutorKey.self) var actionExecutor + @Dependency(\.date.now) var now + @Dependency(\.uuid) var uuid + + nonisolated private static let logger = SupaLogger("WorkflowRuns") + + var body: some Reducer { + Reduce { state, action in + switch action { + case .started(let session, let effects, let requestID): + let runID = session.run.id + state.sessions[runID] = session + for surfaceID in session.boundSurfaceIDs { + state.paneOwners[surfaceID] = runID + } + if let requestID { + state.pendingStarts[requestID] = runID + } + // The queue exists before the first batch is enqueued below; the executor drains it. + let batches = queue.start(runID) + return .merge( + executor(runID: runID, batches: batches), + perform(effects, runID: runID, session: session), + resolvePendingStarts(&state, runID: runID, session: session) + ) + + case .event(let runID, let event): + guard var session = state.sessions[runID], !session.run.status.isTerminal else { + return lateEventCleanup(event, runID: runID, session: state.sessions[runID]) + } + let timestamp = now + let generator = uuid + var machine = session.machine(now: { timestamp }, makeToken: { generator().uuidString }) + let effects = machine.apply(event) + let previous = session.run + session.run = machine.run + state.sessions[runID] = session + if case .launched = event { + rememberLaunchedBindings(previous: previous, current: session) + let previouslyBound = Set(previous.bindings.values.compactMap { $0.pane?.surfaceID }) + for surfaceID in session.boundSurfaceIDs.subtracting(previouslyBound) { + state.paneOwners[surfaceID] = runID + } + } + fenceIfStale(runID: runID, previous: previous, current: session.run) + return .merge( + resolvePendingDeliveries(&state, runID: runID, session: session), + resolvePendingStarts(&state, runID: runID, session: session), + perform(effects, runID: runID, session: session), + staleEventCleanup(event, session: session) + ) + + case .deliver(let request): + guard var session = state.sessions[request.runID], !session.run.status.isTerminal else { + return respond( + request.requestID, + .failed(code: CLIErrorCode.runNotFound, message: "The workflow run is not active.")) + } + let timestamp = now + let generator = uuid + var machine = session.machine(now: { timestamp }, makeToken: { generator().uuidString }) + let (result, effects) = machine.deliver( + ordinal: request.ordinal, selector: request.selector, body: request.body, + verdict: request.verdict) + switch result { + case .failure(let error): + return respond(request.requestID, .failed(code: error.code, message: error.message)) + case .success(let receipt): + session.run = machine.run + state.sessions[request.runID] = session + state.pendingDeliveries[request.requestID] = WorkflowPendingDelivery( + runID: request.runID, ordinal: receipt.ordinal, receipt: receipt) + var ordered = effects + if request.source != "pane" { + ordered.insert( + .log("Step '\(receipt.stepID)': delivery received (source=\(request.source))."), at: 0 + ) + } + return perform(ordered, runID: request.runID, session: session) + } + + case .userAction(let runID, let userAction): + guard var session = state.sessions[runID], !session.run.status.isTerminal else { + return .none + } + let timestamp = now + let generator = uuid + var machine = session.machine(now: { timestamp }, makeToken: { generator().uuidString }) + let effects = machine.apply(.user(userAction)) + let previous = session.run + session.run = machine.run + state.sessions[runID] = session + fenceIfStale(runID: runID, previous: previous, current: session.run) + return .merge( + resolvePendingDeliveries(&state, runID: runID, session: session), + resolvePendingStarts(&state, runID: runID, session: session), + perform(effects, runID: runID, session: session) + ) + + case .markInterruptedRuns(let roots): + let pending = roots.filter { !state.scannedWorktreeRoots.contains($0) } + guard !pending.isEmpty else { return .none } + state.scannedWorktreeRoots.formUnion(pending) + // Read only for a record that is marked: a scan that finds nothing needs no clock. + let clock = _now + return .run { _ in + for root in pending { + let store = WorkflowRunStore(rootURL: URL(filePath: root, directoryHint: .isDirectory)) + do { + let result = try store.markInterruptedRuns(now: { clock.wrappedValue }) + if !result.interrupted.isEmpty || !result.unreadable.isEmpty { + Self.logger.info( + "[Workflow] \(root): \(result.interrupted.count) run(s) marked interrupted, " + + "\(result.unreadable.count) unreadable.") + } + } catch { + Self.logger.warning( + "[Workflow] Could not scan \(root) for interrupted runs: \(error)") + } + } + } + } + } + } + + // MARK: - Rendezvous + + private func respond(_ requestID: UUID, _ resolution: WorkflowRequestResolution) -> Effect { + .run { _ in await responder.respond(requestID, resolution) } + } + + /// Answers a self-initiated `run` once its first activation is open — or once opening it failed + /// and the run sits in attention or ended — so the caller never holds a completion command + /// before the dispatch record `done` is attributed by exists. + private func resolvePendingStarts( + _ state: inout State, runID: UUID, session: WorkflowRunSession + ) -> Effect { + var effects: [Effect] = [] + for (requestID, pendingRunID) in state.pendingStarts where pendingRunID == runID { + if case .injecting = session.run.phase, session.run.status == .running { continue } + state.pendingStarts.removeValue(forKey: requestID) + effects.append(respond(requestID, .started(run: session.run))) + } + return .merge(effects) + } + + /// Queued work belongs to the invocation that was in flight when it was enqueued. Once a + /// transition revokes that invocation (retry, relaunch, skip, cancel, an ended run) the queue is + /// fenced so the executor drops what is left of it instead of typing into a pane a cancel + /// already left or opening a dispatch record nobody will complete. + private func fenceIfStale(runID: UUID, previous: WorkflowRun, current: WorkflowRun) { + guard !previous.status.isTerminal else { return } + let revokedInFlight: Bool = + switch previous.phase { + case .waitingForRole(_, let ordinal), .injecting(let ordinal), .launching(let ordinal): + current.phase != previous.phase && current.currentInvocation?.ordinal != ordinal + case .runningAction(let stepID): + current.phase != previous.phase && current.actionOutputs[stepID] == nil + case .waitingForDelivery, .idle: + false + } + let revokedActivation = previous.invocations.contains { invocation in + guard let before = invocation.activation, + let after = current.invocations.first(where: { $0.ordinal == invocation.ordinal })?.activation + else { return false } + let open: Set = [.waiting, .persisting, .provisional] + return open.contains(before.state) && (after.state == .revoked || after.state == .skipped) + } + if current.status.isTerminal || revokedInFlight || revokedActivation { + queue.fence(runID) + } + } + + /// Answers every `done` whose activation left `persisting` (decision W1): delivered and + /// provisional succeed; a revoked, skipped, or unpersistable activation and a run that ended fail. + private func resolvePendingDeliveries( + _ state: inout State, runID: UUID, session: WorkflowRunSession + ) -> Effect { + var effects: [Effect] = [] + for (requestID, pending) in state.pendingDeliveries where pending.runID == runID { + let activation = session.run.invocations.first { $0.ordinal == pending.ordinal }?.activation + let resolution: WorkflowRequestResolution? + switch activation?.state { + case .delivered: + resolution = .delivered(run: session.run, receipt: pending.receipt) + case .provisional: + resolution = .provisional(run: session.run, receipt: pending.receipt) + case .persisting: + if case .persistFailed(let reason) = session.run.status.attention?.reason, + session.run.status.attention?.ordinal == pending.ordinal + { + resolution = .failed( + code: CLIErrorCode.workflowFailed, + message: + "The output was accepted but could not be saved to the run directory: \(reason)") + } else if session.run.status.isTerminal { + resolution = .failed( + code: CLIErrorCode.stepNotExpecting, + message: + "The run ended (\(WorkflowRunMachine.describe(session.run.status))) before the output was saved." + ) + } else { + resolution = nil + } + case .waiting, .skipped, .revoked, .none: + resolution = .failed( + code: CLIErrorCode.stepNotExpecting, + message: "The step stopped waiting for this delivery before the output was saved.") + } + guard let resolution else { continue } + state.pendingDeliveries.removeValue(forKey: requestID) + effects.append(respond(requestID, resolution)) + } + return .merge(effects) + } + + // MARK: - Late and stale events + + /// An event that arrives after the run ended (or for an unknown run) may own a pane or a + /// dispatch record nobody will use: a `.launched` abandons its record and closes the pane, an + /// `.injectionSucceeded` abandons the record it opened (B2: the machine ignores events on + /// terminal runs, so the wiring must clean up). + private func lateEventCleanup(_ event: WorkflowRunEvent, runID: UUID, session: WorkflowRunSession?) + -> Effect + { + let runName = runID.uuidString + switch event { + case .launched(let ordinal, let pane, let dispatchID): + return closeUnboundLaunch( + pane: pane, dispatchID: dispatchID, + reason: "Workflow run \(runName) ended before role launch \(ordinal) completed.", + worktree: session?.worktree, runID: runID) + case .injectionSucceeded(let ordinal, let dispatchID?): + return abandonStaleActivation( + dispatchID, reason: "Workflow run \(runName) ended before invocation \(ordinal) was typed.") + default: + return .none + } + } + + /// An event the running machine did not take up (its step was retried, relaunched, skipped, or + /// cancelled while the effect was in flight) is cleaned up the same way. + private func staleEventCleanup(_ event: WorkflowRunEvent, session: WorkflowRunSession) -> Effect { + let runName = session.run.id.uuidString + switch event { + case .launched(let ordinal, let pane, let dispatchID) where !session.boundSurfaceIDs.contains(pane.surfaceID): + return closeUnboundLaunch( + pane: pane, dispatchID: dispatchID, + reason: "Workflow run \(runName) moved on before role launch \(ordinal) completed.", + worktree: session.worktree, runID: session.run.id) + case .injectionSucceeded(let ordinal, let dispatchID?) + where session.run.activation(forDispatchID: dispatchID) == nil: + return abandonStaleActivation( + dispatchID, reason: "Workflow run \(runName) moved on before invocation \(ordinal) was typed.") + default: + return .none + } + } + + private func abandonStaleActivation(_ dispatchID: String, reason: String) -> Effect { + .run { _ in + await activation.abandon(dispatchID, reason) + Self.logger.info("[Workflow] Abandoned stale activation \(dispatchID): \(reason)") + } + } + + private func closeUnboundLaunch( + pane: WorkflowPaneIdentity, dispatchID: String?, reason: String, worktree: Worktree?, runID: UUID + ) -> Effect { + .run { _ in + if let dispatchID { + await activation.abandon(dispatchID, reason) + } + if let worktree { + _ = await runtime.close(worktree, pane.surfaceID, runID) + } + Self.logger.info("[Workflow] Closed unbound launch \(pane.handle): \(reason)") + } + } + + // MARK: - Binding memory + + /// A successful launch remembers its profile under B2's requirements digest (dsl-spec §3). + private func rememberLaunchedBindings(previous: WorkflowRun, current: WorkflowRunSession) { + for (role, binding) in current.run.bindings { + guard case .launch(let profile, let pane) = binding, pane != nil, + previous.bindings[role]?.pane == nil, + let key = current.bindingMemoryKeys[role] + else { continue } + @Shared(.userGlobalSettings) var settings + $settings.withLock { $0.remember(workflowBinding: key, profileID: profile.id) } + } + } + + // MARK: - Effects + + nonisolated private enum CancelID: Hashable, Sendable { + case executor(UUID) + case roleWait(UUID, Int) + case watchdog(UUID, Int) + case observers(UUID) + } + + /// The run's ordered effect executor (one per run). It ends when `.finished` closes the queue. + /// Effects of a fenced batch are skipped one by one, so a fence raised mid-batch still stops + /// the rest of it. + private func executor(runID: UUID, batches: AsyncStream) -> Effect { + .run { send in + for await batch in batches { + for effect in batch.effects { + // A fenced batch still performs its bookkeeping (records, logs, dispatch completions + // and abandonments, notify) — those belong to transitions the machine already made — + // but skips what would act on a pane or the worktree for an invocation the run has + // left (`WorkflowRunEffect.isRevocable`), effect by effect. + if effect.isRevocable, await queue.isStale(runID, batch.sequence) { + Self.logger.info("[Workflow] Skipped stale effect of run \(runID): \(effect)") + if case .runAction(let stepID, let actionID, _) = effect { + await appendLog( + "Step '\(stepID)': native action '\(actionID)' not started; the run had moved on.", + store: batch.session.store, runID: runID) + } + continue + } + let outcome = await perform( + effect, runID: runID, session: batch.session, send: send, sequence: batch.sequence) + if outcome == .stop { break } + } + } + } + .cancellable(id: CancelID.executor(runID), cancelInFlight: true) + } + + /// Splits a batch: ordered effects go to the run's queue; observers become cancellable effects. + private func perform(_ effects: [WorkflowRunEffect], runID: UUID, session: WorkflowRunSession) + -> Effect + { + var ordered: [WorkflowRunEffect] = [] + var observers: [Effect] = [] + let armedOrdinals = Set( + effects.compactMap { effect -> Int? in + if case .armWatchdog(let request) = effect { return request.ordinal } + return nil + }) + for effect in effects { + switch effect { + case .awaitRoleIdle(_, let surfaceID, let ordinal): + observers.append(roleWait(runID: runID, surfaceID: surfaceID, ordinal: ordinal)) + case .cancelRoleWait(let ordinal): + observers.append(.cancel(id: CancelID.roleWait(runID, ordinal))) + case .armWatchdog(let request): + observers.append(watchdogObserver(runID: runID, request: request)) + case .disarmWatchdog(let ordinal): + // A re-arm in the same batch replaces the driver through `cancelInFlight`; a lone + // disarm cancels the consuming effect, which tears the driver down. + if !armedOrdinals.contains(ordinal) { + observers.append(.cancel(id: CancelID.watchdog(runID, ordinal))) + } + case .finished: + ordered.append(effect) + observers.append(.cancel(id: CancelID.observers(runID))) + default: + ordered.append(effect) + } + } + if !ordered.isEmpty { + // Enqueued synchronously while reducing so batches keep the machine's order. + queue.enqueue(runID, WorkflowEffectBatch(session: session, effects: ordered)) + } + return .merge(observers) + } + + /// The idle wait of a `message` step (dsl-spec §10): ends as `.roleIdle`, or as the failed + /// injection the machine maps to attention. + private func roleWait(runID: UUID, surfaceID: UUID, ordinal: Int) -> Effect { + .run { send in + switch await runtime.waitForRole(surfaceID) { + case .idle: + await send(.event(runID: runID, .roleIdle(ordinal: ordinal))) + case .blocked: + await send(.event(runID: runID, .roleUnavailable(ordinal: ordinal, .roleBlocked))) + case .gone: + await send(.event(runID: runID, .roleUnavailable(ordinal: ordinal, .surfaceMissing))) + case .noAgent: + await send( + .event( + runID: runID, + .roleUnavailable(ordinal: ordinal, .activationUnavailable("the pane hosts no detected agent")))) + case .dispatchPending(let dispatchID): + await send( + .event( + runID: runID, + .roleUnavailable( + ordinal: ordinal, + .activationUnavailable( + "the pane already holds pending dispatch \(dispatchID); complete or abandon it first")))) + case .cancelled: + break + } + } + .cancellable(id: CancelID.roleWait(runID, ordinal), cancelInFlight: true) + .cancellable(id: CancelID.observers(runID)) + } + + /// One watchdog driver per waiting activation; cancelling the effect tears the driver down. + private func watchdogObserver(runID: UUID, request: WorkflowWatchdogRequest) -> Effect { + .run { send in + let handle = await watchdog.arm(runID, request) + for await verdict in handle.verdicts { + await send(.event(runID: runID, .watchdog(ordinal: request.ordinal, verdict))) + } + await handle.cancel() + } + .cancellable(id: CancelID.watchdog(runID, request.ordinal), cancelInFlight: true) + .cancellable(id: CancelID.observers(runID)) + } + + nonisolated private enum StepOutcome: Equatable { + case `continue` + /// The rest of the batch depends on what just failed (an instruction the line points at). + case stop + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + private func perform( + _ effect: WorkflowRunEffect, + runID: UUID, + session: WorkflowRunSession, + send: Send, + sequence: Int + ) async -> StepOutcome { + let store = session.store + let timestamp = now + let queue = queue + // Read on the main actor right before a pane is touched: no cancel can slip in between. + let isLive: @MainActor () -> Bool = { !queue.isStale(runID, sequence) } + switch effect { + case .awaitRoleIdle, .cancelRoleWait, .armWatchdog, .disarmWatchdog: + // Observers never enter the ordered queue. + return .continue + + case .openActivation(_, let surfaceID, let ordinal): + // Issuance and the machine's take-up of the record share this main-actor turn (`send` + // reduces synchronously); the guard keeps a cancel that landed after the batch check + // from opening a record the run would never own. + guard isLive() else { return .stop } + switch activation.openMessage(surfaceID) { + case .success(let dispatchID): + await send( + .event(runID: runID, .injectionSucceeded(ordinal: ordinal, dispatchID: dispatchID))) + case .failure(let failure): + await send( + .event(runID: runID, .injectionFailed(ordinal: ordinal, failure.injectionFailure))) + } + + case .materializeInstruction(let ordinal, let stepID, let text): + do { + try store.ensureLayout(runID: runID) + _ = try store.writeInstruction(runID: runID, stepID: stepID, ordinal: ordinal, text: text) + } catch { + await send( + .event( + runID: runID, + .injectionFailed( + ordinal: ordinal, + .activationUnavailable("the instruction file could not be written: \(error)")))) + return .stop + } + + case .materializeSkill(let id): + do { + guard let skill = session.skills[id] else { throw WorkflowRunStoreError.skillMissing(id) } + try store.ensureLayout(runID: runID) + _ = try store.materializeSkill(runID: runID, skill: skill) + } catch { + guard let ordinal = session.run.currentInvocation?.ordinal else { return .stop } + await send( + .event( + runID: runID, + .launchFailed( + ordinal: ordinal, reason: "skill '\(id)' could not be materialized: \(error)"))) + return .stop + } + + case .inject(_, let surfaceID, let ordinal, let line, let opensActivation): + // Issuance, the typed line, and — when the terminal refuses it — the issuance's return + // share one main-actor turn: nothing can complete or bind the record in between. + guard isLive() else { return .stop } + var dispatchID: String? + if opensActivation { + switch activation.openMessage(surfaceID) { + case .success(let value): + dispatchID = value + case .failure(let failure): + await send( + .event(runID: runID, .injectionFailed(ordinal: ordinal, failure.injectionFailure))) + return .stop + } + } + switch runtime.deliverLine(session.worktree, surfaceID, line, isLive) { + case .delivered: + await send( + .event(runID: runID, .injectionSucceeded(ordinal: ordinal, dispatchID: dispatchID))) + case .stale: + // A cancel landed while the record was being issued: nothing was typed; give the + // issuance back and let the fence swallow the rest of the batch. + if let dispatchID { activation.cancel(dispatchID) } + return .stop + case .insertFailed: + if let dispatchID { activation.cancel(dispatchID) } + await send(.event(runID: runID, .injectionFailed(ordinal: ordinal, .insertFailed))) + return .stop + case .submitFailed: + if let dispatchID { activation.cancel(dispatchID) } + await send(.event(runID: runID, .injectionFailed(ordinal: ordinal, .submitFailed))) + return .stop + } + + case .typeLine(let role, let surfaceID, let line): + let delivery = runtime.deliverLine(session.worktree, surfaceID, line, isLive) + if delivery != .delivered && delivery != .stale { + Self.logger.warning("[Workflow] Could not type into role '\(role)' of run \(runID).") + } + + case .launch(let request): + guard let plan = session.launchPlans[request.role] else { + await send( + .event( + runID: runID, + .launchFailed( + ordinal: request.ordinal, reason: "role '\(request.role)' has no frozen launch plan")) + ) + return .stop + } + switch await runtime.launch(session.worktree, plan, request) { + case .success(let result): + await send( + .event( + runID: runID, + .launched(ordinal: request.ordinal, pane: result.pane, dispatchID: result.dispatchID))) + case .failure(.failed(let reason)): + await send(.event(runID: runID, .launchFailed(ordinal: request.ordinal, reason: reason))) + return .stop + } + + case .runAction(let stepID, let actionID, let inputs): + let context = WorkflowActionContext( + runID: runID, + rootURL: session.run.context.worktree.rootURL, + roleAgents: session.run.bindings.mapValues { + $0.templateRole.agent.isEmpty ? nil : $0.templateRole.agent + }, + outgoingAgent: session.run.bindings.values.first { $0.source == .current }?.pane?.agent, + now: timestamp) + // The last main-actor operation before the action starts. A cancel that lands during the + // hop to the action's executor can no longer stop it: the action runs to completion (its + // writes are the handoff store's own atomic operations) and the result is discarded. The + // run log records which of the two happened rather than guessing at cancel time. + guard isLive() else { + appendLog( + "Step '\(stepID)': native action '\(actionID)' not started; the run had moved on.", store: store, + runID: runID) + return .stop + } + do { + let outputs = try await actionExecutor.execute(actionID: actionID, inputs: inputs, context: context) + guard isLive() else { + appendLog( + "Step '\(stepID)': native action '\(actionID)' finished after the run moved on; result discarded.", + store: store, runID: runID) + return .stop + } + await send(.event(runID: runID, .actionCompleted(stepID: stepID, outputs: outputs))) + } catch { + guard isLive() else { + appendLog( + "Step '\(stepID)': native action '\(actionID)' failed after the run moved on (\(error)); ignored.", + store: store, runID: runID) + return .stop + } + await send(.event(runID: runID, .actionFailed(stepID: stepID, reason: "\(error)"))) + } + + case .notify(let text): + runtime.notify(session.worktree, text) + + case .close(let role, let surfaceID): + // Revocable: a cancel that beat the close keeps the pane (cancel never closes panes); the + // boundary leaves a pane another run has bound since alone. + guard isLive() else { return .stop } + if !runtime.close(session.worktree, surfaceID, runID) { + Self.logger.warning("[Workflow] Run \(runID) could not close the pane of role '\(role)'.") + } + + case .abandonActivation(let dispatchID, let reason): + activation.abandon(dispatchID, reason) + + case .completeActivation(let dispatchID, let summary): + activation.complete(dispatchID, summary) + + case .persistOutput(let name, let ordinal, let body): + do { + _ = try store.writeOutput(runID: runID, name: name, ordinal: ordinal, body: body) + await send(.event(runID: runID, .outputPersisted(ordinal: ordinal))) + } catch { + await send(.event(runID: runID, .outputPersistFailed(ordinal: ordinal, reason: "\(error)"))) + } + + case .persist: + do { + try store.ensureLayout(runID: runID) + try store.writeRecord(WorkflowRunRecord(run: session.run)) + } catch { + Self.logger.warning("[Workflow] Could not persist run \(runID): \(error)") + } + + case .log(let line): + appendLog(line, store: store, runID: runID) + + case .finished: + queue.finish(runID) + } + return .continue + } + + private func appendLog(_ line: String, store: WorkflowRunStore, runID: UUID) { + do { + try store.ensureLayout(runID: runID) + try store.appendLog(runID: runID, line: line, now: now) + } catch { + Self.logger.warning("[Workflow] Could not append to the log of run \(runID): \(error)") + } + } +} + +extension WorkflowRunEffect { + /// Effects that act on a pane or the worktree for the invocation in flight, which a fence + /// (retry / relaunch / skip / cancel / an ended run) must keep from running late; `close` is + /// one of them because a cancel keeps every pane. Everything else — records, logs, + /// materialized files, dispatch completions and abandonments, notify, teardown — belongs to a + /// transition the machine already made and still runs. The fence a run's own end raises + /// precedes the batch that ends it, so a `close` in that batch is still performed. + nonisolated var isRevocable: Bool { + switch self { + case .openActivation, .inject, .typeLine, .launch, .runAction, .close: true + case .awaitRoleIdle, .cancelRoleWait, .materializeInstruction, .materializeSkill, .notify, + .abandonActivation, .completeActivation, .armWatchdog, .disarmWatchdog, .persistOutput, .persist, .log, + .finished: + false + } + } +} diff --git a/supacodeTests/AgentDispatchCommandHandlerTests.swift b/supacodeTests/AgentDispatchCommandHandlerTests.swift index 4730bc2b7..c378f21f4 100644 --- a/supacodeTests/AgentDispatchCommandHandlerTests.swift +++ b/supacodeTests/AgentDispatchCommandHandlerTests.swift @@ -75,6 +75,32 @@ struct AgentDispatchCommandHandlerTests { #expect(completedSurfaces == [caller.surfaceID, caller.surfaceID]) } + /// A workflow activation is completed by `prowl workflow done`, never here (063 B3, W3). + @Test func completionIsInterceptedBeforeTheStoreForWorkflowActivations() async throws { + let caller = CallerPane(worktreeID: "w1", surfaceID: UUID()) + var completed = 0 + let handler = AgentDispatchCompleteCommandHandler( + resolveCaller: { _ in caller }, + complete: { _, _, _ in + completed += 1 + return .failure(.notFound) + }, + intercept: { surfaceID in + #expect(surfaceID == caller.surfaceID) + return CommandError(code: CLIErrorCode.workflowDeliveryRequired, message: "deliver with prowl workflow done -") + } + ) + let response = await handler.handle( + envelope: envelope(.agentsDispatchComplete(.init(dispatchID: nil, outcome: .succeeded, summary: "Done"))), + context: CLICommandContext(callerProcessID: 123) + ) + #expect(response.ok == false) + #expect(response.command == "agents.dispatch-complete") + #expect(response.error?.code == CLIErrorCode.workflowDeliveryRequired) + #expect(response.error?.message == "deliver with prowl workflow done -") + #expect(completed == 0) + } + @Test func completionMapsStoreFailuresToStableCodes() async { let caller = CallerPane(worktreeID: "w1", surfaceID: UUID()) for (error, code) in [ diff --git a/supacodeTests/AgentProfileHookCarrierTests.swift b/supacodeTests/AgentProfileHookCarrierTests.swift index 3d7a7fdfd..b198b7558 100644 --- a/supacodeTests/AgentProfileHookCarrierTests.swift +++ b/supacodeTests/AgentProfileHookCarrierTests.swift @@ -174,3 +174,81 @@ struct AgentProfileHookCarrierTests { ) } } + +/// A workflow `launch` role (docs-ai 063 B3, decision W6): the kickoff prompt replaces the +/// placeholder the frozen plan was compiled with, and `PROWL_WORKFLOW_*` reach only the child +/// through carriers the typed command names — the token is never spelled in the pane. +struct AgentProfileWorkflowCarrierTests { + @Test func workflowValuesUseChildOnlyCarriersAndReplaceThePromptWithoutTheDispatchProtocol() throws { + let base = AgentProfileLaunchPlan( + profileID: UUID(), + profileName: "Reviewer", + runtime: .codex, + invocation: AgentInvocation(executable: "codex", arguments: ["placeholder"]), + commandEnvironmentTokens: ["CODEX_HOME=/tmp/home"], + placement: .split, + splitDirection: .right, + surfaceEnvironment: [AgentProfileLaunchPlanner.promptCarrierName: "placeholder"], + dedicatedHome: nil + ) + let hooked = base.applyingManagedHook( + AgentHookPreparedInvocation( + invocation: AgentInvocation(executable: "codex", arguments: ["-c", "notify=[]", "placeholder"]), + argumentValues: [1: "notify=[]"] + ), + resources: AgentHookResources(bundledCLIPath: "/bundle/prowl", socketPath: "/tmp/prowl.sock"), + launchCWD: URL(filePath: "/tmp/project", directoryHint: .isDirectory), + token: "hook-token", + coveredEvents: [.turnEnded] + ) + let prompt = "Review the brief.\n\n---\nProwl workflow completion protocol v1:\nprowl workflow done -\n" + let attached = try hooked.attachingWorkflow( + prompt: prompt, + environment: [ + "PROWL_WORKFLOW_TOKEN": "secret-token", + "PROWL_WORKFLOW_RUN": "run-1", + "PROWL_WORKFLOW_ROLE": "reviewer", + ] + ) + + #expect(attached.hookRegistration == hooked.hookRegistration) + #expect(attached.invocation.arguments.last == prompt) + #expect(attached.surfaceEnvironment[AgentProfileLaunchPlanner.promptCarrierName] == prompt) + #expect(attached.surfaceEnvironment[AgentProfileLaunchPlanner.dispatchCarrierName] == nil) + #expect(!prompt.contains("dispatch completion protocol")) + let input = attached.terminalInput + #expect(input.contains("PROWL_WORKFLOW_ROLE=\"$PROWL_LAUNCH_WORKFLOW_0\"")) + #expect(input.contains("PROWL_WORKFLOW_RUN=\"$PROWL_LAUNCH_WORKFLOW_1\"")) + #expect(input.contains("PROWL_WORKFLOW_TOKEN=\"$PROWL_LAUNCH_WORKFLOW_2\"")) + #expect(input.contains("-u PROWL_LAUNCH_WORKFLOW_0")) + #expect(input.contains("-u PROWL_LAUNCH_WORKFLOW_2")) + #expect(input.contains("-u PROWL_LAUNCH_HOOK_TOKEN")) + #expect(input.contains("CODEX_HOME=/tmp/home")) + #expect(!input.contains("secret-token")) + #expect(!input.contains("hook-token")) + #expect(!input.contains("Review the brief")) + #expect(attached.surfaceEnvironment["PROWL_LAUNCH_WORKFLOW_2"] == "secret-token") + } + + @Test func attachingWorkflowRequiresAPromptedPlanAndRejectsNUL() { + let unprompted = AgentProfileLaunchPlan( + profileID: UUID(), profileName: "Plain", runtime: .claude, + invocation: AgentInvocation(executable: "claude", arguments: []), + commandEnvironmentTokens: [], placement: .tab, splitDirection: .right, surfaceEnvironment: [:], + dedicatedHome: nil) + #expect(throws: AgentProfileLaunchPlanError.dispatchRequiresPrompt) { + try unprompted.attachingWorkflow(prompt: "x", environment: [:]) + } + let prompted = AgentProfileLaunchPlan( + profileID: UUID(), profileName: "Prompted", runtime: .claude, + invocation: AgentInvocation(executable: "claude", arguments: ["placeholder"]), + commandEnvironmentTokens: [], placement: .tab, splitDirection: .right, + surfaceEnvironment: [AgentProfileLaunchPlanner.promptCarrierName: "placeholder"], dedicatedHome: nil) + #expect(throws: AgentProfileLaunchPlanError.promptContainsNUL) { + try prompted.attachingWorkflow(prompt: "a\0b", environment: [:]) + } + #expect(throws: AgentProfileLaunchPlanError.promptContainsNUL) { + try prompted.attachingWorkflow(prompt: "ok", environment: ["PROWL_WORKFLOW_TOKEN": "t\0"]) + } + } +} diff --git a/supacodeTests/WorkflowCLIRendezvousTests.swift b/supacodeTests/WorkflowCLIRendezvousTests.swift new file mode 100644 index 000000000..5ee9df468 --- /dev/null +++ b/supacodeTests/WorkflowCLIRendezvousTests.swift @@ -0,0 +1,99 @@ +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct WorkflowCLIRendezvousTests { + @Test func responseResumesTheRegisteredRequest() async { + let rendezvous = WorkflowCLIRendezvous() + let requestID = UUID() + #expect(rendezvous.register(requestID)) + let task = Task { @MainActor in + await rendezvous.wait(for: requestID) + } + + await Task.yield() + #expect(rendezvous.resolve(requestID, with: Self.successResponse)) + + let response = await task.value + #expect(response.ok) + #expect(rendezvous.pendingRequestIDs.isEmpty) + } + + /// The reducer answers inside `store.send`, before the handler reaches its `await`: the + /// answer is buffered on the registered slot instead of being lost. + @Test func aResponseThatArrivesBeforeTheWaitIsBufferedAndReturnedAtOnce() async { + let rendezvous = WorkflowCLIRendezvous() + let requestID = UUID() + rendezvous.register(requestID) + #expect(rendezvous.resolve(requestID, with: Self.successResponse)) + #expect(rendezvous.pendingRequestIDs == [requestID]) + + let response = await rendezvous.wait(for: requestID) + #expect(response.ok) + #expect(rendezvous.pendingRequestIDs.isEmpty) + #expect(!rendezvous.resolve(requestID, with: Self.successResponse)) + } + + @Test func cancellingPendingRequestResumesItWithCancellationResponse() async { + let rendezvous = WorkflowCLIRendezvous() + let requestID = UUID() + rendezvous.register(requestID) + let task = Task { @MainActor in + await rendezvous.wait(for: requestID) + } + + await Task.yield() + #expect(rendezvous.cancel(requestID)) + + let response = await task.value + #expect(!response.ok) + #expect(response.error?.code == CLIErrorCode.requestCancelled) + #expect(rendezvous.pendingRequestIDs.isEmpty) + // A late reducer answer finds nothing to resolve and changes nothing. + #expect(!rendezvous.resolve(requestID, with: Self.successResponse)) + } + + @Test func cancellingTheWaitingTaskReleasesTheSlotWithoutTouchingTheRun() async { + let rendezvous = WorkflowCLIRendezvous() + let requestID = UUID() + rendezvous.register(requestID) + let task = Task { @MainActor in + await rendezvous.wait(for: requestID) + } + await Task.yield() + task.cancel() + let response = await task.value + #expect(response.error?.code == CLIErrorCode.requestCancelled) + #expect(rendezvous.pendingRequestIDs.isEmpty) + } + + @Test func duplicateRequestIDDoesNotReplaceTheOriginalWaiter() async { + let rendezvous = WorkflowCLIRendezvous() + let requestID = UUID() + #expect(rendezvous.register(requestID)) + #expect(!rendezvous.register(requestID)) + let first = Task { @MainActor in await rendezvous.wait(for: requestID) } + + await Task.yield() + let duplicate = await rendezvous.wait(for: requestID) + #expect(!duplicate.ok) + #expect(duplicate.error?.code == CLIErrorCode.requestConflict) + + #expect(rendezvous.resolve(requestID, with: Self.successResponse)) + #expect((await first.value).ok) + } + + @Test func waitingWithoutRegistrationFailsInsteadOfHanging() async { + let rendezvous = WorkflowCLIRendezvous() + let response = await rendezvous.wait(for: UUID()) + #expect(response.error?.code == CLIErrorCode.requestConflict) + } + + private static let successResponse = CommandResponse( + ok: true, + command: WorkflowCommandPayload.commandName, + schemaVersion: WorkflowCommandPayload.schemaVersion + ) +} diff --git a/supacodeTests/WorkflowRoleWaitPolicyTests.swift b/supacodeTests/WorkflowRoleWaitPolicyTests.swift new file mode 100644 index 000000000..bee8b0bea --- /dev/null +++ b/supacodeTests/WorkflowRoleWaitPolicyTests.swift @@ -0,0 +1,118 @@ +// supacodeTests/WorkflowRoleWaitPolicyTests.swift +// The idle wait of a `message` step (063 B3): baseline-aware exact evidence, exact needs-input +// precedence, detector stabilization, blocked grace, appearance grace, pending records. + +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct WorkflowRoleWaitPolicyTests { + nonisolated private static let start = Date(timeIntervalSince1970: 1_000) + private let surfaceID = UUID() + + private func signal(_ kind: AgentSignal.Kind, at seconds: TimeInterval = 0) -> AgentSignal { + AgentSignal( + kind: kind, source: .hook(runtime: .claude, event: "Stop"), confidence: .exact, + timestamp: Self.start.addingTimeInterval(seconds), sessionID: nil, detail: nil, claimedOrigin: nil) + } + + private func snapshot( + _ status: AgentDisplayState?, signal: AgentSignal? = nil, revision: UInt64 = 1, live: Bool = true, + channelCoversTurnEnded: Bool = true + ) -> AgentConditionSnapshot { + let agent = status.map { status in + ActiveAgentEntry( + id: surfaceID, worktreeID: "w1", worktreeName: "App", workingDirectory: URL(fileURLWithPath: "/App"), + tabID: TerminalTabID(rawValue: UUID()), paneTitle: "Agent", surfaceID: surfaceID, paneIndex: 0, + iconLookupToken: "claude", agent: .claude, + rawState: status == .working ? .working : status == .blocked ? .blocked : .idle, + displayState: status, lastChangedAt: Self.start) + } + let channels: [AgentSignalChannelPayload] = + channelCoversTurnEnded + ? [ + AgentSignalChannelPayload( + source: "hook_claude", state: .verifiedLive, confidence: "exact", + events: [.turnEnded, .needsInput, .sessionStart], lastSeenAt: "2026-08-30T00:00:00Z") + ] : [] + return AgentConditionSnapshot( + agent: agent, signal: signal, revision: revision, isLive: live, + signals: AgentSignalsPayload(channels: channels, last: nil, lastBinding: nil)) + } + + @Test func aFreshExactTurnEndedEndsTheWaitEvenWhileTheScreenStillShowsWorking() { + var policy = WorkflowRoleWaitPolicy() + // Armed while the role works; the only signal so far is an old one. + #expect( + policy.observe(snapshot(.working, signal: signal(.sessionStart)), pendingDispatchID: nil, elapsedMilliseconds: 0) + == nil) + #expect( + policy.observe( + snapshot(.working, signal: signal(.sessionStart)), pendingDispatchID: nil, elapsedMilliseconds: 250) == nil) + // A turn-ended that postdates the baseline is fresh exact evidence: no detector needed. + #expect( + policy.observe( + snapshot(.working, signal: signal(.turnEnded, at: 5), revision: 2), pendingDispatchID: nil, + elapsedMilliseconds: 500) == .idle) + } + + @Test func aPreArmTurnEndedCountsOnceTheDetectorCorroboratesIt() { + var policy = WorkflowRoleWaitPolicy() + let stale = signal(.turnEnded) + #expect(policy.observe(snapshot(.working, signal: stale), pendingDispatchID: nil, elapsedMilliseconds: 0) == nil) + // The detector flips to idle: the pre-arm level is corroborated and counts at once (#733 D5). + #expect(policy.observe(snapshot(.idle, signal: stale), pendingDispatchID: nil, elapsedMilliseconds: 250) == .idle) + } + + @Test func aDetectorOnlyIdleViewMustStayStableForTwoSeconds() { + var policy = WorkflowRoleWaitPolicy() + let unhooked = { (status: AgentDisplayState) in self.snapshot(status, channelCoversTurnEnded: false) } + #expect(policy.observe(unhooked(.working), pendingDispatchID: nil, elapsedMilliseconds: 0) == nil) + #expect(policy.observe(unhooked(.idle), pendingDispatchID: nil, elapsedMilliseconds: 250) == nil) + #expect(policy.observe(unhooked(.idle), pendingDispatchID: nil, elapsedMilliseconds: 2_000) == nil) + // A flicker back to working restarts the stabilization. + #expect(policy.observe(unhooked(.working), pendingDispatchID: nil, elapsedMilliseconds: 2_100) == nil) + #expect(policy.observe(unhooked(.idle), pendingDispatchID: nil, elapsedMilliseconds: 2_250) == nil) + #expect(policy.observe(unhooked(.idle), pendingDispatchID: nil, elapsedMilliseconds: 4_250) == .idle) + } + + @Test func anExactNeedsInputOutranksADetectorIdleView() { + var policy = WorkflowRoleWaitPolicy() + // Fresh needs-input after the baseline, screen stale-idle: blocked, never idle. + #expect(policy.observe(snapshot(.working), pendingDispatchID: nil, elapsedMilliseconds: 0) == nil) + let asking = snapshot(.idle, signal: signal(.needsInput, at: 3), revision: 2) + #expect(policy.observe(asking, pendingDispatchID: nil, elapsedMilliseconds: 250) == .blocked) + } + + @Test func aPreArmNeedsInputCountsOnlyWithADetectorBlockedView() { + var policy = WorkflowRoleWaitPolicy() + let old = signal(.needsInput) + #expect(policy.observe(snapshot(.working, signal: old), pendingDispatchID: nil, elapsedMilliseconds: 0) == nil) + #expect( + policy.observe(snapshot(.blocked, signal: old), pendingDispatchID: nil, elapsedMilliseconds: 250) == .blocked) + } + + @Test func heuristicBlockedNeedsTheGraceAndWorkingResetsIt() { + var policy = WorkflowRoleWaitPolicy(blockedGraceMilliseconds: 1_000) + let unhooked = { (status: AgentDisplayState) in self.snapshot(status, channelCoversTurnEnded: false) } + #expect(policy.observe(unhooked(.blocked), pendingDispatchID: nil, elapsedMilliseconds: 0) == nil) + #expect(policy.observe(unhooked(.blocked), pendingDispatchID: nil, elapsedMilliseconds: 750) == nil) + #expect(policy.observe(unhooked(.working), pendingDispatchID: nil, elapsedMilliseconds: 1_000) == nil) + #expect(policy.observe(unhooked(.blocked), pendingDispatchID: nil, elapsedMilliseconds: 1_250) == nil) + #expect(policy.observe(unhooked(.blocked), pendingDispatchID: nil, elapsedMilliseconds: 2_250) == .blocked) + } + + @Test func goneAbsentAndForeignPendingRecordsEndTheWait() { + var policy = WorkflowRoleWaitPolicy(appearanceGraceMilliseconds: 1_000) + #expect(policy.observe(snapshot(nil), pendingDispatchID: nil, elapsedMilliseconds: 0) == nil) + #expect(policy.observe(snapshot(nil), pendingDispatchID: nil, elapsedMilliseconds: 1_000) == .noAgent) + var second = WorkflowRoleWaitPolicy() + #expect(second.observe(snapshot(.idle, live: false), pendingDispatchID: nil, elapsedMilliseconds: 0) == .gone) + var third = WorkflowRoleWaitPolicy() + #expect( + third.observe(snapshot(.idle), pendingDispatchID: "someone-elses", elapsedMilliseconds: 0) + == .dispatchPending("someone-elses")) + } +} diff --git a/supacodeTests/WorkflowRunAdmissionTests.swift b/supacodeTests/WorkflowRunAdmissionTests.swift new file mode 100644 index 000000000..5988e66a2 --- /dev/null +++ b/supacodeTests/WorkflowRunAdmissionTests.swift @@ -0,0 +1,474 @@ +// supacodeTests/WorkflowRunAdmissionTests.swift +// Preflight of `prowl workflow run` (docs-ai 063 B3): definition selection, source and binding +// legality, one run per pane, frozen plans, and the initial record. + +import Foundation +import GhosttyKit +import Testing + +@testable import supacode + +@MainActor +struct WorkflowRunAdmissionTests { + private static let review = """ + schema: prowl.workflow/v1 + id: review + name: Review + inputs: + rounds: { type: integer, default: 2, min: 1, max: 5 } + roles: + author: + source: current + reviewer: + source: launch + agents: [codex, claude] + partner: + source: pick + steps: + - id: brief + message: author + text: "Brief {{ inputs.rounds }}" + expect: { output: brief } + - id: launch + launch: reviewer + prompt: "Review {{ outputs.brief.path }}" + expect: { output: findings } + - id: ping + message: partner + text: "Findings: {{ outputs.findings.path }}" + """ + + private static let contextOnly = """ + schema: prowl.workflow/v1 + id: context + name: Context + roles: + author: + source: current + steps: + - id: ctx + action: git.context + with: { root: "{{ worktree.path }}" } + """ + + private static let worktreeOnly = """ + schema: prowl.workflow/v1 + id: launch-only + name: Launch Only + roles: + worker: + source: launch + steps: + - id: go + launch: worker + prompt: "Go" + """ + + private static let claudeID = UUID(uuidString: "00000000-0000-0000-0000-00000000000A")! + private static let codexID = UUID(uuidString: "00000000-0000-0000-0000-00000000000B")! + private static let ampID = UUID(uuidString: "00000000-0000-0000-0000-00000000000C")! + + @MainActor + final class Fixture { + let root: URL + let repoRoot: URL + let userWorkflows: URL + let authorPane = UUID() + let partnerPane = UUID() + let shellPane = UUID() + let tabID = UUID() + var agents: [UUID: WorkflowDetectedAgent] + var remembered: [WorkflowBindingMemoryKey: UUID] = [:] + var busy: Set = [] + var pendingDispatches: [UUID: String] = [:] + var plannedProfiles: [String] = [] + var disabled: Set = [] + var designated: UUID? + + init() throws { + root = + FileManager.default.temporaryDirectory + .appending( + path: "prowl-workflow-admission-\(UUID().uuidString)", directoryHint: .isDirectory + ) + .standardizedFileURL + repoRoot = root.appending(path: "repo", directoryHint: .isDirectory) + userWorkflows = root.appending(path: "home/.prowl/workflows", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: userWorkflows, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: WorkflowSources.repoDirectory(root: repoRoot), withIntermediateDirectories: true) + agents = [ + authorPane: WorkflowDetectedAgent(token: "claude", displayName: "Claude Code"), + partnerPane: WorkflowDetectedAgent(token: "codex", displayName: "Codex"), + ] + } + + func cleanUp() { + try? FileManager.default.removeItem(at: root) + } + + func write(_ yaml: String, to name: String, scope: WorkflowScope = .repo) throws { + let directory = scope == .repo ? WorkflowSources.repoDirectory(root: repoRoot) : userWorkflows + try Data(yaml.utf8).write(to: directory.appending(path: "\(name).yaml")) + } + + var worktree: Worktree { + Worktree( + id: "wt-1", name: "feature", detail: "", workingDirectory: repoRoot, + repositoryRootURL: repoRoot) + } + + func snapshot() -> WorkflowRuntimeSnapshot { + func pane(_ id: UUID, handle: Int) -> TargetResolutionSnapshot.Pane { + TargetResolutionSnapshot.Pane( + id: id, handle: handle, title: "pane \(handle)", + cwd: repoRoot.path(percentEncoded: false), + isFocusedInTab: handle == 1, + surfaceView: GhosttySurfaceView( + runtime: GhosttyRuntime(), workingDirectory: nil, context: GHOSTTY_SURFACE_CONTEXT_TAB, + skipsSurfaceCreationForTesting: true)) + } + let tab = TargetResolutionSnapshot.Tab( + id: tabID, handle: 1, title: "Tab", selected: true, + panes: [ + pane(authorPane, handle: 1), pane(partnerPane, handle: 2), pane(shellPane, handle: 3), + ], + focusedPaneID: authorPane) + let worktree = TargetResolutionSnapshot.Worktree( + id: "wt-1", name: "feature", path: repoRoot.path(percentEncoded: false), + rootPath: repoRoot.path(percentEncoded: false), kind: .git, tabs: [tab]) + return WorkflowRuntimeSnapshot( + resolution: TargetResolutionSnapshot(worktrees: [worktree], focusedWorktreeID: "wt-1"), + paneByShellPID: [:], + bundleWorkflowsURL: nil, + userWorkflowsURL: userWorkflows, + disabledWorkflowIDs: disabled, + bundledSkillIDs: [], + knownAgents: ["codex", "claude", "amp"], + installedAgents: nil, + enabledProfiles: []) + } + + var environment: WorkflowAdmissionEnvironment { + WorkflowAdmissionEnvironment( + profiles: [ + AgentProfile( + id: WorkflowRunAdmissionTests.claudeID, name: "Claude Code", runtime: .claude), + AgentProfile(id: WorkflowRunAdmissionTests.codexID, name: "Codex", runtime: .codex), + AgentProfile(id: WorkflowRunAdmissionTests.ampID, name: "Amp", runtime: .amp), + ], + recommendation: { [self] _ in (designated, nil) }, + rememberedBinding: { [self] key in remembered[key] }, + detectedAgent: { [self] id in agents[id] }, + pendingDispatchID: { [self] id in pendingDispatches[id] }, + busySurfaceIDs: busy, + worktree: { [self] id in id == "wt-1" ? worktree : nil }, + branchName: { _ in "feat/x" }, + makeLaunchPlan: { [self] profile in + plannedProfiles.append(profile.name) + return AgentProfileLaunchPlan( + profileID: profile.id, profileName: profile.name, runtime: profile.runtime, + invocation: AgentInvocation( + executable: profile.runtime.rawValue, arguments: ["placeholder"]), + commandEnvironmentTokens: [], placement: .split, splitDirection: .right, + surfaceEnvironment: [AgentProfileLaunchPlanner.promptCarrierName: "placeholder"], + dedicatedHome: nil) + }, + now: Date(timeIntervalSince1970: 1_760_000_000), + makeRunID: { UUID(uuidString: "0BADCAFE-0000-4000-8000-000000000042")! }, + makeToken: { "TOKEN" }) + } + + func source(pane: UUID?, isCaller: Bool = true) -> WorkflowRunSource { + WorkflowRunSource( + worktree: snapshot().resolution.worktrees[0], paneID: pane, paneIsCaller: isCaller) + } + } + + private func admit( + _ fixture: Fixture, workflow: String, pane: UUID?, isCaller: Bool = true, roles: [String] = [], + inputs: [String] = [], skips: [String] = [] + ) -> Result { + WorkflowRunAdmission.admit( + WorkflowInput( + action: .run, workflow: workflow, roleBindings: roles, inputValues: inputs, + skippedSteps: skips), + source: fixture.source(pane: pane, isCaller: isCaller), + snapshot: fixture.snapshot(), + environment: fixture.environment) + } + + private func code(_ result: Result) -> String? { + if case .failure(let failure) = result { return failure.code } + return nil + } + + @Test func aCompleteRequestFreezesEveryBindingAndWritesTheInitialRecord() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + try fixture.write(Self.review, to: "review") + let admitted = try admit( + fixture, workflow: "review", pane: fixture.authorPane, + roles: ["partner=p2", "reviewer=Codex"], + inputs: ["rounds=3"] + ).get() + let run = admitted.session.run + #expect(run.id.uuidString == "0BADCAFE-0000-4000-8000-000000000042") + #expect(run.context.scope == .repo(repositoryID: fixture.repoRoot.path(percentEncoded: false))) + #expect(run.context.worktree.branch == "feat/x") + #expect(run.inputs["rounds"] == "3") + #expect( + run.bindings["author"] + == .current( + WorkflowPaneIdentity( + surfaceID: fixture.authorPane, + tabID: fixture.tabID, handle: "p1", + displayName: "Claude Code", agent: "claude"))) + #expect(run.bindings["partner"]?.pane?.handle == "p2") + #expect( + run.bindings["reviewer"]?.profile + == WorkflowProfileBinding(id: Self.codexID, name: "Codex", agent: "codex")) + #expect(admitted.session.launchPlans["reviewer"]?.profileID == Self.codexID) + #expect(admitted.session.bindingMemoryKeys["reviewer"]?.role == "reviewer") + #expect(admitted.callerRole == "author") + #expect( + run.selfInitiatedLine?.contains("PROWL_WORKFLOW_TOKEN=TOKEN prowl workflow done -") == true) + #expect( + run.phase == .injecting(ordinal: 1), "self-initiated: the activation opens without typing") + #expect(fixture.plannedProfiles == ["Codex"]) + let record = try admitted.session.store.readRecord(runID: run.id) + #expect(record.run.status.state == "running") + #expect(record.bindings["reviewer"]?.profile?.name == "Codex") + } + + @Test func anExplicitPaneThatIsNotTheCallerIsNotSelfInitiated() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + try fixture.write(Self.review, to: "review") + let admitted = try admit( + fixture, workflow: "review", pane: fixture.authorPane, isCaller: false, roles: ["partner=p2"] + ).get() + #expect(admitted.callerRole == nil) + #expect(admitted.session.run.selfInitiatedLine == nil) + #expect(admitted.session.run.phase == .waitingForRole(role: "author", ordinal: 1)) + } + + @Test func definitionSelectionCoversIdNameShadowingValidityAndEnabledState() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + try fixture.write(Self.contextOnly, to: "context") + try fixture.write( + Self.contextOnly.replacing("id: context", with: "id: other"), to: "other", scope: .user) + try fixture.write( + Self.contextOnly.replacing("id: context", with: "id: broken").replacing( + "git.context", with: "nope"), to: "broken") + + #expect( + code(admit(fixture, workflow: "missing", pane: fixture.authorPane)) + == CLIErrorCode.workflowNotFound) + #expect( + code(admit(fixture, workflow: "Context", pane: fixture.authorPane)) + == CLIErrorCode.invalidArgument) + #expect(code(admit(fixture, workflow: "other", pane: fixture.authorPane)) == nil) + let broken = admit(fixture, workflow: "broken", pane: fixture.authorPane) + #expect(code(broken) == CLIErrorCode.workflowInvalid) + if case .failure(let failure) = broken { + #expect(failure.details?.valid == false) + } + fixture.disabled = ["repo/context"] + #expect( + code(admit(fixture, workflow: "context", pane: fixture.authorPane)) + == CLIErrorCode.workflowDisabled) + } + + @Test func aCurrentRoleNeedsAPaneAndADetectedAgentOnlyWhenItIsMessaged() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + try fixture.write(Self.review, to: "review") + try fixture.write(Self.contextOnly, to: "context") + #expect(code(admit(fixture, workflow: "review", pane: nil)) == CLIErrorCode.sourceRequired) + #expect( + code(admit(fixture, workflow: "review", pane: fixture.shellPane, roles: ["partner=p2"])) + == CLIErrorCode.agentNotFound) + // A context-only workflow never delivers to its current role: a bare shell is a valid source. + let admitted = try admit(fixture, workflow: "context", pane: fixture.shellPane).get() + #expect(admitted.session.run.bindings["author"]?.pane?.displayName == "shell") + #expect(admitted.session.run.bindings["author"]?.pane?.agent == nil) + // `--skip brief` removes the only message to the current role, so the shell is fine there too. + #expect( + code( + admit( + fixture, workflow: "review", pane: fixture.shellPane, roles: ["partner=p2"], + skips: ["brief"])) == CLIErrorCode.invalidArgument, + "brief feeds launch, so it cannot be skipped") + } + + @Test func oneRunPerPaneIsEnforcedForCurrentAndPickRoles() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + try fixture.write(Self.review, to: "review") + fixture.busy = [fixture.authorPane] + #expect( + code(admit(fixture, workflow: "review", pane: fixture.authorPane, roles: ["partner=p2"])) + == CLIErrorCode.paneBusy) + fixture.busy = [fixture.partnerPane] + #expect( + code(admit(fixture, workflow: "review", pane: fixture.authorPane, roles: ["partner=p2"])) + == CLIErrorCode.paneBusy) + } + + /// A pane that still holds a pending dispatch cannot open an activation (#733 D4); refusing at + /// admission is what keeps a run from looping on `roleBusy` against a record nobody completes. + @Test func panesHoldingAPendingDispatchAreRefusedForCurrentAndPickRoles() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + try fixture.write(Self.review, to: "review") + fixture.pendingDispatches = [fixture.authorPane: "launch-dispatch"] + let current = admit(fixture, workflow: "review", pane: fixture.authorPane, roles: ["partner=p2"]) + #expect(code(current) == CLIErrorCode.dispatchPending) + if case .failure(let failure) = current { + #expect(failure.message.contains("launch-dispatch")) + } + fixture.pendingDispatches = [fixture.partnerPane: "reviewer-dispatch"] + #expect( + code(admit(fixture, workflow: "review", pane: fixture.authorPane, roles: ["partner=p2"])) + == CLIErrorCode.dispatchPending) + } + + @Test func pickRolesNeedAnExplicitAgentPaneInTheSourceWorktree() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + try fixture.write(Self.review, to: "review") + #expect( + code(admit(fixture, workflow: "review", pane: fixture.authorPane)) + == CLIErrorCode.invalidArgument) + #expect( + code(admit(fixture, workflow: "review", pane: fixture.authorPane, roles: ["partner=p9"])) + == CLIErrorCode.targetNotFound) + #expect( + code(admit(fixture, workflow: "review", pane: fixture.authorPane, roles: ["partner=p3"])) + == CLIErrorCode.agentNotFound) + #expect( + code(admit(fixture, workflow: "review", pane: fixture.authorPane, roles: ["partner=p1"])) + == CLIErrorCode.invalidArgument) + let byUUID = admit( + fixture, workflow: "review", pane: fixture.authorPane, + roles: ["partner=\(fixture.partnerPane.uuidString)"]) + #expect(code(byUUID) == nil) + #expect( + code( + admit( + fixture, workflow: "review", pane: fixture.authorPane, roles: ["partner=p2", "author=p2"]) + ) == CLIErrorCode.invalidArgument) + #expect( + code( + admit( + fixture, workflow: "review", pane: fixture.authorPane, roles: ["partner=p2", "ghost=p2"])) + == CLIErrorCode.invalidArgument) + #expect( + code( + admit( + fixture, workflow: "review", pane: fixture.authorPane, + roles: ["partner=p2", "partner=p2"])) == CLIErrorCode.invalidArgument) + } + + @Test func launchRolesResolveOverridesRememberedAndRecommendedProfiles() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + try fixture.write(Self.review, to: "review") + // Recommended: the designated profile of the repository. + fixture.designated = Self.codexID + let recommended = try admit( + fixture, workflow: "review", pane: fixture.authorPane, roles: ["partner=p2"] + ).get() + #expect(recommended.session.run.bindings["reviewer"]?.profile?.id == Self.codexID) + // Remembered beats recommended. + let key = try #require(recommended.session.bindingMemoryKeys["reviewer"]) + fixture.remembered[key] = Self.claudeID + let remembered = try admit( + fixture, workflow: "review", pane: fixture.authorPane, roles: ["partner=p2"] + ).get() + #expect(remembered.session.run.bindings["reviewer"]?.profile?.id == Self.claudeID) + // An explicit override beats both; by UUID or name. + let byName = try admit( + fixture, workflow: "review", pane: fixture.authorPane, + roles: ["partner=p2", "reviewer=Codex"] + ).get() + #expect(byName.session.run.bindings["reviewer"]?.profile?.id == Self.codexID) + let byID = try admit( + fixture, workflow: "review", pane: fixture.authorPane, + roles: ["partner=p2", "reviewer=\(Self.codexID.uuidString)"] + ).get() + #expect(byID.session.run.bindings["reviewer"]?.profile?.id == Self.codexID) + // `auto` falls through the tiers; an unknown name is PROFILE_NOT_FOUND. + let auto = try admit( + fixture, workflow: "review", pane: fixture.authorPane, roles: ["partner=p2", "reviewer=auto"] + ).get() + #expect(auto.session.run.bindings["reviewer"]?.profile?.id == Self.claudeID) + #expect( + code( + admit( + fixture, workflow: "review", pane: fixture.authorPane, + roles: ["partner=p2", "reviewer=Nope"])) == CLIErrorCode.profileNotFound) + // An override the role rejects (Amp is not in `agents`) is logged and falls through. + let fallen = try admit( + fixture, workflow: "review", pane: fixture.authorPane, roles: ["partner=p2", "reviewer=Amp"] + ).get() + #expect(fallen.session.run.bindings["reviewer"]?.profile?.id == Self.claudeID) + #expect( + fallen.effects.first + == .log( + "Role 'reviewer': the requested profile was not used (its agent 'amp' is not allowed by the role); " + + "resolved 'Claude Code' (remembered)." + )) + } + + @Test func aWorkflowWithoutACurrentRoleRunsFromAWorktreeAndAsksWhenNothingResolves() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + try fixture.write(Self.worktreeOnly, to: "launch-only") + let admitted = try admit(fixture, workflow: "launch-only", pane: nil, isCaller: false).get() + #expect(admitted.callerRole == nil) + #expect( + admitted.session.run.bindings["worker"]?.profile?.id == Self.claudeID, + "first enabled profile is Recommended") + #expect(admitted.session.run.phase == .launching(ordinal: 1)) + // Restrict the role to Amp, whose runtime cannot start with a prompt: the resolver reaches `.ask`. + try fixture.write( + Self.worktreeOnly.replacing("source: launch", with: "source: launch\n agents: [amp]"), to: "launch-only") + #expect( + code(admit(fixture, workflow: "launch-only", pane: nil, isCaller: false)) + == CLIErrorCode.profileNotFound) + } + + @Test func startTimeValidationMapsToInvalidArgument() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + try fixture.write(Self.review, to: "review") + #expect( + code( + admit( + fixture, workflow: "review", pane: fixture.authorPane, roles: ["partner=p2"], + inputs: ["rounds=9"])) == CLIErrorCode.invalidArgument) + #expect( + code( + admit( + fixture, workflow: "review", pane: fixture.authorPane, roles: ["partner=p2"], + inputs: ["nope=1"])) == CLIErrorCode.invalidArgument) + #expect( + code( + admit( + fixture, workflow: "review", pane: fixture.authorPane, roles: ["partner=p2"], + inputs: ["rounds"])) == CLIErrorCode.invalidArgument) + #expect( + code( + admit( + fixture, workflow: "review", pane: fixture.authorPane, roles: ["partner=p2"], + skips: ["ghost"])) == CLIErrorCode.invalidArgument) + #expect( + code( + admit( + fixture, workflow: "review", pane: fixture.authorPane, roles: ["partner=p2"], + skips: ["ping"])) == CLIErrorCode.invalidArgument, "ping awaits nothing") + } +} diff --git a/supacodeTests/WorkflowRunMachineTests.swift b/supacodeTests/WorkflowRunMachineTests.swift index bedbf5293..9bff7ddeb 100644 --- a/supacodeTests/WorkflowRunMachineTests.swift +++ b/supacodeTests/WorkflowRunMachineTests.swift @@ -846,6 +846,31 @@ struct WorkflowRunMachineTests { #expect(machine.run.invocations[1].activation?.token == "TOKEN-2") } + @Test func anUnavailableRoleDuringTheIdleWaitEntersAttentionAndRetryWaitsAgain() throws { + var (machine, _) = try makeMachine() + #expect(machine.run.phase == .waitingForRole(role: "author", ordinal: 1)) + let effects = machine.apply(.roleUnavailable(ordinal: 1, .roleBlocked)) + #expect(machine.run.status.attention?.reason.code == "injection_failed:role_blocked") + #expect(machine.run.status.attention?.actions == [.focusPane, .retry, .skip, .cancel]) + #expect(effects.contains(.persist)) + #expect(!effects.contains { if case .inject = $0 { return true } else { return false } }) + + let retried = machine.apply(.user(.retry)) + #expect(machine.run.status == .running) + #expect(retried.contains(.awaitRoleIdle(role: "author", surfaceID: Self.authorPane.surfaceID, ordinal: 2))) + + // Outside the idle wait the event is stale and ignored. + #expect(machine.apply(.roleUnavailable(ordinal: 1, .surfaceMissing)).isEmpty) + #expect(machine.run.status == .running) + } + + @Test func aGoneCurrentRoleDuringTheIdleWaitOffersRetrySkipCancelOnly() throws { + var (machine, _) = try makeMachine() + _ = machine.apply(.roleUnavailable(ordinal: 1, .surfaceMissing)) + #expect(machine.run.status.attention?.reason.code == "injection_failed:surface_missing") + #expect(machine.run.status.attention?.actions == [.retry, .skip, .cancel]) + } + @Test func roleBusyReturnsTheStepToItsIdleWaitAndKeepsItsToken() throws { var (machine, _) = try makeMachine() _ = machine.apply(.roleIdle(ordinal: 1)) diff --git a/supacodeTests/WorkflowRunStoreTests.swift b/supacodeTests/WorkflowRunStoreTests.swift index 4849ffd1b..d8125aa8d 100644 --- a/supacodeTests/WorkflowRunStoreTests.swift +++ b/supacodeTests/WorkflowRunStoreTests.swift @@ -274,7 +274,7 @@ struct WorkflowRunStoreTests { at: root.appending(path: ".prowl", directoryHint: .isDirectory), withDestinationURL: root.appending(path: "elsewhere", directoryHint: .isDirectory)) #expect(throws: WorkflowRunStoreError.self) { - try WorkflowRunStore(rootURL: root).markInterruptedRuns(now: Self.now) + try WorkflowRunStore(rootURL: root).markInterruptedRuns(now: { Self.now }) } let clean = try makeRoot() @@ -288,7 +288,7 @@ struct WorkflowRunStoreTests { try store.ensureLayout(runID: headerlessID) try "{\"version\": 1}".write( to: store.directory(for: headerlessID).appending(path: "run.json"), atomically: true, encoding: .utf8) - let result = try store.markInterruptedRuns(now: Self.now) + let result = try store.markInterruptedRuns(now: { Self.now }) #expect(result.interrupted.isEmpty) #expect( result.unreadable == [store.directory(for: headerlessID).appending(path: "run.json").path(percentEncoded: false)]) @@ -311,7 +311,7 @@ struct WorkflowRunStoreTests { let linkedID = UUID() try FileManager.default.createSymbolicLink(at: store.directory(for: linkedID), withDestinationURL: external) #expect(throws: WorkflowRunStoreError.self) { try store.readRecord(runID: linkedID) } - #expect(try store.markInterruptedRuns(now: Self.now).unreadable.count == 1) + #expect(try store.markInterruptedRuns(now: { Self.now }).unreadable.count == 1) } @Test func symlinkedOutputsDirectoryIsRejected() throws { @@ -365,7 +365,8 @@ struct WorkflowRunStoreTests { let root = try makeRoot() defer { try? FileManager.default.removeItem(at: root) } let store = WorkflowRunStore(rootURL: root) - #expect(try store.markInterruptedRuns(now: Self.now) == WorkflowInterruptedRuns(interrupted: [], unreadable: [])) + #expect( + try store.markInterruptedRuns(now: { Self.now }) == WorkflowInterruptedRuns(interrupted: [], unreadable: [])) let running = try makeRun(root: root) let attention = try makeRun( @@ -382,7 +383,7 @@ struct WorkflowRunStoreTests { try "{ not json".write( to: store.directory(for: brokenID).appending(path: "run.json"), atomically: true, encoding: .utf8) let later = Self.now.addingTimeInterval(60) - let result = try store.markInterruptedRuns(now: later) + let result = try store.markInterruptedRuns(now: { later }) #expect(Set(result.interrupted) == [running.id, attention.id]) #expect( result.unreadable == [store.directory(for: brokenID).appending(path: "run.json").path(percentEncoded: false)]) @@ -393,7 +394,7 @@ struct WorkflowRunStoreTests { #expect(try store.readRecord(runID: cancelled.id).run.status.state == "cancelled") let log = try String(contentsOf: store.directory(for: running.id).appending(path: "log.md"), encoding: .utf8) #expect(log.contains("marked interrupted")) - #expect(try store.markInterruptedRuns(now: later).interrupted.isEmpty) + #expect(try store.markInterruptedRuns(now: { later }).interrupted.isEmpty) } } diff --git a/supacodeTests/WorkflowRunsFeatureTests.swift b/supacodeTests/WorkflowRunsFeatureTests.swift new file mode 100644 index 000000000..8402d51d7 --- /dev/null +++ b/supacodeTests/WorkflowRunsFeatureTests.swift @@ -0,0 +1,1106 @@ +// supacodeTests/WorkflowRunsFeatureTests.swift +// The reducer that wires B2's machine to the boundaries (docs-ai 063 B3): ordered effect +// execution, the two-phase `done` rendezvous, late launches, and the restart scan. + +import ComposableArchitecture +import DependenciesTestSupport +import Foundation +import Testing + +@testable import supacode + +/// A line the fake terminal received, with whether the instruction file it points at existed. +struct WorkflowTypedLineRecord { + let surfaceID: UUID + let line: String + let instructionExisted: Bool +} + +@MainActor +struct WorkflowRunsFeatureTests { + nonisolated private static let now = Date(timeIntervalSince1970: 1_760_000_000) + private static let authorPane = WorkflowRunMachineTests.authorPane + private static let reviewerProfile = WorkflowRunMachineTests.reviewerProfile + + /// A `close` followed by another awaited step, so the close is queued while the run goes on. + nonisolated private static let closeThenSummary = """ + schema: prowl.workflow/v1 + id: test.close-then-summary + name: Close Then Summary + roles: + author: + source: current + reviewer: + source: launch + placement: split + direction: right + steps: + - id: brief + message: author + text: "Write the brief." + expect: { output: brief } + - id: launch + launch: reviewer + prompt: "Review {{ outputs.brief.path }}." + expect: { output: findings } + - id: cleanup + close: reviewer + - id: summary + message: author + text: "Findings: {{ outputs.findings.path }}. Summarize." + expect: { output: summary } + """ + + /// A native action as the first step: the run starts in `runningAction`. + nonisolated private static let actionFirst = """ + schema: prowl.workflow/v1 + id: test.action-first + name: Action First + roles: + author: + source: current + reviewer: + source: launch + placement: tab + steps: + - id: context + action: git.context + with: { root: "{{ worktree.path }}" } + - id: launch + launch: reviewer + prompt: "Review." + - id: done + notify: "Done" + """ + + /// Records every boundary call; the runtime fakes answer synchronously. + @MainActor + final class Fixture { + let root: URL + let worktree: Worktree + var typed: [WorkflowTypedLineRecord] = [] + var launches: [WorkflowLaunchRequest] = [] + var closed: [UUID] = [] + var notifications: [String] = [] + var opened: [UUID] = [] + var cancelled: [String] = [] + var abandoned: [(dispatchID: String, reason: String)] = [] + var completed: [String] = [] + var armed: [WorkflowWatchdogRequest] = [] + var disarmed: [Int] = [] + var responses: [(requestID: UUID, resolution: WorkflowRequestResolution)] = [] + var roleWaitOutcome: WorkflowRoleWaitOutcome = .idle + var launchOutcome: Result? + /// What the liveness guard answered on each `deliverLine`. + var guardAnswers: [Bool] = [] + private var dispatchCounter = 0 + private var paneCounter = 10 + + init() throws { + root = + FileManager.default.temporaryDirectory + .appending(path: "workflow-runs-feature-tests", directoryHint: .isDirectory) + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + .standardizedFileURL + let skill = root.appending( + path: "skills/prowl.adversarial-reviewer", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: skill, withIntermediateDirectories: true) + try "---\nname: r\ndescription: d\n---\n# Reviewer\n".write( + to: skill.appending(path: "SKILL.md"), atomically: true, encoding: .utf8) + worktree = Worktree( + id: "wt", name: "feature", detail: "", workingDirectory: root, repositoryRootURL: root) + } + + func cleanUp() { + try? FileManager.default.removeItem(at: root) + } + + var runtime: WorkflowRuntimeClient { + WorkflowRuntimeClient( + waitForRole: { [self] _ in roleWaitOutcome }, + deliverLine: { [self] _, surfaceID, line, isLive in + let live = isLive() + guardAnswers.append(live) + guard live else { return .stale } + let pointer = line.split(separator: " ").first { $0.hasPrefix("/") }.map(String.init) + let existed = pointer.map { FileManager.default.fileExists(atPath: $0) } ?? true + typed.append(WorkflowTypedLineRecord(surfaceID: surfaceID, line: line, instructionExisted: existed)) + return .delivered + }, + launch: { [self] _, _, request in + launches.append(request) + if let launchOutcome { return launchOutcome } + paneCounter += 1 + let pane = WorkflowPaneIdentity( + surfaceID: UUID(), tabID: UUID(), handle: "p\(paneCounter)", + displayName: request.profile.name, + agent: request.profile.agent) + let dispatchID = request.expectsDelivery ? issue(pane.surfaceID) : nil + return .success(WorkflowLaunchResult(pane: pane, dispatchID: dispatchID)) + }, + close: { [self] _, surfaceID, _ in + closed.append(surfaceID) + return true + }, + notify: { [self] _, text in notifications.append(text) } + ) + } + + var activation: WorkflowActivationClient { + WorkflowActivationClient( + openMessage: { [self] surfaceID in .success(issue(surfaceID)) }, + cancel: { [self] id in cancelled.append(id) }, + abandon: { [self] id, reason in abandoned.append((id, reason)) }, + complete: { [self] id, _ in completed.append(id) }, + observe: { _ in nil } + ) + } + + var watchdog: WorkflowWatchdogClient { + WorkflowWatchdogClient(arm: { [self] _, request in + armed.append(request) + return WorkflowWatchdogHandle( + verdicts: AsyncStream { $0.finish() }, + cancel: { [self] in disarmed.append(request.ordinal) }) + }) + } + + var responder: WorkflowCLIResponderClient { + WorkflowCLIResponderClient(respond: { [self] requestID, resolution in + responses.append((requestID, resolution)) + }) + } + + private func issue(_ surfaceID: UUID) -> String { + dispatchCounter += 1 + opened.append(surfaceID) + return "dispatch-\(dispatchCounter)" + } + + func session( + _ yaml: String = WorkflowRunMachineTests.adversarialReview, + selfInitiated: Bool = false, + inputs: [String: String] = [:], + skipped: Set = [], + startedAt: Date = WorkflowRunsFeatureTests.now + ) throws -> (WorkflowRunSession, [WorkflowRunEffect]) { + let definition = try #require(WorkflowDocumentParser.parse(yaml).definition) + let counter = WorkflowRunMachineTests.TokenCounter() + let started = try WorkflowRunMachine.start( + WorkflowRunStartRequest( + definition: definition, + runID: UUID(), + context: WorkflowRunContext( + scope: .user, definitionPath: nil, + worktree: WorkflowRunWorktree( + id: "wt", name: "feature", branch: "feat/x", path: root.path(percentEncoded: false))), + bindings: [ + definition.roles[0].name: .current(WorkflowRunsFeatureTests.authorPane), + definition.roles[1].name: .launch(WorkflowRunsFeatureTests.reviewerProfile, pane: nil), + ], + inputs: inputs, + skippedSteps: skipped, + selfInitiated: selfInitiated), + now: { startedAt }, + makeToken: { counter.next() }) + let plan = AgentProfileLaunchPlan( + profileID: WorkflowRunsFeatureTests.reviewerProfile.id, profileName: "Pi Reviewer", + runtime: .pi, + invocation: AgentInvocation(executable: "pi", arguments: ["placeholder"]), + commandEnvironmentTokens: [], placement: .split, splitDirection: .right, + surfaceEnvironment: [AgentProfileLaunchPlanner.promptCarrierName: "placeholder"], + dedicatedHome: nil) + let session = WorkflowRunSession( + run: started.machine.run, + worktree: worktree, + launchPlans: [definition.roles[1].name: plan], + bindingMemoryKeys: [ + definition.roles[1].name: WorkflowBindingResolver.memoryKey( + scope: .user, workflowID: definition.id, role: definition.roles[1]) + ], + skills: [ + "prowl.adversarial-reviewer": BundledSkill( + id: "prowl.adversarial-reviewer", name: "Reviewer", description: "d", + audience: .workflow, + directoryURL: root.appending( + path: "skills/prowl.adversarial-reviewer", directoryHint: .isDirectory)) + ]) + return (session, started.effects) + } + } + + private func makeStore( + _ fixture: Fixture, queue: WorkflowEffectQueueClient, + storage: SettingsTestStorage = SettingsTestStorage(), + actionExecutor: (any WorkflowActionExecuting)? = nil + ) -> TestStoreOf { + let store = TestStore(initialState: WorkflowRunsFeature.State()) { + WorkflowRunsFeature() + } withDependencies: { + if let actionExecutor { + $0.workflowActionExecutor = actionExecutor + } + $0.workflowRuntimeClient = fixture.runtime + $0.workflowActivationClient = fixture.activation + $0.workflowWatchdogClient = fixture.watchdog + $0.workflowEffectQueue = queue + $0.workflowCLIResponder = fixture.responder + $0.date.now = Self.now + $0.uuid = .incrementing + $0.settingsFileStorage = storage.storage + } + store.exhaustivity = .off(showSkippedAssertions: false) + return store + } + + /// A queue that records batches without performing them, for transitions under test control. + @MainActor + final class RecordingQueue { + var batches: [(runID: UUID, effects: [WorkflowRunEffect])] = [] + var fenced: [UUID] = [] + var finished: [UUID] = [] + var client: WorkflowEffectQueueClient { + WorkflowEffectQueueClient( + start: { _ in AsyncStream { $0.finish() } }, + enqueue: { [self] runID, batch in batches.append((runID, batch.effects)) }, + fence: { [self] runID in fenced.append(runID) }, + isStale: { _, _ in false }, + finish: { [self] runID in finished.append(runID) }) + } + var effects: [WorkflowRunEffect] { batches.flatMap(\.effects) } + } + + /// A real queue whose fence rises on the n-th staleness check of the run — as a cancel that + /// reduces on that very main-actor turn would raise it; `reached()` returns once it did. + @MainActor + final class FencingQueue { + private let queue = WorkflowEffectQueue() + private let fenceOnCheck: Int + private var checks = 0 + private var waiter: CheckedContinuation? + + init(fenceOnCheck: Int) { + self.fenceOnCheck = fenceOnCheck + } + + var client: WorkflowEffectQueueClient { + WorkflowEffectQueueClient( + start: { [queue] runID in queue.start(runID) }, + enqueue: { [queue] runID, batch in queue.enqueue(runID, batch) }, + fence: { [queue] runID in queue.fence(runID) }, + isStale: { [self] runID, sequence in + checks += 1 + if checks == fenceOnCheck { + queue.fence(runID) + waiter?.resume() + waiter = nil + } + return queue.isStale(runID, sequence: sequence) + }, + finish: { [queue] runID in queue.finish(runID) }) + } + + func reached() async { + guard checks < fenceOnCheck else { return } + await withCheckedContinuation { waiter = $0 } + } + } + + /// A native action that reports when it started and finishes only once released. + nonisolated final class GatedActionExecutor: WorkflowActionExecuting, Sendable { + private let startedStream = AsyncStream.makeStream() + private let releaseStream = AsyncStream.makeStream() + + func execute(actionID: String, inputs: [String: String], context: WorkflowActionContext) async throws + -> [String: String] + { + startedStream.continuation.yield() + for await _ in releaseStream.stream { break } + return ["summary": "done"] + } + + func started() async { + for await _ in startedStream.stream { break } + } + + func release() { + releaseStream.continuation.yield() + } + } + + private static let timeout: Duration = .seconds(5) + /// Tokens are minted by the reducer's `uuid` dependency (`.incrementing`) when a step opens. + nonisolated private static let firstToken = "00000000-0000-0000-0000-000000000000" + nonisolated private static let secondToken = "00000000-0000-0000-0000-000000000001" + + // MARK: - Ordered execution + + @Test(.dependencies) func aRunPerformsItsEffectsInMachineOrderAndAnswersDoneAfterPersistence() + async throws + { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let storage = SettingsTestStorage() + let store = makeStore(fixture, queue: WorkflowEffectQueue().client, storage: storage) + let (session, effects) = try fixture.session() + let runID = session.run.id + let runDirectory = session.store.directory(for: runID) + + await store.send(.started(session, effects: effects)) + await store.receive(.event(runID: runID, .roleIdle(ordinal: 1)), timeout: Self.timeout) + await store.receive( + .event(runID: runID, .injectionSucceeded(ordinal: 1, dispatchID: "dispatch-1")), + timeout: Self.timeout) + #expect(fixture.typed.count == 1) + #expect( + fixture.typed[0].instructionExisted, + "the instruction file must exist before the pointer is typed") + #expect(fixture.typed[0].line.contains("PROWL_WORKFLOW_TOKEN=\(Self.firstToken) prowl workflow done -")) + #expect(fixture.armed.map(\.ordinal) == [1]) + let record = try session.store.readRecord(runID: runID) + #expect(record.run.status.state == "running") + + let requestID = UUID() + await store.send( + .deliver( + WorkflowDeliveryRequest( + requestID: requestID, runID: runID, ordinal: 1, selector: .token(Self.firstToken), + body: "# Brief\n## Scope\nx\n## Claims\ny", verdict: nil, source: "pane")) + ) + #expect(store.state.pendingDeliveries[requestID]?.ordinal == 1) + await store.receive(.event(runID: runID, .outputPersisted(ordinal: 1)), timeout: Self.timeout) + #expect(fixture.responses.count == 1) + guard case .delivered(let run, let receipt) = fixture.responses[0].resolution else { + Issue.record("expected a delivered resolution, got \(fixture.responses[0].resolution)") + return + } + #expect(receipt.ordinal == 1) + #expect(run.outputs["brief"]?.ordinal == 1) + #expect(store.state.pendingDeliveries.isEmpty) + #expect(fixture.disarmed.first == 1, "the accepted delivery disarms its watchdog") + #expect(fixture.completed == ["dispatch-1"]) + + // The next step launches the reviewer: skill materialized, plan frozen, pane bound, memory written. + await store.receive(\.event, timeout: Self.timeout) + #expect(fixture.launches.count == 1) + #expect(fixture.launches[0].environment["PROWL_WORKFLOW_TOKEN"] == Self.secondToken) + #expect( + FileManager.default.fileExists( + atPath: runDirectory.appending(path: "skills/prowl.adversarial-reviewer/SKILL.md").path( + percentEncoded: false))) + let reviewerPane = try #require(store.state.sessions[runID]?.run.bindings["reviewer"]?.pane) + #expect(reviewerPane.handle == "p11") + #expect(store.state.paneOwners[reviewerPane.surfaceID] == runID, "a launch take-up records the owner") + #expect(fixture.armed.map(\.ordinal) == [1, 2]) + let key = try #require(session.bindingMemoryKeys["reviewer"]) + let remembered = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.userGlobalSettings) var settings + return settings.rememberedWorkflowBinding(for: key) + } + #expect(remembered == Self.reviewerProfile.id) + + await store.send(.userAction(runID: runID, .cancel)) { + $0.sessions[runID]?.run.status = .cancelled + } + await store.finish(timeout: Self.timeout) + #expect(fixture.abandoned.map(\.dispatchID) == ["dispatch-2"]) + #expect(fixture.disarmed.sorted() == [1, 2]) + #expect(try session.store.readRecord(runID: runID).run.status.state == "cancelled") + let log = try String(contentsOf: runDirectory.appending(path: "log.md"), encoding: .utf8) + #expect(log.contains("Run finished: cancelled.")) + #expect(!log.contains("TOKEN-")) + } + + @Test(.dependencies) func aFailedInstructionWriteStopsTheBatchBeforeAnythingIsTyped() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let store = makeStore(fixture, queue: WorkflowEffectQueue().client) + let (session, effects) = try fixture.session() + let runID = session.run.id + // The run directory's `instructions` leaf becomes a link, which the store refuses. + try session.store.ensureLayout(runID: runID) + let instructions = session.store.directory(for: runID).appending( + path: "instructions", directoryHint: .isDirectory) + try FileManager.default.removeItem(at: instructions) + let elsewhere = fixture.root.appending(path: "elsewhere", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: elsewhere, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: instructions, withDestinationURL: elsewhere) + + await store.send(.started(session, effects: effects)) + await store.receive(.event(runID: runID, .roleIdle(ordinal: 1)), timeout: Self.timeout) + await store.receive(\.event, timeout: Self.timeout) + #expect( + store.state.sessions[runID]?.run.status.attention?.reason.code == "injection_failed:activation_unavailable") + #expect(fixture.typed.isEmpty) + #expect(fixture.opened.isEmpty) + await store.send(.userAction(runID: runID, .cancel)) + await store.finish(timeout: Self.timeout) + } + + // MARK: - Rendezvous (decision W1) + + @Test(.dependencies) func cancelWhileTheOutputIsPersistingFailsThePendingDone() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let queue = RecordingQueue() + let store = makeStore(fixture, queue: queue.client) + let (session, _) = try fixture.session() + let waiting = try waitingForDelivery(session) + let runID = waiting.run.id + await store.send(.started(waiting, effects: [])) + + let requestID = UUID() + await store.send( + .deliver( + WorkflowDeliveryRequest( + requestID: requestID, runID: runID, ordinal: 1, selector: .token("TOKEN-1"), + body: "## Scope\nx\n## Claims\ny", verdict: nil, source: "pane"))) + #expect(store.state.pendingDeliveries[requestID]?.ordinal == 1) + #expect(store.state.sessions[runID]?.run.activeActivation?.state == .persisting) + #expect(fixture.responses.isEmpty) + #expect( + queue.effects.contains { if case .persistOutput = $0 { return true } else { return false } }) + + await store.send(.userAction(runID: runID, .cancel)) + await store.finish(timeout: Self.timeout) + #expect(store.state.pendingDeliveries.isEmpty) + #expect(fixture.responses.count == 1) + #expect(fixture.responses[0].requestID == requestID) + #expect( + fixture.responses[0].resolution + == .failed( + code: CLIErrorCode.stepNotExpecting, + message: "The step stopped waiting for this delivery before the output was saved.")) + #expect(queue.effects.contains(.finished(.cancelled))) + + // The queued `.outputPersisted` of the abandoned write is stale: ignored, nothing answered twice. + await store.send(.event(runID: runID, .outputPersisted(ordinal: 1))) + #expect(fixture.responses.count == 1) + } + + @Test(.dependencies) func aPersistenceFailureFailsThePendingDoneWithWorkflowFailed() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let queue = RecordingQueue() + let store = makeStore(fixture, queue: queue.client) + let (session, _) = try fixture.session() + let waiting = try waitingForDelivery(session) + let runID = waiting.run.id + await store.send(.started(waiting, effects: [])) + let requestID = UUID() + await store.send( + .deliver( + WorkflowDeliveryRequest( + requestID: requestID, runID: runID, ordinal: 1, selector: .token("TOKEN-1"), + body: "## Scope\nx\n## Claims\ny", verdict: nil, source: "manual"))) + #expect(queue.effects.first == .log("Step 'brief': delivery received (source=manual).")) + + await store.send(.event(runID: runID, .outputPersistFailed(ordinal: 1, reason: "disk full"))) + #expect(store.state.sessions[runID]?.run.status.attention?.reason.code == "persist_failed") + #expect(store.state.pendingDeliveries.isEmpty) + #expect(fixture.responses.count == 1) + guard case .failed(let code, let message) = fixture.responses[0].resolution else { + Issue.record("expected a failure") + return + } + #expect(code == CLIErrorCode.workflowFailed) + #expect(message.contains("disk full")) + await store.send(.userAction(runID: runID, .cancel)) + await store.finish(timeout: Self.timeout) + } + + @Test(.dependencies) func aProvisionalDeliveryIsAnsweredAsProvisionalWithItsIssues() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let queue = RecordingQueue() + let store = makeStore(fixture, queue: queue.client) + let (session, _) = try fixture.session() + let waiting = try waitingForDelivery(session) + let runID = waiting.run.id + await store.send(.started(waiting, effects: [])) + let requestID = UUID() + await store.send( + .deliver( + WorkflowDeliveryRequest( + requestID: requestID, runID: runID, ordinal: 1, selector: .token("TOKEN-1"), + body: "## Scope\nonly", verdict: nil, source: "pane"))) + await store.send(.event(runID: runID, .outputPersisted(ordinal: 1))) + #expect(store.state.sessions[runID]?.run.status.attention?.reason.code == "delivery_issues") + #expect(fixture.responses.count == 1) + guard case .provisional(_, let receipt) = fixture.responses[0].resolution else { + Issue.record("expected a provisional resolution") + return + } + #expect(receipt.issues == [.missingSections(["## Claims"])]) + #expect(fixture.completed.isEmpty, "the dispatch record stays pending until the user accepts") + await store.send(.userAction(runID: runID, .cancel)) + await store.finish(timeout: Self.timeout) + } + + @Test(.dependencies) func aRejectedDeliveryIsAnsweredAtOnceWithoutAPendingRequest() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let queue = RecordingQueue() + let store = makeStore(fixture, queue: queue.client) + let (session, _) = try fixture.session() + let waiting = try waitingForDelivery(session) + let runID = waiting.run.id + await store.send(.started(waiting, effects: [])) + + let wrongToken = UUID() + await store.send( + .deliver( + WorkflowDeliveryRequest( + requestID: wrongToken, runID: runID, ordinal: 1, selector: .token("TOKEN-9"), + body: "## Scope\nx\n## Claims\ny", verdict: nil, source: "pane"))) + let unknownRun = UUID() + await store.send( + .deliver( + WorkflowDeliveryRequest( + requestID: unknownRun, runID: UUID(), ordinal: nil, selector: .manual(stepID: "brief"), + body: "x", verdict: nil, source: "manual"))) + await store.finish(timeout: Self.timeout) + #expect(store.state.pendingDeliveries.isEmpty) + #expect(fixture.responses.map(\.requestID) == [wrongToken, unknownRun]) + #expect( + fixture.responses[0].resolution + == .failed( + code: CLIErrorCode.tokenInvalid, message: WorkflowDeliveryError.tokenInvalid.message)) + #expect( + fixture.responses[1].resolution + == .failed(code: CLIErrorCode.runNotFound, message: "The workflow run is not active.")) + await store.send(.userAction(runID: runID, .cancel)) + await store.finish(timeout: Self.timeout) + } + + // MARK: - Late and stale launches + + @Test(.dependencies) func aLaunchThatCompletesAfterTheRunEndedIsAbandonedAndClosed() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let queue = RecordingQueue() + let store = makeStore(fixture, queue: queue.client) + let (session, _) = try fixture.session() + let runID = session.run.id + await store.send(.started(session, effects: [])) + await store.send(.userAction(runID: runID, .cancel)) + await store.finish(timeout: Self.timeout) + + let pane = WorkflowPaneIdentity( + surfaceID: UUID(), tabID: nil, handle: "p7", displayName: "Pi", agent: "pi") + await store.send( + .event(runID: runID, .launched(ordinal: 2, pane: pane, dispatchID: "late-dispatch"))) + await store.finish(timeout: Self.timeout) + #expect(fixture.abandoned.map(\.dispatchID) == ["late-dispatch"]) + #expect(fixture.closed == [pane.surfaceID]) + } + + @Test(.dependencies) func aLaunchTheMachineNoLongerExpectsIsClosedToo() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let queue = RecordingQueue() + let store = makeStore(fixture, queue: queue.client) + let (session, _) = try fixture.session() + let waiting = try waitingForDelivery(session) + let runID = waiting.run.id + await store.send(.started(waiting, effects: [])) + + let pane = WorkflowPaneIdentity( + surfaceID: UUID(), tabID: nil, handle: "p8", displayName: "Pi", agent: "pi") + await store.send( + .event(runID: runID, .launched(ordinal: 42, pane: pane, dispatchID: "stale-dispatch"))) + await store.finish(timeout: Self.timeout) + #expect(store.state.sessions[runID]?.run.bindings["reviewer"]?.pane == nil) + #expect(fixture.abandoned.map(\.dispatchID) == ["stale-dispatch"]) + #expect(fixture.closed == [pane.surfaceID]) + await store.send(.userAction(runID: runID, .cancel)) + await store.finish(timeout: Self.timeout) + } + + // MARK: - Fences and stale work + + /// Cancel, skip, and retry revoke the invocation whose work may still sit in the queue; the + /// reducer fences the queue so that work is dropped instead of typing into the pane. + @Test(.dependencies) func revokingAnInFlightInvocationFencesTheQueue() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let queue = RecordingQueue() + let store = makeStore(fixture, queue: queue.client) + let (session, effects) = try fixture.session() + let runID = session.run.id + await store.send(.started(session, effects: effects)) + #expect(queue.fenced.isEmpty) + // The idle wait ended and the inject is (conceptually) queued; a cancel must fence it. + await store.send(.userAction(runID: runID, .cancel)) + #expect(queue.fenced == [runID]) + await store.finish(timeout: Self.timeout) + + // A retry from an attention likewise revokes the in-flight invocation. + let second = try fixture.session().0 + let secondID = second.run.id + await store.send(.started(second, effects: [])) + await store.send(.event(runID: secondID, .roleUnavailable(ordinal: 1, .roleBlocked))) + #expect(queue.fenced == [runID], "attention alone revokes nothing") + await store.send(.userAction(runID: secondID, .retry)) + #expect(queue.fenced == [runID, secondID]) + await store.send(.userAction(runID: secondID, .cancel)) + await store.finish(timeout: Self.timeout) + } + + /// A typed line whose `.injectionSucceeded` the machine no longer takes up (the step moved on) + /// leaves a pending dispatch record behind unless the wiring abandons it. + @Test(.dependencies) func anIgnoredInjectionAbandonsTheRecordItOpened() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let queue = RecordingQueue() + let store = makeStore(fixture, queue: queue.client) + let (session, _) = try fixture.session() + let waiting = try waitingForDelivery(session) + let runID = waiting.run.id + await store.send(.started(waiting, effects: [])) + await store.send(.event(runID: runID, .injectionSucceeded(ordinal: 1, dispatchID: "stale-1"))) + await store.finish(timeout: Self.timeout) + #expect(fixture.abandoned.map(\.dispatchID) == ["stale-1"]) + + await store.send(.userAction(runID: runID, .cancel)) + await store.finish(timeout: Self.timeout) + await store.send(.event(runID: runID, .injectionSucceeded(ordinal: 1, dispatchID: "late-1"))) + await store.finish(timeout: Self.timeout) + // The cancel's own abandon of `dispatch-0` is an ordered effect the recording queue never + // performs; the late injection's record is abandoned directly by the reducer. + #expect(fixture.abandoned.map(\.dispatchID) == ["stale-1", "late-1"]) + } + + /// The first `inject` makes three staleness checks in order: the batch check, the guard before + /// the record is issued, and the guard the terminal evaluates on the typing turn. A fence that + /// rises on the last one (a cancel reducing on that turn) returns the issuance: nothing is typed + /// and no event reaches the machine. + @Test(.dependencies) func aFenceOnTheTypingTurnReturnsTheIssuanceAndTypesNothing() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let queue = FencingQueue(fenceOnCheck: 3) + let store = makeStore(fixture, queue: queue.client) + let (session, effects) = try fixture.session() + let runID = session.run.id + await store.send(.started(session, effects: effects)) + await store.receive(.event(runID: runID, .roleIdle(ordinal: 1)), timeout: Self.timeout) + await queue.reached() + #expect(fixture.opened.count == 1) + #expect(fixture.guardAnswers == [false], "the guard the terminal evaluates reflects the real fence") + #expect(fixture.cancelled == ["dispatch-1"]) + #expect(fixture.typed.isEmpty) + #expect(store.state.sessions[runID]?.run.phase == .injecting(ordinal: 1)) + await store.send(.userAction(runID: runID, .cancel)) + await store.finish(timeout: Self.timeout) + } + + /// A fence that rises between the batch check and the issuance opens no record at all. + @Test(.dependencies) func aFenceBeforeTheIssuanceOpensNoRecord() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let queue = FencingQueue(fenceOnCheck: 2) + let store = makeStore(fixture, queue: queue.client) + let (session, effects) = try fixture.session() + let runID = session.run.id + await store.send(.started(session, effects: effects)) + await store.receive(.event(runID: runID, .roleIdle(ordinal: 1)), timeout: Self.timeout) + await queue.reached() + #expect(fixture.opened.isEmpty) + #expect(fixture.cancelled.isEmpty) + #expect(fixture.typed.isEmpty) + #expect(fixture.guardAnswers.isEmpty) + #expect(store.state.sessions[runID]?.run.phase == .injecting(ordinal: 1)) + await store.send(.userAction(runID: runID, .cancel)) + await store.finish(timeout: Self.timeout) + } + + /// A native action checks the fence once more right before it starts: a cancel that lands + /// after the batch check runs nothing, and the run log says so. + @Test(.dependencies) func aFenceBeforeANativeActionStartsRunsNothing() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let queue = FencingQueue(fenceOnCheck: 2) + let store = makeStore(fixture, queue: queue.client) + let (session, effects) = try fixture.session(Self.actionFirst) + let runID = session.run.id + #expect( + effects.contains( + .runAction( + stepID: "context", actionID: "git.context", inputs: ["root": fixture.root.path(percentEncoded: false)]))) + await store.send(.started(session, effects: effects)) + await queue.reached() + #expect(store.state.sessions[runID]?.run.phase == .runningAction(stepID: "context")) + #expect(store.state.sessions[runID]?.run.status == .running) + #expect(store.state.sessions[runID]?.run.actionOutputs.isEmpty == true) + await store.send(.userAction(runID: runID, .cancel)) { + $0.sessions[runID]?.run.status = .cancelled + } + await store.finish(timeout: Self.timeout) + let log = try String( + contentsOf: session.store.directory(for: runID).appending(path: "log.md"), encoding: .utf8) + #expect(log.contains("Step 'context': native action 'git.context' not started; the run had moved on.")) + #expect(!log.contains("finished after the run moved on")) + } + + /// An action that already left the main actor runs to completion; the run that was cancelled + /// meanwhile discards its result and the log records that it finished late. + @Test(.dependencies) func aNativeActionThatOutlivesTheRunIsDiscardedAndLogged() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let gate = GatedActionExecutor() + let store = makeStore(fixture, queue: WorkflowEffectQueue().client, actionExecutor: gate) + let (session, effects) = try fixture.session(Self.actionFirst) + let runID = session.run.id + await store.send(.started(session, effects: effects)) + await gate.started() + await store.send(.userAction(runID: runID, .cancel)) { + $0.sessions[runID]?.run.status = .cancelled + } + gate.release() + await store.finish(timeout: Self.timeout) + #expect(store.state.sessions[runID]?.run.actionOutputs.isEmpty == true) + let log = try String( + contentsOf: session.store.directory(for: runID).appending(path: "log.md"), encoding: .utf8) + #expect( + log.contains( + "Step 'context': native action 'git.context' finished after the run moved on; result discarded.")) + #expect(!log.contains("not started")) + #expect(log.contains("Run finished: cancelled.")) + } + + /// `close` is revocable: a cancel that beats a queued close keeps the pane, while a close the + /// run reaches normally removes it before the next line is typed. + @Test(.dependencies) func aCloseStepRemovesThePaneBeforeTheNextLineIsTyped() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let store = makeStore(fixture, queue: WorkflowEffectQueue().client) + let (session, effects) = try fixture.session(Self.closeThenSummary) + let runID = session.run.id + let reviewer = try await driveToCleanup(store, fixture: fixture, session: session, effects: effects) + await store.receive(.event(runID: runID, .roleIdle(ordinal: 3)), timeout: Self.timeout) + await store.receive( + .event(runID: runID, .injectionSucceeded(ordinal: 3, dispatchID: "dispatch-3")), timeout: Self.timeout) + #expect(fixture.closed == [reviewer.surfaceID]) + #expect(fixture.typed.count == 2) + await store.send(.userAction(runID: runID, .cancel)) + await store.finish(timeout: Self.timeout) + } + + @Test(.dependencies) func aCancelThatBeatsAQueuedCloseKeepsThePane() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + // Staleness checks in FIFO order: inject 1 (batch, issuance, typing turn), launch 2 (batch), + // then the close's batch check — the fence rises there. + let queue = FencingQueue(fenceOnCheck: 5) + let store = makeStore(fixture, queue: queue.client) + let (session, effects) = try fixture.session(Self.closeThenSummary) + let runID = session.run.id + let reviewer = try await driveToCleanup(store, fixture: fixture, session: session, effects: effects) + await queue.reached() + #expect(fixture.closed.isEmpty) + #expect(store.state.sessions[runID]?.run.bindings["reviewer"]?.pane?.surfaceID == reviewer.surfaceID) + await store.send(.userAction(runID: runID, .cancel)) + await store.finish(timeout: Self.timeout) + #expect(fixture.closed.isEmpty, "cancel never closes panes") + } + + /// The boundary's ownership rule: the pane belongs to the run that bound it most recently — + /// recorded at admission and at launch take-up, never read from a clock — also once that run + /// has ended and kept it, never to an earlier run whose close is still queued. + @Test(.dependencies) func theMostRecentBindingOwnsAPaneWhateverTheClockSays() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let queue = RecordingQueue() + let store = makeStore(fixture, queue: queue.client) + let pane = WorkflowRunMachineTests.reviewerPane + var earlier = try fixture.session().0 + earlier.run.bindings["reviewer"] = .launch(Self.reviewerProfile, pane: pane) + await store.send(.started(earlier, effects: [])) { + $0.paneOwners[pane.surfaceID] = earlier.run.id + } + // A later admission whose clock reading is *earlier* still takes the pane over. + var later = try fixture.session(startedAt: Self.now.addingTimeInterval(-60)).0 + later.run.bindings["author"] = .current(pane) + await store.send(.started(later, effects: [])) { + $0.paneOwners[pane.surfaceID] = later.run.id + } + await store.send(.userAction(runID: later.run.id, .cancel)) + await store.send(.userAction(runID: earlier.run.id, .cancel)) + await store.finish(timeout: Self.timeout) + #expect(store.state.activeSession(boundTo: pane.surfaceID) == nil, "an ended run is no longer busy") + #expect( + store.state.paneOwners[pane.surfaceID] == later.run.id, + "the later run keeps the pane it ended with; the earlier run never gets it back") + } + + /// A relaunch drops the old pane from the role's binding, but the pane stays among the panes + /// the run ever owned: admission prunes launch reservations against that set, so the old pane + /// is neither reserved forever nor handed back to the run that left it. + @Test(.dependencies) func aRelaunchKeepsTheOldPaneAmongTheOwnedOnes() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let store = makeStore(fixture, queue: WorkflowEffectQueue().client) + let (session, effects) = try fixture.session() + let runID = session.run.id + await store.send(.started(session, effects: effects)) + await store.receive(.event(runID: runID, .roleIdle(ordinal: 1)), timeout: Self.timeout) + await store.receive( + .event(runID: runID, .injectionSucceeded(ordinal: 1, dispatchID: "dispatch-1")), timeout: Self.timeout) + await store.send( + .deliver( + WorkflowDeliveryRequest( + requestID: UUID(), runID: runID, ordinal: 1, selector: .token(Self.firstToken), + body: "# Brief\n## Scope\nx\n## Claims\ny", verdict: nil, source: "pane"))) + await store.receive(.event(runID: runID, .outputPersisted(ordinal: 1)), timeout: Self.timeout) + await store.receive(\.event, timeout: Self.timeout) + let first = try #require(store.state.sessions[runID]?.run.bindings["reviewer"]?.pane) + #expect(store.state.paneOwners[first.surfaceID] == runID) + // The launch boundary reserved the pane while the launch was in flight. + let reservations = WorkflowPaneReservations() + reservations.reserve(first.surfaceID) + + await store.send(.event(runID: runID, .watchdog(ordinal: 2, .attention(.agentGone(.sessionEnded))))) + await store.send(.userAction(runID: runID, .relaunch)) + await store.receive(\.event, timeout: Self.timeout) + let second = try #require(store.state.sessions[runID]?.run.bindings["reviewer"]?.pane) + #expect(second.surfaceID != first.surfaceID) + #expect(store.state.sessions[runID]?.boundSurfaceIDs == [Self.authorPane.surfaceID, second.surfaceID]) + #expect(store.state.paneOwners[first.surfaceID] == runID, "the pane the relaunch left stays owned") + #expect(store.state.paneOwners[second.surfaceID] == runID) + #expect( + reservations.pending(for: store.state, isLive: { _ in true }).isEmpty, + "admission no longer holds the pane the relaunch left, even while it lives") + await store.send(.userAction(runID: runID, .cancel)) + await store.finish(timeout: Self.timeout) + } + + /// A fence that lands before the executor even reaches the action's batch skips it at the + /// batch check, and the run log still records that the action was not started. + @Test(.dependencies) func anActionSkippedAtTheBatchCheckIsLoggedAsNotStarted() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let queue = FencingQueue(fenceOnCheck: 1) + let store = makeStore(fixture, queue: queue.client) + let (session, effects) = try fixture.session(Self.actionFirst) + let runID = session.run.id + await store.send(.started(session, effects: effects)) + await queue.reached() + #expect(store.state.sessions[runID]?.run.phase == .runningAction(stepID: "context")) + await store.send(.userAction(runID: runID, .cancel)) { + $0.sessions[runID]?.run.status = .cancelled + } + await store.finish(timeout: Self.timeout) + let log = try String( + contentsOf: session.store.directory(for: runID).appending(path: "log.md"), encoding: .utf8) + #expect(log.contains("Step 'context': native action 'git.context' not started; the run had moved on.")) + #expect(!log.contains("finished after the run moved on")) + } + + /// Brief delivered, reviewer launched, findings delivered: the run is at `cleanup` (a queued + /// close) with the `summary` message's idle wait armed. Returns the reviewer's pane. + private func driveToCleanup( + _ store: TestStoreOf, fixture: Fixture, session: WorkflowRunSession, + effects: [WorkflowRunEffect] + ) async throws -> WorkflowPaneIdentity { + let runID = session.run.id + await store.send(.started(session, effects: effects)) + await store.receive(.event(runID: runID, .roleIdle(ordinal: 1)), timeout: Self.timeout) + await store.receive( + .event(runID: runID, .injectionSucceeded(ordinal: 1, dispatchID: "dispatch-1")), timeout: Self.timeout) + await store.send( + .deliver( + WorkflowDeliveryRequest( + requestID: UUID(), runID: runID, ordinal: 1, selector: .token(Self.firstToken), body: "brief", + verdict: nil, source: "pane"))) + await store.receive(.event(runID: runID, .outputPersisted(ordinal: 1)), timeout: Self.timeout) + await store.receive(\.event, timeout: Self.timeout) + let reviewer = try #require(store.state.sessions[runID]?.run.bindings["reviewer"]?.pane) + await store.send( + .deliver( + WorkflowDeliveryRequest( + requestID: UUID(), runID: runID, ordinal: 2, selector: .token(Self.secondToken), body: "findings", + verdict: nil, source: "pane"))) + await store.receive(.event(runID: runID, .outputPersisted(ordinal: 2)), timeout: Self.timeout) + return reviewer + } + + /// Bookkeeping survives a fence; only pane- and worktree-facing effects are revocable. + @Test func onlyPaneAndWorktreeFacingEffectsAreRevocable() { + let pane = UUID() + let request = WorkflowLaunchRequest( + role: "r", ordinal: 1, profile: Self.reviewerProfile, prompt: "p", environment: [:], placement: .tab, + direction: .right, background: false, anchorSurfaceID: nil, skill: nil, expectsDelivery: true, + redelivery: false) + #expect(WorkflowRunEffect.openActivation(role: "r", surfaceID: pane, ordinal: 1).isRevocable) + #expect( + WorkflowRunEffect.inject(role: "r", surfaceID: pane, ordinal: 1, line: "l", opensActivation: true).isRevocable) + #expect(WorkflowRunEffect.typeLine(role: "r", surfaceID: pane, line: "l").isRevocable) + #expect(WorkflowRunEffect.launch(request).isRevocable) + #expect(WorkflowRunEffect.runAction(stepID: "s", actionID: "git.context", inputs: [:]).isRevocable) + #expect(!WorkflowRunEffect.completeActivation(dispatchID: "d", summary: "s").isRevocable) + #expect(!WorkflowRunEffect.abandonActivation(dispatchID: "d", reason: "r").isRevocable) + #expect(!WorkflowRunEffect.persist.isRevocable) + #expect(!WorkflowRunEffect.persistOutput(name: "o", ordinal: 1, body: "b").isRevocable) + #expect(!WorkflowRunEffect.log("l").isRevocable) + #expect(WorkflowRunEffect.close(role: "r", surfaceID: pane).isRevocable, "cancel never closes panes") + #expect(!WorkflowRunEffect.notify("n").isRevocable) + #expect(!WorkflowRunEffect.notify("n").isRevocable) + #expect(!WorkflowRunEffect.finished(.cancelled).isRevocable) + } + + // MARK: - Start rendezvous + + /// A self-initiated `run` is answered only once its first activation is open, so the caller + /// never holds a completion command before the record `done` is attributed by exists. + @Test(.dependencies) func aSelfInitiatedRunIsAnsweredOnceItsActivationIsOpen() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let queue = RecordingQueue() + let store = makeStore(fixture, queue: queue.client) + let (session, effects) = try fixture.session(selfInitiated: true) + let runID = session.run.id + #expect(session.run.phase == .injecting(ordinal: 1)) + #expect(effects.contains(.openActivation(role: "author", surfaceID: Self.authorPane.surfaceID, ordinal: 1))) + let requestID = UUID() + await store.send(.started(session, effects: effects, requestID: requestID)) { + $0.pendingStarts[requestID] = runID + } + #expect(fixture.responses.isEmpty) + await store.send(.event(runID: runID, .injectionSucceeded(ordinal: 1, dispatchID: "dispatch-1"))) { + $0.pendingStarts = [:] + } + await store.finish(timeout: Self.timeout) + #expect(fixture.responses.count == 1) + guard case .started(let run) = fixture.responses[0].resolution else { + Issue.record("expected a started resolution") + return + } + #expect(run.phase == .waitingForDelivery(ordinal: 1)) + #expect(run.activeActivation?.dispatchID == "dispatch-1") + + // A failed opening answers too, with the run in attention. + let (second, secondEffects) = try fixture.session(selfInitiated: true) + let secondRequest = UUID() + await store.send(.started(second, effects: secondEffects, requestID: secondRequest)) + await store.send(.event(runID: second.run.id, .injectionFailed(ordinal: 1, .surfaceMissing))) + await store.finish(timeout: Self.timeout) + guard case .started(let failed) = fixture.responses[1].resolution else { + Issue.record("expected a started resolution") + return + } + #expect(failed.status.attention?.reason.code == "injection_failed:surface_missing") + await store.send(.userAction(runID: runID, .cancel)) + await store.send(.userAction(runID: second.run.id, .cancel)) + await store.finish(timeout: Self.timeout) + } + + // MARK: - Idle wait outcomes + + @Test(.dependencies) func aBlockedRoleDuringTheIdleWaitRaisesAttention() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + fixture.roleWaitOutcome = .blocked + let queue = RecordingQueue() + let store = makeStore(fixture, queue: queue.client) + let (session, effects) = try fixture.session() + let runID = session.run.id + await store.send(.started(session, effects: effects)) + await store.receive( + .event(runID: runID, .roleUnavailable(ordinal: 1, .roleBlocked)), timeout: Self.timeout) + #expect(store.state.sessions[runID]?.run.status.attention?.reason.code == "injection_failed:role_blocked") + await store.send(.userAction(runID: runID, .cancel)) + await store.finish(timeout: Self.timeout) + } + + @Test(.dependencies) func aPaneWithAForeignPendingDispatchEndsTheIdleWaitInAttention() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + fixture.roleWaitOutcome = .dispatchPending("someone-elses-dispatch") + let queue = RecordingQueue() + let store = makeStore(fixture, queue: queue.client) + let (session, effects) = try fixture.session() + let runID = session.run.id + await store.send(.started(session, effects: effects)) + await store.receive(\.event, timeout: Self.timeout) + #expect( + store.state.sessions[runID]?.run.status.attention?.reason.code == "injection_failed:activation_unavailable") + #expect(store.state.sessions[runID]?.run.status.attention?.message.contains("someone-elses-dispatch") == true) + #expect(fixture.typed.isEmpty) + await store.send(.userAction(runID: runID, .cancel)) + await store.finish(timeout: Self.timeout) + } + + // MARK: - Restart scan + + @Test(.dependencies) func interruptedRunsAreMarkedOncePerWorktreeRoot() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let (session, _) = try fixture.session() + try session.store.ensureLayout(runID: session.run.id) + try session.store.writeRecord(WorkflowRunRecord(run: session.run)) + let queue = RecordingQueue() + let store = makeStore(fixture, queue: queue.client) + let root = fixture.root.path(percentEncoded: false) + + await store.send(.markInterruptedRuns(worktreeRoots: [root])) { + $0.scannedWorktreeRoots = [root] + } + await store.finish(timeout: Self.timeout) + #expect(try session.store.readRecord(runID: session.run.id).run.status.state == "interrupted") + + // A second scan of the same root is a no-op even after a new run started there. + await store.send(.started(session, effects: [])) + await store.send(.markInterruptedRuns(worktreeRoots: [root])) + await store.finish(timeout: Self.timeout) + #expect(store.state.sessions[session.run.id]?.run.status == .running) + await store.send(.userAction(runID: session.run.id, .cancel)) + await store.finish(timeout: Self.timeout) + } + + // MARK: - Helpers + + /// The session after its first message was typed: activation 1 waits on `dispatch-0`. + private func waitingForDelivery(_ session: WorkflowRunSession) throws -> WorkflowRunSession { + var machine = session.machine(now: { Self.now }, makeToken: { "TOKEN-1" }) + _ = machine.apply(.roleIdle(ordinal: 1)) + _ = machine.apply(.injectionSucceeded(ordinal: 1, dispatchID: "dispatch-0")) + #expect(machine.run.phase == .waitingForDelivery(ordinal: 1)) + var waiting = session + waiting.run = machine.run + return waiting + } +} + +/// The per-run FIFO's fence: everything enqueued before it is stale, later batches are not. +@MainActor +struct WorkflowEffectQueueTests { + @Test func aFenceMarksEarlierBatchesStaleAndLaterOnesLive() async throws { + let queue = WorkflowEffectQueue() + let runID = UUID() + let stream = queue.start(runID) + let session = try WorkflowRunsFeatureTests.Fixture().session().0 + queue.enqueue(runID, WorkflowEffectBatch(session: session, effects: [.log("one")])) + queue.enqueue(runID, WorkflowEffectBatch(session: session, effects: [.log("two")])) + queue.fence(runID) + queue.enqueue(runID, WorkflowEffectBatch(session: session, effects: [.log("three")])) + queue.finish(runID) + var seen: [(sequence: Int, stale: Bool)] = [] + for await batch in stream { + seen.append((batch.sequence, queue.isStale(runID, sequence: batch.sequence))) + } + #expect(seen.map(\.sequence) == [1, 2, 3]) + // After `finish` nothing is known about the run: every sequence reads stale. + #expect(queue.isStale(runID, sequence: 3)) + let queue2 = WorkflowEffectQueue() + _ = queue2.start(runID) + queue2.enqueue(runID, WorkflowEffectBatch(session: session, effects: [.log("one")])) + queue2.enqueue(runID, WorkflowEffectBatch(session: session, effects: [.log("two")])) + queue2.fence(runID) + queue2.enqueue(runID, WorkflowEffectBatch(session: session, effects: [.log("three")])) + #expect(queue2.isStale(runID, sequence: 1)) + #expect(queue2.isStale(runID, sequence: 2)) + #expect(!queue2.isStale(runID, sequence: 3)) + } +} diff --git a/supacodeTests/WorkflowRuntimeCoordinatorTests.swift b/supacodeTests/WorkflowRuntimeCoordinatorTests.swift new file mode 100644 index 000000000..c02c65e94 --- /dev/null +++ b/supacodeTests/WorkflowRuntimeCoordinatorTests.swift @@ -0,0 +1,543 @@ +// supacodeTests/WorkflowRuntimeCoordinatorTests.swift +// `prowl workflow status / done / cancel` attribution and responses (docs-ai 063 B3, W1/W3/W5). + +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct WorkflowRuntimeCoordinatorTests { + nonisolated private static let now = Date(timeIntervalSince1970: 1_760_000_000) + + @MainActor + final class Fixture { + let root: URL + var sessions: [WorkflowRunSession] = [] + var sent: [WorkflowRunsFeature.Action] = [] + var pendingByPane: [UUID: String] = [:] + let rendezvous = WorkflowCLIRendezvous() + let requestID = UUID() + /// What the reducer would answer to a `.deliver`, applied synchronously inside `send`. + var answer: WorkflowRequestResolution? + private(set) var coordinator: WorkflowRuntimeCoordinator! + + init() throws { + root = + FileManager.default.temporaryDirectory + .appending(path: "workflow-coordinator-tests-\(UUID().uuidString)", directoryHint: .isDirectory) + .standardizedFileURL + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + coordinator = WorkflowRuntimeCoordinator( + dependencies: WorkflowRuntimeCoordinator.Dependencies( + admissionEnvironment: { fatalError("admission is covered by WorkflowRunAdmissionTests") }, + sessions: { [self] in sessions }, + send: { [self] action in + sent.append(action) + if case .deliver(let request) = action, let answer { + coordinator.resolve(request.requestID, answer) + } + if case .userAction(let runID, .cancel) = action, + let index = sessions.firstIndex(where: { $0.run.id == runID }) + { + var machine = sessions[index].machine(now: { WorkflowRuntimeCoordinatorTests.now }, makeToken: { "T" }) + _ = machine.apply(.user(.cancel)) + sessions[index].run = machine.run + } + }, + pendingDispatchID: { [self] surfaceID in pendingByPane[surfaceID] }, + worktreeRoots: { [self] in [root] }, + rendezvous: rendezvous, + makeRequestID: { [self] in requestID })) + } + + func cleanUp() { + try? FileManager.default.removeItem(at: root) + } + + /// A review run whose first activation (`brief`, ordinal 1) waits on `dispatch-1` in the author pane. + func waitingSession() throws -> WorkflowRunSession { + let definition = try #require(WorkflowDocumentParser.parse(WorkflowRunMachineTests.adversarialReview).definition) + let counter = WorkflowRunMachineTests.TokenCounter() + let started = try WorkflowRunMachine.start( + WorkflowRunStartRequest( + definition: definition, + runID: UUID(), + context: WorkflowRunContext( + scope: .user, definitionPath: nil, + worktree: WorkflowRunWorktree( + id: "wt", name: "feature", branch: "feat/x", path: root.path(percentEncoded: false))), + bindings: [ + "author": .current(WorkflowRunMachineTests.authorPane), + "reviewer": .launch(WorkflowRunMachineTests.reviewerProfile, pane: nil), + ]), + now: { WorkflowRuntimeCoordinatorTests.now }, + makeToken: { counter.next() }) + var machine = started.machine + _ = machine.apply(.roleIdle(ordinal: 1)) + _ = machine.apply(.injectionSucceeded(ordinal: 1, dispatchID: "dispatch-1")) + pendingByPane[WorkflowRunMachineTests.authorPane.surfaceID] = "dispatch-1" + return WorkflowRunSession( + run: machine.run, + worktree: Worktree(id: "wt", name: "feature", detail: "", workingDirectory: root, repositoryRootURL: root), + launchPlans: [:]) + } + } + + private static let authorCaller = CallerPane( + worktreeID: "wt", surfaceID: WorkflowRunMachineTests.authorPane.surfaceID) + private static let strangerCaller = CallerPane(worktreeID: "wt", surfaceID: UUID()) + + private func delivered(_ session: WorkflowRunSession) throws -> WorkflowRequestResolution { + var machine = session.machine(now: { Self.now }, makeToken: { "T" }) + let (result, _) = machine.deliver( + ordinal: 1, selector: .token("TOKEN-1"), body: "## Scope\nx\n## Claims\ny", verdict: nil) + _ = machine.apply(.outputPersisted(ordinal: 1)) + return .delivered(run: machine.run, receipt: try result.get()) + } + + private func payload(_ response: CommandResponse) throws -> WorkflowCommandPayload { + try JSONDecoder().decode(WorkflowCommandPayload.self, from: try #require(response.data).bytes) + } + + // MARK: - done attribution (decision W3) + + @Test func doneFromTheRolePaneIsAttributedByItsPendingDispatch() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let session = try fixture.waitingSession() + fixture.sessions = [session] + fixture.answer = try delivered(session) + + let response = await fixture.coordinator.done( + WorkflowInput(action: .done, body: "## Scope\nx\n## Claims\ny", token: "TOKEN-1"), callerPane: Self.authorCaller) + #expect(response.ok, "\(response.error?.message ?? "")") + guard case .deliver(let request) = fixture.sent.first else { + Issue.record("expected a deliver action") + return + } + #expect(request.requestID == fixture.requestID) + #expect(request.runID == session.run.id) + #expect(request.ordinal == 1) + #expect(request.selector == .token("TOKEN-1")) + #expect(request.source == "pane") + #expect(request.body == "## Scope\nx\n## Claims\ny") + guard case .done(let done) = try payload(response) else { + Issue.record("expected a done payload") + return + } + #expect(done.delivery.state == .delivered) + #expect(done.delivery.role == "author") + #expect(done.delivery.output.name == "brief") + #expect(done.run.role == "author") + #expect(fixture.rendezvous.pendingRequestIDs.isEmpty) + } + + @Test func doneWithoutABodyOrHalfAManualTargetIsInvalid() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let noBody = await fixture.coordinator.done(WorkflowInput(action: .done), callerPane: Self.authorCaller) + #expect(noBody.error?.code == CLIErrorCode.invalidArgument) + let half = await fixture.coordinator.done( + WorkflowInput(action: .done, runID: UUID().uuidString, body: "x"), callerPane: Self.authorCaller) + #expect(half.error?.code == CLIErrorCode.invalidArgument) + let badID = await fixture.coordinator.done( + WorkflowInput(action: .done, runID: "nope", stepID: "brief", body: "x"), callerPane: nil) + #expect(badID.error?.code == CLIErrorCode.invalidArgument) + #expect(fixture.sent.isEmpty) + } + + @Test func doneOutsideAnyPaneNeedsAnExplicitTargetAndThenIsManual() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let session = try fixture.waitingSession() + fixture.sessions = [session] + fixture.answer = .failed(code: CLIErrorCode.stepNotExpecting, message: "no") + + let missing = await fixture.coordinator.done(WorkflowInput(action: .done, body: "x"), callerPane: nil) + #expect(missing.error?.code == CLIErrorCode.sourceRequired) + #expect(fixture.sent.isEmpty) + + let manual = await fixture.coordinator.done( + WorkflowInput(action: .done, runID: session.run.id.uuidString, stepID: "brief", body: "x"), callerPane: nil) + #expect(manual.error?.code == CLIErrorCode.stepNotExpecting, "the reducer's answer is passed through") + guard case .deliver(let request) = fixture.sent.first else { + Issue.record("expected a deliver action") + return + } + #expect(request.ordinal == nil) + #expect(request.selector == .manual(stepID: "brief")) + #expect(request.source == "manual") + + let unknown = await fixture.coordinator.done( + WorkflowInput(action: .done, runID: UUID().uuidString, stepID: "brief", body: "x"), callerPane: nil) + #expect(unknown.error?.code == CLIErrorCode.runNotFound) + } + + @Test func aPaneWithoutAnActivationCanStillDeliverManuallyButNotImplicitly() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let session = try fixture.waitingSession() + fixture.sessions = [session] + fixture.answer = .failed(code: CLIErrorCode.stepNotExpecting, message: "no") + + let implicit = await fixture.coordinator.done( + WorkflowInput(action: .done, body: "x"), callerPane: Self.strangerCaller) + #expect(implicit.error?.code == CLIErrorCode.stepNotExpecting) + #expect(fixture.sent.isEmpty) + + _ = await fixture.coordinator.done( + WorkflowInput(action: .done, runID: session.run.id.uuidString, stepID: "brief", body: "x"), + callerPane: Self.strangerCaller) + guard case .deliver(let request) = fixture.sent.first else { + Issue.record("expected a deliver action") + return + } + #expect(request.source == "manual") + } + + @Test func anExplicitTargetThatDisagreesWithTheCallerPaneNeedsForce() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let session = try fixture.waitingSession() + fixture.sessions = [session] + fixture.answer = .failed(code: CLIErrorCode.stepNotExpecting, message: "no") + + let mismatch = await fixture.coordinator.done( + WorkflowInput(action: .done, runID: session.run.id.uuidString, stepID: "launch", body: "x"), + callerPane: Self.authorCaller) + #expect(mismatch.error?.code == CLIErrorCode.roleMismatch) + #expect(fixture.sent.isEmpty) + + let agreeing = await fixture.coordinator.done( + WorkflowInput(action: .done, runID: session.run.id.uuidString, stepID: "brief", body: "x", token: "TOKEN-1"), + callerPane: Self.authorCaller) + #expect(agreeing.error?.code == CLIErrorCode.stepNotExpecting) + guard case .deliver(let agreed) = fixture.sent.last else { + Issue.record("expected a deliver action") + return + } + #expect(agreed.source == "pane") + #expect(agreed.ordinal == 1) + + _ = await fixture.coordinator.done( + WorkflowInput(action: .done, runID: session.run.id.uuidString, stepID: "launch", body: "x", force: true), + callerPane: Self.authorCaller) + guard case .deliver(let forced) = fixture.sent.last else { + Issue.record("expected a deliver action") + return + } + #expect(forced.source == "manual --force") + #expect(forced.selector == .manual(stepID: "launch")) + } + + @Test func doneAwaitsTheReducerAndAProvisionalAnswerIsReportedAsProvisional() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let session = try fixture.waitingSession() + fixture.sessions = [session] + // No synchronous answer: the reducer resolves later, after persistence. + let task = Task { @MainActor in + await fixture.coordinator.done( + WorkflowInput(action: .done, body: "## Scope\nonly", token: "TOKEN-1"), callerPane: Self.authorCaller) + } + await Task.yield() + #expect(fixture.rendezvous.pendingRequestIDs == [fixture.requestID]) + var machine = session.machine(now: { Self.now }, makeToken: { "T" }) + let (result, _) = machine.deliver(ordinal: 1, selector: .token("TOKEN-1"), body: "## Scope\nonly", verdict: nil) + _ = machine.apply(.outputPersisted(ordinal: 1)) + fixture.coordinator.resolve(fixture.requestID, .provisional(run: machine.run, receipt: try result.get())) + let response = await task.value + #expect(response.ok) + guard case .done(let done) = try payload(response) else { + Issue.record("expected a done payload") + return + } + #expect(done.delivery.state == .provisional) + #expect(done.delivery.warnings.map(\.code) == ["missing_sections"]) + #expect(done.run.status.state == "needs_attention") + #expect(done.run.status.attention?.issues == ["missing_sections"]) + } + + @Test func aDuplicateRequestIDIsRefusedWithoutEnteringTheReducer() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let session = try fixture.waitingSession() + fixture.sessions = [session] + // No synchronous answer: the first request stays pending under the fixed request id. + let first = Task { @MainActor in + await fixture.coordinator.done( + WorkflowInput(action: .done, body: "x", token: "TOKEN-1"), callerPane: Self.authorCaller) + } + await Task.yield() + #expect(fixture.rendezvous.pendingRequestIDs == [fixture.requestID]) + let duplicate = await fixture.coordinator.done( + WorkflowInput(action: .done, body: "y", token: "TOKEN-1"), callerPane: Self.authorCaller) + #expect(duplicate.error?.code == CLIErrorCode.requestConflict) + #expect(fixture.sent.count == 1, "the duplicate never reaches the reducer") + fixture.coordinator.resolve(fixture.requestID, .failed(code: CLIErrorCode.stepNotExpecting, message: "no")) + #expect((await first.value).error?.code == CLIErrorCode.stepNotExpecting) + } + + /// A waiter the socket cancelled frees its slot, but its request id stays in flight until the + /// reducer answers: a reused id can neither be answered by the old transaction nor inherit + /// its verified caller role. + @Test func aCancelledWaitersRequestIDStaysUnusableUntilTheReducerAnswers() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let session = try fixture.waitingSession() + fixture.sessions = [session] + let first = Task { @MainActor in + await fixture.coordinator.done( + WorkflowInput(action: .done, body: "x", token: "TOKEN-1"), callerPane: Self.authorCaller) + } + await Task.yield() + first.cancel() + #expect((await first.value).error?.code == CLIErrorCode.requestCancelled) + #expect(fixture.rendezvous.pendingRequestIDs.isEmpty) + + let reuse = await fixture.coordinator.done( + WorkflowInput(action: .done, runID: session.run.id.uuidString, stepID: "brief", body: "y"), callerPane: nil) + #expect(reuse.error?.code == CLIErrorCode.requestConflict) + #expect(fixture.sent.count == 1) + + // The old transaction's answer goes nowhere, and only then is the id free again. + fixture.coordinator.resolve(fixture.requestID, .failed(code: CLIErrorCode.stepNotExpecting, message: "late")) + fixture.answer = .failed(code: CLIErrorCode.stepNotExpecting, message: "fresh") + let fresh = await fixture.coordinator.done( + WorkflowInput(action: .done, runID: session.run.id.uuidString, stepID: "brief", body: "y"), callerPane: nil) + #expect(fresh.error?.message == "fresh") + #expect(fixture.sent.count == 2) + } + + /// Tokens are spelled only to the pane that owns the activation: a manual delivery advancing + /// the run to the same role's next step must not learn that step's completion command. + @Test func aManualDeliveryIsAnsweredWithoutTheNextActivationsToken() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let session = try fixture.waitingSession() + fixture.sessions = [session] + // The delivered resolution's run has advanced to the launch step, whose activation waits. + var machine = session.machine(now: { Self.now }, makeToken: { "TOKEN-2" }) + let (result, _) = machine.deliver( + ordinal: nil, selector: .manual(stepID: "brief"), body: "## Scope\nx\n## Claims\ny", verdict: nil) + _ = machine.apply(.outputPersisted(ordinal: 1)) + #expect(machine.run.phase == .launching(ordinal: 2)) + let reviewerPane = WorkflowPaneIdentity(surfaceID: UUID(), tabID: nil, handle: "p9", displayName: "Pi", agent: "pi") + _ = machine.apply(.launched(ordinal: 2, pane: reviewerPane, dispatchID: "dispatch-2")) + fixture.answer = .delivered(run: machine.run, receipt: try result.get()) + + let response = await fixture.coordinator.done( + WorkflowInput( + action: .done, runID: session.run.id.uuidString, stepID: "brief", body: "## Scope\nx\n## Claims\ny"), + callerPane: nil) + #expect(response.ok, "\(response.error?.message ?? "")") + guard case .done(let done) = try payload(response) else { + Issue.record("expected a done payload") + return + } + #expect(done.delivery.role == "author") + #expect(done.run.role == nil) + #expect(done.run.activation?.role == "reviewer") + #expect(done.run.activation?.expect.completion == [], "a manual caller is not the reviewer's pane") + } + + /// `run` spells the completion command only for the caller's own activation: a workflow whose + /// first awaited step is a launch must not hand the launcher the reviewer's token. + @Test func aRunResponseNeverSpellsAnotherRolesCompletion() throws { + let yaml = """ + schema: prowl.workflow/v1 + id: launch-first + name: Launch First + roles: + author: + source: current + reviewer: + source: launch + steps: + - id: launch + launch: reviewer + prompt: "Review." + expect: { output: findings } + """ + let definition = try #require(WorkflowDocumentParser.parse(yaml).definition) + let started = try WorkflowRunMachine.start( + WorkflowRunStartRequest( + definition: definition, runID: UUID(), + context: WorkflowRunContext( + scope: .user, definitionPath: nil, + worktree: WorkflowRunWorktree(id: "wt", name: "feature", branch: "feat/x", path: "/tmp")), + bindings: [ + "author": .current(WorkflowRunMachineTests.authorPane), + "reviewer": .launch(WorkflowRunMachineTests.reviewerProfile, pane: nil), + ], + selfInitiated: true), + now: { Self.now }, makeToken: { "SECRET" }) + var machine = started.machine + let reviewerPane = WorkflowPaneIdentity(surfaceID: UUID(), tabID: nil, handle: "p9", displayName: "Pi", agent: "pi") + _ = machine.apply(.launched(ordinal: 1, pane: reviewerPane, dispatchID: "dispatch-1")) + #expect(machine.run.currentActivation?.role == "reviewer") + + let asAuthor = WorkflowRunPayload(run: machine.run, callerRole: "author", includeSelfInitiated: true) + #expect(asAuthor.activation?.expect.completion == []) + #expect(asAuthor.selfInitiated == nil) + let asNobody = WorkflowRunPayload(run: machine.run, callerRole: nil, includeSelfInitiated: true) + #expect(asNobody.activation?.expect.completion == []) + let asReviewer = WorkflowRunPayload(run: machine.run, callerRole: "reviewer", includeSelfInitiated: false) + #expect(asReviewer.activation?.expect.completion == ["PROWL_WORKFLOW_TOKEN=SECRET prowl workflow done -"]) + } + + /// `agents dispatch-complete` is refused for a workflow activation even after its run ended: + /// the abandon is queued behind earlier work and must not lose to a plain completion. + @Test func deliveryRefusalCoversTerminalRuns() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let session = try fixture.waitingSession() + let live = WorkflowRuntimeCoordinator.deliveryRefusal(dispatchID: "dispatch-1", sessions: [session]) + #expect(live?.code == CLIErrorCode.workflowDeliveryRequired) + #expect(live?.message.contains("prowl workflow done") == true) + var machine = session.machine(now: { Self.now }, makeToken: { "T" }) + _ = machine.apply(.user(.cancel)) + var ended = session + ended.run = machine.run + let terminal = WorkflowRuntimeCoordinator.deliveryRefusal(dispatchID: "dispatch-1", sessions: [ended]) + #expect(terminal?.code == CLIErrorCode.workflowDeliveryRequired) + #expect(terminal?.message.contains("already ended") == true) + #expect(WorkflowRuntimeCoordinator.deliveryRefusal(dispatchID: "unrelated", sessions: [session, ended]) == nil) + } + + // MARK: - status (decision W5) + + @Test func statusReadsTheCallerPaneRunALiveRunOrARecordedRun() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let session = try fixture.waitingSession() + fixture.sessions = [session] + + let mine = fixture.coordinator.status(WorkflowInput(action: .status), callerPane: Self.authorCaller) + guard case .status(let whoAmI) = try payload(mine) else { + Issue.record("expected a status payload") + return + } + #expect(whoAmI.source == .live) + #expect(whoAmI.role == "author") + #expect(whoAmI.step == "brief") + #expect(whoAmI.activation?.expect.completion == ["PROWL_WORKFLOW_TOKEN=TOKEN-1 prowl workflow done -"]) + #expect(whoAmI.selfInitiated == nil) + + let stranger = fixture.coordinator.status(WorkflowInput(action: .status), callerPane: Self.strangerCaller) + #expect(stranger.error?.code == CLIErrorCode.runNotFound) + let outside = fixture.coordinator.status(WorkflowInput(action: .status), callerPane: nil) + #expect(outside.error?.code == CLIErrorCode.sourceRequired) + + let byID = fixture.coordinator.status( + WorkflowInput(action: .status, runID: session.run.id.uuidString), callerPane: Self.strangerCaller) + guard case .status(let other) = try payload(byID) else { + Issue.record("expected a status payload") + return + } + #expect(other.role == nil) + #expect(other.activation?.expect.completion == [], "tokens are spelled only to the role's own pane") + + // A run that is not live any more is read back from its record: no activation, no tokens. + try session.store.ensureLayout(runID: session.run.id) + try session.store.writeRecord(WorkflowRunRecord(run: session.run).interrupted(at: Self.now)) + fixture.sessions = [] + let recorded = fixture.coordinator.status( + WorkflowInput(action: .status, runID: session.run.id.uuidString), callerPane: nil) + guard case .status(let record) = try payload(recorded) else { + Issue.record("expected a status payload") + return + } + #expect(record.source == .record) + #expect(record.status.state == "interrupted") + #expect(record.activation == nil) + #expect(record.bindings["author"]?.pane?.handle == "p1") + + let missing = fixture.coordinator.status(WorkflowInput(action: .status, runID: UUID().uuidString), callerPane: nil) + #expect(missing.error?.code == CLIErrorCode.runNotFound) + let malformed = fixture.coordinator.status(WorkflowInput(action: .status, runID: "x"), callerPane: nil) + #expect(malformed.error?.code == CLIErrorCode.invalidArgument) + } + + /// An invocation stuck in an injection attention has no activation `done` could address, so + /// `status` must not advertise one (the caller would otherwise retry `done` forever). + @Test func statusReportsNoActivationWhileTheStepIsInAnInjectionAttention() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + var session = try fixture.waitingSession() + var machine = session.machine(now: { Self.now }, makeToken: { "T" }) + _ = machine.apply(.user(.cancel)) + _ = session + // Rebuild a fresh session whose first step failed to inject. + let definition = try #require(WorkflowDocumentParser.parse(WorkflowRunMachineTests.adversarialReview).definition) + let started = try WorkflowRunMachine.start( + WorkflowRunStartRequest( + definition: definition, runID: UUID(), + context: WorkflowRunContext( + scope: .user, definitionPath: nil, + worktree: WorkflowRunWorktree( + id: "wt", name: "feature", branch: "feat/x", path: fixture.root.path(percentEncoded: false))), + bindings: [ + "author": .current(WorkflowRunMachineTests.authorPane), + "reviewer": .launch(WorkflowRunMachineTests.reviewerProfile, pane: nil), + ]), + now: { Self.now }, makeToken: { "T" }) + machine = started.machine + _ = machine.apply(.roleUnavailable(ordinal: 1, .roleBlocked)) + session = WorkflowRunSession( + run: machine.run, + worktree: Worktree( + id: "wt", name: "feature", detail: "", workingDirectory: fixture.root, repositoryRootURL: fixture.root), + launchPlans: [:]) + fixture.sessions = [session] + let response = fixture.coordinator.status(WorkflowInput(action: .status), callerPane: Self.authorCaller) + guard case .status(let payload) = try payload(response) else { + Issue.record("expected a status payload") + return + } + #expect(payload.status.state == "needs_attention") + #expect(payload.status.attention?.reason == "injection_failed:role_blocked") + #expect(payload.activation == nil) + } + + /// A pane a finished run kept is free again: reservations are pruned against every run that + /// ever bound the pane, not only the active ones. + @Test func reservationsAreReleasedOncePaneWasBoundEvenByAFinishedRun() { + let reservations = WorkflowPaneReservations() + let launched = UUID() + let gone = UUID() + reservations.reserve(launched) + reservations.reserve(gone) + #expect(reservations.pending(everBound: [], isLive: { _ in true }) == [launched, gone]) + #expect(reservations.pending(everBound: [], isLive: { $0 == launched }) == [launched]) + #expect(reservations.pending(everBound: [launched], isLive: { _ in true }).isEmpty) + #expect(reservations.pending(everBound: [], isLive: { _ in true }).isEmpty, "pruning is permanent") + } + + // MARK: - cancel + + @Test func cancelEntersTheReducerAndReportsTheEndedRun() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let session = try fixture.waitingSession() + fixture.sessions = [session] + + let response = fixture.coordinator.cancel( + WorkflowInput(action: .cancel, runID: session.run.id.uuidString), callerPane: Self.authorCaller) + #expect(fixture.sent == [.userAction(runID: session.run.id, .cancel)]) + guard case .cancel(let cancelled) = try payload(response) else { + Issue.record("expected a cancel payload") + return + } + #expect(cancelled.status.state == "cancelled") + #expect(cancelled.role == "author") + + let again = fixture.coordinator.cancel( + WorkflowInput(action: .cancel, runID: session.run.id.uuidString), callerPane: nil) + #expect(again.error?.code == CLIErrorCode.runNotFound) + #expect(again.error?.message.contains("already ended") == true) + let unknown = fixture.coordinator.cancel(WorkflowInput(action: .cancel, runID: UUID().uuidString), callerPane: nil) + #expect(unknown.error?.code == CLIErrorCode.runNotFound) + } +} diff --git a/supacodeTests/WorkflowWatchdogTests.swift b/supacodeTests/WorkflowWatchdogTests.swift index 3ce5e0a42..7a5e5076f 100644 --- a/supacodeTests/WorkflowWatchdogTests.swift +++ b/supacodeTests/WorkflowWatchdogTests.swift @@ -54,28 +54,48 @@ struct WorkflowWatchdogPolicyTests { var policy = WorkflowWatchdogPolicy(settings: settings, timeoutSeconds: nil, nudgedAlready: false) _ = policy.apply(.armed(exact)) _ = policy.apply(.turnEnded) - #expect(policy.apply(.deadline(.turnGrace, working())) == []) + let reArm: WorkflowWatchdogCommands = [.schedule(.turnGrace, .seconds(15))] + #expect(policy.apply(.deadline(.turnGrace, working())) == reArm) #expect(!policy.nudged) _ = policy.apply(.turnEnded) #expect(policy.apply(.signal(.progress)) == []) - #expect(policy.apply(.deadline(.turnGrace, idle())) == []) + #expect(policy.apply(.deadline(.turnGrace, idle())) == reArm) _ = policy.apply(.turnEnded) #expect(policy.apply(.signal(.sessionStart)) == []) - #expect(policy.apply(.deadline(.turnGrace, idle())) == []) + #expect(policy.apply(.deadline(.turnGrace, idle())) == reArm) _ = policy.apply(.turnEnded) #expect(policy.apply(.detector(state: "working")) == []) - #expect(policy.apply(.deadline(.turnGrace, idle())) == []) + #expect(policy.apply(.deadline(.turnGrace, idle())) == reArm) _ = policy.apply(.turnEnded) #expect(policy.apply(.deadline(.turnGrace, idle())) == [.emit(.nudge), .schedule(.idleGrace, .seconds(180))]) } + /// Seen live (063 B3): a launched agent answered before the detector first saw it, so the + /// detector's first `working` arrived after the exact `turn-ended`. Activity at the grace + /// expiry must re-arm the grace, never leave the watchdog waiting for an event that never comes. + @Test func activityAtATurnGraceExpiryReArmsTheGraceInsteadOfGoingSilent() { + var policy = WorkflowWatchdogPolicy(settings: settings, timeoutSeconds: nil, nudgedAlready: false) + _ = policy.apply( + .armed( + WorkflowWatchdogSnapshot(state: "absent", liveChannelCoversTurnEnded: false, liveChannelCoversSessionEnd: false) + )) + #expect(policy.apply(.turnEnded) == [.schedule(.turnGrace, .seconds(15))]) + #expect(policy.apply(.detector(state: "working")) == [.cancel(.appearanceGrace)]) + #expect(policy.apply(.deadline(.turnGrace, idle())) == [.schedule(.turnGrace, .seconds(15))]) + #expect(policy.apply(.deadline(.turnGrace, idle())) == [.emit(.nudge), .schedule(.idleGrace, .seconds(180))]) + // The same after the nudge: a spurious activity mark at idle_grace re-arms idle_grace. + _ = policy.apply(.signal(.progress)) + #expect(policy.apply(.deadline(.idleGrace, idle())) == [.schedule(.idleGrace, .seconds(180))]) + #expect(policy.apply(.deadline(.idleGrace, idle())) == [.emit(.attention(.idleWithoutDelivery)), .stop]) + } + @Test func idleGraceReArmsWhileTheNudgedRoleWorksAndNeverNudgesTwice() { var policy = WorkflowWatchdogPolicy(settings: settings, timeoutSeconds: nil, nudgedAlready: false) _ = policy.apply(.armed(exact)) _ = policy.apply(.turnEnded) _ = policy.apply(.deadline(.turnGrace, idle())) - #expect(policy.apply(.deadline(.idleGrace, working())) == []) - #expect(policy.apply(.turnEnded) == [.schedule(.turnGrace, .seconds(15))]) + #expect(policy.apply(.deadline(.idleGrace, working())) == [.schedule(.idleGrace, .seconds(180))]) + #expect(policy.apply(.turnEnded) == [.cancel(.idleGrace), .schedule(.turnGrace, .seconds(15))]) #expect(policy.apply(.deadline(.turnGrace, idle())) == [.schedule(.idleGrace, .seconds(180))]) #expect(policy.apply(.deadline(.idleGrace, idle())) == [.emit(.attention(.idleWithoutDelivery)), .stop]) }