From e5db090117441fbceefdaac6d32e48a245bc76e9 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Wed, 11 Mar 2026 15:23:08 +0800 Subject: [PATCH 1/9] feat(flow): add flow DSL v1 with CLI runtime and tests --- .github/workflows/ci.yml | 5 + AGENTS.md | 18 + Package.resolved | 11 +- Package.swift | 2 + README.md | 31 + Sources/ScriptoriaCLI/CLI.swift | 1 + .../ScriptoriaCLI/Commands/FlowCommand.swift | 356 +++++ .../Execution/PostScriptAgentRunner.swift | 16 +- .../Execution/ScriptRunner.swift | 143 +- .../ScriptoriaCore/Flow/AgentStepRunner.swift | 162 ++ .../Flow/ExpressionEvaluator.swift | 119 ++ .../ScriptoriaCore/Flow/FlowCompiler.swift | 189 +++ .../ScriptoriaCore/Flow/FlowDefinition.swift | 329 ++++ .../Flow/FlowDryRunFixture.swift | 47 + Sources/ScriptoriaCore/Flow/FlowEngine.swift | 535 +++++++ Sources/ScriptoriaCore/Flow/FlowError.swift | 133 ++ .../Flow/FlowGateOutputParser.swift | 81 + .../Flow/FlowPathResolver.swift | 114 ++ .../Flow/FlowStepRunnerSupport.swift | 167 ++ .../ScriptoriaCore/Flow/FlowValidator.swift | 1085 +++++++++++++ Sources/ScriptoriaCore/Flow/FlowValue.swift | 192 +++ .../ScriptoriaCore/Flow/GateStepRunner.swift | 137 ++ .../Flow/ScriptStepRunner.swift | 146 ++ .../ScriptoriaCore/Flow/WaitStepRunner.swift | 52 + .../Flow/ExecutionM0Tests.swift | 79 + .../Flow/FlowCLIAdditionalTests.swift | 913 +++++++++++ .../Flow/FlowCLITests.swift | 230 +++ .../Flow/FlowCompileTests.swift | 387 +++++ .../Flow/FlowDocumentationExamplesTests.swift | 56 + .../Flow/FlowDryRunStrictnessTests.swift | 57 + .../Flow/FlowEngineAdditionalTests.swift | 464 ++++++ .../Flow/FlowEngineCoverageTests.swift | 586 +++++++ .../Flow/FlowEngineTests.swift | 397 +++++ .../Flow/FlowErrorBoundaryTests.swift | 240 +++ .../Flow/FlowPRGateScenarioTests.swift | 42 + .../Flow/FlowPlanCoverageTests.swift | 61 + .../Flow/FlowProviderE2ETests.swift | 162 ++ .../Flow/FlowTestSupport.swift | 69 + .../FlowYAMLAdditionalCoverageTests.swift | 381 +++++ .../Flow/FlowYAMLValidationTests.swift | 324 ++++ .../Flow/GateOutputParserTests.swift | 76 + .../ScriptoriaCLITests.swift | 12 +- Tests/ScriptoriaCoreTests/TestSupport.swift | 31 +- docs/examples/flow-v1/README.md | 26 + .../flow-v1/local-gate-script/flow.yaml | 42 + .../scripts/collect-summary.sh | 3 + .../local-gate-script/scripts/precheck.sh | 3 + .../flow-v1/pr-loop/fixture.success.json | 16 + docs/examples/flow-v1/pr-loop/flow.yaml | 72 + .../pr-loop/scripts/check-eslint-issues.sh | 3 + .../pr-loop/scripts/check-pr-ci-review.sh | 10 + docs/flow-dsl-architecture-plan.md | 1363 +++++++++++++++++ docs/flow-dsl-v1.md | 194 +++ docs/flow-migration-v1.md | 49 + docs/flow-tc-mapping.md | 242 +++ scripts/generate-flow-tc-mapping.sh | 70 + 56 files changed, 10711 insertions(+), 20 deletions(-) create mode 100644 Sources/ScriptoriaCLI/Commands/FlowCommand.swift create mode 100644 Sources/ScriptoriaCore/Flow/AgentStepRunner.swift create mode 100644 Sources/ScriptoriaCore/Flow/ExpressionEvaluator.swift create mode 100644 Sources/ScriptoriaCore/Flow/FlowCompiler.swift create mode 100644 Sources/ScriptoriaCore/Flow/FlowDefinition.swift create mode 100644 Sources/ScriptoriaCore/Flow/FlowDryRunFixture.swift create mode 100644 Sources/ScriptoriaCore/Flow/FlowEngine.swift create mode 100644 Sources/ScriptoriaCore/Flow/FlowError.swift create mode 100644 Sources/ScriptoriaCore/Flow/FlowGateOutputParser.swift create mode 100644 Sources/ScriptoriaCore/Flow/FlowPathResolver.swift create mode 100644 Sources/ScriptoriaCore/Flow/FlowStepRunnerSupport.swift create mode 100644 Sources/ScriptoriaCore/Flow/FlowValidator.swift create mode 100644 Sources/ScriptoriaCore/Flow/FlowValue.swift create mode 100644 Sources/ScriptoriaCore/Flow/GateStepRunner.swift create mode 100644 Sources/ScriptoriaCore/Flow/ScriptStepRunner.swift create mode 100644 Sources/ScriptoriaCore/Flow/WaitStepRunner.swift create mode 100644 Tests/ScriptoriaCoreTests/Flow/ExecutionM0Tests.swift create mode 100644 Tests/ScriptoriaCoreTests/Flow/FlowCLIAdditionalTests.swift create mode 100644 Tests/ScriptoriaCoreTests/Flow/FlowCLITests.swift create mode 100644 Tests/ScriptoriaCoreTests/Flow/FlowCompileTests.swift create mode 100644 Tests/ScriptoriaCoreTests/Flow/FlowDocumentationExamplesTests.swift create mode 100644 Tests/ScriptoriaCoreTests/Flow/FlowDryRunStrictnessTests.swift create mode 100644 Tests/ScriptoriaCoreTests/Flow/FlowEngineAdditionalTests.swift create mode 100644 Tests/ScriptoriaCoreTests/Flow/FlowEngineCoverageTests.swift create mode 100644 Tests/ScriptoriaCoreTests/Flow/FlowEngineTests.swift create mode 100644 Tests/ScriptoriaCoreTests/Flow/FlowErrorBoundaryTests.swift create mode 100644 Tests/ScriptoriaCoreTests/Flow/FlowPRGateScenarioTests.swift create mode 100644 Tests/ScriptoriaCoreTests/Flow/FlowPlanCoverageTests.swift create mode 100644 Tests/ScriptoriaCoreTests/Flow/FlowProviderE2ETests.swift create mode 100644 Tests/ScriptoriaCoreTests/Flow/FlowTestSupport.swift create mode 100644 Tests/ScriptoriaCoreTests/Flow/FlowYAMLAdditionalCoverageTests.swift create mode 100644 Tests/ScriptoriaCoreTests/Flow/FlowYAMLValidationTests.swift create mode 100644 Tests/ScriptoriaCoreTests/Flow/GateOutputParserTests.swift create mode 100644 docs/examples/flow-v1/README.md create mode 100644 docs/examples/flow-v1/local-gate-script/flow.yaml create mode 100755 docs/examples/flow-v1/local-gate-script/scripts/collect-summary.sh create mode 100755 docs/examples/flow-v1/local-gate-script/scripts/precheck.sh create mode 100644 docs/examples/flow-v1/pr-loop/fixture.success.json create mode 100644 docs/examples/flow-v1/pr-loop/flow.yaml create mode 100755 docs/examples/flow-v1/pr-loop/scripts/check-eslint-issues.sh create mode 100755 docs/examples/flow-v1/pr-loop/scripts/check-pr-ci-review.sh create mode 100644 docs/flow-dsl-architecture-plan.md create mode 100644 docs/flow-dsl-v1.md create mode 100644 docs/flow-migration-v1.md create mode 100644 docs/flow-tc-mapping.md create mode 100755 scripts/generate-flow-tc-mapping.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b563ecf..c0d7347 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,5 +28,10 @@ jobs: - name: Run full test suite run: swift test + - name: Verify Flow TC mapping is up to date + run: | + scripts/generate-flow-tc-mapping.sh + git diff --exit-code -- docs/flow-tc-mapping.md + - name: Run run+agent E2E test only run: swift test --filter ScriptoriaCoreTests.ScriptoriaCLITests/testRunCommandAgentStage diff --git a/AGENTS.md b/AGENTS.md index 7e87e53..9108c54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,6 +131,23 @@ scriptoria config show # Show current config scriptoria config set-dir ~/my-data # Set data directory ``` +### `scriptoria flow` — Flow DSL (state machine automation) + +#### Validate / Compile / Run / Dry-Run + +```bash +scriptoria flow validate ./flow.yaml +scriptoria flow compile ./flow.yaml --out ./flow.ir.json +scriptoria flow run ./flow.yaml --var repo=org/repo --max-agent-rounds 10 --command "/interrupt" +scriptoria flow dry-run ./flow.yaml --fixture ./fixture.json +``` + +Subcommands: +- `flow validate [--no-fs-check]` +- `flow compile --out [--no-fs-check]` +- `flow run [--var ...] [--max-agent-rounds ] [--no-steer] [--command ...]` +- `flow dry-run --fixture ` + ## Typical AI Workflow A complete example of adding a script, scheduling it, and verifying: @@ -164,6 +181,7 @@ scriptoria schedule list - Models: `Sources/ScriptoriaCore/Models/` (Script, ScriptRun, Schedule) - Storage: `Sources/ScriptoriaCore/Storage/` (ScriptStore, DatabaseManager, Config) - Execution: `Sources/ScriptoriaCore/Execution/ScriptRunner.swift` +- Flow: `Sources/ScriptoriaCore/Flow/` - Scheduling: `Sources/ScriptoriaCore/Scheduling/` (ScheduleStore, LaunchdHelper) - App views: `Sources/ScriptoriaApp/Views/` - App state: `Sources/ScriptoriaApp/AppState.swift` diff --git a/Package.resolved b/Package.resolved index 50dbb09..1edf4a4 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f37ffb57f1da30f844ac306ce34e28096f0621b6754d34d62c98d31a1a26993a", + "originHash" : "dc6c3b78875cdfc7ac48122857cbc8f5acd14f59703905a97b8d70bb126a6415", "pins" : [ { "identity" : "grdb.swift", @@ -18,6 +18,15 @@ "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", "version" : "1.7.0" } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams.git", + "state" : { + "revision" : "deaf82e867fa2cbd3cd865978b079bfcf384ac28", + "version" : "6.2.1" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 5875aec..2113241 100644 --- a/Package.swift +++ b/Package.swift @@ -14,6 +14,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), .package(url: "https://github.com/groue/GRDB.swift.git", from: "7.0.0"), + .package(url: "https://github.com/jpsim/Yams.git", from: "6.0.1"), ], targets: [ // Shared core library @@ -21,6 +22,7 @@ let package = Package( name: "ScriptoriaCore", dependencies: [ .product(name: "GRDB", package: "GRDB.swift"), + .product(name: "Yams", package: "Yams"), ], path: "Sources/ScriptoriaCore" ), diff --git a/README.md b/README.md index 7853334..e1f6a53 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ A full-featured command-line tool for terminal workflows and automation: scriptoria add ./backup.sh --title "Backup" --task-name "Daily Backup" --default-model gpt-5.3-codex --tags "daily,infra" scriptoria list --tag daily scriptoria run "Backup" --model gpt-5.3-codex --no-steer +scriptoria flow validate ./flows/pr-check.yaml +scriptoria flow run ./flows/pr-check.yaml --var repo=org/repo --command "/interrupt" scriptoria schedule add "Backup" --daily 09:00 scriptoria search "deploy" scriptoria tags @@ -35,6 +37,7 @@ scriptoria config show | `add ` | Register a script (title/description/interpreter/tags/agent task/model defaults) | | `list` | List scripts (filter by `--tag`, `--favorites`, `--recent`) | | `run ` | Execute a script, optional post-script agent stage (`--model`, `--agent-prompt`, `--command`, `--skip-agent`) | +| `flow validate/compile/run/dry-run` | Validate, compile, execute, and fixture-drive Flow DSL (`flow/v1`) | | `search ` | Search by title, description, or tags | | `remove ` | Remove a script from the database | | `tags` | List all tags with script counts | @@ -44,6 +47,34 @@ scriptoria config show | `config show` | Show current configuration | | `config set-dir ` | Change data directory | +### Flow DSL (`flow/v1`) + +Scriptoria supports state-machine automation flows for gate/agent/wait/script loops. + +```bash +# 1) Validate YAML +scriptoria flow validate ./flows/pr-check.yaml + +# 2) Compile to canonical IR JSON +scriptoria flow compile ./flows/pr-check.yaml --out ./build/pr-check.flow.json + +# 3) Execute with context overrides and command queue +scriptoria flow run ./flows/pr-check.yaml \ + --var repo=org/repo \ + --max-agent-rounds 10 \ + --command "Please focus only on flaky tests" + +# 4) Execute deterministic dry-run with fixture data +scriptoria flow dry-run ./flows/pr-check.yaml --fixture ./fixtures/pr-check.fixture.json +``` + +Flow docs: + +- [Flow DSL v1 Guide](docs/flow-dsl-v1.md) +- [Flow v1 Migration Notes](docs/flow-migration-v1.md) +- [Flow v1 Examples](docs/examples/flow-v1/README.md) +- [Flow TC Mapping](docs/flow-tc-mapping.md) + ### AI Agent Friendly Scriptoria includes a reusable [skill](skills/scriptoria/SKILL.md) and a provider-agnostic agent runtime, so coding agents can manage scripts end to end. diff --git a/Sources/ScriptoriaCLI/CLI.swift b/Sources/ScriptoriaCLI/CLI.swift index ddcc4af..1ac7a6d 100644 --- a/Sources/ScriptoriaCLI/CLI.swift +++ b/Sources/ScriptoriaCLI/CLI.swift @@ -20,6 +20,7 @@ struct ScriptoriaCLI: AsyncParsableCommand { PsCommand.self, LogsCommand.self, KillCommand.self, + FlowCommand.self, ] ) } diff --git a/Sources/ScriptoriaCLI/Commands/FlowCommand.swift b/Sources/ScriptoriaCLI/Commands/FlowCommand.swift new file mode 100644 index 0000000..3f20feb --- /dev/null +++ b/Sources/ScriptoriaCLI/Commands/FlowCommand.swift @@ -0,0 +1,356 @@ +import ArgumentParser +import Darwin +import Foundation +import ScriptoriaCore + +struct FlowCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "flow", + abstract: "Validate, compile and run Flow DSL", + subcommands: [ + FlowValidateCommand.self, + FlowCompileCommand.self, + FlowRunCommand.self, + FlowDryRunCommand.self, + ] + ) +} + +struct FlowValidateCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration(commandName: "validate", abstract: "Validate a flow YAML file") + + @Argument(help: "Path to flow YAML") + var flowPath: String + + @Flag(name: .long, help: "Skip filesystem run path existence check") + var noFSCheck: Bool = false + + func run() async throws { + do { + _ = try FlowValidator.validateFile( + atPath: flowPath, + options: .init(checkFileSystem: !noFSCheck) + ) + print("flow validate ok") + } catch let error as FlowError { + printFlowError(error, flowPath: flowPath) + throw ExitCode.failure + } catch { + print("phase=validate error_code=flow.validate.schema_error error_message=\(error.localizedDescription) flow_path=\(flowPath)") + throw ExitCode.failure + } + } +} + +struct FlowCompileCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration(commandName: "compile", abstract: "Compile flow YAML to canonical IR JSON") + + @Argument(help: "Path to flow YAML") + var flowPath: String + + @Option(name: .long, help: "Output JSON file path") + var out: String + + @Flag(name: .long, help: "Skip filesystem run path existence check") + var noFSCheck: Bool = false + + func run() async throws { + do { + let ir = try FlowCompiler.compileFile( + atPath: flowPath, + options: .init(checkFileSystem: !noFSCheck) + ) + let json = try FlowCompiler.renderCanonicalJSON(ir: ir) + + let outputPath = absolutePath(from: out) + let outputURL = URL(fileURLWithPath: outputPath) + try FileManager.default.createDirectory( + at: outputURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try json.write(to: outputURL, atomically: true, encoding: .utf8) + print("flow compile ok") + } catch let error as FlowError { + printFlowError(error, flowPath: flowPath) + throw ExitCode.failure + } catch { + print("phase=compile error_code=flow.validate.schema_error error_message=\(error.localizedDescription) flow_path=\(flowPath)") + throw ExitCode.failure + } + } +} + +struct FlowRunCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration(commandName: "run", abstract: "Run a flow YAML") + + @Argument(help: "Path to flow YAML") + var flowPath: String + + @Option(name: .customLong("var"), parsing: .upToNextOption, help: "Context override: key=value") + var variable: [String] = [] + + @Option(name: .long, help: "Global agent round hard cap") + var maxAgentRounds: Int? + + @Flag(name: .long, help: "Disable interactive steer") + var noSteer: Bool = false + + @Option(name: .long, help: "Send scripted command to agent turn (repeatable)") + var command: [String] = [] + + func run() async throws { + let contextOverrides: [String: String] + do { + contextOverrides = try parseVarAssignments(variable) + } catch let error as FlowError { + printFlowError(error, flowPath: flowPath) + throw ExitCode.failure + } catch { + print("phase=runtime-preflight error_code=flow.cli.var_key_invalid error_message=\(error.localizedDescription) flow_path=\(flowPath)") + throw ExitCode.failure + } + + let ir: FlowIR + do { + ir = try FlowCompiler.compileFile(atPath: flowPath) + } catch let error as FlowError { + let mapped = FlowError( + code: error.code, + message: error.message, + phase: .runtimePreflight, + stateID: error.stateID, + fieldPath: error.fieldPath, + line: error.line, + column: error.column + ) + printFlowError(mapped, flowPath: flowPath) + throw ExitCode.failure + } catch { + print("phase=runtime-preflight error_code=flow.validate.schema_error error_message=\(error.localizedDescription) flow_path=\(flowPath)") + throw ExitCode.failure + } + + if let cap = maxAgentRounds, cap > ir.defaults.maxAgentRounds { + print( + "warning_code=flow.cli.max_agent_rounds_cap_ignored " + + "warning_message=--max-agent-rounds (\(cap)) does not relax configured max_agent_rounds (\(ir.defaults.maxAgentRounds)); using configured cap." + ) + } + + let interactiveSteerEnabled = shouldEnableInteractiveSteer(noSteer: noSteer) + if interactiveSteerEnabled { + print("[steer] Enter text to guide the running agent. Use /interrupt to stop.") + } + + var commands = command + if !noSteer && !interactiveSteerEnabled { + commands.append(contentsOf: collectPipedSteerInputs()) + } + let commandInput = interactiveSteerEnabled ? makeInteractiveSteerStream() : nil + + do { + let result = try await FlowEngine().run( + ir: ir, + mode: .live, + options: .init( + contextOverrides: contextOverrides, + maxAgentRoundsCap: maxAgentRounds, + noSteer: noSteer, + commands: commands + ), + commandInput: commandInput, + logSink: { line in + print(line) + } + ) + for warning in result.warnings { + print("warning_code=\(warning.code) warning_message=\(warning.message)") + } + } catch let error as FlowError { + printFlowError(error, flowPath: flowPath) + throw ExitCode.failure + } catch { + print("phase=runtime error_code=flow.validate.schema_error error_message=\(error.localizedDescription) flow_path=\(flowPath)") + throw ExitCode.failure + } + } +} + +struct FlowDryRunCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration(commandName: "dry-run", abstract: "Run flow with fixture") + + @Argument(help: "Path to flow YAML") + var flowPath: String + + @Option(name: .long, help: "Fixture JSON path") + var fixture: String + + func run() async throws { + let ir: FlowIR + do { + ir = try FlowCompiler.compileFile(atPath: flowPath) + } catch let error as FlowError { + let mapped = FlowError( + code: error.code, + message: error.message, + phase: .runtimePreflight, + stateID: error.stateID, + fieldPath: error.fieldPath, + line: error.line, + column: error.column + ) + printFlowError(mapped, flowPath: flowPath) + throw ExitCode.failure + } catch { + print("phase=runtime-preflight error_code=flow.validate.schema_error error_message=\(error.localizedDescription) flow_path=\(flowPath)") + throw ExitCode.failure + } + + let dryFixture: FlowDryRunFixture + do { + dryFixture = try FlowDryRunFixture.load(fromPath: fixture) + } catch let error as FlowError { + printFlowError(error, flowPath: flowPath) + throw ExitCode.failure + } catch { + print("phase=runtime-dry-run error_code=flow.validate.schema_error error_message=\(error.localizedDescription) flow_path=\(flowPath)") + throw ExitCode.failure + } + + do { + let result = try await FlowEngine().run( + ir: ir, + mode: .dryRun(dryFixture), + options: .init(), + logSink: { line in + print(line) + } + ) + for warning in result.warnings { + print("warning_code=\(warning.code) warning_message=\(warning.message)") + } + } catch let error as FlowError { + printFlowError(error, flowPath: flowPath) + throw ExitCode.failure + } catch { + print("phase=runtime-dry-run error_code=flow.validate.schema_error error_message=\(error.localizedDescription) flow_path=\(flowPath)") + throw ExitCode.failure + } + } +} + +private func parseVarAssignments(_ values: [String]) throws -> [String: String] { + let regex = try NSRegularExpression(pattern: "^[A-Za-z_][A-Za-z0-9_]*$") + var result: [String: String] = [:] + + for raw in values { + let parts = raw.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2 else { + throw FlowError( + code: "flow.cli.var_key_invalid", + message: "Invalid --var format: \(raw)", + phase: .runtimePreflight + ) + } + + let key = String(parts[0]) + let value = String(parts[1]) + + let range = NSRange(location: 0, length: key.utf16.count) + if regex.firstMatch(in: key, options: [], range: range) == nil { + throw FlowError( + code: "flow.cli.var_key_invalid", + message: "Invalid --var key: \(key)", + phase: .runtimePreflight + ) + } + + result[key] = value + } + + return result +} + +private func printFlowError(_ error: FlowError, flowPath: String) { + var fields: [String] = [ + "phase=\(error.phase.rawValue)", + "error_code=\(error.code)", + "error_message=\(sanitizeLogValue(error.message))", + "flow_path=\(flowPath)" + ] + if let stateID = error.stateID { + fields.append("state_id=\(stateID)") + } + if let fieldPath = error.fieldPath { + fields.append("field_path=\(fieldPath)") + } + if let line = error.line { + fields.append("line=\(line)") + } + if let column = error.column { + fields.append("column=\(column)") + } + print(fields.joined(separator: " ")) +} + +private func sanitizeLogValue(_ value: String) -> String { + value.replacingOccurrences(of: "\n", with: " ") +} + +private func absolutePath(from raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("/") { + return URL(fileURLWithPath: trimmed).standardizedFileURL.path + } + if trimmed.hasPrefix("~/") { + let expanded = NSString(string: trimmed).expandingTildeInPath + return URL(fileURLWithPath: expanded).standardizedFileURL.path + } + return URL(fileURLWithPath: trimmed, relativeTo: URL(fileURLWithPath: FileManager.default.currentDirectoryPath)) + .standardizedFileURL + .path +} + +private func collectPipedSteerInputs() -> [String] { + let stdinFD = FileHandle.standardInput.fileDescriptor + // Avoid blocking interactive terminals; only consume piped stdin. + guard isatty(stdinFD) == 0 else { + return [] + } + + let data = FileHandle.standardInput.readDataToEndOfFile() + guard !data.isEmpty, + let raw = String(data: data, encoding: .utf8) else { + return [] + } + + return raw + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } +} + +private func shouldEnableInteractiveSteer(noSteer: Bool) -> Bool { + !noSteer && isatty(fileno(stdin)) == 1 +} + +private func makeInteractiveSteerStream() -> AsyncStream { + AsyncStream { continuation in + let readerTask = Task.detached(priority: .utility) { + while !Task.isCancelled { + guard let line = readLine(strippingNewline: true) else { + break + } + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + continue + } + continuation.yield(trimmed) + } + continuation.finish() + } + continuation.onTermination = { _ in + readerTask.cancel() + } + } +} diff --git a/Sources/ScriptoriaCore/Execution/PostScriptAgentRunner.swift b/Sources/ScriptoriaCore/Execution/PostScriptAgentRunner.swift index b5f971c..44c0797 100644 --- a/Sources/ScriptoriaCore/Execution/PostScriptAgentRunner.swift +++ b/Sources/ScriptoriaCore/Execution/PostScriptAgentRunner.swift @@ -137,8 +137,14 @@ public actor PostScriptAgentSession { public func waitForCompletion() async throws -> AgentExecutionResult { if let completionResult { return completionResult } - return try await withCheckedThrowingContinuation { continuation in - completionContinuation = continuation + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + completionContinuation = continuation + } + } onCancel: { + Task { [weak self] in + await self?.cancelPendingWait() + } } } @@ -188,6 +194,12 @@ public actor PostScriptAgentSession { finish(status: mapStatus(pendingTurnCompletion.status)) } + private func cancelPendingWait() { + guard completionResult == nil else { return } + completionContinuation?.resume(throwing: CancellationError()) + completionContinuation = nil + } + private func mapStatus(_ status: String) -> AgentRunStatus { switch status { case "completed": diff --git a/Sources/ScriptoriaCore/Execution/ScriptRunner.swift b/Sources/ScriptoriaCore/Execution/ScriptRunner.swift index b5e0d14..49d49df 100644 --- a/Sources/ScriptoriaCore/Execution/ScriptRunner.swift +++ b/Sources/ScriptoriaCore/Execution/ScriptRunner.swift @@ -1,12 +1,37 @@ +import Darwin import Foundation +public struct ScriptRunOptions: Sendable { + public var args: [String] + public var env: [String: String] + public var timeoutSec: Int? + public var workingDirectory: String? + + public init( + args: [String] = [], + env: [String: String] = [:], + timeoutSec: Int? = nil, + workingDirectory: String? = nil + ) { + self.args = args + self.env = env + self.timeoutSec = timeoutSec + self.workingDirectory = workingDirectory + } +} + /// Executes scripts and captures output public final class ScriptRunner: Sendable { public init() {} /// Run a script and return the result public func run(_ script: Script) async throws -> ScriptRun { - try await runStreaming(script, onOutput: nil) + try await run(script, options: .init()) + } + + /// Run a script with options. + public func run(_ script: Script, options: ScriptRunOptions) async throws -> ScriptRun { + try await runStreaming(script, options: options, onOutput: nil) } /// Run a script with real-time streaming output. @@ -15,7 +40,24 @@ public final class ScriptRunner: Sendable { _ script: Script, onOutput: (@Sendable (String, Bool) -> Void)? ) async throws -> ScriptRun { - try await runStreaming(script, runId: UUID(), logManager: nil, onStart: nil, onOutput: onOutput) + try await runStreaming(script, options: .init(), onOutput: onOutput) + } + + /// Run a script with real-time streaming output and options. + /// `onOutput` is called on each chunk of stdout/stderr data: (text, isStderr) + public func runStreaming( + _ script: Script, + options: ScriptRunOptions, + onOutput: (@Sendable (String, Bool) -> Void)? + ) async throws -> ScriptRun { + try await runStreaming( + script, + options: options, + runId: UUID(), + logManager: nil, + onStart: nil, + onOutput: onOutput + ) } /// Run a script with persistent logging, PID tracking, and real-time streaming output. @@ -30,6 +72,31 @@ public final class ScriptRunner: Sendable { logManager: LogManager?, onStart: (@Sendable (Int32) -> Void)?, onOutput: (@Sendable (String, Bool) -> Void)? + ) async throws -> ScriptRun { + try await runStreaming( + script, + options: .init(), + runId: runId, + logManager: logManager, + onStart: onStart, + onOutput: onOutput + ) + } + + /// Run a script with persistent logging, PID tracking, and real-time streaming output. + /// - Parameters: + /// - options: Additional execution options (args/env/timeout/workingDirectory) + /// - runId: The UUID to use for the ScriptRun record + /// - logManager: If provided, output is written to a log file on disk + /// - onStart: Called with the process PID immediately after launch + /// - onOutput: Called on each chunk of stdout/stderr data: (text, isStderr) + public func runStreaming( + _ script: Script, + options: ScriptRunOptions, + runId: UUID, + logManager: LogManager?, + onStart: (@Sendable (Int32) -> Void)?, + onOutput: (@Sendable (String, Bool) -> Void)? ) async throws -> ScriptRun { var record = ScriptRun( id: runId, @@ -48,6 +115,7 @@ public final class ScriptRunner: Sendable { if interpreter == .binary { process.executableURL = URL(fileURLWithPath: script.path) + process.arguments = options.args } else if let execPath = interpreter.executablePath { // For interpreters that may be installed via version managers (nvm, pyenv, etc.), // resolve the actual path at runtime if the hardcoded path doesn't exist. @@ -60,16 +128,22 @@ public final class ScriptRunner: Sendable { resolvedPath = execPath } process.executableURL = URL(fileURLWithPath: resolvedPath) - process.arguments = [script.path] + process.arguments = [script.path] + options.args } else { // Fallback: use /bin/sh process.executableURL = URL(fileURLWithPath: "/bin/sh") - process.arguments = [script.path] + process.arguments = [script.path] + options.args } - // Set working directory to script's directory + // Set working directory to script's directory unless explicitly overridden. let scriptDir = URL(fileURLWithPath: script.path).deletingLastPathComponent() - process.currentDirectoryURL = scriptDir + let configuredWorkingDirectory = options.workingDirectory? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let configuredWorkingDirectory, !configuredWorkingDirectory.isEmpty { + process.currentDirectoryURL = URL(fileURLWithPath: configuredWorkingDirectory) + } else { + process.currentDirectoryURL = scriptDir + } // Inherit full login shell environment (critical for launchd which has minimal PATH) var env = ScriptRunner.loginShellEnvironment() ?? ProcessInfo.processInfo.environment @@ -77,6 +151,9 @@ public final class ScriptRunner: Sendable { if let existingPath = env["PATH"] { env["PATH"] = (extraPaths + [existingPath]).joined(separator: ":") } + for (key, value) in options.env { + env[key] = value + } process.environment = env process.standardOutput = stdoutPipe @@ -137,10 +214,50 @@ public final class ScriptRunner: Sendable { record.pid = pid onStart?(pid) - // Wait for process on a background thread to avoid blocking - await withCheckedContinuation { (continuation: CheckedContinuation) in - process.terminationHandler = { _ in - continuation.resume() + var didTimeout = false + if let timeoutSec = options.timeoutSec { + let deadline = Date().addingTimeInterval(TimeInterval(timeoutSec)) + while process.isRunning { + if Date() >= deadline { + didTimeout = true + process.terminate() + break + } + try? await Task.sleep(nanoseconds: 50_000_000) + } + if didTimeout { + let graceEnd = Date().addingTimeInterval(1.0) + while process.isRunning, Date() < graceEnd { + try? await Task.sleep(nanoseconds: 20_000_000) + } + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + } + let killWaitEnd = Date().addingTimeInterval(1.0) + while process.isRunning, Date() < killWaitEnd { + try? await Task.sleep(nanoseconds: 20_000_000) + } + if process.isRunning { + // Do not block forever when Process bookkeeping fails to observe termination. + // Return a timeout failure using captured output so far. + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + record.output = outputCollector.stdout + record.errorOutput = outputCollector.stderr + let suffix = record.errorOutput.isEmpty ? "" : "\n" + record.errorOutput += "\(suffix)Script timed out after \(options.timeoutSec ?? 0) seconds." + record.exitCode = 124 + record.finishedAt = Date() + record.status = .failure + return record + } + } + } else { + // Wait for process on a background thread to avoid blocking. + await withCheckedContinuation { (continuation: CheckedContinuation) in + process.terminationHandler = { _ in + continuation.resume() + } } } @@ -178,6 +295,12 @@ public final class ScriptRunner: Sendable { } else { record.status = process.terminationStatus == 0 ? .success : .failure } + if didTimeout { + record.status = .failure + record.exitCode = 124 + let suffix = record.errorOutput.isEmpty ? "" : "\n" + record.errorOutput += "\(suffix)Script timed out after \(options.timeoutSec ?? 0) seconds." + } } catch { record.finishedAt = Date() record.status = .failure diff --git a/Sources/ScriptoriaCore/Flow/AgentStepRunner.swift b/Sources/ScriptoriaCore/Flow/AgentStepRunner.swift new file mode 100644 index 0000000..50a345c --- /dev/null +++ b/Sources/ScriptoriaCore/Flow/AgentStepRunner.swift @@ -0,0 +1,162 @@ +import Foundation + +struct FlowAgentStepResult { + var nextStateID: String + var stateOutput: [String: FlowValue] +} + +struct AgentStepRunner { + func execute( + state: FlowIRState, + ir: FlowIR, + mode: FlowRunMode, + dryFixture: inout FlowDryRunFixture?, + context: inout [String: FlowValue], + counters: [String: Int], + stateLast: [String: FlowValue], + prev: FlowValue?, + commandQueue: FlowCommandQueue, + logSink: ((String) -> Void)? + ) async throws -> FlowAgentStepResult { + guard let agent = state.agent, + let next = state.next else { + throw FlowErrors.runtime(code: "flow.validate.schema_error", "agent payload missing", stateID: state.id) + } + + var stateOutput: [String: FlowValue] = [:] + switch mode { + case .live: + let flowDirectory = URL(fileURLWithPath: ir.sourcePath).deletingLastPathComponent().path + let prompt: String + if let promptText = agent.prompt, !promptText.isEmpty { + prompt = "Task: \(agent.task)\n\n\(promptText)" + } else { + prompt = "Task: \(agent.task)" + } + + let session = try await PostScriptAgentRunner.launch( + options: PostScriptAgentLaunchOptions( + workingDirectory: flowDirectory, + model: AgentRuntimeCatalog.normalizeModel(agent.model), + userPrompt: prompt, + developerInstructions: "You are running inside a Scriptoria flow state." + ) + ) + + let interruptMarker = FlowInterruptMarker() + let immediateInterrupt = await commandQueue.consume(for: session) + if immediateInterrupt { + await interruptMarker.markInterrupted() + } + let commandRelayTask = Task.detached(priority: .utility) { + while !Task.isCancelled { + let sentInterrupt = await commandQueue.consume(for: session) + if sentInterrupt { + await interruptMarker.markInterrupted() + return + } + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + let completion = try await FlowStepRunnerSupport.waitForAgentCompletion( + session: session, + timeoutSec: agent.timeoutSec, + graceSec: 10, + stateID: state.id + ) + if let logSink, !completion.output.isEmpty { + logSink(completion.output) + } + commandRelayTask.cancel() + + let interruptedByUser = await interruptMarker.isInterrupted() + if interruptedByUser { + throw FlowErrors.runtime(code: "flow.agent.interrupted", "Agent interrupted by command", stateID: state.id) + } + + switch completion.status { + case .completed: + break + case .interrupted: + throw FlowErrors.runtime(code: "flow.agent.failed", "Agent was interrupted", stateID: state.id) + case .failed: + throw FlowErrors.runtime(code: "flow.agent.failed", "Agent failed", stateID: state.id) + case .running: + throw FlowErrors.runtime(code: "flow.agent.failed", "Agent still running unexpectedly", stateID: state.id) + } + + stateOutput["status"] = .string(completion.status.rawValue) + stateOutput["output"] = .string(completion.output) + + if let export = state.export { + guard let final = try FlowStepRunnerSupport.parseLastLineJSONObject(text: completion.output) else { + throw FlowErrors.runtime( + code: "flow.agent.output_parse_error", + "agent export requires final JSON line", + stateID: state.id + ) + } + stateOutput["final"] = .object(final) + try FlowStepRunnerSupport.applyExport( + export, + context: &context, + counters: counters, + stateLast: stateLast, + prev: prev, + current: .object(["final": .object(final)]), + missingCode: "flow.agent.export_field_missing", + stateID: state.id + ) + } + + case .dryRun: + guard var fixture = dryFixture else { + throw FlowErrors.runtimeDryRun(code: "flow.validate.schema_error", "dry-run fixture missing") + } + guard let item = fixture.consume(stateID: state.id) else { + throw FlowErrors.runtimeDryRun( + code: "flow.dryrun.fixture_missing_state_data", + "Dry-run fixture missing data for state \(state.id)", + stateID: state.id + ) + } + dryFixture = fixture + + guard case .object(let object) = item else { + throw FlowErrors.runtimeDryRun(code: "flow.validate.schema_error", "agent fixture entry must be object", stateID: state.id) + } + stateOutput = object + + if let status = object["status"]?.stringValue { + if status == "failed" { + throw FlowErrors.runtime(code: "flow.agent.failed", "agent fixture indicated failure", stateID: state.id) + } + if status == "interrupted" { + throw FlowErrors.runtime(code: "flow.agent.interrupted", "agent fixture indicated interrupted", stateID: state.id) + } + } + + if let export = state.export { + guard case .object(let final)? = object["final"] else { + throw FlowErrors.runtime( + code: "flow.agent.output_parse_error", + "agent export requires final JSON object", + stateID: state.id + ) + } + try FlowStepRunnerSupport.applyExport( + export, + context: &context, + counters: counters, + stateLast: stateLast, + prev: prev, + current: .object(["final": .object(final)]), + missingCode: "flow.agent.export_field_missing", + stateID: state.id + ) + } + } + + return FlowAgentStepResult(nextStateID: next, stateOutput: stateOutput) + } +} diff --git a/Sources/ScriptoriaCore/Flow/ExpressionEvaluator.swift b/Sources/ScriptoriaCore/Flow/ExpressionEvaluator.swift new file mode 100644 index 0000000..f5ea22f --- /dev/null +++ b/Sources/ScriptoriaCore/Flow/ExpressionEvaluator.swift @@ -0,0 +1,119 @@ +import Foundation + +struct FlowExpressionScope { + var context: [String: FlowValue] + var counters: [String: Int] + var stateLast: [String: FlowValue] + var prev: FlowValue? + var current: FlowValue? +} + +enum ExpressionEvaluator { + static func evaluate( + _ expression: String, + scope: FlowExpressionScope, + resolveErrorCode: String = "flow.expr.resolve_error" + ) throws -> FlowValue { + let trimmed = expression.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("$.") else { + throw FlowErrors.runtime(code: resolveErrorCode, "Invalid expression: \(expression)") + } + let path = String(trimmed.dropFirst(2)) + let parts = path.split(separator: ".").map(String.init) + guard let root = parts.first else { + throw FlowErrors.runtime(code: resolveErrorCode, "Empty expression: \(expression)") + } + + let result: FlowValue? + switch root { + case "context": + let rootValue = FlowValue.object(scope.context) + result = rootValue.lookup(path: ArraySlice(parts.dropFirst())) + + case "counters": + var counterObject: [String: FlowValue] = [:] + for (key, value) in scope.counters { + counterObject[key] = .number(Double(value)) + } + result = FlowValue.object(counterObject).lookup(path: ArraySlice(parts.dropFirst())) + + case "state": + guard parts.count >= 3 else { + throw FlowErrors.runtime(code: resolveErrorCode, "Invalid state expression: \(expression)") + } + let stateID = parts[1] + let cursor = parts[2] + guard cursor == "last" else { + throw FlowErrors.runtime(code: resolveErrorCode, "state expression must use .last") + } + guard let stateValue = scope.stateLast[stateID] else { + throw FlowErrors.runtime(code: resolveErrorCode, "Missing state.last for \(stateID)") + } + result = stateValue.lookup(path: ArraySlice(parts.dropFirst(3))) + + case "prev": + guard let prev = scope.prev else { + throw FlowErrors.runtime(code: resolveErrorCode, "prev is not available") + } + result = prev.lookup(path: ArraySlice(parts.dropFirst())) + + case "current": + guard let current = scope.current else { + throw FlowErrors.runtime(code: resolveErrorCode, "current is not available") + } + result = current.lookup(path: ArraySlice(parts.dropFirst())) + + default: + throw FlowErrors.runtime(code: resolveErrorCode, "Unsupported expression root: \(root)") + } + + guard let value = result else { + throw FlowErrors.runtime(code: resolveErrorCode, "Expression path not found: \(expression)") + } + return value + } + + static func evaluateString( + _ expression: String, + scope: FlowExpressionScope + ) throws -> String { + let value = try evaluate(expression, scope: scope) + do { + return try flowJSONString(from: value) + } catch { + throw FlowErrors.runtime(code: "flow.expr.type_error", "Expression type mismatch for \(expression)") + } + } + + static func evaluateWaitSeconds( + _ expression: String, + scope: FlowExpressionScope + ) throws -> Int { + let value = try evaluate( + expression, + scope: scope, + resolveErrorCode: "flow.wait.seconds_resolve_error" + ) + + switch value { + case .number(let number): + guard number.rounded(.towardZero) == number else { + throw FlowErrors.runtime(code: "flow.wait.seconds_resolve_error", "seconds_from must resolve to integer") + } + let intValue = Int(number) + guard intValue >= 0 else { + throw FlowErrors.runtime(code: "flow.wait.seconds_resolve_error", "seconds_from must be >= 0") + } + return intValue + + case .string(let text): + guard let intValue = Int(text), intValue >= 0 else { + throw FlowErrors.runtime(code: "flow.wait.seconds_resolve_error", "seconds_from string must be decimal integer") + } + return intValue + + default: + throw FlowErrors.runtime(code: "flow.wait.seconds_resolve_error", "seconds_from must resolve to integer") + } + } +} diff --git a/Sources/ScriptoriaCore/Flow/FlowCompiler.swift b/Sources/ScriptoriaCore/Flow/FlowCompiler.swift new file mode 100644 index 0000000..66dceea --- /dev/null +++ b/Sources/ScriptoriaCore/Flow/FlowCompiler.swift @@ -0,0 +1,189 @@ +import Foundation + +public enum FlowCompiler { + public static func compileFile( + atPath path: String, + options: FlowCompileOptions = .init() + ) throws -> FlowIR { + let absolutePath = FlowPathResolver.absolutePath(from: path) + let sourceURL = URL(fileURLWithPath: absolutePath) + + let definition = try FlowValidator.validateFile( + atPath: absolutePath, + options: .init(checkFileSystem: options.checkFileSystem) + ) + + var states: [FlowIRState] = [] + states.reserveCapacity(definition.states.count) + + for state in definition.states { + states.append( + try compileState( + state, + defaults: definition.defaults, + sourceURL: sourceURL, + checkFileSystem: options.checkFileSystem + ) + ) + } + + return FlowIR( + version: "flow-ir/v1", + start: definition.start, + defaults: definition.defaults, + context: definition.context, + states: states, + sourcePath: absolutePath + ) + } + + public static func renderCanonicalJSON(ir: FlowIR) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(ir) + guard let text = String(data: data, encoding: .utf8) else { + throw FlowError( + code: "flow.validate.schema_error", + message: "Failed to encode IR JSON as UTF-8 text", + phase: .compile + ) + } + return text + } + + private static func compileState( + _ state: FlowStateDefinition, + defaults: FlowDefaults, + sourceURL: URL, + checkFileSystem: Bool + ) throws -> FlowIRState { + switch state.type { + case .gate: + let runRaw = try required(state.run, message: "gate.run required", field: "states.\(state.id).run") + let resolved = try FlowPathResolver.resolveRunPath( + runRaw, + flowDirectory: sourceURL.deletingLastPathComponent(), + phase: .compile, + checkFileSystem: checkFileSystem, + stateID: state.id + ) + let transitions = try required(state.on, message: "gate.on required", field: "states.\(state.id).on") + var ir = FlowIRState(id: state.id, kind: .gate) + ir.exec = FlowIRExec( + run: resolved.irPath, + args: try compileArgs(state.args, field: "states.\(state.id).args"), + env: try compileEnv(state.env, fieldPrefix: "states.\(state.id).env"), + parse: state.parseMode ?? .jsonLastLine, + interpreter: state.interpreter ?? .auto, + timeoutSec: state.timeoutSec ?? defaults.stepTimeoutSec + ) + ir.transitions = FlowIRTransitions( + pass: transitions.pass, + needsAgent: transitions.needsAgent, + wait: transitions.wait, + fail: transitions.fail, + parseError: transitions.parseError + ) + return ir + + case .script: + let runRaw = try required(state.run, message: "script.run required", field: "states.\(state.id).run") + let resolved = try FlowPathResolver.resolveRunPath( + runRaw, + flowDirectory: sourceURL.deletingLastPathComponent(), + phase: .compile, + checkFileSystem: checkFileSystem, + stateID: state.id + ) + let next = try required(state.next, message: "script.next required", field: "states.\(state.id).next") + var ir = FlowIRState(id: state.id, kind: .script) + ir.exec = FlowIRExec( + run: resolved.irPath, + args: try compileArgs(state.args, field: "states.\(state.id).args"), + env: try compileEnv(state.env, fieldPrefix: "states.\(state.id).env"), + parse: nil, + interpreter: state.interpreter ?? .auto, + timeoutSec: state.timeoutSec ?? defaults.stepTimeoutSec + ) + ir.export = state.export + ir.next = next + return ir + + case .agent: + let task = try required(state.task, message: "agent.task required", field: "states.\(state.id).task") + let next = try required(state.next, message: "agent.next required", field: "states.\(state.id).next") + let counter = state.counter ?? "agent_round.\(state.id)" + let maxRounds = state.maxRounds ?? defaults.maxAgentRounds + var ir = FlowIRState(id: state.id, kind: .agent) + ir.agent = FlowIRAgent( + task: task, + model: state.model, + counter: counter, + maxRounds: maxRounds, + prompt: state.prompt, + timeoutSec: state.timeoutSec ?? defaults.stepTimeoutSec + ) + ir.export = state.export + ir.next = next + return ir + + case .wait: + let next = try required(state.next, message: "wait.next required", field: "states.\(state.id).next") + var ir = FlowIRState(id: state.id, kind: .wait) + ir.wait = FlowIRWait( + seconds: state.seconds, + secondsFrom: state.secondsFrom, + timeoutSec: state.timeoutSec ?? defaults.stepTimeoutSec + ) + ir.next = next + return ir + + case .end: + let status = try required(state.endStatus, message: "end.status required", field: "states.\(state.id).status") + var ir = FlowIRState(id: state.id, kind: .end) + ir.end = FlowIREnd(status: status, message: state.message) + return ir + } + } + + private static func compileArgs(_ args: [FlowValue]?, field: String) throws -> [String] { + guard let args else { return [] } + return try args.map { try compileStringField($0, field: field) } + } + + private static func compileEnv(_ env: [String: FlowValue]?, fieldPrefix: String) throws -> [String: String] { + guard let env else { return [:] } + var compiled: [String: String] = [:] + for key in env.keys.sorted() { + guard let value = env[key] else { continue } + compiled[key] = try compileStringField(value, field: "\(fieldPrefix).\(key)") + } + return compiled + } + + private static func compileStringField(_ value: FlowValue, field: String) throws -> String { + switch value { + case .string(let text): + if FlowValidator.isExpression(text) { + try FlowValidator.validateExpression(text, field: field) + } + return text + case .number(let number): + if number.rounded(.towardZero) == number { + return String(Int(number)) + } + return String(number) + case .bool(let flag): + return flag ? "true" : "false" + case .null, .array, .object: + throw FlowErrors.fieldType("\(field) only accepts string/number/bool", field: field) + } + } + + private static func required(_ value: T?, message: String, field: String) throws -> T { + guard let value else { + throw FlowErrors.schema(message, field: field) + } + return value + } +} diff --git a/Sources/ScriptoriaCore/Flow/FlowDefinition.swift b/Sources/ScriptoriaCore/Flow/FlowDefinition.swift new file mode 100644 index 0000000..a5a0753 --- /dev/null +++ b/Sources/ScriptoriaCore/Flow/FlowDefinition.swift @@ -0,0 +1,329 @@ +import Foundation + +public enum FlowStateType: String, Sendable, Codable { + case gate + case agent + case wait + case script + case end +} + +public enum FlowGateParseMode: String, Sendable, Codable { + case jsonLastLine = "json_last_line" + case jsonFullStdout = "json_full_stdout" +} + +public enum FlowGateDecision: String, Sendable, Codable { + case pass + case needsAgent = "needs_agent" + case wait + case fail + case parseError = "parse_error" +} + +public enum FlowEndStatus: String, Sendable, Codable { + case success + case failure +} + +public struct FlowDefaults: Sendable, Codable, Equatable { + public var maxAgentRounds: Int + public var maxWaitCycles: Int + public var maxTotalSteps: Int + public var stepTimeoutSec: Int + public var failOnParseError: Bool + + public init( + maxAgentRounds: Int = 20, + maxWaitCycles: Int = 200, + maxTotalSteps: Int = 2000, + stepTimeoutSec: Int = 1800, + failOnParseError: Bool = true + ) { + self.maxAgentRounds = maxAgentRounds + self.maxWaitCycles = maxWaitCycles + self.maxTotalSteps = maxTotalSteps + self.stepTimeoutSec = stepTimeoutSec + self.failOnParseError = failOnParseError + } +} + +public struct FlowGateTransitions: Sendable, Codable, Equatable { + public var pass: String + public var needsAgent: String + public var wait: String + public var fail: String + public var parseError: String? + + public init(pass: String, needsAgent: String, wait: String, fail: String, parseError: String? = nil) { + self.pass = pass + self.needsAgent = needsAgent + self.wait = wait + self.fail = fail + self.parseError = parseError + } +} + +public struct FlowStateDefinition: Sendable, Codable, Equatable { + public var id: String + public var type: FlowStateType + + public var run: String? + public var task: String? + public var next: String? + public var on: FlowGateTransitions? + + public var parseMode: FlowGateParseMode? + public var timeoutSec: Int? + public var interpreter: Interpreter? + + public var args: [FlowValue]? + public var env: [String: FlowValue]? + + public var seconds: Int? + public var secondsFrom: String? + + public var model: String? + public var counter: String? + public var maxRounds: Int? + public var prompt: String? + + public var export: [String: String]? + + public var endStatus: FlowEndStatus? + public var message: String? + + public init(id: String, type: FlowStateType) { + self.id = id + self.type = type + } +} + +public struct FlowYAMLDefinition: Sendable, Codable, Equatable { + public var version: String + public var start: String + public var defaults: FlowDefaults + public var context: [String: FlowValue] + public var states: [FlowStateDefinition] + + public init( + version: String, + start: String, + defaults: FlowDefaults, + context: [String: FlowValue], + states: [FlowStateDefinition] + ) { + self.version = version + self.start = start + self.defaults = defaults + self.context = context + self.states = states + } +} + +public enum FlowIRStateKind: String, Sendable, Codable { + case gate + case agent + case wait + case script + case end +} + +public struct FlowIRExec: Sendable, Codable, Equatable { + public var run: String + public var args: [String] + public var env: [String: String] + public var parse: FlowGateParseMode? + public var interpreter: Interpreter + public var timeoutSec: Int + + public init( + run: String, + args: [String] = [], + env: [String: String] = [:], + parse: FlowGateParseMode? = nil, + interpreter: Interpreter = .auto, + timeoutSec: Int + ) { + self.run = run + self.args = args + self.env = env + self.parse = parse + self.interpreter = interpreter + self.timeoutSec = timeoutSec + } + + enum CodingKeys: String, CodingKey { + case run + case args + case env + case parse + case interpreter + case timeoutSec = "timeout_sec" + } +} + +public struct FlowIRTransitions: Sendable, Codable, Equatable { + public var pass: String + public var needsAgent: String + public var wait: String + public var fail: String + public var parseError: String? + + public init(pass: String, needsAgent: String, wait: String, fail: String, parseError: String? = nil) { + self.pass = pass + self.needsAgent = needsAgent + self.wait = wait + self.fail = fail + self.parseError = parseError + } + + enum CodingKeys: String, CodingKey { + case pass + case needsAgent = "needs_agent" + case wait + case fail + case parseError = "parse_error" + } +} + +public struct FlowIRAgent: Sendable, Codable, Equatable { + public var task: String + public var model: String? + public var counter: String + public var maxRounds: Int + public var prompt: String? + public var timeoutSec: Int + + public init( + task: String, + model: String? = nil, + counter: String, + maxRounds: Int, + prompt: String? = nil, + timeoutSec: Int + ) { + self.task = task + self.model = model + self.counter = counter + self.maxRounds = maxRounds + self.prompt = prompt + self.timeoutSec = timeoutSec + } + + enum CodingKeys: String, CodingKey { + case task + case model + case counter + case maxRounds = "max_rounds" + case prompt + case timeoutSec = "timeout_sec" + } +} + +public struct FlowIRWait: Sendable, Codable, Equatable { + public var seconds: Int? + public var secondsFrom: String? + public var timeoutSec: Int + + public init(seconds: Int? = nil, secondsFrom: String? = nil, timeoutSec: Int) { + self.seconds = seconds + self.secondsFrom = secondsFrom + self.timeoutSec = timeoutSec + } + + enum CodingKeys: String, CodingKey { + case seconds + case secondsFrom = "seconds_from" + case timeoutSec = "timeout_sec" + } +} + +public struct FlowIREnd: Sendable, Codable, Equatable { + public var status: FlowEndStatus + public var message: String? + + public init(status: FlowEndStatus, message: String? = nil) { + self.status = status + self.message = message + } +} + +public struct FlowIRState: Sendable, Codable, Equatable { + public var id: String + public var kind: FlowIRStateKind + + public var exec: FlowIRExec? + public var transitions: FlowIRTransitions? + + public var agent: FlowIRAgent? + public var wait: FlowIRWait? + public var next: String? + public var export: [String: String]? + + public var end: FlowIREnd? + + public init(id: String, kind: FlowIRStateKind) { + self.id = id + self.kind = kind + } +} + +public struct FlowIR: Sendable, Encodable, Equatable { + public var version: String + public var start: String + public var defaults: FlowDefaults + public var context: [String: FlowValue] + public var states: [FlowIRState] + + // Needed for runtime path resolution. + public var sourcePath: String + + public init( + version: String, + start: String, + defaults: FlowDefaults, + context: [String: FlowValue], + states: [FlowIRState], + sourcePath: String + ) { + self.version = version + self.start = start + self.defaults = defaults + self.context = context + self.states = states + self.sourcePath = sourcePath + } + + enum CodingKeys: String, CodingKey { + case version + case start + case defaults + case context + case states + } + + public func stateMap() -> [String: FlowIRState] { + var map: [String: FlowIRState] = [:] + for state in states { + map[state.id] = state + } + return map + } +} + +public struct FlowValidationOptions: Sendable { + public var checkFileSystem: Bool + + public init(checkFileSystem: Bool = true) { + self.checkFileSystem = checkFileSystem + } +} + +public struct FlowCompileOptions: Sendable { + public var checkFileSystem: Bool + + public init(checkFileSystem: Bool = true) { + self.checkFileSystem = checkFileSystem + } +} diff --git a/Sources/ScriptoriaCore/Flow/FlowDryRunFixture.swift b/Sources/ScriptoriaCore/Flow/FlowDryRunFixture.swift new file mode 100644 index 0000000..e0378a6 --- /dev/null +++ b/Sources/ScriptoriaCore/Flow/FlowDryRunFixture.swift @@ -0,0 +1,47 @@ +import Foundation + +public struct FlowDryRunFixture: Sendable, Equatable { + public var states: [String: [FlowValue]] + + public init(states: [String: [FlowValue]]) { + self.states = states + } + + public static func load(fromPath path: String) throws -> FlowDryRunFixture { + let absolute = FlowPathResolver.absolutePath(from: path) + let data = try Data(contentsOf: URL(fileURLWithPath: absolute)) + let raw = try JSONSerialization.jsonObject(with: data, options: []) + guard let root = raw as? [String: Any] else { + throw FlowErrors.runtimeDryRun(code: "flow.validate.schema_error", "fixture root must be object") + } + guard let statesRaw = root["states"] as? [String: Any] else { + throw FlowErrors.runtimeDryRun(code: "flow.validate.schema_error", "fixture.states must be object") + } + + var states: [String: [FlowValue]] = [:] + for (stateID, itemsRaw) in statesRaw { + guard let itemsArray = itemsRaw as? [Any] else { + throw FlowErrors.runtimeDryRun( + code: "flow.validate.schema_error", + "fixture.states.\(stateID) must be an array" + ) + } + states[stateID] = try itemsArray.map { try FlowValue.from(any: $0) } + } + + return FlowDryRunFixture(states: states) + } + + mutating func consume(stateID: String) -> FlowValue? { + guard var entries = states[stateID], !entries.isEmpty else { + return nil + } + let first = entries.removeFirst() + states[stateID] = entries + return first + } + + func remainingCount(for stateID: String) -> Int { + states[stateID]?.count ?? 0 + } +} diff --git a/Sources/ScriptoriaCore/Flow/FlowEngine.swift b/Sources/ScriptoriaCore/Flow/FlowEngine.swift new file mode 100644 index 0000000..1decccb --- /dev/null +++ b/Sources/ScriptoriaCore/Flow/FlowEngine.swift @@ -0,0 +1,535 @@ +import Foundation + +public enum FlowRunMode: Sendable { + case live + case dryRun(FlowDryRunFixture) +} + +public struct FlowRunOptions: Sendable { + public var contextOverrides: [String: String] + public var maxAgentRoundsCap: Int? + public var noSteer: Bool + public var commands: [String] + + public init( + contextOverrides: [String: String] = [:], + maxAgentRoundsCap: Int? = nil, + noSteer: Bool = false, + commands: [String] = [] + ) { + self.contextOverrides = contextOverrides + self.maxAgentRoundsCap = maxAgentRoundsCap + self.noSteer = noSteer + self.commands = commands + } +} + +public enum FlowRunStatus: String, Sendable { + case success +} + +public struct FlowRunResult: Sendable { + public var status: FlowRunStatus + public var runID: String + public var endedAtStateID: String + public var context: [String: FlowValue] + public var counters: [String: Int] + public var steps: Int + public var warnings: [FlowWarning] +} + +private struct FlowRuntimeState { + var context: [String: FlowValue] + var counters: [String: Int] + var stateLast: [String: FlowValue] + var prev: FlowValue? + var attempts: [String: Int] + var waitCycles: Int + var totalSteps: Int +} + +private struct FlowStepOutcome { + var nextStateID: String? + var decision: FlowGateDecision? + var counter: (name: String, value: Int, effectiveMax: Int)? + var stateOutput: FlowValue? +} + +actor FlowCommandQueue { + private var items: [String] + + init(items: [String]) { + self.items = items + } + + func append(_ raw: String) { + items.append(raw) + } + + func consume(for session: PostScriptAgentSession) async -> Bool { + var sentInterrupt = false + + while !items.isEmpty { + let raw = items[0] + guard let command = AgentCommandInput.parseCLI(raw) else { + items.removeFirst() + continue + } + + do { + switch command { + case .steer(let text): + try await session.steer(text) + items.removeFirst() + case .interrupt: + try await session.interrupt() + items.removeFirst() + sentInterrupt = true + return sentInterrupt + } + } catch { + // Command was not accepted by the current turn; keep it at queue head + // and retry when the next agent turn is active. + break + } + } + + return sentInterrupt + } + + func remainingCount() -> Int { + items.count + } +} + +public final class FlowEngine: Sendable { + private let scriptRunner: ScriptRunner + + public init(scriptRunner: ScriptRunner = ScriptRunner()) { + self.scriptRunner = scriptRunner + } + + public func run( + ir: FlowIR, + mode: FlowRunMode, + options: FlowRunOptions = .init(), + commandInput: AsyncStream? = nil, + logSink: ((String) -> Void)? = nil + ) async throws -> FlowRunResult { + let runID = UUID().uuidString.lowercased() + let stateMap = ir.stateMap() + + var runtime = FlowRuntimeState( + context: ir.context, + counters: [:], + stateLast: [:], + prev: nil, + attempts: [:], + waitCycles: 0, + totalSteps: 0 + ) + for (key, value) in options.contextOverrides { + runtime.context[key] = .string(value) + } + + var warnings: [FlowWarning] = [] + var dryFixture: FlowDryRunFixture? + var executedStates: Set = [] + + switch mode { + case .live: + break + case .dryRun(let fixture): + for stateID in fixture.states.keys where stateMap[stateID] == nil { + throw FlowErrors.runtimeDryRun( + code: "flow.dryrun.fixture_unknown_state", + "Dry-run fixture contains unknown state: \(stateID)", + stateID: stateID + ) + } + dryFixture = fixture + } + + let commandQueue = FlowCommandQueue(items: options.commands) + let commandInputTask: Task? + if let commandInput { + commandInputTask = Task.detached(priority: .utility) { + for await raw in commandInput { + await commandQueue.append(raw) + } + } + } else { + commandInputTask = nil + } + defer { + commandInputTask?.cancel() + } + + var currentStateID = ir.start + + while true { + runtime.totalSteps += 1 + if runtime.totalSteps > ir.defaults.maxTotalSteps { + throw FlowErrors.runtime(code: "flow.steps.exceeded", "max_total_steps exceeded") + } + + guard let state = stateMap[currentStateID] else { + throw FlowErrors.runtime(code: "flow.validate.schema_error", "State not found during runtime: \(currentStateID)") + } + executedStates.insert(currentStateID) + + runtime.attempts[currentStateID, default: 0] += 1 + let attempt = runtime.attempts[currentStateID] ?? 1 + let started = Date() + + do { + let outcome = try await executeStep( + state, + ir: ir, + mode: mode, + dryFixture: &dryFixture, + runtime: &runtime, + commandQueue: commandQueue, + options: options, + logSink: logSink + ) + let duration = Date().timeIntervalSince(started) + + if let output = outcome.stateOutput { + runtime.stateLast[currentStateID] = output + runtime.prev = output + } + + emitLog( + phase: .runtime, + runID: runID, + stateID: currentStateID, + stateType: state.kind.rawValue, + attempt: attempt, + counter: outcome.counter, + decision: outcome.decision?.rawValue, + transition: outcome.nextStateID, + duration: duration, + logSink: logSink + ) + + if state.kind == .end { + guard let end = state.end else { + throw FlowErrors.runtime(code: "flow.validate.schema_error", "end payload missing", stateID: state.id) + } + if end.status == .failure { + throw FlowErrors.runtime(code: "flow.business_failed", end.message ?? "Business failure reached", stateID: state.id) + } + + if case .dryRun = mode, + let dryFixture { + let fixtureWarningsOrError = try finalizeDryRunFixture( + fixture: dryFixture, + executedStates: executedStates + ) + warnings.append(contentsOf: fixtureWarningsOrError) + } + + let remainingCommands = await commandQueue.remainingCount() + if remainingCommands > 0 { + warnings.append( + FlowWarning( + code: "flow.cli.command_unused", + message: "Unused --command entries: \(remainingCommands)" + ) + ) + } + + return FlowRunResult( + status: .success, + runID: runID, + endedAtStateID: state.id, + context: runtime.context, + counters: runtime.counters, + steps: runtime.totalSteps, + warnings: warnings + ) + } + + guard let next = outcome.nextStateID else { + throw FlowErrors.runtime(code: "flow.validate.schema_error", "Missing transition from state \(state.id)") + } + currentStateID = next + } catch let error as FlowError { + let duration = Date().timeIntervalSince(started) + emitLog( + phase: error.phase, + runID: runID, + stateID: currentStateID, + stateType: state.kind.rawValue, + attempt: attempt, + counter: nil, + decision: nil, + transition: nil, + duration: duration, + errorCode: error.code, + errorMessage: error.message, + logSink: logSink + ) + throw error + } + } + } + + private func executeStep( + _ state: FlowIRState, + ir: FlowIR, + mode: FlowRunMode, + dryFixture: inout FlowDryRunFixture?, + runtime: inout FlowRuntimeState, + commandQueue: FlowCommandQueue, + options: FlowRunOptions, + logSink: ((String) -> Void)? + ) async throws -> FlowStepOutcome { + switch state.kind { + case .gate: + return try await executeGate( + state, + ir: ir, + mode: mode, + dryFixture: &dryFixture, + runtime: &runtime + ) + + case .script: + return try await executeScript( + state, + ir: ir, + mode: mode, + dryFixture: &dryFixture, + runtime: &runtime + ) + + case .agent: + return try await executeAgent( + state, + ir: ir, + mode: mode, + dryFixture: &dryFixture, + runtime: &runtime, + commandQueue: commandQueue, + options: options, + logSink: logSink + ) + + case .wait: + return try await executeWait(state, ir: ir, mode: mode, runtime: &runtime) + + case .end: + return FlowStepOutcome(nextStateID: nil, decision: nil, counter: nil, stateOutput: .object([:])) + } + } + + private func executeGate( + _ state: FlowIRState, + ir: FlowIR, + mode: FlowRunMode, + dryFixture: inout FlowDryRunFixture?, + runtime: inout FlowRuntimeState + ) async throws -> FlowStepOutcome { + let result = try await GateStepRunner(scriptRunner: scriptRunner).execute( + state: state, + ir: ir, + mode: mode, + dryFixture: &dryFixture, + context: runtime.context, + counters: runtime.counters, + stateLast: runtime.stateLast, + prev: runtime.prev + ) + return FlowStepOutcome( + nextStateID: result.nextStateID, + decision: result.decision, + counter: nil, + stateOutput: .object(result.stateOutput) + ) + } + + private func executeScript( + _ state: FlowIRState, + ir: FlowIR, + mode: FlowRunMode, + dryFixture: inout FlowDryRunFixture?, + runtime: inout FlowRuntimeState + ) async throws -> FlowStepOutcome { + let result = try await ScriptStepRunner(scriptRunner: scriptRunner).execute( + state: state, + ir: ir, + mode: mode, + dryFixture: &dryFixture, + context: &runtime.context, + counters: runtime.counters, + stateLast: runtime.stateLast, + prev: runtime.prev + ) + return FlowStepOutcome( + nextStateID: result.nextStateID, + decision: nil, + counter: nil, + stateOutput: .object(result.stateOutput) + ) + } + + private func executeAgent( + _ state: FlowIRState, + ir: FlowIR, + mode: FlowRunMode, + dryFixture: inout FlowDryRunFixture?, + runtime: inout FlowRuntimeState, + commandQueue: FlowCommandQueue, + options: FlowRunOptions, + logSink: ((String) -> Void)? + ) async throws -> FlowStepOutcome { + guard let agent = state.agent, + state.next != nil else { + throw FlowErrors.runtime(code: "flow.validate.schema_error", "agent payload missing", stateID: state.id) + } + + let current = runtime.counters[agent.counter, default: 0] + let nextCounterValue = current + 1 + let effectiveMax: Int + if let cliCap = options.maxAgentRoundsCap { + effectiveMax = min(agent.maxRounds, ir.defaults.maxAgentRounds, cliCap) + } else { + effectiveMax = min(agent.maxRounds, ir.defaults.maxAgentRounds) + } + if nextCounterValue > effectiveMax { + throw FlowErrors.runtime( + code: "flow.agent.rounds_exceeded", + "agent rounds exceeded for counter \(agent.counter)", + stateID: state.id + ) + } + runtime.counters[agent.counter] = nextCounterValue + + let result = try await AgentStepRunner().execute( + state: state, + ir: ir, + mode: mode, + dryFixture: &dryFixture, + context: &runtime.context, + counters: runtime.counters, + stateLast: runtime.stateLast, + prev: runtime.prev, + commandQueue: commandQueue, + logSink: logSink + ) + return FlowStepOutcome( + nextStateID: result.nextStateID, + decision: nil, + counter: (name: agent.counter, value: nextCounterValue, effectiveMax: effectiveMax), + stateOutput: .object(result.stateOutput) + ) + } + + private func executeWait( + _ state: FlowIRState, + ir: FlowIR, + mode: FlowRunMode, + runtime: inout FlowRuntimeState + ) async throws -> FlowStepOutcome { + guard state.wait != nil, + state.next != nil else { + throw FlowErrors.runtime(code: "flow.validate.schema_error", "wait payload missing", stateID: state.id) + } + + runtime.waitCycles += 1 + if runtime.waitCycles > ir.defaults.maxWaitCycles { + throw FlowErrors.runtime(code: "flow.wait.cycles_exceeded", "max_wait_cycles exceeded", stateID: state.id) + } + + let result = try await WaitStepRunner().execute( + state: state, + ir: ir, + mode: mode, + context: runtime.context, + counters: runtime.counters, + stateLast: runtime.stateLast, + prev: runtime.prev + ) + return FlowStepOutcome( + nextStateID: result.nextStateID, + decision: nil, + counter: nil, + stateOutput: .object(result.stateOutput) + ) + } + + private func finalizeDryRunFixture( + fixture: FlowDryRunFixture, + executedStates: Set + ) throws -> [FlowWarning] { + var warnings: [FlowWarning] = [] + for (stateID, entries) in fixture.states { + guard !entries.isEmpty else { continue } + if executedStates.contains(stateID) { + throw FlowErrors.runtimeDryRun( + code: "flow.dryrun.fixture_unconsumed_items", + "Dry-run fixture has unconsumed entries for executed state \(stateID)", + stateID: stateID + ) + } + warnings.append( + FlowWarning( + code: "flow.dryrun.fixture_unused_state_data", + message: "Dry-run fixture has unused entries for non-executed state \(stateID)" + ) + ) + } + return warnings + } + + private func emitLog( + phase: FlowPhase, + runID: String, + stateID: String, + stateType: String, + attempt: Int, + counter: (name: String, value: Int, effectiveMax: Int)?, + decision: String?, + transition: String?, + duration: TimeInterval, + errorCode: String? = nil, + errorMessage: String? = nil, + logSink: ((String) -> Void)? + ) { + guard let logSink else { return } + + let counterText: String + if let counter { + counterText = "{\"name\":\"\(counter.name)\",\"value\":\(counter.value),\"effective_max\":\(counter.effectiveMax)}" + } else { + counterText = "null" + } + + var fields: [String] = [ + "phase=\(phase.rawValue)", + "run_id=\(runID)", + "state_id=\(stateID)", + "state_type=\(stateType)", + "attempt=\(attempt)", + "counter=\(counterText)", + "decision=\(decision ?? "null")", + "transition=\(transition ?? "null")", + String(format: "duration=%.3f", duration) + ] + + if let errorCode { + fields.append("error_code=\(errorCode)") + } + if let errorMessage { + fields.append("error_message=\(sanitizeLogValue(errorMessage))") + } + + logSink(fields.joined(separator: " ")) + } + + private func sanitizeLogValue(_ value: String) -> String { + value.replacingOccurrences(of: "\n", with: " ") + } +} diff --git a/Sources/ScriptoriaCore/Flow/FlowError.swift b/Sources/ScriptoriaCore/Flow/FlowError.swift new file mode 100644 index 0000000..f0a42ec --- /dev/null +++ b/Sources/ScriptoriaCore/Flow/FlowError.swift @@ -0,0 +1,133 @@ +import Foundation + +public enum FlowPhase: String, Sendable, Codable { + case validate + case compile + case runtimePreflight = "runtime-preflight" + case runtime + case runtimeDryRun = "runtime-dry-run" +} + +public struct FlowError: Error, LocalizedError, Sendable { + public var code: String + public var message: String + public var phase: FlowPhase + public var stateID: String? + public var fieldPath: String? + public var line: Int? + public var column: Int? + + public init( + code: String, + message: String, + phase: FlowPhase, + stateID: String? = nil, + fieldPath: String? = nil, + line: Int? = nil, + column: Int? = nil + ) { + self.code = code + self.message = message + self.phase = phase + self.stateID = stateID + self.fieldPath = fieldPath + self.line = line + self.column = column + } + + public var errorDescription: String? { + message + } +} + +public struct FlowWarning: Sendable { + public var code: String + public var message: String + + public init(code: String, message: String) { + self.code = code + self.message = message + } +} + +enum FlowErrors { + static func schema(_ message: String, field: String? = nil) -> FlowError { + FlowError(code: "flow.validate.schema_error", message: message, phase: .validate, fieldPath: field) + } + + static func unknownField(_ field: String) -> FlowError { + FlowError(code: "flow.validate.unknown_field", message: "Unknown field: \(field)", phase: .validate, fieldPath: field) + } + + static func numericRange(_ message: String, field: String) -> FlowError { + FlowError(code: "flow.validate.numeric_range_error", message: message, phase: .validate, fieldPath: field) + } + + static func fieldType(_ message: String, field: String) -> FlowError { + FlowError(code: "flow.validate.field_type_error", message: message, phase: .validate, fieldPath: field) + } + + static func pathKind( + _ run: String, + phase: FlowPhase, + stateID: String? = nil, + fieldPath: String? = "run" + ) -> FlowError { + FlowError( + code: "flow.path.invalid_path_kind", + message: "Invalid run path token '\(run)'. Use an explicit path literal.", + phase: phase, + stateID: stateID, + fieldPath: fieldPath + ) + } + + static func pathNotFound( + _ path: String, + phase: FlowPhase, + stateID: String? = nil, + fieldPath: String? = nil + ) -> FlowError { + FlowError( + code: "flow.path.not_found", + message: "Script path not found or unreadable: \(path)", + phase: phase, + stateID: stateID, + fieldPath: fieldPath + ) + } + + static func parseModeInvalid(_ mode: String, fieldPath: String = "parse", stateID: String? = nil) -> FlowError { + FlowError( + code: "flow.gate.parse_mode_invalid", + message: "Unsupported gate parse mode: \(mode)", + phase: .validate, + stateID: stateID, + fieldPath: fieldPath + ) + } + + static func unreachable(_ stateID: String) -> FlowError { + FlowError(code: "flow.validate.unreachable_state", message: "Unreachable state: \(stateID)", phase: .validate, stateID: stateID) + } + + static func runtime(code: String, _ message: String, stateID: String? = nil, field: String? = nil) -> FlowError { + FlowError(code: code, message: message, phase: .runtime, stateID: stateID, fieldPath: field) + } + + static func runtimePreflight(from error: FlowError) -> FlowError { + FlowError( + code: error.code, + message: error.message, + phase: .runtimePreflight, + stateID: error.stateID, + fieldPath: error.fieldPath, + line: error.line, + column: error.column + ) + } + + static func runtimeDryRun(code: String, _ message: String, stateID: String? = nil) -> FlowError { + FlowError(code: code, message: message, phase: .runtimeDryRun, stateID: stateID) + } +} diff --git a/Sources/ScriptoriaCore/Flow/FlowGateOutputParser.swift b/Sources/ScriptoriaCore/Flow/FlowGateOutputParser.swift new file mode 100644 index 0000000..9c1e953 --- /dev/null +++ b/Sources/ScriptoriaCore/Flow/FlowGateOutputParser.swift @@ -0,0 +1,81 @@ +import Foundation + +public struct FlowGateParseResult: Sendable, Equatable { + public var decision: FlowGateDecision + public var retryAfterSec: Int? + public var object: [String: FlowValue] + + public init(decision: FlowGateDecision, retryAfterSec: Int? = nil, object: [String: FlowValue] = [:]) { + self.decision = decision + self.retryAfterSec = retryAfterSec + self.object = object + } +} + +public enum FlowGateOutputParser { + public static func parse(stdout: String, mode: FlowGateParseMode) throws -> FlowGateParseResult { + let jsonText: String + switch mode { + case .jsonLastLine: + guard let line = stdout + .split(whereSeparator: \.isNewline) + .map({ String($0).trimmingCharacters(in: .whitespacesAndNewlines) }) + .last(where: { !$0.isEmpty }) else { + throw FlowErrors.runtime(code: "flow.gate.parse_error", "gate output has no JSON line") + } + jsonText = line + case .jsonFullStdout: + jsonText = stdout.trimmingCharacters(in: .whitespacesAndNewlines) + } + + guard let data = jsonText.data(using: .utf8) else { + throw FlowErrors.runtime(code: "flow.gate.parse_error", "gate output is not valid UTF-8") + } + let rawObject: Any + do { + rawObject = try JSONSerialization.jsonObject(with: data, options: []) + } catch { + throw FlowErrors.runtime(code: "flow.gate.parse_error", "gate output JSON parse failed") + } + + guard let object = rawObject as? [String: Any] else { + throw FlowErrors.runtime(code: "flow.gate.parse_error", "gate JSON must be an object") + } + guard let decisionRaw = object["decision"] as? String, + let decision = FlowGateDecision(rawValue: decisionRaw) else { + throw FlowErrors.runtime(code: "flow.gate.parse_error", "gate JSON missing valid decision") + } + + var parsedObject: [String: FlowValue] = [:] + for (key, value) in object { + parsedObject[key] = try FlowValue.from(any: value) + } + + let retryAfter: Int? + if let value = object["retry_after_sec"] { + switch value { + case let number as Int: + retryAfter = number + case let number as NSNumber: + if CFGetTypeID(number) == CFBooleanGetTypeID() { + throw FlowErrors.runtime(code: "flow.gate.parse_error", "retry_after_sec must be integer") + } + if number.doubleValue.rounded(.towardZero) != number.doubleValue { + throw FlowErrors.runtime(code: "flow.gate.parse_error", "retry_after_sec must be integer") + } + retryAfter = number.intValue + case let text as String: + guard let parsed = Int(text) else { + throw FlowErrors.runtime(code: "flow.gate.parse_error", "retry_after_sec must be integer") + } + retryAfter = parsed + default: + throw FlowErrors.runtime(code: "flow.gate.parse_error", "retry_after_sec must be integer") + } + } else { + retryAfter = nil + } + + return FlowGateParseResult(decision: decision, retryAfterSec: retryAfter, object: parsedObject) + } +} diff --git a/Sources/ScriptoriaCore/Flow/FlowPathResolver.swift b/Sources/ScriptoriaCore/Flow/FlowPathResolver.swift new file mode 100644 index 0000000..4a9b16b --- /dev/null +++ b/Sources/ScriptoriaCore/Flow/FlowPathResolver.swift @@ -0,0 +1,114 @@ +import Foundation + +struct FlowResolvedRunPath { + var absolutePath: String + var irPath: String +} + +enum FlowPathResolver { + static func absolutePath(from raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("/") { + return URL(fileURLWithPath: trimmed).standardizedFileURL.path + } + if trimmed.hasPrefix("~/") { + let expanded = NSString(string: trimmed).expandingTildeInPath + return URL(fileURLWithPath: expanded).standardizedFileURL.path + } + let cwd = FileManager.default.currentDirectoryPath + return URL(fileURLWithPath: trimmed, relativeTo: URL(fileURLWithPath: cwd)).standardizedFileURL.path + } + + static func resolveRunPath( + _ run: String, + flowDirectory: URL, + phase: FlowPhase, + checkFileSystem: Bool, + stateID: String? + ) throws -> FlowResolvedRunPath { + let trimmed = run.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw FlowErrors.schema("run must not be empty", field: "run") + } + + let isAbsolute = trimmed.hasPrefix("/") + let isHome = trimmed.hasPrefix("~/") + let isExplicitRelative = trimmed.hasPrefix("./") || trimmed.hasPrefix("../") + let containsSlash = trimmed.contains("/") + + if !(isAbsolute || isHome || isExplicitRelative || containsSlash) { + let fieldPath = stateID.map { "states.\($0).run" } ?? "run" + throw FlowErrors.pathKind(trimmed, phase: phase, stateID: stateID, fieldPath: fieldPath) + } + + let absolutePath: String + let irPath: String + if isAbsolute { + absolutePath = URL(fileURLWithPath: trimmed).standardizedFileURL.path + irPath = absolutePath + } else if isHome { + absolutePath = URL(fileURLWithPath: NSString(string: trimmed).expandingTildeInPath).standardizedFileURL.path + irPath = absolutePath + } else { + absolutePath = URL(fileURLWithPath: trimmed, relativeTo: flowDirectory).standardizedFileURL.path + irPath = relativePath(from: flowDirectory.path, to: absolutePath) + } + + if checkFileSystem { + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: absolutePath, isDirectory: &isDirectory) + let readable = FileManager.default.isReadableFile(atPath: absolutePath) + if !exists || isDirectory.boolValue || !readable { + let fieldPath = stateID.map { "states.\($0).run" } ?? "run" + throw FlowErrors.pathNotFound( + absolutePath, + phase: phase, + stateID: stateID, + fieldPath: fieldPath + ) + } + } + + return FlowResolvedRunPath(absolutePath: absolutePath, irPath: irPath) + } + + static func resolveIRRunPath(irRun: String, sourcePath: String) -> String { + if irRun.hasPrefix("/") { + return URL(fileURLWithPath: irRun).standardizedFileURL.path + } + if irRun.hasPrefix("~/") { + let expanded = NSString(string: irRun).expandingTildeInPath + return URL(fileURLWithPath: expanded).standardizedFileURL.path + } + + let flowDir = URL(fileURLWithPath: sourcePath).deletingLastPathComponent() + return URL(fileURLWithPath: irRun, relativeTo: flowDir).standardizedFileURL.path + } + + private static func relativePath(from basePath: String, to targetPath: String) -> String { + let baseComponents = URL(fileURLWithPath: basePath).standardizedFileURL.pathComponents + let targetComponents = URL(fileURLWithPath: targetPath).standardizedFileURL.pathComponents + + var index = 0 + while index < baseComponents.count, + index < targetComponents.count, + baseComponents[index] == targetComponents[index] { + index += 1 + } + + var parts: [String] = [] + if index < baseComponents.count { + for _ in index..<(baseComponents.count) { + parts.append("..") + } + } + if index < targetComponents.count { + parts.append(contentsOf: targetComponents[index...]) + } + + if parts.isEmpty { + return "." + } + return parts.joined(separator: "/") + } +} diff --git a/Sources/ScriptoriaCore/Flow/FlowStepRunnerSupport.swift b/Sources/ScriptoriaCore/Flow/FlowStepRunnerSupport.swift new file mode 100644 index 0000000..c9594b8 --- /dev/null +++ b/Sources/ScriptoriaCore/Flow/FlowStepRunnerSupport.swift @@ -0,0 +1,167 @@ +import Foundation + +enum FlowStepRunnerSupport { + static func resolveArgs(_ args: [String], scope: FlowExpressionScope) throws -> [String] { + try args.map { value in + if FlowValidator.isExpression(value) { + return try ExpressionEvaluator.evaluateString(value, scope: scope) + } + return value + } + } + + static func resolveEnv(_ env: [String: String], scope: FlowExpressionScope) throws -> [String: String] { + var resolved: [String: String] = [:] + for (key, value) in env { + if FlowValidator.isExpression(value) { + resolved[key] = try ExpressionEvaluator.evaluateString(value, scope: scope) + } else { + resolved[key] = value + } + } + return resolved + } + + static func applyExport( + _ export: [String: String], + context: inout [String: FlowValue], + counters: [String: Int], + stateLast: [String: FlowValue], + prev: FlowValue?, + current: FlowValue, + missingCode: String, + stateID: String + ) throws { + for key in export.keys.sorted() { + guard let expression = export[key] else { continue } + do { + let scope = FlowExpressionScope( + context: context, + counters: counters, + stateLast: stateLast, + prev: prev, + current: current + ) + let value = try ExpressionEvaluator.evaluate(expression, scope: scope) + context[key] = value + } catch let error as FlowError { + if error.code == "flow.expr.resolve_error" { + throw FlowErrors.runtime(code: missingCode, "Export field missing for key \(key)", stateID: stateID) + } + throw error + } + } + } + + static func parseLastLineJSONObject(text: String) throws -> [String: FlowValue]? { + guard let last = lastNonEmptyLine(in: text) else { + return nil + } + guard let data = last.data(using: .utf8) else { + return nil + } + let raw: Any + do { + raw = try JSONSerialization.jsonObject(with: data, options: []) + } catch { + return nil + } + guard let object = raw as? [String: Any] else { + return nil + } + var result: [String: FlowValue] = [:] + for (key, value) in object { + result[key] = try FlowValue.from(any: value) + } + return result + } + + static func lastNonEmptyLine(in text: String) -> String? { + text + .split(whereSeparator: \.isNewline) + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .last(where: { !$0.isEmpty }) + } + + static func ensureReadablePath(_ path: String, stateID: String) throws { + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) + if !exists || isDirectory.boolValue || !FileManager.default.isReadableFile(atPath: path) { + throw FlowErrors.pathNotFound( + path, + phase: .runtime, + stateID: stateID, + fieldPath: "states.\(stateID).run" + ) + } + } + + static func isScriptRunTimedOut(_ run: ScriptRun) -> Bool { + run.errorOutput.contains("Script timed out after") + } + + static func waitForAgentCompletion( + session: PostScriptAgentSession, + timeoutSec: Int, + graceSec: Int, + stateID: String + ) async throws -> AgentExecutionResult { + enum Outcome { + case completed(AgentExecutionResult) + case timedOut + } + + let outcome: Outcome + do { + outcome = try await withThrowingTaskGroup(of: Outcome.self) { group in + group.addTask { + let result = try await session.waitForCompletion() + return .completed(result) + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeoutSec) * 1_000_000_000) + return .timedOut + } + let first = try await group.next()! + group.cancelAll() + return first + } + } catch { + throw FlowErrors.runtime(code: "flow.agent.failed", "Agent execution failed: \(error.localizedDescription)", stateID: stateID) + } + + switch outcome { + case .completed(let result): + return result + + case .timedOut: + try? await session.interrupt() + + _ = try? await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + _ = try await session.waitForCompletion() + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(graceSec) * 1_000_000_000) + } + _ = try await group.next() + group.cancelAll() + } + + await session.close() + throw FlowErrors.runtime(code: "flow.step.timeout", "Agent step timed out", stateID: stateID) + } + } +} + +actor FlowInterruptMarker { + private var interruptedByUser = false + + func markInterrupted() { + interruptedByUser = true + } + + func isInterrupted() -> Bool { + interruptedByUser + } +} diff --git a/Sources/ScriptoriaCore/Flow/FlowValidator.swift b/Sources/ScriptoriaCore/Flow/FlowValidator.swift new file mode 100644 index 0000000..e4fbd28 --- /dev/null +++ b/Sources/ScriptoriaCore/Flow/FlowValidator.swift @@ -0,0 +1,1085 @@ +import Foundation +import Yams + +public enum FlowValidator { + public static func validateFile( + atPath path: String, + options: FlowValidationOptions = .init() + ) throws -> FlowYAMLDefinition { + let absolutePath = FlowPathResolver.absolutePath(from: path) + let sourceURL = URL(fileURLWithPath: absolutePath) + let yamlText: String + do { + yamlText = try String(contentsOf: sourceURL, encoding: .utf8) + } catch { + throw FlowErrors.schema("Unable to read flow file: \(absolutePath)") + } + + let locationIndex = FlowSourceLocationIndex(yamlText: yamlText) + do { + return try validateDefinition( + yamlText: yamlText, + sourceURL: sourceURL, + options: options + ) + } catch let error as FlowError { + throw locationIndex.enrich(error) + } + } + + // MARK: - Internal Entry + + private static func validateDefinition( + yamlText: String, + sourceURL: URL, + options: FlowValidationOptions + ) throws -> FlowYAMLDefinition { + let rootAny: Any + do { + rootAny = try Yams.load(yaml: yamlText) as Any + } catch { + throw FlowErrors.schema("YAML parse failed: \(error.localizedDescription)") + } + + guard let root = rootAny as? [String: Any] else { + throw FlowErrors.schema("Top-level YAML must be an object") + } + + let allowedTopFields: Set = ["version", "start", "defaults", "context", "states"] + for key in root.keys where !allowedTopFields.contains(key) { + throw FlowErrors.unknownField(key) + } + + guard let version = root["version"] as? String else { + throw FlowErrors.schema("Missing required field: version", field: "version") + } + guard version == "flow/v1" else { + throw FlowErrors.schema("Unsupported flow version: \(version)", field: "version") + } + + guard let start = root["start"] as? String, !start.isEmpty else { + throw FlowErrors.schema("Missing required field: start", field: "start") + } + + let defaults = try parseDefaults(root["defaults"]) + let context = try parseContext(root["context"]) + + guard let statesRaw = root["states"] else { + throw FlowErrors.schema("Missing required field: states", field: "states") + } + + guard let statesArray = statesRaw as? [Any] else { + throw FlowErrors.schema("states must be an array", field: "states") + } + if statesArray.isEmpty { + throw FlowErrors.schema("states must not be empty", field: "states") + } + + var states: [FlowStateDefinition] = [] + states.reserveCapacity(statesArray.count) + + for (index, raw) in statesArray.enumerated() { + guard let stateObject = raw as? [String: Any] else { + throw FlowErrors.schema("State at index \(index) must be an object", field: "states[\(index)]") + } + states.append(try parseState(stateObject, index: index)) + } + + try validateSemantics( + start: start, + states: states, + defaults: defaults, + sourceURL: sourceURL, + options: options + ) + + return FlowYAMLDefinition( + version: version, + start: start, + defaults: defaults, + context: context, + states: states + ) + } + + // MARK: - Parsing + + private static func parseDefaults(_ raw: Any?) throws -> FlowDefaults { + guard let raw else { + return FlowDefaults() + } + guard let object = raw as? [String: Any] else { + throw FlowErrors.schema("defaults must be an object", field: "defaults") + } + + let allowed: Set = [ + "max_agent_rounds", + "max_wait_cycles", + "max_total_steps", + "step_timeout_sec", + "fail_on_parse_error" + ] + for key in object.keys where !allowed.contains(key) { + throw FlowErrors.unknownField("defaults.\(key)") + } + + var defaults = FlowDefaults() + if let value = object["max_agent_rounds"] { + defaults.maxAgentRounds = try parseInt(value, field: "defaults.max_agent_rounds", min: 1) + } + if let value = object["max_wait_cycles"] { + defaults.maxWaitCycles = try parseInt(value, field: "defaults.max_wait_cycles", min: 1) + } + if let value = object["max_total_steps"] { + defaults.maxTotalSteps = try parseInt(value, field: "defaults.max_total_steps", min: 1) + } + if let value = object["step_timeout_sec"] { + defaults.stepTimeoutSec = try parseInt(value, field: "defaults.step_timeout_sec", min: 1) + } + if let value = object["fail_on_parse_error"] { + guard let flag = value as? Bool else { + throw FlowErrors.schema("defaults.fail_on_parse_error must be a boolean", field: "defaults.fail_on_parse_error") + } + defaults.failOnParseError = flag + } + return defaults + } + + private static func parseContext(_ raw: Any?) throws -> [String: FlowValue] { + guard let raw else { return [:] } + guard let object = raw as? [String: Any] else { + throw FlowErrors.schema("context must be an object", field: "context") + } + + var context: [String: FlowValue] = [:] + for (key, value) in object { + context[key] = try FlowValue.from(any: value) + } + return context + } + + private static func parseState(_ object: [String: Any], index: Int) throws -> FlowStateDefinition { + guard let id = object["id"] as? String, !id.isEmpty else { + throw FlowErrors.schema("State at index \(index) missing id", field: "states[\(index)].id") + } + guard let typeRaw = object["type"] as? String, + let type = FlowStateType(rawValue: typeRaw) else { + throw FlowErrors.schema("State '\(id)' has invalid type", field: "states[\(index)].type") + } + + let allowedFields = allowedFields(for: type) + for key in object.keys where !allowedFields.contains(key) { + throw FlowErrors.unknownField("states.\(id).\(key)") + } + + var state = FlowStateDefinition(id: id, type: type) + + if let run = object["run"] as? String { + state.run = run + } + if let task = object["task"] as? String { + state.task = task + } + if let next = object["next"] as? String { + state.next = next + } + if let timeout = object["timeout_sec"] { + state.timeoutSec = try parseInt(timeout, field: "states.\(id).timeout_sec", min: 1) + } + + if let interpreterRaw = object["interpreter"] as? String { + guard let interpreter = Interpreter(rawValue: interpreterRaw) else { + throw FlowErrors.schema("Invalid interpreter: \(interpreterRaw)", field: "states.\(id).interpreter") + } + state.interpreter = interpreter + } + + if let argsRaw = object["args"] { + guard let list = argsRaw as? [Any] else { + throw FlowErrors.schema("args must be an array", field: "states.\(id).args") + } + state.args = try list.map { try FlowValue.from(any: $0) } + } + + if let envRaw = object["env"] { + guard let envObject = envRaw as? [String: Any] else { + throw FlowErrors.schema("env must be an object", field: "states.\(id).env") + } + var env: [String: FlowValue] = [:] + for (key, value) in envObject { + env[key] = try FlowValue.from(any: value) + } + state.env = env + } + + if let onRaw = object["on"] { + guard let onObject = onRaw as? [String: Any] else { + throw FlowErrors.schema("on must be an object", field: "states.\(id).on") + } + let allowedOn: Set = ["pass", "needs_agent", "wait", "fail", "parse_error"] + for key in onObject.keys where !allowedOn.contains(key) { + throw FlowErrors.unknownField("states.\(id).on.\(key)") + } + guard let pass = onObject["pass"] as? String, + let needsAgent = onObject["needs_agent"] as? String, + let wait = onObject["wait"] as? String, + let fail = onObject["fail"] as? String else { + throw FlowErrors.schema("gate.on must include pass/needs_agent/wait/fail", field: "states.\(id).on") + } + let parseError = onObject["parse_error"] as? String + state.on = FlowGateTransitions( + pass: pass, + needsAgent: needsAgent, + wait: wait, + fail: fail, + parseError: parseError + ) + } + + if let parseRaw = object["parse"] as? String { + guard let mode = FlowGateParseMode(rawValue: parseRaw) else { + throw FlowErrors.parseModeInvalid( + parseRaw, + fieldPath: "states.\(id).parse", + stateID: id + ) + } + state.parseMode = mode + } + + if let value = object["seconds"] { + state.seconds = try parseInt(value, field: "states.\(id).seconds", min: 0) + } + if let secondsFrom = object["seconds_from"] as? String { + state.secondsFrom = secondsFrom + } + + if let model = object["model"] as? String { + state.model = model + } + if let counter = object["counter"] as? String { + state.counter = counter + } + if let maxRounds = object["max_rounds"] { + state.maxRounds = try parseInt(maxRounds, field: "states.\(id).max_rounds", min: 1) + } + if let prompt = object["prompt"] as? String { + state.prompt = prompt + } + + if let exportRaw = object["export"] { + guard let exportObject = exportRaw as? [String: Any] else { + throw FlowErrors.schema("export must be an object", field: "states.\(id).export") + } + var export: [String: String] = [:] + for (key, value) in exportObject { + guard let expr = value as? String else { + throw FlowErrors.schema("export value must be string expression", field: "states.\(id).export.\(key)") + } + export[key] = expr + } + state.export = export + } + + if let statusRaw = object["status"] as? String { + guard let status = FlowEndStatus(rawValue: statusRaw) else { + throw FlowErrors.schema("Invalid end status: \(statusRaw)", field: "states.\(id).status") + } + state.endStatus = status + } + if let message = object["message"] as? String { + state.message = message + } + + return state + } + + private static func allowedFields(for type: FlowStateType) -> Set { + switch type { + case .gate: + return ["id", "type", "run", "args", "env", "interpreter", "timeout_sec", "parse", "on"] + case .agent: + return ["id", "type", "task", "next", "model", "counter", "max_rounds", "prompt", "export", "timeout_sec"] + case .wait: + return ["id", "type", "next", "seconds", "seconds_from", "timeout_sec"] + case .script: + return ["id", "type", "run", "next", "args", "env", "interpreter", "timeout_sec", "export"] + case .end: + return ["id", "type", "status", "message"] + } + } + + private static func parseInt(_ raw: Any, field: String, min: Int) throws -> Int { + let parsed: Int? + switch raw { + case let value as Int: + parsed = value + case let value as NSNumber: + if CFGetTypeID(value) == CFBooleanGetTypeID() { + parsed = nil + } else if value.doubleValue.rounded(.towardZero) == value.doubleValue { + parsed = value.intValue + } else { + parsed = nil + } + case let value as Double: + if value.rounded(.towardZero) == value { + parsed = Int(value) + } else { + parsed = nil + } + case let value as String: + parsed = Int(value) + default: + parsed = nil + } + + guard let number = parsed else { + throw FlowErrors.numericRange("\(field) must be an integer", field: field) + } + guard number >= min else { + throw FlowErrors.numericRange("\(field) must be >= \(min)", field: field) + } + return number + } + + // MARK: - Semantics + + private static func validateSemantics( + start: String, + states: [FlowStateDefinition], + defaults: FlowDefaults, + sourceURL: URL, + options: FlowValidationOptions + ) throws { + var ids = Set() + for state in states { + if ids.contains(state.id) { + throw FlowErrors.schema("Duplicate state id: \(state.id)", field: "states") + } + ids.insert(state.id) + } + + guard ids.contains(start) else { + throw FlowErrors.schema("start state not found: \(start)", field: "start") + } + + for state in states { + try validateStateFields(state, defaults: defaults, sourceURL: sourceURL, options: options) + } + + for state in states { + switch state.type { + case .gate: + if let on = state.on { + try ensureTargetExists(on.pass, ids: ids, stateID: state.id) + try ensureTargetExists(on.needsAgent, ids: ids, stateID: state.id) + try ensureTargetExists(on.wait, ids: ids, stateID: state.id) + try ensureTargetExists(on.fail, ids: ids, stateID: state.id) + if let parseError = on.parseError { + try ensureTargetExists(parseError, ids: ids, stateID: state.id) + } + } + case .wait, .agent, .script: + if let next = state.next { + try ensureTargetExists(next, ids: ids, stateID: state.id) + } + case .end: + break + } + } + + try validateReachability(start: start, states: states) + } + + private static func validateStateFields( + _ state: FlowStateDefinition, + defaults: FlowDefaults, + sourceURL: URL, + options: FlowValidationOptions + ) throws { + let flowDir = sourceURL.deletingLastPathComponent() + switch state.type { + case .gate: + guard let run = state.run, !run.isEmpty else { + throw FlowErrors.schema("gate state requires run", field: "states.\(state.id).run") + } + _ = try FlowPathResolver.resolveRunPath( + run, + flowDirectory: flowDir, + phase: .validate, + checkFileSystem: options.checkFileSystem, + stateID: state.id + ) + guard state.on != nil else { + throw FlowErrors.schema("gate state requires on", field: "states.\(state.id).on") + } + if !defaults.failOnParseError, state.on?.parseError == nil { + throw FlowErrors.schema( + "gate.on.parse_error is required when fail_on_parse_error=false", + field: "states.\(state.id).on.parse_error" + ) + } + try validateArgsEnv(state: state) + + case .script: + guard let run = state.run, !run.isEmpty else { + throw FlowErrors.schema("script state requires run", field: "states.\(state.id).run") + } + _ = try FlowPathResolver.resolveRunPath( + run, + flowDirectory: flowDir, + phase: .validate, + checkFileSystem: options.checkFileSystem, + stateID: state.id + ) + guard let next = state.next, !next.isEmpty else { + throw FlowErrors.schema("script state requires next", field: "states.\(state.id).next") + } + _ = next + try validateArgsEnv(state: state) + try validateExport(state: state) + + case .agent: + guard let task = state.task, !task.isEmpty else { + throw FlowErrors.schema("agent state requires task", field: "states.\(state.id).task") + } + _ = task + guard let next = state.next, !next.isEmpty else { + throw FlowErrors.schema("agent state requires next", field: "states.\(state.id).next") + } + _ = next + if let maxRounds = state.maxRounds, maxRounds < 1 { + throw FlowErrors.numericRange("max_rounds must be >= 1", field: "states.\(state.id).max_rounds") + } + try validateExport(state: state) + + case .wait: + guard let next = state.next, !next.isEmpty else { + throw FlowErrors.schema("wait state requires next", field: "states.\(state.id).next") + } + _ = next + if state.seconds == nil && state.secondsFrom == nil { + throw FlowErrors.schema("wait requires one of seconds or seconds_from", field: "states.\(state.id)") + } + if state.seconds != nil && state.secondsFrom != nil { + throw FlowErrors.schema("wait cannot specify both seconds and seconds_from", field: "states.\(state.id)") + } + if let seconds = state.seconds, seconds < 0 { + throw FlowErrors.numericRange("wait.seconds must be >= 0", field: "states.\(state.id).seconds") + } + if let secondsFrom = state.secondsFrom { + try validateExpression(secondsFrom) + } + + case .end: + guard state.endStatus != nil else { + throw FlowErrors.schema("end state requires status", field: "states.\(state.id).status") + } + } + + if let timeout = state.timeoutSec, timeout < 1 { + throw FlowErrors.numericRange("timeout_sec must be >= 1", field: "states.\(state.id).timeout_sec") + } + } + + private static func validateArgsEnv(state: FlowStateDefinition) throws { + if let args = state.args { + for value in args { + try validateStringFieldValue( + value, + field: "states.\(state.id).args", + allowExpression: true + ) + } + } + + if let env = state.env { + for (key, value) in env { + try validateStringFieldValue( + value, + field: "states.\(state.id).env.\(key)", + allowExpression: true + ) + } + } + } + + private static func validateExport(state: FlowStateDefinition) throws { + guard let export = state.export else { return } + for (key, expr) in export { + try validateExpression(expr, field: "states.\(state.id).export.\(key)") + } + } + + private static func validateStringFieldValue( + _ value: FlowValue, + field: String, + allowExpression: Bool + ) throws { + switch value { + case .string(let text): + if allowExpression, isExpression(text) { + try validateExpression(text, field: field) + } + case .number, .bool: + break + case .null, .array, .object: + throw FlowErrors.fieldType("\(field) only accepts string/number/bool literals", field: field) + } + } + + static func isExpression(_ text: String) -> Bool { + text.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("$.") + } + + static func validateExpression(_ expression: String, field: String = "expression") throws { + let trimmed = expression.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("$.") else { + throw FlowErrors.schema("Expression must start with '$.'", field: field) + } + let path = String(trimmed.dropFirst(2)) + let parts = path.split(separator: ".", omittingEmptySubsequences: false).map(String.init) + guard !parts.isEmpty else { + throw FlowErrors.schema("Expression path is empty", field: field) + } + if parts.contains(where: { $0.isEmpty }) { + throw FlowErrors.schema("Expression contains empty path segment", field: field) + } + + let root = parts[0] + switch root { + case "context", "counters", "prev", "current": + return + + case "state": + guard parts.count >= 3 else { + throw FlowErrors.schema("state expression must be $.state..last", field: field) + } + guard parts[2] == "last" else { + throw FlowErrors.schema("state expression must use .last", field: field) + } + return + + default: + throw FlowErrors.schema("Expression prefix is not supported", field: field) + } + } + + private static func ensureTargetExists(_ target: String, ids: Set, stateID: String) throws { + guard ids.contains(target) else { + throw FlowErrors.schema("State '\(stateID)' targets unknown state '\(target)'", field: "states.\(stateID)") + } + } + + private static func validateReachability(start: String, states: [FlowStateDefinition]) throws { + var adjacency: [String: [String]] = [:] + for state in states { + switch state.type { + case .gate: + let targets = [ + state.on?.pass, + state.on?.needsAgent, + state.on?.wait, + state.on?.fail, + state.on?.parseError + ].compactMap { $0 } + adjacency[state.id] = targets + case .wait, .agent, .script: + adjacency[state.id] = state.next.map { [$0] } ?? [] + case .end: + adjacency[state.id] = [] + } + } + + var visited = Set() + var queue = [start] + + while !queue.isEmpty { + let current = queue.removeFirst() + if !visited.insert(current).inserted { + continue + } + let targets = adjacency[current] ?? [] + for target in targets where !visited.contains(target) { + queue.append(target) + } + } + + for state in states where !visited.contains(state.id) { + throw FlowErrors.unreachable(state.id) + } + } +} + +private struct FlowSourceLocationIndex { + private struct FieldLocation { + var line: Int + var keyColumn: Int + var valueColumn: Int? + } + + private struct SourceLocation { + var line: Int + var column: Int + } + + private struct StateLocation { + var startLine: Int + var startColumn: Int + var id: String? + var fields: [String: FieldLocation] + var nested: [String: [String: FieldLocation]] + } + + private let topLevel: [String: FieldLocation] + private let topNested: [String: [String: FieldLocation]] + private let statesByID: [String: StateLocation] + private let statesByIndex: [Int: StateLocation] + + init(yamlText: String) { + var topLevel: [String: FieldLocation] = [:] + var topNested: [String: [String: FieldLocation]] = [:] + var states: [StateLocation] = [] + + let lines = yamlText.components(separatedBy: .newlines) + + var activeTopParent: String? + var activeTopParentIndent = 0 + var inStates = false + var statesIndent = 0 + + var currentStateIndex: Int? + var currentStateIndent = 0 + var activeStateParent: String? + var activeStateParentIndent = 0 + + for (zeroBasedLine, rawLine) in lines.enumerated() { + let lineNo = zeroBasedLine + 1 + let trimmed = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || trimmed.hasPrefix("#") { + continue + } + + let indent = rawLine.prefix { $0 == " " }.count + + if indent == 0, let record = parseYAMLKeyRecord(from: rawLine) { + let key = record.key + inStates = key == "states" + statesIndent = 0 + + let location = FieldLocation( + line: lineNo, + keyColumn: record.keyColumn, + valueColumn: record.valueColumn + ) + topLevel[key] = topLevel[key] ?? location + + if key == "defaults" || key == "context" { + activeTopParent = key + activeTopParentIndent = indent + } else { + activeTopParent = nil + } + + if !inStates { + currentStateIndex = nil + activeStateParent = nil + } + continue + } + + if let parent = activeTopParent { + if indent > activeTopParentIndent, + let record = parseYAMLKeyRecord(from: rawLine), + !trimmed.hasPrefix("- ") + { + let subKey = record.key + var nested = topNested[parent] ?? [:] + let location = FieldLocation( + line: lineNo, + keyColumn: record.keyColumn, + valueColumn: record.valueColumn + ) + nested[subKey] = nested[subKey] ?? location + topNested[parent] = nested + } else if indent <= activeTopParentIndent { + activeTopParent = nil + } + } + + if inStates { + if trimmed.hasPrefix("- "), indent > statesIndent { + let index = states.count + let startColumn = rawLine.firstIndex(of: "-").map { + rawLine.distance(from: rawLine.startIndex, to: $0) + 1 + } ?? (indent + 1) + states.append( + StateLocation( + startLine: lineNo, + startColumn: startColumn, + id: nil, + fields: [:], + nested: [:] + ) + ) + currentStateIndex = index + currentStateIndent = indent + activeStateParent = nil + + if let record = parseYAMLKeyRecord(from: rawLine) { + let key = record.key + let location = FieldLocation( + line: lineNo, + keyColumn: record.keyColumn, + valueColumn: record.valueColumn + ) + states[index].fields[key] = states[index].fields[key] ?? location + if key == "id", let id = parseYAMLScalarValue(from: rawLine) { + states[index].id = id + } + } + continue + } + + guard let stateIndex = currentStateIndex else { + continue + } + + if indent <= currentStateIndent { + activeStateParent = nil + continue + } + + if let record = parseYAMLKeyRecord(from: rawLine), !trimmed.hasPrefix("- ") { + let key = record.key + let location = FieldLocation( + line: lineNo, + keyColumn: record.keyColumn, + valueColumn: record.valueColumn + ) + if indent <= currentStateIndent + 2 { + states[stateIndex].fields[key] = states[stateIndex].fields[key] ?? location + if key == "id", let id = parseYAMLScalarValue(from: rawLine) { + states[stateIndex].id = id + } + + if key == "on" || key == "env" || key == "export" { + activeStateParent = key + activeStateParentIndent = indent + } else { + activeStateParent = nil + } + } else if let parent = activeStateParent, indent > activeStateParentIndent { + var nested = states[stateIndex].nested[parent] ?? [:] + nested[key] = nested[key] ?? location + states[stateIndex].nested[parent] = nested + } + } else if indent <= activeStateParentIndent { + activeStateParent = nil + } + } + } + + var byID: [String: StateLocation] = [:] + var byIndex: [Int: StateLocation] = [:] + for (index, state) in states.enumerated() { + byIndex[index] = state + if let id = state.id, byID[id] == nil { + byID[id] = state + } + } + + self.topLevel = topLevel + self.topNested = topNested + self.statesByID = byID + self.statesByIndex = byIndex + } + + func enrich(_ error: FlowError) -> FlowError { + if error.line != nil, error.column != nil { + return error + } + + var enriched = error + if let location = locate(error: error, fieldPath: error.fieldPath, stateID: error.stateID) { + if enriched.line == nil { + enriched.line = location.line + } + if enriched.column == nil { + enriched.column = location.column + } + } + return enriched + } + + private func locate(error: FlowError, fieldPath: String?, stateID: String?) -> SourceLocation? { + guard let fieldLocation = locateFieldLocation(fieldPath: fieldPath, stateID: stateID) else { + return nil + } + return SourceLocation( + line: fieldLocation.line, + column: preferredColumn(for: error, location: fieldLocation) + ) + } + + private func locateFieldLocation(fieldPath: String?, stateID: String?) -> FieldLocation? { + if let fieldPath, fieldPath.hasPrefix("states[") { + let components = fieldPath.components(separatedBy: "].") + if let head = components.first, + let open = head.firstIndex(of: "["), + let index = Int(head[head.index(after: open)...]) { + if let state = statesByIndex[index] { + let rest = components.count > 1 ? components[1] : "" + return stateLocation(state: state, restPath: rest) + } + } + } + + if let fieldPath, fieldPath.hasPrefix("states.") { + let parts = fieldPath.split(separator: ".").map(String.init) + if parts.count >= 2 { + let id = parts[1] + if let state = statesByID[id] { + let rest = parts.dropFirst(2).joined(separator: ".") + return stateLocation(state: state, restPath: rest) + } + } + } + + if let stateID, let state = statesByID[stateID] { + if let fieldPath { + if fieldPath == "run", let location = state.fields["run"] { + return location + } + if let direct = state.fields[fieldPath] { + return direct + } + if let nested = nestedLocation(state: state, path: fieldPath) { + return nested + } + } + return FieldLocation( + line: state.startLine, + keyColumn: state.startColumn, + valueColumn: nil + ) + } + + if let fieldPath { + let parts = fieldPath.split(separator: ".").map(String.init) + if let first = parts.first { + if parts.count == 1 { + return topLevel[first] + } + if first == "defaults" || first == "context" { + if let second = parts.dropFirst().first { + if let location = topNested[first]?[second] { + return location + } + } + return topLevel[first] + } + } + } + + return nil + } + + private func stateLocation(state: StateLocation, restPath: String) -> FieldLocation? { + if restPath.isEmpty { + return FieldLocation( + line: state.startLine, + keyColumn: state.startColumn, + valueColumn: nil + ) + } + + let parts = restPath.split(separator: ".").map(String.init) + guard let head = parts.first else { + return FieldLocation( + line: state.startLine, + keyColumn: state.startColumn, + valueColumn: nil + ) + } + + if parts.count == 1 { + return state.fields[head] ?? FieldLocation( + line: state.startLine, + keyColumn: state.startColumn, + valueColumn: nil + ) + } + + if let nested = state.nested[head], let second = parts.dropFirst().first { + return nested[second] ?? state.fields[head] ?? FieldLocation( + line: state.startLine, + keyColumn: state.startColumn, + valueColumn: nil + ) + } + + return state.fields[head] ?? FieldLocation( + line: state.startLine, + keyColumn: state.startColumn, + valueColumn: nil + ) + } + + private func nestedLocation(state: StateLocation, path: String) -> FieldLocation? { + let parts = path.split(separator: ".").map(String.init) + guard parts.count >= 2 else { return nil } + let parent = parts[0] + let child = parts[1] + return state.nested[parent]?[child] + } + + private func preferredColumn(for error: FlowError, location: FieldLocation) -> Int { + if shouldPreferValueColumn(error), let valueColumn = location.valueColumn { + return valueColumn + } + return location.keyColumn + } + + private func shouldPreferValueColumn(_ error: FlowError) -> Bool { + switch error.code { + case "flow.validate.unknown_field", "flow.validate.unreachable_state": + return false + case "flow.path.invalid_path_kind", + "flow.path.not_found", + "flow.gate.parse_mode_invalid", + "flow.validate.numeric_range_error", + "flow.validate.field_type_error": + return true + default: + break + } + + guard error.code == "flow.validate.schema_error" else { + return false + } + + let message = error.message.lowercased() + if message.contains("missing required field") + || message.contains("requires run") + || message.contains("requires next") + || message.contains("requires task") + || message.contains("requires status") + || message.contains("gate state requires on") + || message.contains("wait requires one of") + || message.contains("wait cannot specify both") + { + return false + } + + if message.contains("unsupported flow version") + || message.contains("start state not found") + || message.contains("invalid end status") + || message.contains("invalid interpreter") + || message.contains("has invalid type") + || message.contains("must be") + || message.contains("unsupported") + || message.contains("invalid") + { + return true + } + + return false + } +} + +private struct YAMLKeyRecord { + var key: String + var keyColumn: Int + var valueColumn: Int? +} + +private func parseYAMLKeyRecord(from rawLine: String) -> YAMLKeyRecord? { + let trimmed = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || trimmed.hasPrefix("#") { + return nil + } + + var cursor = rawLine.startIndex + while cursor < rawLine.endIndex, rawLine[cursor] == " " { + cursor = rawLine.index(after: cursor) + } + + if cursor < rawLine.endIndex, rawLine[cursor] == "-" { + let next = rawLine.index(after: cursor) + if next < rawLine.endIndex, rawLine[next] == " " { + cursor = rawLine.index(after: next) + while cursor < rawLine.endIndex, rawLine[cursor] == " " { + cursor = rawLine.index(after: cursor) + } + } + } + + let keyStart = cursor + guard keyStart < rawLine.endIndex, + let colon = rawLine[keyStart...].firstIndex(of: ":") else { + return nil + } + + let key = String(rawLine[keyStart.. Bool { + guard !key.isEmpty else { return false } + let first = key[key.startIndex] + guard (first >= "A" && first <= "Z") + || (first >= "a" && first <= "z") + || first == "_" else { + return false + } + for char in key.dropFirst() { + let isAlpha = (char >= "A" && char <= "Z") || (char >= "a" && char <= "z") + let isDigit = (char >= "0" && char <= "9") + if !(isAlpha || isDigit || char == "_") { + return false + } + } + return true +} + +private func parseYAMLScalarValue(from line: String) -> String? { + let source = line.trimmingCharacters(in: .whitespacesAndNewlines) + let working: String + if source.hasPrefix("- ") { + working = String(source.dropFirst(2)).trimmingCharacters(in: .whitespacesAndNewlines) + } else { + working = source + } + guard let colon = working.firstIndex(of: ":") else { + return nil + } + var value = String(working[working.index(after: colon)...]).trimmingCharacters(in: .whitespacesAndNewlines) + if value.isEmpty { + return nil + } + if let commentStart = value.range(of: " #") { + value = String(value[..= 2 { + value = String(value.dropFirst().dropLast()) + } else if value.hasPrefix("'"), value.hasSuffix("'"), value.count >= 2 { + value = String(value.dropFirst().dropLast()) + } + return value.isEmpty ? nil : value +} diff --git a/Sources/ScriptoriaCore/Flow/FlowValue.swift b/Sources/ScriptoriaCore/Flow/FlowValue.swift new file mode 100644 index 0000000..5cbdde0 --- /dev/null +++ b/Sources/ScriptoriaCore/Flow/FlowValue.swift @@ -0,0 +1,192 @@ +import Foundation + +public enum FlowValue: Sendable, Equatable, Codable { + case null + case string(String) + case number(Double) + case bool(Bool) + case object([String: FlowValue]) + case array([FlowValue]) + + public var stringValue: String? { + if case .string(let value) = self { + return value + } + return nil + } + + public var intValue: Int? { + switch self { + case .number(let value): + if value.rounded(.towardZero) == value { + return Int(value) + } + return nil + case .string(let value): + return Int(value) + default: + return nil + } + } + + public subscript(key: String) -> FlowValue? { + if case .object(let object) = self { + return object[key] + } + return nil + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([String: FlowValue].self) { + self = .object(value) + } else if let value = try? container.decode([FlowValue].self) { + self = .array(value) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .null: + try container.encodeNil() + case .string(let value): + try container.encode(value) + case .number(let value): + if value.rounded(.towardZero) == value { + try container.encode(Int(value)) + } else { + try container.encode(value) + } + case .bool(let value): + try container.encode(value) + case .object(let object): + try container.encode(object) + case .array(let array): + try container.encode(array) + } + } + + static func from(any raw: Any) throws -> FlowValue { + switch raw { + case is NSNull: + return .null + case let value as String: + return .string(value) + case let value as NSString: + return .string(String(value)) + case let value as Int: + return .number(Double(value)) + case let value as Int8: + return .number(Double(value)) + case let value as Int16: + return .number(Double(value)) + case let value as Int32: + return .number(Double(value)) + case let value as Int64: + return .number(Double(value)) + case let value as UInt: + return .number(Double(value)) + case let value as UInt8: + return .number(Double(value)) + case let value as UInt16: + return .number(Double(value)) + case let value as UInt32: + return .number(Double(value)) + case let value as UInt64: + return .number(Double(value)) + case let value as Double: + return .number(value) + case let value as Float: + return .number(Double(value)) + case let value as NSNumber: + // NSNumber can represent bool/number in bridged YAML trees. + if CFGetTypeID(value) == CFBooleanGetTypeID() { + return .bool(value.boolValue) + } + return .number(value.doubleValue) + case let value as Bool: + return .bool(value) + case let dictionary as [String: Any]: + var object: [String: FlowValue] = [:] + for (key, value) in dictionary { + object[key] = try from(any: value) + } + return .object(object) + case let dictionary as [AnyHashable: Any]: + var object: [String: FlowValue] = [:] + for (key, value) in dictionary { + guard let stringKey = key as? String else { + throw FlowErrors.schema("Object key must be string", field: nil) + } + object[stringKey] = try from(any: value) + } + return .object(object) + case let array as [Any]: + return .array(try array.map { try from(any: $0) }) + default: + throw FlowErrors.schema("Unsupported value type: \(type(of: raw))") + } + } + + func toAny() -> Any { + switch self { + case .null: + return NSNull() + case .string(let value): + return value + case .number(let value): + if value.rounded(.towardZero) == value { + return Int(value) + } + return value + case .bool(let value): + return value + case .object(let object): + return object.mapValues { $0.toAny() } + case .array(let array): + return array.map { $0.toAny() } + } + } + + func lookup(path components: ArraySlice) -> FlowValue? { + guard let head = components.first else { + return self + } + + guard case .object(let object) = self, + let next = object[head] else { + return nil + } + + return next.lookup(path: components.dropFirst()) + } +} + +func flowJSONString(from value: FlowValue) throws -> String { + switch value { + case .string(let text): + return text + case .number(let number): + if number.rounded(.towardZero) == number { + return String(Int(number)) + } + return String(number) + case .bool(let flag): + return flag ? "true" : "false" + default: + throw FlowErrors.runtime(code: "flow.expr.type_error", "Expression result must be scalar string/number/bool") + } +} diff --git a/Sources/ScriptoriaCore/Flow/GateStepRunner.swift b/Sources/ScriptoriaCore/Flow/GateStepRunner.swift new file mode 100644 index 0000000..744500e --- /dev/null +++ b/Sources/ScriptoriaCore/Flow/GateStepRunner.swift @@ -0,0 +1,137 @@ +import Foundation + +struct FlowGateStepResult { + var nextStateID: String + var decision: FlowGateDecision + var stateOutput: [String: FlowValue] +} + +struct GateStepRunner { + let scriptRunner: ScriptRunner + + func execute( + state: FlowIRState, + ir: FlowIR, + mode: FlowRunMode, + dryFixture: inout FlowDryRunFixture?, + context: [String: FlowValue], + counters: [String: Int], + stateLast: [String: FlowValue], + prev: FlowValue? + ) async throws -> FlowGateStepResult { + guard let transitions = state.transitions else { + throw FlowErrors.runtime(code: "flow.validate.schema_error", "gate transitions missing", stateID: state.id) + } + + let result: FlowGateParseResult + switch mode { + case .live: + guard let exec = state.exec else { + throw FlowErrors.runtime(code: "flow.validate.schema_error", "gate exec missing", stateID: state.id) + } + let resolvedPath = FlowPathResolver.resolveIRRunPath(irRun: exec.run, sourcePath: ir.sourcePath) + try FlowStepRunnerSupport.ensureReadablePath(resolvedPath, stateID: state.id) + + let scope = FlowExpressionScope( + context: context, + counters: counters, + stateLast: stateLast, + prev: prev, + current: nil + ) + let args = try FlowStepRunnerSupport.resolveArgs(exec.args, scope: scope) + let env = try FlowStepRunnerSupport.resolveEnv(exec.env, scope: scope) + + let script = Script(title: state.id, path: resolvedPath, interpreter: exec.interpreter) + let run = try await scriptRunner.run( + script, + options: .init( + args: args, + env: env, + timeoutSec: exec.timeoutSec, + workingDirectory: URL(fileURLWithPath: resolvedPath).deletingLastPathComponent().path + ) + ) + if FlowStepRunnerSupport.isScriptRunTimedOut(run) { + throw FlowErrors.runtime( + code: "flow.step.timeout", + "gate step timed out", + stateID: state.id + ) + } + if run.status != .success || run.exitCode != 0 { + throw FlowErrors.runtime( + code: "flow.gate.process_exit_nonzero", + "gate process exited non-zero", + stateID: state.id + ) + } + + do { + result = try FlowGateOutputParser.parse(stdout: run.output, mode: exec.parse ?? .jsonLastLine) + } catch let parseError as FlowError { + if ir.defaults.failOnParseError { + throw FlowErrors.runtime( + code: "flow.gate.parse_error", + parseError.message, + stateID: state.id + ) + } + result = FlowGateParseResult(decision: .parseError) + } + + case .dryRun: + guard var fixture = dryFixture else { + throw FlowErrors.runtimeDryRun(code: "flow.validate.schema_error", "dry-run fixture missing") + } + guard let item = fixture.consume(stateID: state.id) else { + throw FlowErrors.runtimeDryRun( + code: "flow.dryrun.fixture_missing_state_data", + "Dry-run fixture missing data for state \(state.id)", + stateID: state.id + ) + } + dryFixture = fixture + + guard case .object(let object) = item, + let decisionRaw = object["decision"]?.stringValue, + let decision = FlowGateDecision(rawValue: decisionRaw) else { + throw FlowErrors.runtimeDryRun( + code: "flow.gate.parse_error", + "Invalid gate fixture payload for state \(state.id)", + stateID: state.id + ) + } + let retry = object["retry_after_sec"]?.intValue + result = FlowGateParseResult(decision: decision, retryAfterSec: retry, object: object) + } + + let transition: String? + switch result.decision { + case .pass: + transition = transitions.pass + case .needsAgent: + transition = transitions.needsAgent + case .wait: + transition = transitions.wait + case .fail: + transition = transitions.fail + case .parseError: + transition = transitions.parseError + } + + guard let next = transition else { + throw FlowErrors.runtime( + code: "flow.gate.parse_error", + "parse_error transition is not configured", + stateID: state.id + ) + } + + return FlowGateStepResult( + nextStateID: next, + decision: result.decision, + stateOutput: result.object + ) + } +} diff --git a/Sources/ScriptoriaCore/Flow/ScriptStepRunner.swift b/Sources/ScriptoriaCore/Flow/ScriptStepRunner.swift new file mode 100644 index 0000000..1b15a40 --- /dev/null +++ b/Sources/ScriptoriaCore/Flow/ScriptStepRunner.swift @@ -0,0 +1,146 @@ +import Foundation + +struct FlowScriptStepResult { + var nextStateID: String + var stateOutput: [String: FlowValue] +} + +struct ScriptStepRunner { + let scriptRunner: ScriptRunner + + func execute( + state: FlowIRState, + ir: FlowIR, + mode: FlowRunMode, + dryFixture: inout FlowDryRunFixture?, + context: inout [String: FlowValue], + counters: [String: Int], + stateLast: [String: FlowValue], + prev: FlowValue? + ) async throws -> FlowScriptStepResult { + guard let next = state.next else { + throw FlowErrors.runtime(code: "flow.validate.schema_error", "script next missing", stateID: state.id) + } + + var stateOutput: [String: FlowValue] = [:] + switch mode { + case .live: + guard let exec = state.exec else { + throw FlowErrors.runtime(code: "flow.validate.schema_error", "script exec missing", stateID: state.id) + } + let resolvedPath = FlowPathResolver.resolveIRRunPath(irRun: exec.run, sourcePath: ir.sourcePath) + try FlowStepRunnerSupport.ensureReadablePath(resolvedPath, stateID: state.id) + + let scope = FlowExpressionScope( + context: context, + counters: counters, + stateLast: stateLast, + prev: prev, + current: nil + ) + let args = try FlowStepRunnerSupport.resolveArgs(exec.args, scope: scope) + let env = try FlowStepRunnerSupport.resolveEnv(exec.env, scope: scope) + + let script = Script(title: state.id, path: resolvedPath, interpreter: exec.interpreter) + let run = try await scriptRunner.run( + script, + options: .init( + args: args, + env: env, + timeoutSec: exec.timeoutSec, + workingDirectory: URL(fileURLWithPath: resolvedPath).deletingLastPathComponent().path + ) + ) + if FlowStepRunnerSupport.isScriptRunTimedOut(run) { + throw FlowErrors.runtime( + code: "flow.step.timeout", + "script step timed out", + stateID: state.id + ) + } + if run.status != .success || run.exitCode != 0 { + throw FlowErrors.runtime( + code: "flow.script.process_exit_nonzero", + "script process exited non-zero", + stateID: state.id + ) + } + + stateOutput["stdout"] = .string(run.output) + stateOutput["stderr"] = .string(run.errorOutput) + if let lastLine = FlowStepRunnerSupport.lastNonEmptyLine(in: run.output) { + stateOutput["stdout_last_line"] = .string(lastLine) + } + + if let export = state.export { + guard let final = try FlowStepRunnerSupport.parseLastLineJSONObject(text: run.output) else { + throw FlowErrors.runtime( + code: "flow.script.output_parse_error", + "script export requires final JSON line", + stateID: state.id + ) + } + stateOutput["final"] = .object(final) + try FlowStepRunnerSupport.applyExport( + export, + context: &context, + counters: counters, + stateLast: stateLast, + prev: prev, + current: .object(["final": .object(final)]), + missingCode: "flow.script.export_field_missing", + stateID: state.id + ) + } + + case .dryRun: + guard var fixture = dryFixture else { + throw FlowErrors.runtimeDryRun(code: "flow.validate.schema_error", "dry-run fixture missing") + } + guard let item = fixture.consume(stateID: state.id) else { + throw FlowErrors.runtimeDryRun( + code: "flow.dryrun.fixture_missing_state_data", + "Dry-run fixture missing data for state \(state.id)", + stateID: state.id + ) + } + dryFixture = fixture + + guard case .object(let object) = item else { + throw FlowErrors.runtimeDryRun( + code: "flow.validate.schema_error", + "script fixture entry must be object", + stateID: state.id + ) + } + stateOutput = object + + if let status = object["status"]?.stringValue, + status == "failed" { + throw FlowErrors.runtime(code: "flow.script.process_exit_nonzero", "script fixture indicated failure", stateID: state.id) + } + + if let export = state.export { + guard case .object(let final)? = object["final"] else { + throw FlowErrors.runtime( + code: "flow.script.output_parse_error", + "script export requires final JSON object", + stateID: state.id + ) + } + try FlowStepRunnerSupport.applyExport( + export, + context: &context, + counters: counters, + stateLast: stateLast, + prev: prev, + current: .object(["final": .object(final)]), + missingCode: "flow.script.export_field_missing", + stateID: state.id + ) + } + } + + return FlowScriptStepResult(nextStateID: next, stateOutput: stateOutput) + } +} diff --git a/Sources/ScriptoriaCore/Flow/WaitStepRunner.swift b/Sources/ScriptoriaCore/Flow/WaitStepRunner.swift new file mode 100644 index 0000000..464445a --- /dev/null +++ b/Sources/ScriptoriaCore/Flow/WaitStepRunner.swift @@ -0,0 +1,52 @@ +import Foundation + +struct FlowWaitStepResult { + var nextStateID: String + var stateOutput: [String: FlowValue] +} + +struct WaitStepRunner { + func execute( + state: FlowIRState, + ir: FlowIR, + mode: FlowRunMode, + context: [String: FlowValue], + counters: [String: Int], + stateLast: [String: FlowValue], + prev: FlowValue? + ) async throws -> FlowWaitStepResult { + guard let wait = state.wait, + let next = state.next else { + throw FlowErrors.runtime(code: "flow.validate.schema_error", "wait payload missing", stateID: state.id) + } + + let waitSeconds: Int + if let seconds = wait.seconds { + waitSeconds = seconds + } else if let secondsFrom = wait.secondsFrom { + let scope = FlowExpressionScope( + context: context, + counters: counters, + stateLast: stateLast, + prev: prev, + current: nil + ) + waitSeconds = try ExpressionEvaluator.evaluateWaitSeconds(secondsFrom, scope: scope) + } else { + throw FlowErrors.runtime(code: "flow.wait.seconds_resolve_error", "wait.seconds or wait.seconds_from required", stateID: state.id) + } + + if waitSeconds > wait.timeoutSec { + throw FlowErrors.runtime(code: "flow.step.timeout", "wait exceeds timeout", stateID: state.id) + } + + if case .live = mode, waitSeconds > 0 { + try await Task.sleep(nanoseconds: UInt64(waitSeconds) * 1_000_000_000) + } + + return FlowWaitStepResult( + nextStateID: next, + stateOutput: ["seconds": .number(Double(waitSeconds))] + ) + } +} diff --git a/Tests/ScriptoriaCoreTests/Flow/ExecutionM0Tests.swift b/Tests/ScriptoriaCoreTests/Flow/ExecutionM0Tests.swift new file mode 100644 index 0000000..5fe7b02 --- /dev/null +++ b/Tests/ScriptoriaCoreTests/Flow/ExecutionM0Tests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing +@testable import ScriptoriaCore + +@Suite("Execution M0", .serialized) +struct ExecutionM0Tests { + @Test("ScriptRunner supports args/env/workingDirectory") + func testScriptRunnerOptionsMapping() async throws { + try await withTestWorkspace(prefix: "flow-m0-script-options") { workspace in + let scriptPath = try workspace.makeScript( + name: "inspect.sh", + content: "#!/bin/sh\necho \"arg:$1\"\necho \"env:$FLAG\"\npwd\n" + ) + let script = Script(title: "Inspect", path: scriptPath, interpreter: .sh) + let customCwd = workspace.rootURL.appendingPathComponent("custom-cwd") + try FileManager.default.createDirectory(at: customCwd, withIntermediateDirectories: true) + + let runner = ScriptRunner() + let result = try await runner.run( + script, + options: .init( + args: ["hello"], + env: ["FLAG": "on"], + timeoutSec: 30, + workingDirectory: customCwd.path + ) + ) + + #expect(result.status == .success) + #expect(result.output.contains("arg:hello")) + #expect(result.output.contains("env:on")) + #expect(result.output.contains(customCwd.path)) + } + } + + @Test("ScriptRunner default workingDirectory is script parent and independent from shell cwd") + func testScriptRunnerDefaultWorkingDirectory() async throws { + try await withTestWorkspace(prefix: "flow-m0-script-cwd") { workspace in + let scriptPath = try workspace.makeScript( + name: "pwd.sh", + content: "#!/bin/sh\npwd\n" + ) + let script = Script(title: "Pwd", path: scriptPath, interpreter: .sh) + let scriptDir = URL(fileURLWithPath: scriptPath).deletingLastPathComponent().path + + let otherCwd = workspace.rootURL.appendingPathComponent("other-cwd") + try FileManager.default.createDirectory(at: otherCwd, withIntermediateDirectories: true) + let previous = FileManager.default.currentDirectoryPath + defer { _ = FileManager.default.changeCurrentDirectoryPath(previous) } + _ = FileManager.default.changeCurrentDirectoryPath(otherCwd.path) + + let runner = ScriptRunner() + let result = try await runner.run(script, options: .init(args: [], env: [:], timeoutSec: 30)) + + #expect(result.status == .success) + #expect(result.output.contains(scriptDir)) + #expect(result.output.contains(otherCwd.path) == false) + } + } + + @Test("ScriptRunner timeout terminates long-running script") + func testScriptRunnerTimeout() async throws { + try await withTestWorkspace(prefix: "flow-m0-script-timeout") { workspace in + let scriptPath = try workspace.makeScript( + name: "sleep.sh", + content: "#!/bin/sh\nsleep 3\necho done\n" + ) + let script = Script(title: "Sleep", path: scriptPath, interpreter: .sh) + let runner = ScriptRunner() + let started = Date() + let result = try await runner.run(script, options: .init(timeoutSec: 1)) + + #expect(result.status == .failure) + #expect(result.output.contains("done") == false) + #expect(result.finishedAt?.timeIntervalSince(started) ?? 99 < 2.5) + #expect(result.errorOutput.contains("timed out")) + } + } +} diff --git a/Tests/ScriptoriaCoreTests/Flow/FlowCLIAdditionalTests.swift b/Tests/ScriptoriaCoreTests/Flow/FlowCLIAdditionalTests.swift new file mode 100644 index 0000000..93dc357 --- /dev/null +++ b/Tests/ScriptoriaCoreTests/Flow/FlowCLIAdditionalTests.swift @@ -0,0 +1,913 @@ +import Foundation +import Testing +@testable import ScriptoriaCore + +@Suite("Flow CLI Additional", .serialized) +struct FlowCLIAdditionalTests { + @Test("TC-CLI04 flow run success path should return 0") + func testFlowRunSuccessPath() async throws { + try await withTestWorkspace(prefix: "flow-cli-run-success") { workspace in + _ = try workspace.makeScript(name: "gate-pass.sh", content: "#!/bin/sh\necho '{\"decision\":\"pass\"}'\n") + let flow = """ + version: flow/v1 + start: gate + states: + - id: gate + type: gate + run: ./scripts/gate-pass.sh + on: + pass: done + needs_agent: done + wait: done + fail: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, name: "run-success.yaml", content: flow) + let run = try runCLI(arguments: ["flow", "run", flowPath, "--no-steer"]) + #expect(run.exitCode == 0) + #expect(run.stdout.contains("phase=runtime")) + } + } + + @Test("TC-CLI05 flow run rounds exceeded should return non-zero") + func testFlowRunRoundsExceededNonZero() async throws { + try await withTestWorkspace(prefix: "flow-cli-run-rounds-exceeded") { workspace in + let codexPath = try workspace.makeFakeCodex() + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "complete" + ]) { + let flow = """ + version: flow/v1 + start: fix + defaults: + max_agent_rounds: 1 + states: + - id: fix + type: agent + task: loop + max_rounds: 20 + next: fix + """ + let flowPath = try writeFlowFile(workspace: workspace, name: "rounds-exceeded.yaml", content: flow) + let run = try runCLI(arguments: ["flow", "run", flowPath, "--no-steer"]) + #expect(run.exitCode != 0) + #expect(run.stdout.contains("flow.agent.rounds_exceeded")) + } + } + } + + @Test("TC-CLI07 flow dry-run fixture should drive transitions correctly") + func testFlowDryRunFixtureSuccess() async throws { + try await withTestWorkspace(prefix: "flow-cli-dry-run-success") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flowPath = try writeFlowFile(workspace: workspace, content: minimalFlowYAML()) + let fixturePath = try writeFixtureFile( + workspace: workspace, + json: """ + {"states":{"precheck":[{"decision":"pass"}]}} + """ + ) + let run = try runCLI(arguments: ["flow", "dry-run", flowPath, "--fixture", fixturePath]) + #expect(run.exitCode == 0) + #expect(run.stdout.contains("phase=runtime")) + } + } + + @Test("TC-CLI18 --var value should be injected as string") + func testVarInjectionAsString() async throws { + try await withTestWorkspace(prefix: "flow-cli-var-string") { workspace in + let outputPath = workspace.rootURL.appendingPathComponent("var-string.txt").path + _ = try workspace.makeScript(name: "gate-pass.sh", content: "#!/bin/sh\necho '{\"decision\":\"pass\"}'\n") + _ = try workspace.makeScript(name: "write.sh", content: "#!/bin/sh\necho \"$1\" > \"\(outputPath)\"\n") + let flow = """ + version: flow/v1 + start: gate + context: + x: old + states: + - id: gate + type: gate + run: ./scripts/gate-pass.sh + on: + pass: write + needs_agent: done + wait: done + fail: done + - id: write + type: script + run: ./scripts/write.sh + args: + - "$.context.x" + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, name: "var-string.yaml", content: flow) + let run = try runCLI(arguments: ["flow", "run", flowPath, "--var", "x=1", "--no-steer"]) + #expect(run.exitCode == 0) + let value = try String(contentsOfFile: outputPath, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(value == "1") + } + } + + @Test("TC-CLI06 --max-agent-rounds should tighten cap") + func testMaxAgentRoundsCapTightens() async throws { + try await withTestWorkspace(prefix: "flow-cli-cap-tighten") { workspace in + let codexPath = try workspace.makeFakeCodex() + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "complete" + ]) { + let flow = """ + version: flow/v1 + start: fix + defaults: + max_agent_rounds: 20 + max_total_steps: 20 + states: + - id: fix + type: agent + task: loop + next: fix + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let run = try runCLI(arguments: [ + "flow", "run", flowPath, + "--max-agent-rounds", "1", + "--no-steer" + ]) + #expect(run.exitCode != 0) + #expect(run.stdout.contains("flow.agent.rounds_exceeded")) + } + } + } + + @Test("TC-CLI14 cap greater than config should warn and not relax") + func testMaxAgentRoundsCapGreaterThanConfigWarns() async throws { + try await withTestWorkspace(prefix: "flow-cli-cap-warning") { workspace in + let codexPath = try workspace.makeFakeCodex() + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "complete" + ]) { + let flow = """ + version: flow/v1 + start: fix + defaults: + max_agent_rounds: 1 + max_total_steps: 10 + states: + - id: fix + type: agent + task: loop + max_rounds: 1 + next: fix + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let run = try runCLI(arguments: [ + "flow", "run", flowPath, + "--max-agent-rounds", "5", + "--no-steer" + ]) + #expect(run.exitCode != 0) + #expect(run.stdout.contains("flow.agent.rounds_exceeded")) + #expect(run.stdout.contains("flow.cli.max_agent_rounds_cap_ignored")) + } + } + } + + @Test("TC-CLI15 business failure should return flow.business_failed") + func testBusinessFailureCode() async throws { + try await withTestWorkspace(prefix: "flow-cli-business-fail") { workspace in + _ = try workspace.makeScript(name: "gate.sh", content: "#!/bin/sh\necho '{\"decision\":\"fail\"}'\n") + let flow = """ + version: flow/v1 + start: gate + states: + - id: gate + type: gate + run: ./scripts/gate.sh + on: + pass: done + needs_agent: done + wait: done + fail: done_fail + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let run = try runCLI(arguments: ["flow", "run", flowPath, "--no-steer"]) + #expect(run.exitCode != 0) + #expect(run.stdout.contains("flow.business_failed")) + } + } + + @Test("TC-CLI16/TC-CLI17 steps and wait limits should surface correct error codes") + func testStepAndWaitLimitCodes() async throws { + try await withTestWorkspace(prefix: "flow-cli-limits") { workspace in + _ = try workspace.makeScript(name: "gate.sh", content: "#!/bin/sh\necho '{\"decision\":\"wait\"}'\n") + + let stepsFlow = """ + version: flow/v1 + start: gate + defaults: + max_total_steps: 2 + states: + - id: gate + type: gate + run: ./scripts/gate.sh + on: + pass: done + needs_agent: done + wait: hold + fail: done + - id: hold + type: wait + seconds: 0 + next: gate + - id: done + type: end + status: success + """ + let stepsPath = try writeFlowFile(workspace: workspace, name: "steps.yaml", content: stepsFlow) + let stepsRun = try runCLI(arguments: ["flow", "run", stepsPath, "--no-steer"]) + #expect(stepsRun.exitCode != 0) + #expect(stepsRun.stdout.contains("flow.steps.exceeded")) + + let waitFlow = """ + version: flow/v1 + start: gate + defaults: + max_wait_cycles: 1 + states: + - id: gate + type: gate + run: ./scripts/gate.sh + on: + pass: done + needs_agent: done + wait: hold + fail: done + - id: hold + type: wait + seconds: 0 + next: gate + - id: done + type: end + status: success + """ + let waitPath = try writeFlowFile(workspace: workspace, name: "wait.yaml", content: waitFlow) + let waitRun = try runCLI(arguments: ["flow", "run", waitPath, "--no-steer"]) + #expect(waitRun.exitCode != 0) + #expect(waitRun.stdout.contains("flow.wait.cycles_exceeded")) + } + } + + @Test("TC-CLI22 runtime path missing should return flow.path.not_found") + func testRuntimePathMissingCode() async throws { + try await withTestWorkspace(prefix: "flow-cli-runtime-path-missing") { workspace in + let victimPath = try workspace.makeScript(name: "victim.sh", content: "#!/bin/sh\necho hi\n") + _ = try workspace.makeScript(name: "remover.sh", content: "#!/bin/sh\nrm -f \"$TARGET\"\n") + let flow = """ + version: flow/v1 + start: remove + states: + - id: remove + type: script + run: ./scripts/remover.sh + env: + TARGET: "\(victimPath)" + next: victim + - id: victim + type: script + run: ./scripts/victim.sh + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let run = try runCLI(arguments: ["flow", "run", flowPath, "--no-steer"]) + #expect(run.exitCode != 0) + #expect(run.stdout.contains("flow.path.not_found")) + } + } + + @Test("TC-CLI26/TC-CLI27 script non-zero and agent failure codes") + func testScriptAndAgentFailureCodes() async throws { + try await withTestWorkspace(prefix: "flow-cli-script-agent-fail") { workspace in + _ = try workspace.makeScript(name: "script-fail.sh", content: "#!/bin/sh\nexit 9\n") + let scriptFlow = """ + version: flow/v1 + start: run + states: + - id: run + type: script + run: ./scripts/script-fail.sh + next: done + - id: done + type: end + status: success + """ + let scriptFlowPath = try writeFlowFile(workspace: workspace, name: "script-fail.yaml", content: scriptFlow) + let scriptRun = try runCLI(arguments: ["flow", "run", scriptFlowPath, "--no-steer"]) + #expect(scriptRun.exitCode != 0) + #expect(scriptRun.stdout.contains("flow.script.process_exit_nonzero")) + + let codexPath = try workspace.makeFakeCodex() + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "exit_after_turn_start" + ]) { + let agentFlow = """ + version: flow/v1 + start: fix + states: + - id: fix + type: agent + task: fail-agent + next: done + - id: done + type: end + status: success + """ + let agentFlowPath = try writeFlowFile(workspace: workspace, name: "agent-fail.yaml", content: agentFlow) + let agentRun = try runCLI(arguments: ["flow", "run", agentFlowPath, "--no-steer"]) + #expect(agentRun.exitCode != 0) + #expect(agentRun.stdout.contains("flow.agent.failed")) + } + } + } + + @Test("TC-CLI29/TC-CLI31 interrupt and command-unused behaviors") + func testCommandInterruptAndUnusedWarning() async throws { + try await withTestWorkspace(prefix: "flow-cli-command-behavior") { workspace in + let codexPath = try workspace.makeFakeCodex() + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "wait_for_command" + ]) { + let interruptFlow = """ + version: flow/v1 + start: fix + states: + - id: fix + type: agent + task: interrupt-me + next: done + - id: done + type: end + status: success + """ + let interruptPath = try writeFlowFile(workspace: workspace, name: "interrupt.yaml", content: interruptFlow) + let interruptRun = try runCLI(arguments: [ + "flow", "run", interruptPath, + "--command", "/interrupt", + "--no-steer" + ]) + #expect(interruptRun.exitCode != 0) + #expect(interruptRun.stdout.contains("flow.agent.interrupted")) + } + + _ = try workspace.makeScript(name: "gate-pass.sh", content: "#!/bin/sh\necho '{\"decision\":\"pass\"}'\n") + let noAgentFlow = """ + version: flow/v1 + start: gate + states: + - id: gate + type: gate + run: ./scripts/gate-pass.sh + on: + pass: done + needs_agent: done + wait: done + fail: done + - id: done + type: end + status: success + """ + let noAgentPath = try writeFlowFile(workspace: workspace, name: "no-agent.yaml", content: noAgentFlow) + let noAgentRun = try runCLI(arguments: [ + "flow", "run", noAgentPath, + "--command", "refine this", + "--no-steer" + ]) + #expect(noAgentRun.exitCode == 0) + #expect(noAgentRun.stdout.contains("flow.cli.command_unused")) + } + } + + @Test("TC-CLI36 all commands should be unused when flow never enters agent") + func testAllCommandsUnusedWithoutAgentState() async throws { + try await withTestWorkspace(prefix: "flow-cli-all-commands-unused") { workspace in + _ = try workspace.makeScript(name: "gate-pass.sh", content: "#!/bin/sh\necho '{\"decision\":\"pass\"}'\n") + let flow = """ + version: flow/v1 + start: gate + states: + - id: gate + type: gate + run: ./scripts/gate-pass.sh + on: + pass: done + needs_agent: done + wait: done + fail: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, name: "all-unused.yaml", content: flow) + let run = try runCLI(arguments: [ + "flow", "run", flowPath, + "--command", "cmd-a", + "--command", "cmd-b", + "--no-steer" + ]) + #expect(run.exitCode == 0) + #expect(run.stdout.contains("flow.cli.command_unused")) + #expect(run.stdout.contains("Unused --command entries: 2")) + } + } + + @Test("TC-CLI44 agent counter log value should start at 1") + func testAgentCounterLogValue() async throws { + try await withTestWorkspace(prefix: "flow-cli-counter-log") { workspace in + let codexPath = try workspace.makeFakeCodex() + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "complete" + ]) { + let flow = """ + version: flow/v1 + start: fix + states: + - id: fix + type: agent + task: one-shot + counter: fix_round + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let run = try runCLI(arguments: ["flow", "run", flowPath, "--no-steer"]) + #expect(run.exitCode == 0) + #expect(run.stdout.contains("state_type=agent")) + #expect(run.stdout.contains("\"name\":\"fix_round\"")) + #expect(run.stdout.contains("\"value\":1")) + } + } + } + + @Test("TC-P05 flow run accepts stdin steer while agent turn is active") + func testInteractiveSteerViaStdin() async throws { + try await withTestWorkspace(prefix: "flow-cli-stdin-steer") { workspace in + let codexPath = try workspace.makeFakeCodex() + let outputPath = workspace.rootURL.appendingPathComponent("stdin-steer.txt").path + _ = try workspace.makeScript( + name: "write.sh", + content: "#!/bin/sh\necho \"$1\" > \"\(outputPath)\"\n" + ) + + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "wait_for_command_json" + ]) { + let flow = """ + version: flow/v1 + start: fix + states: + - id: fix + type: agent + task: stdin-steer + export: + received: "$.current.final.received" + next: write + - id: write + type: script + run: ./scripts/write.sh + args: + - "$.context.received" + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let run = try runCLI( + arguments: ["flow", "run", flowPath], + stdin: "from-stdin\n", + timeout: 5 + ) + #expect(run.timedOut == false) + #expect(run.exitCode == 0) + let content = try String(contentsOfFile: outputPath, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(content == "from-stdin") + } + } + } + + @Test("TC-CLI28 --no-steer should disable stdin steer input") + func testNoSteerDisablesStdinInput() async throws { + try await withTestWorkspace(prefix: "flow-cli-no-steer-stdin") { workspace in + let codexPath = try workspace.makeFakeCodex() + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "wait_for_command_json" + ]) { + let flow = """ + version: flow/v1 + start: fix + states: + - id: fix + type: agent + task: waiting-command + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let run = try runCLI( + arguments: ["flow", "run", flowPath, "--no-steer"], + stdin: "should-be-ignored\n", + timeout: 2 + ) + #expect(run.timedOut == true) + } + } + } + + @Test("TC-CLI19/TC-R05/TC-R06 run path and workingDirectory should be flow-dir based and cwd-independent") + func testRunPathAndWorkingDirectoryAreCWDIndependent() async throws { + try await withTestWorkspace(prefix: "flow-cli-cwd-independent") { workspace in + let gatePwdPath = workspace.rootURL.appendingPathComponent("gate-pwd.txt").path + let scriptPwdPath = workspace.rootURL.appendingPathComponent("script-pwd.txt").path + _ = try workspace.makeExecutable( + relativePath: "flows/scripts/gate.sh", + content: "#!/bin/sh\npwd > \"\(gatePwdPath)\"\necho '{\"decision\":\"pass\"}'\n" + ) + _ = try workspace.makeExecutable( + relativePath: "flows/scripts/run.sh", + content: "#!/bin/sh\npwd > \"\(scriptPwdPath)\"\n" + ) + + let flow = """ + version: flow/v1 + start: precheck + states: + - id: precheck + type: gate + run: ./scripts/gate.sh + on: + pass: run + needs_agent: done_fail + wait: done_fail + fail: done_fail + - id: run + type: script + run: ./scripts/run.sh + next: done + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let flowPath = try workspace.makeFile(relativePath: "flows/flow.yaml", content: flow) + let outsideCWD = workspace.rootURL.appendingPathComponent("outside") + try FileManager.default.createDirectory(at: outsideCWD, withIntermediateDirectories: true) + + let run = try runCLI( + arguments: ["flow", "run", flowPath, "--no-steer"], + cwd: outsideCWD.path + ) + + #expect(run.exitCode == 0) + let expectedCWD = workspace.rootURL + .appendingPathComponent("flows/scripts") + .resolvingSymlinksInPath() + .path + let gatePwd = URL(fileURLWithPath: try String(contentsOfFile: gatePwdPath, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + ).resolvingSymlinksInPath().path + let scriptPwd = URL(fileURLWithPath: try String(contentsOfFile: scriptPwdPath, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + ).resolvingSymlinksInPath().path + #expect(gatePwd == expectedCWD) + #expect(scriptPwd == expectedCWD) + } + } + + @Test("TC-R04 flow gate/script interpreter should map to ScriptRunner interpreter") + func testFlowInterpreterMappingForGateAndScript() async throws { + try await withTestWorkspace(prefix: "flow-cli-interpreter-map") { workspace in + let markerPath = workspace.rootURL.appendingPathComponent("python-marker.txt").path + _ = try workspace.makeFile( + relativePath: "scripts/gate.py", + content: "import json\nprint(json.dumps({\"decision\": \"pass\"}))\n" + ) + _ = try workspace.makeFile( + relativePath: "scripts/run.py", + content: "from pathlib import Path\nPath(\"\(markerPath)\").write_text(\"ok\", encoding=\"utf-8\")\n" + ) + + let flow = """ + version: flow/v1 + start: gate + states: + - id: gate + type: gate + run: ./scripts/gate.py + interpreter: python3 + on: + pass: run + needs_agent: done_fail + wait: done_fail + fail: done_fail + - id: run + type: script + run: ./scripts/run.py + interpreter: python3 + next: done + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let run = try runCLI(arguments: ["flow", "run", flowPath, "--no-steer"]) + #expect(run.exitCode == 0) + let marker = try String(contentsOfFile: markerPath, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(marker == "ok") + } + } + + @Test("TC-CLI13 flow run step timeout should return timeout code with failed state") + func testCLIStepTimeoutCodeAndStateID() async throws { + try await withTestWorkspace(prefix: "flow-cli-step-timeout") { workspace in + _ = try workspace.makeScript(name: "slow.sh", content: "#!/bin/sh\nsleep 2\n") + let flow = """ + version: flow/v1 + start: run + states: + - id: run + type: script + run: ./scripts/slow.sh + timeout_sec: 1 + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, name: "step-timeout.yaml", content: flow) + let run = try runCLI(arguments: ["flow", "run", flowPath, "--no-steer"]) + #expect(run.exitCode != 0) + #expect(run.stdout.contains("flow.step.timeout")) + #expect(run.stdout.contains("state_id=run")) + } + } + + @Test("TC-CLI37 wait timeout should return flow.step.timeout") + func testCLIWaitTimeoutCode() async throws { + try await withTestWorkspace(prefix: "flow-cli-wait-timeout") { workspace in + let flow = """ + version: flow/v1 + start: hold + states: + - id: hold + type: wait + seconds: 2 + timeout_sec: 1 + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, name: "wait-timeout.yaml", content: flow) + let run = try runCLI(arguments: ["flow", "run", flowPath, "--no-steer"]) + #expect(run.exitCode != 0) + #expect(run.stdout.contains("flow.step.timeout")) + #expect(run.stdout.contains("state_id=hold")) + } + } + + @Test("TC-CLI41 gate process non-zero should return flow.gate.process_exit_nonzero") + func testCLIGateProcessExitNonZeroCode() async throws { + try await withTestWorkspace(prefix: "flow-cli-gate-exit-nonzero") { workspace in + _ = try workspace.makeScript(name: "gate-fail.sh", content: "#!/bin/sh\nexit 7\n") + let flow = """ + version: flow/v1 + start: gate + states: + - id: gate + type: gate + run: ./scripts/gate-fail.sh + on: + pass: done + needs_agent: done + wait: done + fail: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, name: "gate-exit.yaml", content: flow) + let run = try runCLI(arguments: ["flow", "run", flowPath, "--no-steer"]) + #expect(run.exitCode != 0) + #expect(run.stdout.contains("flow.gate.process_exit_nonzero")) + #expect(run.stdout.contains("state_id=gate")) + } + } + + @Test("TC-CLI32/TC-CLI34/TC-CLI35 command queue should preserve FIFO and retry next turn") + func testCommandQueueFIFOAndRetryAcrossTurns() async throws { + try await withTestWorkspace(prefix: "flow-cli-command-fifo-retry") { workspace in + let codexPath = try workspace.makeFakeCodex() + let outputPath = workspace.rootURL.appendingPathComponent("command-order.txt").path + _ = try workspace.makeScript( + name: "write-order.sh", + content: "#!/bin/sh\necho \"$1|$2\" > \"\(outputPath)\"\n" + ) + + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "wait_for_command_single_accept_json" + ]) { + let flow = """ + version: flow/v1 + start: first + defaults: + step_timeout_sec: 5 + states: + - id: first + type: agent + task: first-turn + timeout_sec: 5 + export: + first_cmd: "$.current.final.received" + next: second + - id: second + type: agent + task: second-turn + timeout_sec: 5 + export: + second_cmd: "$.current.final.received" + next: write + - id: write + type: script + run: ./scripts/write-order.sh + args: + - "$.context.first_cmd" + - "$.context.second_cmd" + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let run = try runCLI(arguments: [ + "flow", "run", flowPath, + "--command", "first", + "--command", "second", + "--no-steer" + ]) + + #expect(run.exitCode == 0) + #expect(run.stdout.contains("flow.cli.command_unused") == false) + let content = try String(contentsOfFile: outputPath, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(content == "first|second") + } + } + } + + @Test("TC-CLI33 command queued before first agent turn should not be lost") + func testCommandQueuedBeforeFirstAgentTurn() async throws { + try await withTestWorkspace(prefix: "flow-cli-command-pre-agent") { workspace in + let codexPath = try workspace.makeFakeCodex() + let outputPath = workspace.rootURL.appendingPathComponent("queued-before-agent.txt").path + _ = try workspace.makeScript(name: "gate.sh", content: "#!/bin/sh\necho '{\"decision\":\"needs_agent\"}'\n") + _ = try workspace.makeScript( + name: "write.sh", + content: "#!/bin/sh\necho \"$1\" > \"\(outputPath)\"\n" + ) + + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "wait_for_command_json" + ]) { + let flow = """ + version: flow/v1 + start: gate + defaults: + step_timeout_sec: 2 + states: + - id: gate + type: gate + run: ./scripts/gate.sh + on: + pass: done_fail + needs_agent: fix + wait: done_fail + fail: done_fail + - id: fix + type: agent + task: queued-command + timeout_sec: 2 + export: + received: "$.current.final.received" + next: write + - id: write + type: script + run: ./scripts/write.sh + args: + - "$.context.received" + next: done + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let run = try runCLI(arguments: [ + "flow", "run", flowPath, + "--command", "queued-before-agent", + "--no-steer" + ]) + #expect(run.exitCode == 0) + let content = try String(contentsOfFile: outputPath, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(content == "queued-before-agent") + } + } + } + + @Test("TC-CLI39/TC-CLI40 runtime log field contract") + func testRuntimeLogFieldContract() async throws { + try await withTestWorkspace(prefix: "flow-cli-log-contract") { workspace in + _ = try workspace.makeScript(name: "ok.sh", content: "#!/bin/sh\necho ok\n") + let successFlow = """ + version: flow/v1 + start: run + states: + - id: run + type: script + run: ./scripts/ok.sh + next: done + - id: done + type: end + status: success + """ + let successPath = try writeFlowFile(workspace: workspace, name: "log-success.yaml", content: successFlow) + let successRun = try runCLI(arguments: ["flow", "run", successPath, "--no-steer"]) + #expect(successRun.exitCode == 0) + #expect(successRun.stdout.contains("phase=runtime")) + #expect(successRun.stdout.contains("state_type=script")) + #expect(successRun.stdout.contains("attempt=1")) + #expect(successRun.stdout.contains("counter=null")) + #expect(successRun.stdout.contains("decision=null")) + #expect(successRun.stdout.contains("duration=")) + + _ = try workspace.makeScript(name: "fail.sh", content: "#!/bin/sh\nexit 8\n") + let failureFlow = """ + version: flow/v1 + start: run + states: + - id: run + type: script + run: ./scripts/fail.sh + next: done + - id: done + type: end + status: success + """ + let failurePath = try writeFlowFile(workspace: workspace, name: "log-failure.yaml", content: failureFlow) + let failureRun = try runCLI(arguments: ["flow", "run", failurePath, "--no-steer"]) + #expect(failureRun.exitCode != 0) + #expect(failureRun.stdout.contains("phase=runtime")) + #expect(failureRun.stdout.contains("state_id=run")) + #expect(failureRun.stdout.contains("transition=null")) + #expect(failureRun.stdout.contains("error_code=flow.script.process_exit_nonzero")) + #expect(failureRun.stdout.contains("error_message=")) + } + } +} diff --git a/Tests/ScriptoriaCoreTests/Flow/FlowCLITests.swift b/Tests/ScriptoriaCoreTests/Flow/FlowCLITests.swift new file mode 100644 index 0000000..59880a9 --- /dev/null +++ b/Tests/ScriptoriaCoreTests/Flow/FlowCLITests.swift @@ -0,0 +1,230 @@ +import Foundation +import Testing +@testable import ScriptoriaCore + +@Suite("Flow CLI", .serialized) +struct FlowCLITests { + @Test("TC-CLI01/TC-CLI03 flow validate and compile") + func testFlowValidateAndCompile() async throws { + try await withTestWorkspace(prefix: "flow-cli-validate-compile") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flowPath = try writeFlowFile(workspace: workspace, content: minimalFlowYAML()) + let outPath = workspace.rootURL.appendingPathComponent("compiled/flow.json").path + + let validate = try runCLI(arguments: ["flow", "validate", flowPath]) + #expect(validate.exitCode == 0) + #expect(validate.stdout.contains("flow validate ok")) + + let compile = try runCLI(arguments: ["flow", "compile", flowPath, "--out", outPath]) + #expect(compile.exitCode == 0) + #expect(FileManager.default.fileExists(atPath: outPath)) + let compiled = try String(contentsOfFile: outPath, encoding: .utf8) + #expect(compiled.contains("\"version\"")) + #expect(compiled.contains("flow-ir")) + } + } + + @Test("TC-CLI02 flow validate invalid file should fail") + func testFlowValidateInvalidFile() async throws { + try await withTestWorkspace(prefix: "flow-cli-validate-invalid") { workspace in + let invalidPath = try writeFlowFile( + workspace: workspace, + name: "invalid.yaml", + content: "version: flow/v1\nstart: x\nstates: [" + ) + let validate = try runCLI(arguments: ["flow", "validate", invalidPath]) + #expect(validate.exitCode != 0) + #expect(validate.stdout.contains("flow.validate.schema_error")) + } + } + + @Test("TC-CLI20/TC-CLI21 validate and compile allow --no-fs-check") + func testNoFSCheckCommands() async throws { + try await withTestWorkspace(prefix: "flow-cli-no-fs") { workspace in + let flowPath = try writeFlowFile(workspace: workspace, content: minimalFlowYAML(runPath: "./scripts/missing.sh")) + let outPath = workspace.rootURL.appendingPathComponent("compiled/flow.json").path + + let validate = try runCLI(arguments: ["flow", "validate", flowPath, "--no-fs-check"]) + #expect(validate.exitCode == 0) + + let compile = try runCLI(arguments: ["flow", "compile", flowPath, "--out", outPath, "--no-fs-check"]) + #expect(compile.exitCode == 0) + #expect(FileManager.default.fileExists(atPath: outPath)) + } + } + + @Test("TC-CLI23 invalid --var key should fail") + func testInvalidVarKey() async throws { + try await withTestWorkspace(prefix: "flow-cli-var-key") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flowPath = try writeFlowFile(workspace: workspace, content: minimalFlowYAML()) + + let run = try runCLI(arguments: ["flow", "run", flowPath, "--var", "a.b=1", "--no-steer"]) + #expect(run.exitCode != 0) + #expect(run.stdout.contains("flow.cli.var_key_invalid")) + } + } + + @Test("TC-CLI08/TC-CLI24 duplicate --var uses last value") + func testDuplicateVarLastWins() async throws { + try await withTestWorkspace(prefix: "flow-cli-var-last-wins") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho '{\"decision\":\"pass\"}'\n") + let outPath = workspace.rootURL.appendingPathComponent("var-output.txt").path + let flow = """ + version: flow/v1 + start: precheck + context: + name: old + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + pass: report + needs_agent: report + wait: report + fail: done_fail + - id: report + type: script + run: ./scripts/report.sh + args: + - "$.context.name" + next: done + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + _ = try workspace.makeScript(name: "report.sh", content: "#!/bin/sh\necho \"$1\" > \"\(outPath)\"\n") + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + + let run = try runCLI(arguments: [ + "flow", "run", flowPath, + "--var", "name=alice", + "--var", "name=bob", + "--no-steer" + ]) + #expect(run.exitCode == 0) + let content = try String(contentsOfFile: outPath, encoding: .utf8) + #expect(content.trimmingCharacters(in: .whitespacesAndNewlines) == "bob") + } + } + + @Test("TC-CLI30/TC-CLI38 preflight path-kind failure should not enter runtime") + func testPreflightPathKindFailure() async throws { + try await withTestWorkspace(prefix: "flow-cli-preflight") { workspace in + let flowPath = try writeFlowFile(workspace: workspace, content: minimalFlowYAML(runPath: "eslint")) + let run = try runCLI(arguments: ["flow", "run", flowPath, "--no-steer"]) + #expect(run.exitCode != 0) + #expect(run.stdout.contains("phase=runtime-preflight")) + #expect(run.stdout.contains("flow.path.invalid_path_kind")) + #expect(run.stdout.contains("phase=runtime state_id=") == false) + } + } + + @Test("TC-CLI09/TC-CLI43 dry-run fixture missing state data emits runtime-dry-run") + func testDryRunFixtureFailurePhase() async throws { + try await withTestWorkspace(prefix: "flow-cli-dryrun-phase") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flowPath = try writeFlowFile(workspace: workspace, content: minimalFlowYAML()) + let fixturePath = try writeFixtureFile(workspace: workspace, json: "{\"states\":{}}") + + let run = try runCLI(arguments: ["flow", "dry-run", flowPath, "--fixture", fixturePath]) + #expect(run.exitCode != 0) + #expect(run.stdout.contains("phase=runtime-dry-run")) + #expect(run.stdout.contains("flow.dryrun.fixture_missing_state_data")) + } + } + + @Test("TC-CLI25 validate --no-fs-check should still reject bare command token") + func testValidateNoFSCheckStillRejectsBareToken() async throws { + try await withTestWorkspace(prefix: "flow-cli-validate-no-fs-path-kind") { workspace in + let flowPath = try writeFlowFile(workspace: workspace, content: minimalFlowYAML(runPath: "eslint")) + let validate = try runCLI(arguments: ["flow", "validate", flowPath, "--no-fs-check"]) + #expect(validate.exitCode != 0) + #expect(validate.stdout.contains("flow.path.invalid_path_kind")) + } + } + + @Test("TC-CLI42 preflight path-not-found should not enter runtime") + func testPreflightPathMissingFailure() async throws { + try await withTestWorkspace(prefix: "flow-cli-preflight-path-missing") { workspace in + let flow = """ + version: flow/v1 + start: precheck + states: + - id: precheck + type: gate + run: ./scripts/missing.sh + on: + pass: done + needs_agent: done + wait: done + fail: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let run = try runCLI(arguments: ["flow", "run", flowPath, "--no-steer"]) + #expect(run.exitCode != 0) + #expect(run.stdout.contains("phase=runtime-preflight")) + #expect(run.stdout.contains("flow.path.not_found")) + #expect(run.stdout.contains("phase=runtime state_id=") == false) + } + } + + @Test("validate errors should include source line number when available") + func testValidateErrorIncludesLineNumber() async throws { + try await withTestWorkspace(prefix: "flow-cli-validate-line") { workspace in + let flow = """ + version: bad/v1 + start: done + states: + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let validate = try runCLI(arguments: ["flow", "validate", flowPath]) + #expect(validate.exitCode != 0) + #expect(validate.stdout.contains("flow.validate.schema_error")) + #expect(validate.stdout.contains("line=1")) + #expect(validate.stdout.contains("column=10")) + } + } + + @Test("compile errors should include source line number when available") + func testCompileErrorIncludesLineNumber() async throws { + try await withTestWorkspace(prefix: "flow-cli-compile-line") { workspace in + let flow = """ + version: flow/v1 + start: precheck + states: + - id: precheck + type: gate + run: ./scripts/check.sh + parse: json_unknown + on: + pass: done + needs_agent: done + wait: done + fail: done + - id: done + type: end + status: success + """ + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let outPath = workspace.rootURL.appendingPathComponent("compiled/flow.json").path + + let compile = try runCLI(arguments: ["flow", "compile", flowPath, "--out", outPath]) + #expect(compile.exitCode != 0) + #expect(compile.stdout.contains("flow.gate.parse_mode_invalid")) + #expect(compile.stdout.contains("line=7")) + #expect(compile.stdout.contains("column=12")) + } + } +} diff --git a/Tests/ScriptoriaCoreTests/Flow/FlowCompileTests.swift b/Tests/ScriptoriaCoreTests/Flow/FlowCompileTests.swift new file mode 100644 index 0000000..606e179 --- /dev/null +++ b/Tests/ScriptoriaCoreTests/Flow/FlowCompileTests.swift @@ -0,0 +1,387 @@ +import Foundation +import Testing +@testable import ScriptoriaCore + +@Suite("Flow Compile", .serialized) +struct FlowCompileTests { + @Test("TC-C01/TC-C02/TC-C08 compile injects defaults and preserves states") + func testCompileInjectsDefaults() async throws { + try await withTestWorkspace(prefix: "flow-compile-defaults") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flowPath = try writeFlowFile(workspace: workspace, content: minimalFlowYAML()) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + + #expect(ir.version == "flow-ir/v1") + #expect(ir.start == "precheck") + #expect(ir.defaults.maxAgentRounds == 20) + #expect(ir.defaults.maxWaitCycles == 200) + #expect(ir.defaults.maxTotalSteps == 2000) + #expect(ir.defaults.stepTimeoutSec == 1800) + #expect(ir.states.map(\.id) == ["precheck", "wait1", "fix", "done", "done_fail"]) + } + } + + @Test("TC-C03/TC-C07 canonical compile output is deterministic") + func testCanonicalOutputDeterministic() async throws { + try await withTestWorkspace(prefix: "flow-compile-canonical") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flowPath = try writeFlowFile(workspace: workspace, content: minimalFlowYAML()) + + let ir1 = try FlowCompiler.compileFile(atPath: flowPath) + let json1 = try FlowCompiler.renderCanonicalJSON(ir: ir1) + + let ir2 = try FlowCompiler.compileFile(atPath: flowPath) + let json2 = try FlowCompiler.renderCanonicalJSON(ir: ir2) + + #expect(json1 == json2) + } + } + + @Test("TC-C11/TC-C12/TC-C17 relative run path is normalized and cwd-independent") + func testPathNormalizationAndCwdIndependence() async throws { + try await withTestWorkspace(prefix: "flow-compile-path") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flow = minimalFlowYAML(runPath: "./scripts/./check.sh") + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + + let ir1 = try FlowCompiler.compileFile(atPath: flowPath) + let json1 = try FlowCompiler.renderCanonicalJSON(ir: ir1) + + let otherCwd = workspace.rootURL.appendingPathComponent("another-cwd") + try FileManager.default.createDirectory(at: otherCwd, withIntermediateDirectories: true) + let previous = FileManager.default.currentDirectoryPath + defer { _ = FileManager.default.changeCurrentDirectoryPath(previous) } + _ = FileManager.default.changeCurrentDirectoryPath(otherCwd.path) + + let ir2 = try FlowCompiler.compileFile(atPath: flowPath) + let json2 = try FlowCompiler.renderCanonicalJSON(ir: ir2) + + #expect(json1 == json2) + let precheck = try #require(ir2.states.first(where: { $0.id == "precheck" })) + #expect(precheck.exec?.run == "scripts/check.sh") + } + } + + @Test("TC-C14/TC-C15 no-fs-check behavior") + func testCompileNoFileSystemCheck() async throws { + try await withTestWorkspace(prefix: "flow-compile-fs-check") { workspace in + let flow = minimalFlowYAML(runPath: "./scripts/missing.sh") + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + + requireFlowErrorSync("flow.path.not_found") { + _ = try FlowCompiler.compileFile(atPath: flowPath) + } + + let ir = try FlowCompiler.compileFile(atPath: flowPath, options: .init(checkFileSystem: false)) + let precheck = try #require(ir.states.first(where: { $0.id == "precheck" })) + #expect(precheck.exec?.run == "scripts/missing.sh") + } + } + + @Test("TC-C16 bare token still fails even with no-fs-check") + func testNoFSCheckStillValidatesPathKind() async throws { + try await withTestWorkspace(prefix: "flow-compile-no-fs-path-kind") { workspace in + let flowPath = try writeFlowFile(workspace: workspace, content: minimalFlowYAML(runPath: "check.sh")) + requireFlowErrorSync("flow.path.invalid_path_kind") { + _ = try FlowCompiler.compileFile(atPath: flowPath, options: .init(checkFileSystem: false)) + } + } + } + + @Test("TC-C18 args/env number-bool literal stringify in IR") + func testLiteralStringificationInIR() async throws { + try await withTestWorkspace(prefix: "flow-compile-stringify") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flow = """ + version: flow/v1 + start: precheck + states: + - id: precheck + type: gate + run: ./scripts/check.sh + args: + - 42 + - true + env: + FLAG: false + COUNT: 9 + on: + pass: done + needs_agent: fix + wait: wait1 + fail: done_fail + - id: wait1 + type: wait + seconds: 0 + next: precheck + - id: fix + type: agent + task: fix + next: done + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let precheck = try #require(ir.states.first(where: { $0.id == "precheck" })) + #expect(precheck.exec?.args == ["42", "true"]) + #expect(precheck.exec?.env["FLAG"] == "false") + #expect(precheck.exec?.env["COUNT"] == "9") + } + } + + @Test("TC-C09 expression syntax invalid should fail at compile") + func testCompileRejectsMalformedExpression() async throws { + try await withTestWorkspace(prefix: "flow-compile-expr-malformed") { workspace in + _ = try workspace.makeScript(name: "echo.sh", content: "#!/bin/sh\necho ok\n") + let flow = """ + version: flow/v1 + start: run + states: + - id: run + type: script + run: ./scripts/echo.sh + args: + - "$.context." + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + requireFlowErrorSync("flow.validate.schema_error") { + _ = try FlowCompiler.compileFile(atPath: flowPath) + } + } + } + + @Test("TC-C10 unsupported expression scope prefix should fail at compile") + func testCompileRejectsUnsupportedExpressionPrefix() async throws { + try await withTestWorkspace(prefix: "flow-compile-expr-prefix") { workspace in + _ = try workspace.makeScript(name: "echo.sh", content: "#!/bin/sh\necho ok\n") + let flow = """ + version: flow/v1 + start: run + states: + - id: run + type: script + run: ./scripts/echo.sh + args: + - "$.foo.value" + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + requireFlowErrorSync("flow.validate.schema_error") { + _ = try FlowCompiler.compileFile(atPath: flowPath) + } + } + } + + @Test("TC-C19 wait.timeout_sec should be preserved in IR") + func testWaitTimeoutSecInIR() async throws { + try await withTestWorkspace(prefix: "flow-compile-wait-timeout") { workspace in + let flow = """ + version: flow/v1 + start: hold + defaults: + step_timeout_sec: 30 + states: + - id: hold + type: wait + seconds: 1 + timeout_sec: 9 + next: hold2 + - id: hold2 + type: wait + seconds: 1 + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let hold = try #require(ir.states.first(where: { $0.id == "hold" })) + let hold2 = try #require(ir.states.first(where: { $0.id == "hold2" })) + #expect(hold.wait?.timeoutSec == 9) + #expect(hold2.wait?.timeoutSec == 30) + } + } + + @Test("TC-C04 expression fields should be preserved in IR") + func testExpressionFieldsPreservedInIR() async throws { + try await withTestWorkspace(prefix: "flow-compile-expr-preserve") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + _ = try workspace.makeScript(name: "run.sh", content: "#!/bin/sh\necho '{\"value\":\"x\"}'\n") + let flow = """ + version: flow/v1 + start: precheck + context: + name: dev + states: + - id: precheck + type: gate + run: ./scripts/check.sh + args: + - "$.context.name" + on: + pass: run + needs_agent: done + wait: done + fail: done + - id: run + type: script + run: ./scripts/run.sh + args: + - "$.state.precheck.last.decision" + export: + v: "$.current.final.value" + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let precheck = try #require(ir.states.first(where: { $0.id == "precheck" })) + let run = try #require(ir.states.first(where: { $0.id == "run" })) + #expect(precheck.exec?.args == ["$.context.name"]) + #expect(run.exec?.args == ["$.state.precheck.last.decision"]) + #expect(run.export?["v"] == "$.current.final.value") + } + } + + @Test("TC-C05 compile errors should include state context and field path") + func testCompileErrorContainsStateAndFieldPath() async throws { + try await withTestWorkspace(prefix: "flow-compile-error-context") { workspace in + _ = try workspace.makeScript(name: "run.sh", content: "#!/bin/sh\necho ok\n") + let flow = """ + version: flow/v1 + start: run + states: + - id: run + type: script + run: ./scripts/run.sh + args: + - "$.state.run.output" + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + do { + _ = try FlowCompiler.compileFile(atPath: flowPath) + Issue.record("Expected compile to fail") + } catch let error as FlowError { + #expect(error.code == "flow.validate.schema_error") + #expect(error.fieldPath?.contains("states.run.args") == true) + #expect(error.message.contains("state expression must use .last")) + } + } + } + + @Test("TC-C06 canonical JSON should match minimal golden") + func testCanonicalJSONMatchesGolden() async throws { + try await withTestWorkspace(prefix: "flow-compile-golden") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flowPath = try writeFlowFile(workspace: workspace, content: minimalFlowYAML()) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let json = try FlowCompiler.renderCanonicalJSON(ir: ir) + .replacingOccurrences(of: "\\/", with: "/") + + let golden = """ + { + "context" : { + + }, + "defaults" : { + "failOnParseError" : true, + "maxAgentRounds" : 20, + "maxTotalSteps" : 2000, + "maxWaitCycles" : 200, + "stepTimeoutSec" : 1800 + }, + "start" : "precheck", + "states" : [ + { + "exec" : { + "args" : [ + + ], + "env" : { + + }, + "interpreter" : "auto", + "parse" : "json_last_line", + "run" : "scripts/check.sh", + "timeout_sec" : 1800 + }, + "id" : "precheck", + "kind" : "gate", + "transitions" : { + "fail" : "done_fail", + "needs_agent" : "fix", + "pass" : "done", + "wait" : "wait1" + } + }, + { + "id" : "wait1", + "kind" : "wait", + "next" : "precheck", + "wait" : { + "seconds" : 0, + "timeout_sec" : 1800 + } + }, + { + "agent" : { + "counter" : "agent_round.fix", + "max_rounds" : 20, + "task" : "fix", + "timeout_sec" : 1800 + }, + "id" : "fix", + "kind" : "agent", + "next" : "done" + }, + { + "end" : { + "status" : "success" + }, + "id" : "done", + "kind" : "end" + }, + { + "end" : { + "status" : "failure" + }, + "id" : "done_fail", + "kind" : "end" + } + ], + "version" : "flow-ir/v1" + } + """ + #expect(json == golden) + } + } + + @Test("TC-C13 compile should reject bare command run token") + func testCompileRejectsBareCommandToken() async throws { + try await withTestWorkspace(prefix: "flow-compile-path-kind-default") { workspace in + let flowPath = try writeFlowFile(workspace: workspace, content: minimalFlowYAML(runPath: "eslint")) + requireFlowErrorSync("flow.path.invalid_path_kind") { + _ = try FlowCompiler.compileFile(atPath: flowPath) + } + } + } +} diff --git a/Tests/ScriptoriaCoreTests/Flow/FlowDocumentationExamplesTests.swift b/Tests/ScriptoriaCoreTests/Flow/FlowDocumentationExamplesTests.swift new file mode 100644 index 0000000..be98aa7 --- /dev/null +++ b/Tests/ScriptoriaCoreTests/Flow/FlowDocumentationExamplesTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing +@testable import ScriptoriaCore + +@Suite("Flow Documentation Examples", .serialized) +struct FlowDocumentationExamplesTests { + @Test("documentation example flows should compile with default checks") + func testExamplesCompile() throws { + let root = repositoryRoot() + let exampleFlows = [ + root.appendingPathComponent("docs/examples/flow-v1/local-gate-script/flow.yaml").path, + root.appendingPathComponent("docs/examples/flow-v1/pr-loop/flow.yaml").path + ] + + for flowPath in exampleFlows { + _ = try FlowCompiler.compileFile(atPath: flowPath) + } + } + + @Test("documentation PR loop fixture should dry-run to success") + func testPRLoopDryRunFixture() async throws { + let root = repositoryRoot() + let flowPath = root.appendingPathComponent("docs/examples/flow-v1/pr-loop/flow.yaml").path + let fixturePath = root.appendingPathComponent("docs/examples/flow-v1/pr-loop/fixture.success.json").path + + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let fixture = try FlowDryRunFixture.load(fromPath: fixturePath) + let result = try await FlowEngine().run(ir: ir, mode: .dryRun(fixture)) + + #expect(result.status == .success) + #expect(result.endedAtStateID == "done") + #expect(result.counters["fix_round"] == 2) + #expect(result.context["pr_url"]?.stringValue == "https://github.com/org/repo/pull/123") + } + + @Test("documentation local gate-script flow should run live without agent") + func testLocalGateScriptLiveRun() async throws { + let root = repositoryRoot() + let flowPath = root.appendingPathComponent("docs/examples/flow-v1/local-gate-script/flow.yaml").path + + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let result = try await FlowEngine().run(ir: ir, mode: .live) + + #expect(result.status == .success) + #expect(result.endedAtStateID == "done") + #expect(result.context["summary"]?.stringValue == "local gate-script sample completed") + } + + private func repositoryRoot() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Flow + .deletingLastPathComponent() // ScriptoriaCoreTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // repo root + } +} diff --git a/Tests/ScriptoriaCoreTests/Flow/FlowDryRunStrictnessTests.swift b/Tests/ScriptoriaCoreTests/Flow/FlowDryRunStrictnessTests.swift new file mode 100644 index 0000000..8bce9e6 --- /dev/null +++ b/Tests/ScriptoriaCoreTests/Flow/FlowDryRunStrictnessTests.swift @@ -0,0 +1,57 @@ +import Foundation +import Testing +@testable import ScriptoriaCore + +@Suite("Flow Dry Run Strictness", .serialized) +struct FlowDryRunStrictnessTests { + @Test("TC-CLI10 dry-run fixture unknown state should fail") + func testDryRunUnknownState() async throws { + try await withTestWorkspace(prefix: "flow-dryrun-unknown-state") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flowPath = try writeFlowFile(workspace: workspace, content: minimalFlowYAML()) + let fixturePath = try writeFixtureFile(workspace: workspace, json: """ + {"states":{"ghost":[{"decision":"pass"}]}} + """) + + let run = try runCLI(arguments: ["flow", "dry-run", flowPath, "--fixture", fixturePath]) + #expect(run.exitCode != 0) + #expect(run.stdout.contains("flow.dryrun.fixture_unknown_state")) + #expect(run.stdout.contains("phase=runtime-dry-run")) + } + } + + @Test("TC-CLI11 dry-run executed state with unconsumed entries should fail") + func testDryRunUnconsumedItems() async throws { + try await withTestWorkspace(prefix: "flow-dryrun-unconsumed") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flowPath = try writeFlowFile(workspace: workspace, content: minimalFlowYAML()) + let fixturePath = try writeFixtureFile(workspace: workspace, json: """ + {"states":{"precheck":[{"decision":"pass"},{"decision":"pass"}]}} + """) + + let run = try runCLI(arguments: ["flow", "dry-run", flowPath, "--fixture", fixturePath]) + #expect(run.exitCode != 0) + #expect(run.stdout.contains("flow.dryrun.fixture_unconsumed_items")) + } + } + + @Test("TC-CLI12 dry-run unexecuted state data should warn only") + func testDryRunUnusedStateDataWarning() async throws { + try await withTestWorkspace(prefix: "flow-dryrun-unused") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flowPath = try writeFlowFile(workspace: workspace, content: minimalFlowYAML()) + let fixturePath = try writeFixtureFile(workspace: workspace, json: """ + { + "states": { + "precheck": [{"decision":"pass"}], + "fix": [{"status":"completed","final":{}}] + } + } + """) + + let run = try runCLI(arguments: ["flow", "dry-run", flowPath, "--fixture", fixturePath]) + #expect(run.exitCode == 0) + #expect(run.stdout.contains("flow.dryrun.fixture_unused_state_data")) + } + } +} diff --git a/Tests/ScriptoriaCoreTests/Flow/FlowEngineAdditionalTests.swift b/Tests/ScriptoriaCoreTests/Flow/FlowEngineAdditionalTests.swift new file mode 100644 index 0000000..2208b00 --- /dev/null +++ b/Tests/ScriptoriaCoreTests/Flow/FlowEngineAdditionalTests.swift @@ -0,0 +1,464 @@ +import Foundation +import Testing +@testable import ScriptoriaCore + +@Suite("Flow Engine Additional", .serialized) +struct FlowEngineAdditionalTests { + @Test("TC-E17 script process non-zero should fail") + func testScriptProcessExitNonZero() async throws { + try await withTestWorkspace(prefix: "flow-engine-script-exit") { workspace in + _ = try workspace.makeScript(name: "fail.sh", content: "#!/bin/sh\nexit 3\n") + let flow = """ + version: flow/v1 + start: run + states: + - id: run + type: script + run: ./scripts/fail.sh + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + await requireFlowError("flow.script.process_exit_nonzero") { + _ = try await FlowEngine().run(ir: ir, mode: .live) + } + } + } + + @Test("TC-E47/TC-GP09 gate process non-zero should fail") + func testGateProcessExitNonZero() async throws { + try await withTestWorkspace(prefix: "flow-engine-gate-exit") { workspace in + _ = try workspace.makeScript(name: "gate-fail.sh", content: "#!/bin/sh\nexit 7\n") + let flow = """ + version: flow/v1 + start: gate + states: + - id: gate + type: gate + run: ./scripts/gate-fail.sh + on: + pass: done + needs_agent: done + wait: done + fail: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + await requireFlowError("flow.gate.process_exit_nonzero") { + _ = try await FlowEngine().run(ir: ir, mode: .live) + } + } + } + + @Test("TC-E37/TC-GP11 fail_on_parse_error=false should jump via on.parse_error") + func testGateParseErrorBranch() async throws { + try await withTestWorkspace(prefix: "flow-engine-parse-error-branch") { workspace in + _ = try workspace.makeScript(name: "gate.sh", content: "#!/bin/sh\necho not-json\n") + let flow = """ + version: flow/v1 + start: gate + defaults: + fail_on_parse_error: false + states: + - id: gate + type: gate + run: ./scripts/gate.sh + on: + pass: done_fail + needs_agent: done_fail + wait: done_fail + fail: done_fail + parse_error: done + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let result = try await FlowEngine().run(ir: ir, mode: .live) + #expect(result.status == .success) + #expect(result.endedAtStateID == "done") + } + } + + @Test("TC-E10 expression resolve error") + func testExpressionResolveError() async throws { + try await withTestWorkspace(prefix: "flow-engine-expr-resolve") { workspace in + _ = try workspace.makeScript(name: "echo.sh", content: "#!/bin/sh\necho ok\n") + let flow = """ + version: flow/v1 + start: run + states: + - id: run + type: script + run: ./scripts/echo.sh + args: + - "$.context.missing_key" + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + await requireFlowError("flow.expr.resolve_error") { + _ = try await FlowEngine().run(ir: ir, mode: .live) + } + } + } + + @Test("TC-E34 expression type error for args/env") + func testExpressionTypeError() async throws { + try await withTestWorkspace(prefix: "flow-engine-expr-type") { workspace in + _ = try workspace.makeScript(name: "echo.sh", content: "#!/bin/sh\necho ok\n") + let flow = """ + version: flow/v1 + start: run + context: + obj: + a: 1 + states: + - id: run + type: script + run: ./scripts/echo.sh + args: + - "$.context.obj" + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + await requireFlowError("flow.expr.type_error") { + _ = try await FlowEngine().run(ir: ir, mode: .live) + } + } + } + + @Test("TC-E35 wait.seconds_from non-integer should fail") + func testWaitSecondsFromInvalid() async throws { + try await withTestWorkspace(prefix: "flow-engine-wait-seconds-from") { workspace in + let flow = """ + version: flow/v1 + start: hold + context: + retry: abc + states: + - id: hold + type: wait + seconds_from: "$.context.retry" + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + await requireFlowError("flow.wait.seconds_resolve_error") { + _ = try await FlowEngine().run(ir: ir, mode: .live) + } + } + } + + @Test("TC-E45 wait.seconds == timeout should pass") + func testWaitSecondsEqualsTimeout() async throws { + try await withTestWorkspace(prefix: "flow-engine-wait-eq-timeout") { workspace in + let flow = """ + version: flow/v1 + start: hold + states: + - id: hold + type: wait + seconds: 1 + timeout_sec: 1 + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let result = try await FlowEngine().run(ir: ir, mode: .live) + #expect(result.status == .success) + } + } + + @Test("TC-E36 args/env number-bool expression result should stringify") + func testArgsEnvExpressionStringify() async throws { + try await withTestWorkspace(prefix: "flow-engine-args-env-str") { workspace in + _ = try workspace.makeScript( + name: "echo-json.sh", + content: "#!/bin/sh\nprintf '{\"value\":\"%s|%s\"}\n' \"$1\" \"$FLAG\"\n" + ) + let flow = """ + version: flow/v1 + start: run + context: + num: 42 + flag: true + states: + - id: run + type: script + run: ./scripts/echo-json.sh + args: + - "$.context.num" + env: + FLAG: "$.context.flag" + export: + out: "$.current.final.value" + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let result = try await FlowEngine().run(ir: ir, mode: .live) + #expect(result.context["out"]?.stringValue == "42|true") + } + } + + @Test("TC-E42 script.export null should be accepted") + func testScriptExportNull() async throws { + try await withTestWorkspace(prefix: "flow-engine-script-null") { workspace in + _ = try workspace.makeScript(name: "null-json.sh", content: "#!/bin/sh\necho '{\"value\":null}'\n") + let flow = """ + version: flow/v1 + start: run + states: + - id: run + type: script + run: ./scripts/null-json.sh + export: + output: "$.current.final.value" + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let result = try await FlowEngine().run(ir: ir, mode: .live) + #expect(result.status == .success) + #expect(result.context["output"] == .null) + } + } + + @Test("TC-E38 runtime path missing after preflight") + func testRuntimePathMissingAfterPreflight() async throws { + try await withTestWorkspace(prefix: "flow-engine-runtime-path-missing") { workspace in + let victimPath = try workspace.makeScript(name: "victim.sh", content: "#!/bin/sh\necho hi\n") + _ = try workspace.makeScript(name: "remover.sh", content: "#!/bin/sh\nrm -f \"$TARGET\"\n") + + let flow = """ + version: flow/v1 + start: remove + states: + - id: remove + type: script + run: ./scripts/remover.sh + env: + TARGET: "\(victimPath)" + next: victim + - id: victim + type: script + run: ./scripts/victim.sh + next: done + - id: done + type: end + status: success + """ + + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + await requireFlowError("flow.path.not_found") { + _ = try await FlowEngine().run(ir: ir, mode: .live) + } + } + } + + @Test("TC-E20/TC-E21 default counters isolated, shared counters merged") + func testCounterIsolationAndSharing() async throws { + try await withTestWorkspace(prefix: "flow-engine-counters") { workspace in + let isolated = """ + version: flow/v1 + start: a + states: + - id: a + type: agent + task: task-a + next: b + - id: b + type: agent + task: task-b + next: done + - id: done + type: end + status: success + """ + let isolatedFlowPath = try writeFlowFile(workspace: workspace, name: "isolated.yaml", content: isolated) + let isolatedFixturePath = try writeFixtureFile(workspace: workspace, name: "isolated.json", json: """ + {"states":{"a":[{"status":"completed","final":{}}],"b":[{"status":"completed","final":{}}]}} + """) + + let isolatedIR = try FlowCompiler.compileFile(atPath: isolatedFlowPath) + let isolatedFixture = try FlowDryRunFixture.load(fromPath: isolatedFixturePath) + let isolatedResult = try await FlowEngine().run(ir: isolatedIR, mode: .dryRun(isolatedFixture)) + #expect(isolatedResult.counters["agent_round.a"] == 1) + #expect(isolatedResult.counters["agent_round.b"] == 1) + + let shared = """ + version: flow/v1 + start: a + states: + - id: a + type: agent + task: task-a + counter: shared_round + next: b + - id: b + type: agent + task: task-b + counter: shared_round + next: done + - id: done + type: end + status: success + """ + let sharedFlowPath = try writeFlowFile(workspace: workspace, name: "shared.yaml", content: shared) + let sharedFixturePath = try writeFixtureFile(workspace: workspace, name: "shared.json", json: """ + {"states":{"a":[{"status":"completed","final":{}}],"b":[{"status":"completed","final":{}}]}} + """) + + let sharedIR = try FlowCompiler.compileFile(atPath: sharedFlowPath) + let sharedFixture = try FlowDryRunFixture.load(fromPath: sharedFixturePath) + let sharedResult = try await FlowEngine().run(ir: sharedIR, mode: .dryRun(sharedFixture)) + #expect(sharedResult.counters["shared_round"] == 2) + } + } + + @Test("TC-E06 wait should not increase agent rounds") + func testWaitDoesNotIncreaseAgentCounter() async throws { + try await withTestWorkspace(prefix: "flow-engine-wait-no-round") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho should-not-run\n") + let flow = """ + version: flow/v1 + start: precheck + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: fix + wait: hold + fail: done_fail + - id: fix + type: agent + task: fix + counter: fix_round + next: post + - id: post + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: done_fail + wait: hold + fail: done_fail + - id: hold + type: wait + seconds: 0 + next: post + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let fixturePath = try writeFixtureFile(workspace: workspace, name: "wait-no-round.json", json: """ + { + "states": { + "precheck": [{"decision": "needs_agent"}], + "fix": [{"status": "completed", "final": {}}], + "post": [{"decision": "wait"}, {"decision": "pass"}] + } + } + """) + let flowPath = try writeFlowFile(workspace: workspace, name: "wait-no-round.yaml", content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let fixture = try FlowDryRunFixture.load(fromPath: fixturePath) + let result = try await FlowEngine().run(ir: ir, mode: .dryRun(fixture)) + #expect(result.status == .success) + #expect(result.counters["fix_round"] == 1) + } + } + + @Test("TC-E15/TC-E29 gate timeout should map to flow.step.timeout") + func testGateTimeoutMapsToStepTimeout() async throws { + try await withTestWorkspace(prefix: "flow-engine-gate-timeout") { workspace in + _ = try workspace.makeScript( + name: "gate-slow.sh", + content: "#!/bin/sh\nsleep 2\necho '{\"decision\":\"pass\"}'\n" + ) + let flow = """ + version: flow/v1 + start: gate + states: + - id: gate + type: gate + run: ./scripts/gate-slow.sh + timeout_sec: 1 + on: + pass: done + needs_agent: done + wait: done + fail: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + await requireFlowError("flow.step.timeout") { + _ = try await FlowEngine().run(ir: ir, mode: .live) + } + } + } + + @Test("TC-E15/TC-E29 script timeout should map to flow.step.timeout") + func testScriptTimeoutMapsToStepTimeout() async throws { + try await withTestWorkspace(prefix: "flow-engine-script-timeout") { workspace in + _ = try workspace.makeScript(name: "slow.sh", content: "#!/bin/sh\nsleep 2\n") + let flow = """ + version: flow/v1 + start: run + states: + - id: run + type: script + run: ./scripts/slow.sh + timeout_sec: 1 + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + await requireFlowError("flow.step.timeout") { + _ = try await FlowEngine().run(ir: ir, mode: .live) + } + } + } +} diff --git a/Tests/ScriptoriaCoreTests/Flow/FlowEngineCoverageTests.swift b/Tests/ScriptoriaCoreTests/Flow/FlowEngineCoverageTests.swift new file mode 100644 index 0000000..1f29e0a --- /dev/null +++ b/Tests/ScriptoriaCoreTests/Flow/FlowEngineCoverageTests.swift @@ -0,0 +1,586 @@ +import Foundation +import Testing +@testable import ScriptoriaCore + +@Suite("Flow Engine Coverage", .serialized) +struct FlowEngineCoverageTests { + @Test("TC-E03 needs_agent loop should succeed at round N(<20)") + func testNeedsAgentMultiRoundSuccess() async throws { + try await withTestWorkspace(prefix: "flow-engine-round-n-success") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho should-not-run\n") + let flow = """ + version: flow/v1 + start: precheck + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: fix + wait: hold + fail: done_fail + - id: fix + type: agent + task: fix + counter: fix_round + max_rounds: 20 + next: postcheck + - id: postcheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: fix + wait: hold + fail: done_fail + - id: hold + type: wait + seconds: 0 + next: postcheck + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let fixture = """ + { + "states": { + "precheck": [{"decision":"needs_agent"}], + "fix": [ + {"status":"completed","final":{}}, + {"status":"completed","final":{}}, + {"status":"completed","final":{}} + ], + "postcheck": [ + {"decision":"needs_agent"}, + {"decision":"needs_agent"}, + {"decision":"pass"} + ] + } + } + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let fixturePath = try writeFixtureFile(workspace: workspace, json: fixture) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let dryFixture = try FlowDryRunFixture.load(fromPath: fixturePath) + let result = try await FlowEngine().run(ir: ir, mode: .dryRun(dryFixture)) + #expect(result.status == .success) + #expect(result.counters["fix_round"] == 3) + } + } + + @Test("TC-E04 needs_agent loop should fail at round 21 with default cap 20") + func testNeedsAgentRound21Fails() async throws { + try await withTestWorkspace(prefix: "flow-engine-round-21-fail") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho should-not-run\n") + let flow = """ + version: flow/v1 + start: precheck + defaults: + max_agent_rounds: 20 + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: fix + wait: done_fail + fail: done_fail + - id: fix + type: agent + task: fix + counter: fix_round + max_rounds: 20 + next: postcheck + - id: postcheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: fix + wait: done_fail + fail: done_fail + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + + let needsAgentDecisions = Array(repeating: "{\"decision\":\"needs_agent\"}", count: 20).joined(separator: ",") + let fixEntries = Array(repeating: "{\"status\":\"completed\",\"final\":{}}", count: 20).joined(separator: ",") + let fixture = """ + { + "states": { + "precheck": [{"decision":"needs_agent"}], + "postcheck": [\(needsAgentDecisions)], + "fix": [\(fixEntries)] + } + } + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let fixturePath = try writeFixtureFile(workspace: workspace, json: fixture) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let dryFixture = try FlowDryRunFixture.load(fromPath: fixturePath) + await requireFlowError("flow.agent.rounds_exceeded") { + _ = try await FlowEngine().run(ir: ir, mode: .dryRun(dryFixture)) + } + } + } + + @Test("TC-E05 postcheck wait should enter wait state then recheck") + func testPostcheckWaitThenRecheck() async throws { + try await withTestWorkspace(prefix: "flow-engine-postcheck-wait") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho should-not-run\n") + let flow = """ + version: flow/v1 + start: precheck + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: fix + wait: done_fail + fail: done_fail + - id: fix + type: agent + task: fix + counter: fix_round + next: postcheck + - id: postcheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: done_fail + wait: hold + fail: done_fail + - id: hold + type: wait + seconds: 0 + next: postcheck + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let fixture = """ + { + "states": { + "precheck": [{"decision":"needs_agent"}], + "fix": [{"status":"completed","final":{}}], + "postcheck": [{"decision":"wait"}, {"decision":"pass"}] + } + } + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let fixturePath = try writeFixtureFile(workspace: workspace, json: fixture) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let dryFixture = try FlowDryRunFixture.load(fromPath: fixturePath) + let result = try await FlowEngine().run(ir: ir, mode: .dryRun(dryFixture)) + #expect(result.status == .success) + #expect(result.counters["fix_round"] == 1) + } + } + + @Test("TC-E07/TC-E19 max_wait_cycles should be global cumulative across wait states") + func testGlobalWaitCyclesAcrossStates() async throws { + try await withTestWorkspace(prefix: "flow-engine-global-wait-cycles") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho should-not-run\n") + let flow = """ + version: flow/v1 + start: g1 + defaults: + max_wait_cycles: 2 + states: + - id: g1 + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: done + wait: w1 + fail: done + - id: w1 + type: wait + seconds: 0 + next: g2 + - id: g2 + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: done + wait: w2 + fail: done + - id: w2 + type: wait + seconds: 0 + next: g1 + - id: done + type: end + status: success + """ + let fixture = """ + { + "states": { + "g1": [{"decision":"wait"}, {"decision":"wait"}], + "g2": [{"decision":"wait"}] + } + } + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let fixturePath = try writeFixtureFile(workspace: workspace, json: fixture) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let dryFixture = try FlowDryRunFixture.load(fromPath: fixturePath) + await requireFlowError("flow.wait.cycles_exceeded") { + _ = try await FlowEngine().run(ir: ir, mode: .dryRun(dryFixture)) + } + } + } + + @Test("TC-E08/TC-E22 agent failed should hard-fail and not map to business failure") + func testAgentFailureIsHardFailure() async throws { + try await withTestWorkspace(prefix: "flow-engine-agent-hard-fail") { workspace in + let flow = """ + version: flow/v1 + start: fix + states: + - id: fix + type: agent + task: fail + next: done_fail + - id: done_fail + type: end + status: failure + """ + let fixture = """ + { + "states": { + "fix": [{"status":"failed","final":{}}] + } + } + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let fixturePath = try writeFixtureFile(workspace: workspace, json: fixture) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let dryFixture = try FlowDryRunFixture.load(fromPath: fixturePath) + await requireFlowError("flow.agent.failed") { + _ = try await FlowEngine().run(ir: ir, mode: .dryRun(dryFixture)) + } + } + } + + @Test("TC-E09 user interrupt command should return flow.agent.interrupted") + func testUserInterruptByCommand() async throws { + try await withTestWorkspace(prefix: "flow-engine-user-interrupt") { workspace in + let codexPath = try workspace.makeFakeCodex() + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "wait_for_command" + ]) { + let flow = """ + version: flow/v1 + start: fix + states: + - id: fix + type: agent + task: interrupt + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + await requireFlowError("flow.agent.interrupted") { + _ = try await FlowEngine().run( + ir: ir, + mode: .live, + options: .init(noSteer: true, commands: ["/interrupt"]) + ) + } + } + } + } + + @Test("TC-E12/TC-E13/TC-E14 seconds_from resolve edge cases") + func testWaitSecondsFromEdgeCases() async throws { + try await withTestWorkspace(prefix: "flow-engine-wait-seconds-from-edge") { workspace in + let missingValueFlow = """ + version: flow/v1 + start: hold + states: + - id: hold + type: wait + seconds_from: "$.context.missing" + next: done + - id: done + type: end + status: success + """ + let missingPath = try writeFlowFile(workspace: workspace, name: "missing.yaml", content: missingValueFlow) + let missingIR = try FlowCompiler.compileFile(atPath: missingPath) + await requireFlowError("flow.wait.seconds_resolve_error") { + _ = try await FlowEngine().run(ir: missingIR, mode: .live) + } + + let zeroFlow = """ + version: flow/v1 + start: hold + context: + sec: 0 + states: + - id: hold + type: wait + seconds_from: "$.context.sec" + next: done + - id: done + type: end + status: success + """ + let zeroPath = try writeFlowFile(workspace: workspace, name: "zero.yaml", content: zeroFlow) + let zeroIR = try FlowCompiler.compileFile(atPath: zeroPath) + let zeroResult = try await FlowEngine().run(ir: zeroIR, mode: .live) + #expect(zeroResult.status == .success) + + let negativeFlow = """ + version: flow/v1 + start: hold + context: + sec: -1 + states: + - id: hold + type: wait + seconds_from: "$.context.sec" + next: done + - id: done + type: end + status: success + """ + let negativePath = try writeFlowFile(workspace: workspace, name: "negative.yaml", content: negativeFlow) + let negativeIR = try FlowCompiler.compileFile(atPath: negativePath) + await requireFlowError("flow.wait.seconds_resolve_error") { + _ = try await FlowEngine().run(ir: negativeIR, mode: .live) + } + } + } + + @Test("TC-E16 script success path should complete") + func testScriptSuccessPath() async throws { + try await withTestWorkspace(prefix: "flow-engine-script-success") { workspace in + _ = try workspace.makeScript(name: "ok.sh", content: "#!/bin/sh\necho ok\n") + let flow = """ + version: flow/v1 + start: run + states: + - id: run + type: script + run: ./scripts/ok.sh + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let result = try await FlowEngine().run(ir: ir, mode: .live) + #expect(result.status == .success) + #expect(result.endedAtStateID == "done") + } + } + + @Test("TC-E18 gate-script loop without agent/wait should be terminated by max_total_steps") + func testGateScriptLoopStopsByStepLimit() async throws { + try await withTestWorkspace(prefix: "flow-engine-gate-script-loop-limit") { workspace in + _ = try workspace.makeScript(name: "gate-pass.sh", content: "#!/bin/sh\necho '{\"decision\":\"pass\"}'\n") + _ = try workspace.makeScript(name: "noop.sh", content: "#!/bin/sh\necho noop\n") + let flow = """ + version: flow/v1 + start: gate + defaults: + max_total_steps: 3 + states: + - id: gate + type: gate + run: ./scripts/gate-pass.sh + on: + pass: run + needs_agent: run + wait: run + fail: run + - id: run + type: script + run: ./scripts/noop.sh + next: gate + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + await requireFlowError("flow.steps.exceeded") { + _ = try await FlowEngine().run(ir: ir, mode: .live) + } + } + } + + @Test("TC-E26 gate decision fail should follow on.fail branch") + func testGateFailDecisionUsesBusinessTransition() async throws { + try await withTestWorkspace(prefix: "flow-engine-gate-fail-branch") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho should-not-run\n") + let flow = """ + version: flow/v1 + start: gate + states: + - id: gate + type: gate + run: ./scripts/check.sh + on: + pass: done_fail + needs_agent: done_fail + wait: done_fail + fail: done + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let fixture = """ + { + "states": { + "gate": [{"decision":"fail"}] + } + } + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let fixturePath = try writeFixtureFile(workspace: workspace, json: fixture) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let dryFixture = try FlowDryRunFixture.load(fromPath: fixturePath) + let result = try await FlowEngine().run(ir: ir, mode: .dryRun(dryFixture)) + #expect(result.status == .success) + #expect(result.endedAtStateID == "done") + } + } + + @Test("TC-E32/TC-E33 inclusive limits should allow exactly-at-limit execution") + func testInclusiveLimitBoundaries() async throws { + try await withTestWorkspace(prefix: "flow-engine-inclusive-limits") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho should-not-run\n") + let waitFlow = """ + version: flow/v1 + start: gate + defaults: + max_wait_cycles: 2 + states: + - id: gate + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: done + wait: hold + fail: done + - id: hold + type: wait + seconds: 0 + next: gate + - id: done + type: end + status: success + """ + let waitFixture = """ + { + "states": { + "gate": [{"decision":"wait"}, {"decision":"wait"}, {"decision":"pass"}] + } + } + """ + let waitFlowPath = try writeFlowFile(workspace: workspace, name: "wait-limit.yaml", content: waitFlow) + let waitFixturePath = try writeFixtureFile(workspace: workspace, name: "wait-limit.json", json: waitFixture) + let waitIR = try FlowCompiler.compileFile(atPath: waitFlowPath) + let waitDryFixture = try FlowDryRunFixture.load(fromPath: waitFixturePath) + let waitResult = try await FlowEngine().run(ir: waitIR, mode: .dryRun(waitDryFixture)) + #expect(waitResult.status == .success) + + _ = try workspace.makeScript(name: "noop.sh", content: "#!/bin/sh\necho ok\n") + let stepFlow = """ + version: flow/v1 + start: a + defaults: + max_total_steps: 3 + states: + - id: a + type: script + run: ./scripts/noop.sh + next: b + - id: b + type: script + run: ./scripts/noop.sh + next: done + - id: done + type: end + status: success + """ + let stepFlowPath = try writeFlowFile(workspace: workspace, name: "step-limit.yaml", content: stepFlow) + let stepIR = try FlowCompiler.compileFile(atPath: stepFlowPath) + let stepResult = try await FlowEngine().run(ir: stepIR, mode: .live) + #expect(stepResult.status == .success) + #expect(stepResult.steps == 3) + } + } + + @Test("TC-P05 running command stream should be consumed during active agent turn") + func testRunningCommandStreamConsumption() async throws { + try await withTestWorkspace(prefix: "flow-engine-running-command-stream") { workspace in + let codexPath = try workspace.makeFakeCodex() + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "wait_for_command_json" + ]) { + let flow = """ + version: flow/v1 + start: fix + states: + - id: fix + type: agent + task: delayed-command + timeout_sec: 2 + export: + received: "$.current.final.received" + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + + let stream = AsyncStream { continuation in + Task.detached { + try? await Task.sleep(nanoseconds: 300_000_000) + continuation.yield("delayed-live-command") + continuation.finish() + } + } + + let result = try await FlowEngine().run( + ir: ir, + mode: .live, + options: .init(), + commandInput: stream + ) + #expect(result.status == .success) + #expect(result.context["received"]?.stringValue == "delayed-live-command") + } + } + } +} diff --git a/Tests/ScriptoriaCoreTests/Flow/FlowEngineTests.swift b/Tests/ScriptoriaCoreTests/Flow/FlowEngineTests.swift new file mode 100644 index 0000000..bbbea82 --- /dev/null +++ b/Tests/ScriptoriaCoreTests/Flow/FlowEngineTests.swift @@ -0,0 +1,397 @@ +import Foundation +import Testing +@testable import ScriptoriaCore + +@Suite("Flow Engine", .serialized) +struct FlowEngineTests { + @Test("TC-E01 precheck pass ends success without agent") + func testGatePassDirectSuccess() async throws { + try await withTestWorkspace(prefix: "flow-engine-pass") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho should-not-run\n") + let flowPath = try writeFlowFile(workspace: workspace, content: minimalFlowYAML()) + let fixturePath = try writeFixtureFile(workspace: workspace, json: """ + {"states":{"precheck":[{"decision":"pass"}]}} + """) + + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let fixture = try FlowDryRunFixture.load(fromPath: fixturePath) + let result = try await FlowEngine().run(ir: ir, mode: .dryRun(fixture), options: .init()) + + #expect(result.status == .success) + #expect(result.counters.isEmpty) + #expect(result.endedAtStateID == "done") + } + } + + @Test("TC-E02 one agent round then success") + func testOneRoundSuccess() async throws { + try await withTestWorkspace(prefix: "flow-engine-one-round") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho should-not-run\n") + let flow = """ + version: flow/v1 + start: precheck + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: fix + wait: pre_wait + fail: done_fail + - id: pre_wait + type: wait + seconds: 0 + next: precheck + - id: fix + type: agent + task: fix + counter: fix_round + max_rounds: 20 + export: + pr_url: "$.current.final.pr_url" + next: postcheck + - id: postcheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: fix + wait: pre_wait + fail: done_fail + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let fixturePath = try writeFixtureFile(workspace: workspace, json: """ + { + "states": { + "precheck": [{"decision": "needs_agent"}], + "fix": [{"status": "completed", "final": {"pr_url": "https://example/pr/1"}}], + "postcheck": [{"decision": "pass"}] + } + } + """) + + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let fixture = try FlowDryRunFixture.load(fromPath: fixturePath) + let result = try await FlowEngine().run(ir: ir, mode: .dryRun(fixture), options: .init()) + #expect(result.status == .success) + #expect(result.counters["fix_round"] == 1) + #expect(result.context["pr_url"]?.stringValue == "https://example/pr/1") + } + } + + @Test("TC-E30 rounds exceeded returns flow.agent.rounds_exceeded") + func testAgentRoundsExceeded() async throws { + try await withTestWorkspace(prefix: "flow-engine-rounds") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho should-not-run\n") + let flow = """ + version: flow/v1 + start: precheck + defaults: + max_agent_rounds: 2 + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: fix + wait: pre_wait + fail: done_fail + - id: pre_wait + type: wait + seconds: 0 + next: precheck + - id: fix + type: agent + task: fix + counter: fix_round + max_rounds: 20 + next: precheck + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let fixturePath = try writeFixtureFile(workspace: workspace, json: """ + { + "states": { + "precheck": [ + {"decision":"needs_agent"}, + {"decision":"needs_agent"}, + {"decision":"needs_agent"} + ], + "fix": [ + {"status":"completed","final":{}}, + {"status":"completed","final":{}} + ] + } + } + """) + + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let fixture = try FlowDryRunFixture.load(fromPath: fixturePath) + await requireFlowError("flow.agent.rounds_exceeded") { + _ = try await FlowEngine().run(ir: ir, mode: .dryRun(fixture), options: .init()) + } + } + } + + @Test("TC-E27 wait cycles exceeded") + func testWaitCyclesExceeded() async throws { + try await withTestWorkspace(prefix: "flow-engine-wait-limit") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho should-not-run\n") + let flow = """ + version: flow/v1 + start: precheck + defaults: + max_wait_cycles: 1 + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: fix + wait: hold + fail: done_fail + - id: hold + type: wait + seconds: 0 + next: precheck + - id: fix + type: agent + task: fix + next: done + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let fixturePath = try writeFixtureFile(workspace: workspace, json: """ + {"states":{"precheck":[{"decision":"wait"},{"decision":"wait"}]}} + """) + + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let fixture = try FlowDryRunFixture.load(fromPath: fixturePath) + await requireFlowError("flow.wait.cycles_exceeded") { + _ = try await FlowEngine().run(ir: ir, mode: .dryRun(fixture), options: .init()) + } + } + } + + @Test("TC-E28 max total steps exceeded") + func testTotalStepsExceeded() async throws { + try await withTestWorkspace(prefix: "flow-engine-steps-limit") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho should-not-run\n") + let flow = """ + version: flow/v1 + start: precheck + defaults: + max_total_steps: 3 + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: fix + wait: hold + fail: done_fail + - id: hold + type: wait + seconds: 0 + next: precheck + - id: fix + type: agent + task: fix + next: done + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let fixturePath = try writeFixtureFile(workspace: workspace, json: """ + {"states":{"precheck":[{"decision":"wait"},{"decision":"wait"}]}} + """) + + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let fixture = try FlowDryRunFixture.load(fromPath: fixturePath) + await requireFlowError("flow.steps.exceeded") { + _ = try await FlowEngine().run(ir: ir, mode: .dryRun(fixture), options: .init()) + } + } + } + + @Test("TC-E31 end failure maps to flow.business_failed") + func testBusinessFailure() async throws { + try await withTestWorkspace(prefix: "flow-engine-business-fail") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho should-not-run\n") + let flowPath = try writeFlowFile(workspace: workspace, content: minimalFlowYAML()) + let fixturePath = try writeFixtureFile(workspace: workspace, json: """ + {"states":{"precheck":[{"decision":"fail"}]}} + """) + + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let fixture = try FlowDryRunFixture.load(fromPath: fixturePath) + await requireFlowError("flow.business_failed") { + _ = try await FlowEngine().run(ir: ir, mode: .dryRun(fixture), options: .init()) + } + } + } + + @Test("TC-E43 wait.seconds larger than effective timeout fails immediately") + func testWaitSecondsGreaterThanTimeout() async throws { + try await withTestWorkspace(prefix: "flow-engine-wait-timeout") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho should-not-run\n") + let flow = """ + version: flow/v1 + start: hold + defaults: + step_timeout_sec: 1 + states: + - id: hold + type: wait + seconds: 3 + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + + await requireFlowError("flow.step.timeout") { + _ = try await FlowEngine().run(ir: ir, mode: .live, options: .init()) + } + } + } + + @Test("TC-E46 script export parse failure") + func testScriptExportParseFailure() async throws { + try await withTestWorkspace(prefix: "flow-engine-script-export") { workspace in + _ = try workspace.makeScript(name: "collect.sh", content: "#!/bin/sh\necho not-json\n") + let flow = """ + version: flow/v1 + start: collect + states: + - id: collect + type: script + run: ./scripts/collect.sh + export: + value: "$.current.final.value" + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + + await requireFlowError("flow.script.output_parse_error") { + _ = try await FlowEngine().run(ir: ir, mode: .live, options: .init()) + } + } + } + + @Test("TC-E41 agent export null should be accepted") + func testAgentExportNullValue() async throws { + try await withTestWorkspace(prefix: "flow-engine-agent-null") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho should-not-run\n") + let flow = """ + version: flow/v1 + start: precheck + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: fix + wait: hold + fail: done_fail + - id: hold + type: wait + seconds: 0 + next: precheck + - id: fix + type: agent + task: fix + export: + pr_url: "$.current.final.pr_url" + next: done + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let fixturePath = try writeFixtureFile(workspace: workspace, json: """ + { + "states": { + "precheck": [{"decision":"needs_agent"}], + "fix": [{"status":"completed","final":{"pr_url":null}}] + } + } + """) + + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let fixture = try FlowDryRunFixture.load(fromPath: fixturePath) + let result = try await FlowEngine().run(ir: ir, mode: .dryRun(fixture), options: .init()) + #expect(result.status == .success) + #expect(result.context["pr_url"] == .null) + } + } +} + +extension FlowEngineTests { + @Test("TC-E39/TC-E40 agent timeout returns flow.step.timeout") + func testAgentTimeout() async throws { + try await withTestWorkspace(prefix: "flow-engine-agent-timeout") { workspace in + let codexPath = try workspace.makeFakeCodex() + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "wait_for_command" + ]) { + let flow = """ + version: flow/v1 + start: fix + defaults: + step_timeout_sec: 1 + states: + - id: fix + type: agent + task: fix-timeout + timeout_sec: 1 + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + + await requireFlowError("flow.step.timeout") { + _ = try await FlowEngine().run(ir: ir, mode: .live, options: .init(noSteer: true)) + } + } + } + } +} diff --git a/Tests/ScriptoriaCoreTests/Flow/FlowErrorBoundaryTests.swift b/Tests/ScriptoriaCoreTests/Flow/FlowErrorBoundaryTests.swift new file mode 100644 index 0000000..fea5966 --- /dev/null +++ b/Tests/ScriptoriaCoreTests/Flow/FlowErrorBoundaryTests.swift @@ -0,0 +1,240 @@ +import Foundation +import Testing +@testable import ScriptoriaCore + +@Suite("Flow Error Boundaries", .serialized) +struct FlowErrorBoundaryTests { + @Test("TC-E24 agent export field missing") + func testAgentExportFieldMissing() async throws { + try await withTestWorkspace(prefix: "flow-error-agent-export-missing") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho should-not-run\n") + let flow = """ + version: flow/v1 + start: precheck + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + pass: done_fail + needs_agent: fix + wait: done_fail + fail: done_fail + - id: fix + type: agent + task: fix + export: + pr_url: "$.current.final.pr_url" + next: done + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let fixture = """ + { + "states": { + "precheck": [{"decision":"needs_agent"}], + "fix": [{"status":"completed","final":{}}] + } + } + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let fixturePath = try writeFixtureFile(workspace: workspace, json: fixture) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let dryFixture = try FlowDryRunFixture.load(fromPath: fixturePath) + await requireFlowError("flow.agent.export_field_missing") { + _ = try await FlowEngine().run(ir: ir, mode: .dryRun(dryFixture)) + } + } + } + + @Test("TC-E25 script export field missing") + func testScriptExportFieldMissing() async throws { + try await withTestWorkspace(prefix: "flow-error-script-export-missing") { workspace in + _ = try workspace.makeScript(name: "obj.sh", content: "#!/bin/sh\necho '{\"x\":1}'\n") + let flow = """ + version: flow/v1 + start: run + states: + - id: run + type: script + run: ./scripts/obj.sh + export: + value: "$.current.final.value" + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + await requireFlowError("flow.script.export_field_missing") { + _ = try await FlowEngine().run(ir: ir, mode: .live) + } + } + } + + @Test("TC-E23 agent output parse error when export required") + func testAgentOutputParseError() async throws { + try await withTestWorkspace(prefix: "flow-error-agent-output-parse") { workspace in + let codexPath = try workspace.makeFakeCodex() + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "complete" + ]) { + let flow = """ + version: flow/v1 + start: fix + states: + - id: fix + type: agent + task: parse + export: + value: "$.current.final.value" + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + await requireFlowError("flow.agent.output_parse_error") { + _ = try await FlowEngine().run(ir: ir, mode: .live) + } + } + } + } + + @Test("TC-GP10 parse error with fail_on_parse_error=true should fail") + func testGateParseErrorFailFast() async throws { + try await withTestWorkspace(prefix: "flow-error-gate-parse-fast") { workspace in + _ = try workspace.makeScript(name: "gate.sh", content: "#!/bin/sh\necho not-json\n") + let flow = """ + version: flow/v1 + start: gate + defaults: + fail_on_parse_error: true + states: + - id: gate + type: gate + run: ./scripts/gate.sh + on: + pass: done + needs_agent: done + wait: done + fail: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + await requireFlowError("flow.gate.parse_error") { + _ = try await FlowEngine().run(ir: ir, mode: .live) + } + } + } + + @Test("TC-E44 wait.seconds_from greater than timeout should fail with timeout") + func testWaitSecondsFromGreaterThanTimeout() async throws { + try await withTestWorkspace(prefix: "flow-error-wait-from-timeout") { workspace in + let flow = """ + version: flow/v1 + start: hold + context: + wait_sec: 3 + states: + - id: hold + type: wait + seconds_from: "$.context.wait_sec" + timeout_sec: 1 + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + await requireFlowError("flow.step.timeout") { + _ = try await FlowEngine().run(ir: ir, mode: .live) + } + } + } + + @Test("TC-E11 max rounds equals limit should still pass") + func testAgentRoundsAtLimitPasses() async throws { + try await withTestWorkspace(prefix: "flow-error-rounds-at-limit") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho should-not-run\n") + let flow = """ + version: flow/v1 + start: gate + defaults: + max_agent_rounds: 2 + states: + - id: gate + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: fix + wait: done_fail + fail: done_fail + - id: fix + type: agent + task: fix + counter: fix_round + max_rounds: 2 + next: gate2 + - id: gate2 + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: fix2 + wait: done_fail + fail: done_fail + - id: fix2 + type: agent + task: fix + counter: fix_round + max_rounds: 2 + next: gate3 + - id: gate3 + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: done_fail + wait: done_fail + fail: done_fail + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let fixture = """ + { + "states": { + "gate": [{"decision":"needs_agent"}], + "fix": [{"status":"completed","final":{}}], + "gate2": [{"decision":"needs_agent"}], + "fix2": [{"status":"completed","final":{}}], + "gate3": [{"decision":"pass"}] + } + } + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let fixturePath = try writeFixtureFile(workspace: workspace, json: fixture) + let ir = try FlowCompiler.compileFile(atPath: flowPath) + let dryFixture = try FlowDryRunFixture.load(fromPath: fixturePath) + let result = try await FlowEngine().run(ir: ir, mode: .dryRun(dryFixture)) + #expect(result.status == .success) + #expect(result.counters["fix_round"] == 2) + } + } +} diff --git a/Tests/ScriptoriaCoreTests/Flow/FlowPRGateScenarioTests.swift b/Tests/ScriptoriaCoreTests/Flow/FlowPRGateScenarioTests.swift new file mode 100644 index 0000000..30e1cc5 --- /dev/null +++ b/Tests/ScriptoriaCoreTests/Flow/FlowPRGateScenarioTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import ScriptoriaCore + +@Suite("Flow PR Gate Scenarios", .serialized) +struct FlowPRGateScenarioTests { + @Test("TC-PR01 CI success + no blocking comments -> pass") + func testPRScenarioPass() throws { + let stdout = #"{"decision":"pass","ci_status":"success","blocking_comments":0}"# + let parsed = try FlowGateOutputParser.parse(stdout: stdout, mode: .jsonLastLine) + #expect(parsed.decision == .pass) + } + + @Test("TC-PR02 CI failure -> needs_agent") + func testPRScenarioNeedsAgentByCIFailure() throws { + let stdout = #"{"decision":"needs_agent","ci_status":"failure"}"# + let parsed = try FlowGateOutputParser.parse(stdout: stdout, mode: .jsonLastLine) + #expect(parsed.decision == .needsAgent) + } + + @Test("TC-PR03 CI pending -> wait") + func testPRScenarioWaitByCIPending() throws { + let stdout = #"{"decision":"wait","ci_status":"pending","retry_after_sec":30}"# + let parsed = try FlowGateOutputParser.parse(stdout: stdout, mode: .jsonLastLine) + #expect(parsed.decision == .wait) + #expect(parsed.retryAfterSec == 30) + } + + @Test("TC-PR04 request changes review -> needs_agent") + func testPRScenarioNeedsAgentByReview() throws { + let stdout = #"{"decision":"needs_agent","review_state":"REQUEST_CHANGES"}"# + let parsed = try FlowGateOutputParser.parse(stdout: stdout, mode: .jsonLastLine) + #expect(parsed.decision == .needsAgent) + } + + @Test("TC-PR05 missing PR URL -> fail") + func testPRScenarioFailWhenPRURLMissing() throws { + let stdout = #"{"decision":"fail","reason":"pr_url_missing"}"# + let parsed = try FlowGateOutputParser.parse(stdout: stdout, mode: .jsonLastLine) + #expect(parsed.decision == .fail) + } +} diff --git a/Tests/ScriptoriaCoreTests/Flow/FlowPlanCoverageTests.swift b/Tests/ScriptoriaCoreTests/Flow/FlowPlanCoverageTests.swift new file mode 100644 index 0000000..1a29502 --- /dev/null +++ b/Tests/ScriptoriaCoreTests/Flow/FlowPlanCoverageTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing + +@Suite("Flow Plan Coverage", .serialized) +struct FlowPlanCoverageTests { + @Test("all TC ids in plan should be present in tests") + func testAllPlanTCIDsAreCovered() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Flow + .deletingLastPathComponent() // ScriptoriaCoreTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // repo root + + let planURL = root.appendingPathComponent("docs/flow-dsl-architecture-plan.md") + let testsRoot = root.appendingPathComponent("Tests") + + let planText = try String(contentsOf: planURL, encoding: .utf8) + let planTCIDs = extractTCIDs(from: planText) + + let testFiles = try allSwiftFiles(in: testsRoot) + var testTCIDs: Set = [] + for fileURL in testFiles { + let text = try String(contentsOf: fileURL, encoding: .utf8) + testTCIDs.formUnion(extractTCIDs(from: text)) + } + + let missing = planTCIDs.subtracting(testTCIDs).sorted() + #expect( + missing.isEmpty, + "Missing TC ids: \(missing.joined(separator: ", "))" + ) + } + + private func extractTCIDs(from text: String) -> Set { + let pattern = "TC-[A-Z]+[0-9]{2}" + let regex = try? NSRegularExpression(pattern: pattern) + let range = NSRange(text.startIndex.. = [] + for match in matches { + guard let r = Range(match.range, in: text) else { continue } + ids.insert(String(text[r])) + } + return ids + } + + private func allSwiftFiles(in root: URL) throws -> [URL] { + let keys: [URLResourceKey] = [.isDirectoryKey] + let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: keys + ) + var files: [URL] = [] + while let url = enumerator?.nextObject() as? URL { + guard url.pathExtension == "swift" else { continue } + files.append(url) + } + return files + } +} diff --git a/Tests/ScriptoriaCoreTests/Flow/FlowProviderE2ETests.swift b/Tests/ScriptoriaCoreTests/Flow/FlowProviderE2ETests.swift new file mode 100644 index 0000000..19f5fcf --- /dev/null +++ b/Tests/ScriptoriaCoreTests/Flow/FlowProviderE2ETests.swift @@ -0,0 +1,162 @@ +import Foundation +import Testing +@testable import ScriptoriaCore + +@Suite("Flow Provider E2E", .serialized) +struct FlowProviderE2ETests { + @Test("TC-P01 flow run + codex provider should run end-to-end") + func testCodexProviderE2E() async throws { + try await withTestWorkspace(prefix: "flow-provider-codex") { workspace in + let codexPath = try workspace.makeFakeCodex() + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "complete" + ]) { + let flow = """ + version: flow/v1 + start: fix + states: + - id: fix + type: agent + task: codex-e2e + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let run = try runCLI(arguments: ["flow", "run", flowPath, "--no-steer"]) + #expect(run.exitCode == 0) + } + } + } + + @Test("TC-P02 flow run + claude adapter executable should run end-to-end") + func testClaudeAdapterE2E() async throws { + try await withTestWorkspace(prefix: "flow-provider-claude") { workspace in + let codexPath = try workspace.makeFakeCodex() + let adapterURL = workspace.rootURL.appendingPathComponent("agents/claude-adapter") + try FileManager.default.createDirectory( + at: adapterURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.copyItem(atPath: codexPath, toPath: adapterURL.path) + try FileManager.default.setAttributes([.posixPermissions: NSNumber(value: Int16(0o755))], ofItemAtPath: adapterURL.path) + + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": adapterURL.path, + "SCRIPTORIA_FAKE_CODEX_MODE": "complete" + ]) { + let flow = """ + version: flow/v1 + start: fix + states: + - id: fix + type: agent + task: claude-e2e + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let run = try runCLI(arguments: ["flow", "run", flowPath, "--no-steer"]) + #expect(run.exitCode == 0) + } + } + } + + @Test("TC-P03 flow run + kimi adapter executable should run end-to-end") + func testKimiAdapterE2E() async throws { + try await withTestWorkspace(prefix: "flow-provider-kimi") { workspace in + let codexPath = try workspace.makeFakeCodex() + let adapterURL = workspace.rootURL.appendingPathComponent("agents/kimi-adapter") + try FileManager.default.createDirectory( + at: adapterURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.copyItem(atPath: codexPath, toPath: adapterURL.path) + try FileManager.default.setAttributes([.posixPermissions: NSNumber(value: Int16(0o755))], ofItemAtPath: adapterURL.path) + + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": adapterURL.path, + "SCRIPTORIA_FAKE_CODEX_MODE": "complete" + ]) { + let flow = """ + version: flow/v1 + start: fix + states: + - id: fix + type: agent + task: kimi-e2e + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let run = try runCLI(arguments: ["flow", "run", flowPath, "--no-steer"]) + #expect(run.exitCode == 0) + } + } + } + + @Test("TC-P04 flow run should show streaming output during agent execution") + func testStreamingOutputVisible() async throws { + try await withTestWorkspace(prefix: "flow-provider-streaming") { workspace in + let codexPath = try workspace.makeFakeCodex() + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "complete" + ]) { + let flow = """ + version: flow/v1 + start: fix + states: + - id: fix + type: agent + task: stream + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let run = try runCLI(arguments: ["flow", "run", flowPath, "--no-steer"]) + #expect(run.exitCode == 0) + #expect(run.stdout.contains("agent delta")) + #expect(run.stdout.contains("command delta")) + } + } + } + + @Test("TC-P06 flow run --command /interrupt should return flow.agent.interrupted") + func testInterruptCommandProviderPath() async throws { + try await withTestWorkspace(prefix: "flow-provider-interrupt") { workspace in + let codexPath = try workspace.makeFakeCodex() + try await withEnvironment([ + "SCRIPTORIA_CODEX_EXECUTABLE": codexPath, + "SCRIPTORIA_FAKE_CODEX_MODE": "wait_for_command" + ]) { + let flow = """ + version: flow/v1 + start: fix + states: + - id: fix + type: agent + task: interrupt + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + let run = try runCLI( + arguments: ["flow", "run", flowPath, "--command", "/interrupt", "--no-steer"] + ) + #expect(run.exitCode != 0) + #expect(run.stdout.contains("flow.agent.interrupted")) + } + } + } +} diff --git a/Tests/ScriptoriaCoreTests/Flow/FlowTestSupport.swift b/Tests/ScriptoriaCoreTests/Flow/FlowTestSupport.swift new file mode 100644 index 0000000..8ac3c5f --- /dev/null +++ b/Tests/ScriptoriaCoreTests/Flow/FlowTestSupport.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing +@testable import ScriptoriaCore + +func writeFlowFile(workspace: TestWorkspace, name: String = "flow.yaml", content: String) throws -> String { + try workspace.makeFile(relativePath: name, content: content) +} + +func writeFixtureFile(workspace: TestWorkspace, name: String = "fixture.json", json: String) throws -> String { + try workspace.makeFile(relativePath: "fixtures/\(name)", content: json) +} + +func requireFlowError( + _ expectedCode: String, + _ operation: () async throws -> Void +) async { + do { + try await operation() + Issue.record("Expected FlowError with code \(expectedCode), but operation succeeded.") + } catch let error as FlowError { + #expect(error.code == expectedCode) + } catch { + Issue.record("Expected FlowError with code \(expectedCode), got \(error)") + } +} + +func requireFlowErrorSync( + _ expectedCode: String, + _ operation: () throws -> Void +) { + do { + try operation() + Issue.record("Expected FlowError with code \(expectedCode), but operation succeeded.") + } catch let error as FlowError { + #expect(error.code == expectedCode) + } catch { + Issue.record("Expected FlowError with code \(expectedCode), got \(error)") + } +} + +func minimalFlowYAML(runPath: String = "./scripts/check.sh") -> String { + """ + version: flow/v1 + start: precheck + states: + - id: precheck + type: gate + run: \(runPath) + on: + pass: done + needs_agent: fix + wait: wait1 + fail: done_fail + - id: wait1 + type: wait + seconds: 0 + next: precheck + - id: fix + type: agent + task: fix + next: done + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ +} diff --git a/Tests/ScriptoriaCoreTests/Flow/FlowYAMLAdditionalCoverageTests.swift b/Tests/ScriptoriaCoreTests/Flow/FlowYAMLAdditionalCoverageTests.swift new file mode 100644 index 0000000..d46ecc6 --- /dev/null +++ b/Tests/ScriptoriaCoreTests/Flow/FlowYAMLAdditionalCoverageTests.swift @@ -0,0 +1,381 @@ +import Foundation +import Testing +@testable import ScriptoriaCore + +@Suite("Flow YAML Additional Coverage", .serialized) +struct FlowYAMLAdditionalCoverageTests { + @Test("TC-Y02/TC-Y04 missing required top-level fields should fail") + func testMissingRequiredTopLevelFields() async throws { + try await withTestWorkspace(prefix: "flow-yaml-missing-top-fields") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + + let missingVersion = """ + start: precheck + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: done + wait: done + fail: done + - id: done + type: end + status: success + """ + let missingVersionPath = try writeFlowFile(workspace: workspace, name: "missing-version.yaml", content: missingVersion) + requireFlowErrorSync("flow.validate.schema_error") { + _ = try FlowValidator.validateFile(atPath: missingVersionPath) + } + + let missingStart = """ + version: flow/v1 + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: done + wait: done + fail: done + - id: done + type: end + status: success + """ + let missingStartPath = try writeFlowFile(workspace: workspace, name: "missing-start.yaml", content: missingStart) + requireFlowErrorSync("flow.validate.schema_error") { + _ = try FlowValidator.validateFile(atPath: missingStartPath) + } + } + } + + @Test("TC-Y06 invalid state type should fail") + func testInvalidStateType() async throws { + try await withTestWorkspace(prefix: "flow-yaml-invalid-type") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flow = """ + version: flow/v1 + start: precheck + states: + - id: precheck + type: checker + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + requireFlowErrorSync("flow.validate.schema_error") { + _ = try FlowValidator.validateFile(atPath: flowPath) + } + } + } + + @Test("TC-Y12 wait missing seconds and seconds_from should fail") + func testWaitMissingSecondsSource() async throws { + try await withTestWorkspace(prefix: "flow-yaml-wait-missing-seconds") { workspace in + let flow = """ + version: flow/v1 + start: hold + states: + - id: hold + type: wait + next: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + requireFlowErrorSync("flow.validate.schema_error") { + _ = try FlowValidator.validateFile(atPath: flowPath) + } + } + } + + @Test("TC-Y14 jump target missing should fail") + func testMissingTransitionTarget() async throws { + try await withTestWorkspace(prefix: "flow-yaml-missing-target") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flow = """ + version: flow/v1 + start: precheck + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: ghost + wait: done + fail: done + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + requireFlowErrorSync("flow.validate.schema_error") { + _ = try FlowValidator.validateFile(atPath: flowPath) + } + } + } + + @Test("TC-Y18 script missing run/next should fail") + func testScriptMissingRequiredFields() async throws { + try await withTestWorkspace(prefix: "flow-yaml-script-missing-fields") { workspace in + let missingRun = """ + version: flow/v1 + start: run + states: + - id: run + type: script + next: done + - id: done + type: end + status: success + """ + let missingRunPath = try writeFlowFile(workspace: workspace, name: "missing-run.yaml", content: missingRun) + requireFlowErrorSync("flow.validate.schema_error") { + _ = try FlowValidator.validateFile(atPath: missingRunPath) + } + + _ = try workspace.makeScript(name: "ok.sh", content: "#!/bin/sh\necho ok\n") + let missingNext = """ + version: flow/v1 + start: run + states: + - id: run + type: script + run: ./scripts/ok.sh + - id: done + type: end + status: success + """ + let missingNextPath = try writeFlowFile(workspace: workspace, name: "missing-next.yaml", content: missingNext) + requireFlowErrorSync("flow.validate.schema_error") { + _ = try FlowValidator.validateFile(atPath: missingNextPath) + } + } + } + + @Test("TC-Y20/TC-Y35 states schema damage should fail") + func testStatesSchemaDamage() async throws { + try await withTestWorkspace(prefix: "flow-yaml-states-schema") { workspace in + let statesMap = """ + version: flow/v1 + start: done + states: + done: + type: end + status: success + """ + let statesMapPath = try writeFlowFile(workspace: workspace, name: "states-map.yaml", content: statesMap) + requireFlowErrorSync("flow.validate.schema_error") { + _ = try FlowValidator.validateFile(atPath: statesMapPath) + } + + let statesScalar = """ + version: flow/v1 + start: done + states: nope + """ + let statesScalarPath = try writeFlowFile(workspace: workspace, name: "states-scalar.yaml", content: statesScalar) + requireFlowErrorSync("flow.validate.schema_error") { + _ = try FlowValidator.validateFile(atPath: statesScalarPath) + } + } + } + + @Test("TC-Y21 duplicate state id should fail") + func testDuplicateStateID() async throws { + try await withTestWorkspace(prefix: "flow-yaml-duplicate-id") { workspace in + let flow = """ + version: flow/v1 + start: done + states: + - id: done + type: end + status: success + - id: done + type: end + status: success + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + requireFlowErrorSync("flow.validate.schema_error") { + _ = try FlowValidator.validateFile(atPath: flowPath) + } + } + } + + @Test("TC-Y24/TC-Y25/TC-Y26/TC-Y27/TC-Y28/TC-Y29/TC-Y30 numeric constraints should fail") + func testNumericConstraintFailures() async throws { + try await withTestWorkspace(prefix: "flow-yaml-numeric-constraints") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + + let cases: [(String, String)] = [ + ("defaults-max-wait-cycles", """ + version: flow/v1 + start: precheck + defaults: + max_wait_cycles: 0 + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: done + wait: done + fail: done + - id: done + type: end + status: success + """), + ("defaults-max-total-steps", """ + version: flow/v1 + start: precheck + defaults: + max_total_steps: 0 + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: done + wait: done + fail: done + - id: done + type: end + status: success + """), + ("defaults-step-timeout", """ + version: flow/v1 + start: precheck + defaults: + step_timeout_sec: 0 + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: done + wait: done + fail: done + - id: done + type: end + status: success + """), + ("agent-max-rounds", """ + version: flow/v1 + start: fix + states: + - id: fix + type: agent + task: fix + max_rounds: 0 + next: done + - id: done + type: end + status: success + """), + ("state-timeout", """ + version: flow/v1 + start: hold + states: + - id: hold + type: wait + seconds: 1 + timeout_sec: 0 + next: done + - id: done + type: end + status: success + """), + ("wait-seconds-negative", """ + version: flow/v1 + start: hold + states: + - id: hold + type: wait + seconds: -1 + next: done + - id: done + type: end + status: success + """), + ("wait-seconds-non-integer", """ + version: flow/v1 + start: hold + states: + - id: hold + type: wait + seconds: 1.5 + next: done + - id: done + type: end + status: success + """), + ] + + for (name, content) in cases { + let flowPath = try writeFlowFile(workspace: workspace, name: "\(name).yaml", content: content) + requireFlowErrorSync("flow.validate.numeric_range_error") { + _ = try FlowValidator.validateFile(atPath: flowPath) + } + } + } + } + + @Test("TC-Y32/TC-Y33 args-env null-array-object literal should fail") + func testArgsEnvComplexLiteralRejection() async throws { + try await withTestWorkspace(prefix: "flow-yaml-args-env-complex") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + _ = try workspace.makeScript(name: "run.sh", content: "#!/bin/sh\necho ok\n") + + let gateEnvObject = """ + version: flow/v1 + start: precheck + states: + - id: precheck + type: gate + run: ./scripts/check.sh + env: + BAD: + x: 1 + on: + pass: done + needs_agent: done + wait: done + fail: done + - id: done + type: end + status: success + """ + let gatePath = try writeFlowFile(workspace: workspace, name: "gate-env-object.yaml", content: gateEnvObject) + requireFlowErrorSync("flow.validate.field_type_error") { + _ = try FlowValidator.validateFile(atPath: gatePath) + } + + let scriptArgsArray = """ + version: flow/v1 + start: run + states: + - id: run + type: script + run: ./scripts/run.sh + args: + - [1, 2] + next: done + - id: done + type: end + status: success + """ + let scriptPath = try writeFlowFile(workspace: workspace, name: "script-args-array.yaml", content: scriptArgsArray) + requireFlowErrorSync("flow.validate.field_type_error") { + _ = try FlowValidator.validateFile(atPath: scriptPath) + } + } + } +} diff --git a/Tests/ScriptoriaCoreTests/Flow/FlowYAMLValidationTests.swift b/Tests/ScriptoriaCoreTests/Flow/FlowYAMLValidationTests.swift new file mode 100644 index 0000000..b712fe3 --- /dev/null +++ b/Tests/ScriptoriaCoreTests/Flow/FlowYAMLValidationTests.swift @@ -0,0 +1,324 @@ +import Foundation +import Testing +@testable import ScriptoriaCore + +@Suite("Flow YAML Validation", .serialized) +struct FlowYAMLValidationTests { + @Test("TC-Y01 minimal legal flow should pass") + func testMinimalValidFlow() async throws { + try await withTestWorkspace(prefix: "flow-yaml-valid") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flowPath = try writeFlowFile(workspace: workspace, content: minimalFlowYAML()) + let definition = try FlowValidator.validateFile(atPath: flowPath) + #expect(definition.version == "flow/v1") + #expect(definition.start == "precheck") + #expect(definition.states.count == 5) + } + } + + @Test("TC-Y03 invalid version should fail") + func testInvalidVersion() async throws { + try await withTestWorkspace(prefix: "flow-yaml-version") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flow = minimalFlowYAML().replacingOccurrences(of: "flow/v1", with: "flow/v2") + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + requireFlowErrorSync("flow.validate.schema_error") { + _ = try FlowValidator.validateFile(atPath: flowPath) + } + } + } + + @Test("TC-Y05 start points to unknown state") + func testUnknownStartState() async throws { + try await withTestWorkspace(prefix: "flow-yaml-start") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flow = minimalFlowYAML().replacingOccurrences(of: "start: precheck", with: "start: ghost") + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + requireFlowErrorSync("flow.validate.schema_error") { + _ = try FlowValidator.validateFile(atPath: flowPath) + } + } + } + + @Test("TC-Y07/TC-Y08/TC-Y09/TC-Y10 gate.on missing branch should fail") + func testGateMissingTransition() async throws { + try await withTestWorkspace(prefix: "flow-yaml-gate-on") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flow = """ + version: flow/v1 + start: precheck + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + needs_agent: fix + wait: wait1 + fail: done_fail + - id: wait1 + type: wait + seconds: 0 + next: precheck + - id: fix + type: agent + task: fix + next: done + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + requireFlowErrorSync("flow.validate.schema_error") { + _ = try FlowValidator.validateFile(atPath: flowPath) + } + } + } + + @Test("TC-Y11 wait cannot define both seconds and seconds_from") + func testWaitBothSecondsAndSecondsFrom() async throws { + try await withTestWorkspace(prefix: "flow-yaml-wait-both") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flow = """ + version: flow/v1 + start: precheck + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: fix + wait: wait1 + fail: done_fail + - id: wait1 + type: wait + seconds: 0 + seconds_from: "$.context.retry" + next: precheck + - id: fix + type: agent + task: fix + next: done + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + requireFlowErrorSync("flow.validate.schema_error") { + _ = try FlowValidator.validateFile(atPath: flowPath) + } + } + } + + @Test("TC-Y13 invalid end status should fail") + func testInvalidEndStatus() async throws { + try await withTestWorkspace(prefix: "flow-yaml-end-status") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flow = minimalFlowYAML().replacingOccurrences(of: "status: success", with: "status: done") + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + requireFlowErrorSync("flow.validate.schema_error") { + _ = try FlowValidator.validateFile(atPath: flowPath) + } + } + } + + @Test("TC-Y15 unreachable state should fail") + func testUnreachableState() async throws { + try await withTestWorkspace(prefix: "flow-yaml-unreachable") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flow = minimalFlowYAML() + "\n - id: orphan\n type: end\n status: success\n" + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + requireFlowErrorSync("flow.validate.unreachable_state") { + _ = try FlowValidator.validateFile(atPath: flowPath) + } + } + } + + @Test("TC-Y17 fail_on_parse_error=false requires gate.on.parse_error") + func testParseErrorBranchRequiredWhenDisabled() async throws { + try await withTestWorkspace(prefix: "flow-yaml-parse-toggle") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flow = """ + version: flow/v1 + start: precheck + defaults: + fail_on_parse_error: false + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: fix + wait: wait1 + fail: done_fail + - id: wait1 + type: wait + seconds: 0 + next: precheck + - id: fix + type: agent + task: fix + next: done + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + requireFlowErrorSync("flow.validate.schema_error") { + _ = try FlowValidator.validateFile(atPath: flowPath) + } + } + } + + @Test("TC-Y19 run command token should fail path-kind check") + func testRunBareTokenIsRejected() async throws { + try await withTestWorkspace(prefix: "flow-yaml-path-kind") { workspace in + let flow = minimalFlowYAML(runPath: "eslint") + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + requireFlowErrorSync("flow.path.invalid_path_kind") { + _ = try FlowValidator.validateFile(atPath: flowPath, options: .init(checkFileSystem: false)) + } + } + } + + @Test("TC-Y22 invalid gate parse mode") + func testInvalidGateParseMode() async throws { + try await withTestWorkspace(prefix: "flow-yaml-parse-mode") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flow = """ + version: flow/v1 + start: precheck + states: + - id: precheck + type: gate + run: ./scripts/check.sh + parse: yaml + on: + pass: done + needs_agent: fix + wait: wait1 + fail: done_fail + - id: wait1 + type: wait + seconds: 0 + next: precheck + - id: fix + type: agent + task: fix + next: done + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + requireFlowErrorSync("flow.gate.parse_mode_invalid") { + _ = try FlowValidator.validateFile(atPath: flowPath) + } + } + } + + @Test("TC-Y23 unknown field should fail") + func testUnknownField() async throws { + try await withTestWorkspace(prefix: "flow-yaml-unknown") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flow = minimalFlowYAML().replacingOccurrences(of: "start: precheck", with: "start: precheck\nbogus: true") + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + requireFlowErrorSync("flow.validate.unknown_field") { + _ = try FlowValidator.validateFile(atPath: flowPath) + } + } + } + + @Test("TC-Y31 gate args disallow null/array/object literal") + func testArgsLiteralTypeValidation() async throws { + try await withTestWorkspace(prefix: "flow-yaml-args-type") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flow = """ + version: flow/v1 + start: precheck + states: + - id: precheck + type: gate + run: ./scripts/check.sh + args: + - null + on: + pass: done + needs_agent: fix + wait: wait1 + fail: done_fail + - id: wait1 + type: wait + seconds: 0 + next: precheck + - id: fix + type: agent + task: fix + next: done + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + requireFlowErrorSync("flow.validate.field_type_error") { + _ = try FlowValidator.validateFile(atPath: flowPath) + } + } + } + + @Test("TC-Y16/TC-Y34 numeric range validation") + func testNumericRangeValidation() async throws { + try await withTestWorkspace(prefix: "flow-yaml-range") { workspace in + _ = try workspace.makeScript(name: "check.sh", content: "#!/bin/sh\necho ok\n") + let flow = """ + version: flow/v1 + start: precheck + defaults: + max_agent_rounds: 0 + states: + - id: precheck + type: gate + run: ./scripts/check.sh + on: + pass: done + needs_agent: fix + wait: wait1 + fail: done_fail + - id: wait1 + type: wait + seconds: 0 + next: precheck + - id: fix + type: agent + task: fix + next: done + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure + """ + let flowPath = try writeFlowFile(workspace: workspace, content: flow) + requireFlowErrorSync("flow.validate.numeric_range_error") { + _ = try FlowValidator.validateFile(atPath: flowPath) + } + } + } +} diff --git a/Tests/ScriptoriaCoreTests/Flow/GateOutputParserTests.swift b/Tests/ScriptoriaCoreTests/Flow/GateOutputParserTests.swift new file mode 100644 index 0000000..3312596 --- /dev/null +++ b/Tests/ScriptoriaCoreTests/Flow/GateOutputParserTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import ScriptoriaCore + +@Suite("Gate Output Parser") +struct GateOutputParserTests { + @Test("TC-GP01/TC-GP02/TC-GP03/TC-GP04 parse all legal decisions") + func testParseDecisions() throws { + let pass = try FlowGateOutputParser.parse( + stdout: "log\n{\"decision\":\"pass\",\"reason\":\"ok\"}\n", + mode: .jsonLastLine + ) + #expect(pass.decision == .pass) + + let needsAgent = try FlowGateOutputParser.parse( + stdout: "{\"decision\":\"needs_agent\"}\n", + mode: .jsonLastLine + ) + #expect(needsAgent.decision == .needsAgent) + + let wait = try FlowGateOutputParser.parse( + stdout: "{\"decision\":\"wait\",\"retry_after_sec\":3}\n", + mode: .jsonLastLine + ) + #expect(wait.decision == .wait) + #expect(wait.retryAfterSec == 3) + + let fail = try FlowGateOutputParser.parse( + stdout: "{\"decision\":\"fail\"}\n", + mode: .jsonLastLine + ) + #expect(fail.decision == .fail) + } + + @Test("TC-GP05/TC-GP06/TC-GP07/TC-GP08 invalid gate output should throw parse_error") + func testInvalidGateOutput() { + requireFlowErrorSync("flow.gate.parse_error") { + _ = try FlowGateOutputParser.parse(stdout: "not-json\n", mode: .jsonLastLine) + } + + requireFlowErrorSync("flow.gate.parse_error") { + _ = try FlowGateOutputParser.parse(stdout: "{}\n", mode: .jsonLastLine) + } + + requireFlowErrorSync("flow.gate.parse_error") { + _ = try FlowGateOutputParser.parse(stdout: "{\"decision\":\"unknown\"}\n", mode: .jsonLastLine) + } + + requireFlowErrorSync("flow.gate.parse_error") { + _ = try FlowGateOutputParser.parse( + stdout: "{\"decision\":\"wait\",\"retry_after_sec\":\"abc\"}\n", + mode: .jsonLastLine + ) + } + } + + @Test("TC-GP12/TC-GP13 parse json_full_stdout mode") + func testJsonFullStdoutMode() { + do { + let parsed = try FlowGateOutputParser.parse( + stdout: "{\"decision\":\"pass\",\"meta\":{\"a\":1}}", + mode: .jsonFullStdout + ) + #expect(parsed.decision == .pass) + } catch { + Issue.record("Expected valid json_full_stdout parse, got \(error)") + } + + requireFlowErrorSync("flow.gate.parse_error") { + _ = try FlowGateOutputParser.parse( + stdout: "prefix\n{\"decision\":\"pass\"}", + mode: .jsonFullStdout + ) + } + } +} diff --git a/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift b/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift index b17f8e3..7dbb1b4 100644 --- a/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift +++ b/Tests/ScriptoriaCoreTests/ScriptoriaCLITests.swift @@ -132,7 +132,7 @@ struct ScriptoriaCLITests { } } - @Test("run command success with skip-agent") + @Test("TC-R01 run command success with skip-agent") func testRunCommandSuccess() async throws { try await withTestWorkspace(prefix: "scriptoria-cli-run-success") { workspace in let scriptPath = try workspace.makeScript(name: "ok.sh", content: "#!/bin/sh\necho run-ok\n") @@ -202,7 +202,7 @@ struct ScriptoriaCLITests { } } - @Test("run command agent stage + memory") + @Test("TC-P07 run command agent stage + memory") func testRunCommandAgentStage() async throws { try await withTestWorkspace(prefix: "scriptoria-cli-agent") { workspace in let codexPath = try workspace.makeFakeCodex() @@ -243,7 +243,7 @@ struct ScriptoriaCLITests { } } - @Test("run command skips agent when pre-script output is false") + @Test("TC-R02 run command skips agent when pre-script output is false") func testRunCommandAgentTriggerSkip() async throws { try await withTestWorkspace(prefix: "scriptoria-cli-agent-skip") { workspace in let scriptPath = try workspace.makeScript(name: "gate-false.sh", content: "#!/bin/sh\necho false\n") @@ -267,7 +267,7 @@ struct ScriptoriaCLITests { } } - @Test("run command starts agent when pre-script output is true") + @Test("TC-R02 run command starts agent when pre-script output is true") func testRunCommandAgentTriggerRun() async throws { try await withTestWorkspace(prefix: "scriptoria-cli-agent-run") { workspace in let codexPath = try workspace.makeFakeCodex() @@ -465,7 +465,7 @@ struct ScriptoriaCLITests { } } - @Test("schedule command family") + @Test("TC-R03 schedule command family") func testScheduleCommands() async throws { try await withTestWorkspace(prefix: "scriptoria-cli-schedule") { workspace in let scriptPath = try workspace.makeScript(name: "schedule.sh", content: "#!/bin/sh\necho schedule\n") @@ -510,7 +510,7 @@ struct ScriptoriaCLITests { } } - @Test("ps/logs/kill commands") + @Test("TC-R03 ps/logs/kill commands") func testPsLogsKillCommands() async throws { try await withTestWorkspace(prefix: "scriptoria-cli-process") { workspace in let scriptPath = try workspace.makeScript(name: "process.sh", content: "#!/bin/sh\necho process\n") diff --git a/Tests/ScriptoriaCoreTests/TestSupport.swift b/Tests/ScriptoriaCoreTests/TestSupport.swift index 5bb25c1..3377c3c 100644 --- a/Tests/ScriptoriaCoreTests/TestSupport.swift +++ b/Tests/ScriptoriaCoreTests/TestSupport.swift @@ -95,6 +95,8 @@ struct TestWorkspace { thread_id = os.environ.get("SCRIPTORIA_FAKE_CODEX_THREAD_ID", "thread-test") turn_id = os.environ.get("SCRIPTORIA_FAKE_CODEX_TURN_ID", "turn-test") pid_file = os.environ.get("SCRIPTORIA_FAKE_CODEX_PID_FILE") + turn_open = False + command_accepted = False if pid_file: try: @@ -131,16 +133,23 @@ struct TestWorkspace { elif method == "turn/start": send({"jsonrpc": "2.0", "id": req_id, "result": {"turn": {"id": turn_id}}}) send({"jsonrpc": "2.0", "method": "turn/started", "params": {"turn": {"id": turn_id}}}) + turn_open = True + command_accepted = False if mode == "complete": send({"jsonrpc": "2.0", "method": "item/agentMessage/delta", "params": {"itemId": "agent-1", "delta": "agent delta\n"}}) send({"jsonrpc": "2.0", "method": "item/commandExecution/outputDelta", "params": {"itemId": "cmd-1", "delta": "command delta\n"}}) send({"jsonrpc": "2.0", "method": "item/completed", "params": {"item": {"type": "agentMessage", "phase": "final_answer", "text": "final answer"}}}) send({"jsonrpc": "2.0", "method": "turn/completed", "params": {"turn": {"id": turn_id, "status": "completed"}}}) + turn_open = False elif mode == "interrupt_on_start": send({"jsonrpc": "2.0", "method": "turn/completed", "params": {"turn": {"id": turn_id, "status": "interrupted"}}}) + turn_open = False elif mode == "exit_after_turn_start": sys.exit(3) elif method == "turn/steer": + if mode == "wait_for_command_single_accept_json" and (not turn_open or command_accepted): + send({"jsonrpc": "2.0", "id": req_id, "error": {"code": -32000, "message": "turn not active"}}) + continue steer_text = "" try: steer_text = request["params"]["input"][0]["text"] @@ -148,11 +157,20 @@ struct TestWorkspace { pass send({"jsonrpc": "2.0", "id": req_id, "result": {}}) send({"jsonrpc": "2.0", "method": "item/agentMessage/delta", "params": {"itemId": "agent-1", "delta": f"steer:{steer_text}\n"}}) - send({"jsonrpc": "2.0", "method": "item/completed", "params": {"item": {"type": "agentMessage", "phase": "final_answer", "text": "steer done"}}}) + final_text = "steer done" + if mode in ("wait_for_command_json", "wait_for_command_single_accept_json"): + final_text = json.dumps({"received": steer_text}) + send({"jsonrpc": "2.0", "method": "item/completed", "params": {"item": {"type": "agentMessage", "phase": "final_answer", "text": final_text}}}) send({"jsonrpc": "2.0", "method": "turn/completed", "params": {"turn": {"id": turn_id, "status": "completed"}}}) + command_accepted = True + turn_open = False elif method == "turn/interrupt": + if mode == "wait_for_command_single_accept_json" and not turn_open: + send({"jsonrpc": "2.0", "id": req_id, "error": {"code": -32000, "message": "turn not active"}}) + continue send({"jsonrpc": "2.0", "id": req_id, "result": {}}) send({"jsonrpc": "2.0", "method": "turn/completed", "params": {"turn": {"id": turn_id, "status": "interrupted"}}}) + turn_open = False else: send({"jsonrpc": "2.0", "id": req_id, "result": {}}) """# @@ -288,6 +306,7 @@ func runCLI( arguments: [String], extraEnvironment: [String: String?] = [:], cwd: String? = nil, + stdin: String? = nil, timeout: TimeInterval = 15 ) throws -> CLIResult { let process = Process() @@ -328,8 +347,18 @@ func runCLI( let stderrPipe = Pipe() process.standardOutput = stdoutPipe process.standardError = stderrPipe + var stdinPipe: Pipe? + if stdin != nil { + let pipe = Pipe() + process.standardInput = pipe + stdinPipe = pipe + } try process.run() + if let stdin, let data = stdin.data(using: .utf8) { + stdinPipe?.fileHandleForWriting.write(data) + stdinPipe?.fileHandleForWriting.closeFile() + } let deadline = Date().addingTimeInterval(timeout) var timedOut = false while process.isRunning && Date() < deadline { diff --git a/docs/examples/flow-v1/README.md b/docs/examples/flow-v1/README.md new file mode 100644 index 0000000..9197b70 --- /dev/null +++ b/docs/examples/flow-v1/README.md @@ -0,0 +1,26 @@ +# Flow v1 Examples + +This folder provides runnable `flow/v1` examples. + +## 1) Local Gate + Script (no agent required) + +```bash +scriptoria flow validate ./docs/examples/flow-v1/local-gate-script/flow.yaml +scriptoria flow run ./docs/examples/flow-v1/local-gate-script/flow.yaml +``` + +## 2) PR Loop (gate -> agent -> gate loop) + +Dry-run (deterministic, no real provider required): + +```bash +scriptoria flow dry-run \ + ./docs/examples/flow-v1/pr-loop/flow.yaml \ + --fixture ./docs/examples/flow-v1/pr-loop/fixture.success.json +``` + +Live run (requires configured agent runtime/provider): + +```bash +scriptoria flow run ./docs/examples/flow-v1/pr-loop/flow.yaml --var repo=org/repo +``` diff --git a/docs/examples/flow-v1/local-gate-script/flow.yaml b/docs/examples/flow-v1/local-gate-script/flow.yaml new file mode 100644 index 0000000..c8a724e --- /dev/null +++ b/docs/examples/flow-v1/local-gate-script/flow.yaml @@ -0,0 +1,42 @@ +version: flow/v1 +start: precheck +defaults: + max_agent_rounds: 20 + max_wait_cycles: 50 + max_total_steps: 200 + step_timeout_sec: 60 + fail_on_parse_error: true +context: + summary: "" +states: + - id: precheck + type: gate + run: ./scripts/precheck.sh + parse: json_last_line + on: + pass: collect + needs_agent: done_fail + wait: hold + fail: done_fail + + - id: hold + type: wait + seconds: 0 + next: precheck + + - id: collect + type: script + run: ./scripts/collect-summary.sh + export: + summary: "$.current.final.summary" + next: done + + - id: done + type: end + status: success + message: "Local sample completed." + + - id: done_fail + type: end + status: failure + message: "Local sample failed." diff --git a/docs/examples/flow-v1/local-gate-script/scripts/collect-summary.sh b/docs/examples/flow-v1/local-gate-script/scripts/collect-summary.sh new file mode 100755 index 0000000..fcb67bf --- /dev/null +++ b/docs/examples/flow-v1/local-gate-script/scripts/collect-summary.sh @@ -0,0 +1,3 @@ +#!/bin/sh +echo "collecting summary" +echo '{"summary":"local gate-script sample completed"}' diff --git a/docs/examples/flow-v1/local-gate-script/scripts/precheck.sh b/docs/examples/flow-v1/local-gate-script/scripts/precheck.sh new file mode 100755 index 0000000..0c73364 --- /dev/null +++ b/docs/examples/flow-v1/local-gate-script/scripts/precheck.sh @@ -0,0 +1,3 @@ +#!/bin/sh +echo "local precheck" +echo '{"decision":"pass","reason":"sample gate pass"}' diff --git a/docs/examples/flow-v1/pr-loop/fixture.success.json b/docs/examples/flow-v1/pr-loop/fixture.success.json new file mode 100644 index 0000000..54f4589 --- /dev/null +++ b/docs/examples/flow-v1/pr-loop/fixture.success.json @@ -0,0 +1,16 @@ +{ + "states": { + "precheck": [ + {"decision": "needs_agent", "reason": "12 eslint issues found"} + ], + "fix": [ + {"status": "completed", "final": {"pr_url": "https://github.com/org/repo/pull/123", "summary": "round1 fix"}}, + {"status": "completed", "final": {"pr_url": "https://github.com/org/repo/pull/123", "summary": "round2 fix"}} + ], + "postcheck": [ + {"decision": "wait", "retry_after_sec": 1, "reason": "checks pending"}, + {"decision": "needs_agent", "reason": "request changes"}, + {"decision": "pass", "reason": "all green"} + ] + } +} diff --git a/docs/examples/flow-v1/pr-loop/flow.yaml b/docs/examples/flow-v1/pr-loop/flow.yaml new file mode 100644 index 0000000..e1e1882 --- /dev/null +++ b/docs/examples/flow-v1/pr-loop/flow.yaml @@ -0,0 +1,72 @@ +version: flow/v1 +start: precheck +defaults: + max_agent_rounds: 20 + max_wait_cycles: 200 + max_total_steps: 2000 + step_timeout_sec: 1800 + fail_on_parse_error: false +context: + pr_url: null + repo: "org/repo" +states: + - id: precheck + type: gate + run: ./scripts/check-eslint-issues.sh + parse: json_last_line + on: + pass: done + needs_agent: fix + wait: pre_wait + fail: done_fail + parse_error: done_fail + + - id: pre_wait + type: wait + seconds: 30 + next: precheck + + - id: fix + type: agent + task: fix-eslint-and-open-pr + model: gpt-5.3-codex + counter: fix_round + max_rounds: 20 + prompt: | + Fix eslint issues only. + If code changes are needed, commit and open/update pull request. + Return final structured JSON with at least: + {"pr_url":"...", "summary":"..."} + export: + pr_url: "$.current.final.pr_url" + fix_summary: "$.current.final.summary" + next: postcheck + + - id: postcheck + type: gate + run: ./scripts/check-pr-ci-review.sh + parse: json_last_line + args: + - "$.context.pr_url" + - "$.context.repo" + on: + pass: done + needs_agent: fix + wait: post_wait + fail: done_fail + parse_error: done_fail + + - id: post_wait + type: wait + seconds_from: "$.state.postcheck.last.retry_after_sec" + next: postcheck + + - id: done + type: end + status: success + message: "CI all green and no blocking code review comments." + + - id: done_fail + type: end + status: failure + message: "Business-level failure branch." diff --git a/docs/examples/flow-v1/pr-loop/scripts/check-eslint-issues.sh b/docs/examples/flow-v1/pr-loop/scripts/check-eslint-issues.sh new file mode 100755 index 0000000..07b1e34 --- /dev/null +++ b/docs/examples/flow-v1/pr-loop/scripts/check-eslint-issues.sh @@ -0,0 +1,3 @@ +#!/bin/sh +echo "sample precheck" +echo '{"decision":"needs_agent","reason":"sample: eslint issues found"}' diff --git a/docs/examples/flow-v1/pr-loop/scripts/check-pr-ci-review.sh b/docs/examples/flow-v1/pr-loop/scripts/check-pr-ci-review.sh new file mode 100755 index 0000000..2ab64a8 --- /dev/null +++ b/docs/examples/flow-v1/pr-loop/scripts/check-pr-ci-review.sh @@ -0,0 +1,10 @@ +#!/bin/sh +PR_URL="$1" +REPO="$2" + +if [ -z "$PR_URL" ]; then + echo '{"decision":"needs_agent","reason":"sample: missing pr url"}' + exit 0 +fi + +echo "{\"decision\":\"pass\",\"reason\":\"sample: checks green for ${REPO}\"}" diff --git a/docs/flow-dsl-architecture-plan.md b/docs/flow-dsl-architecture-plan.md new file mode 100644 index 0000000..42a69d8 --- /dev/null +++ b/docs/flow-dsl-architecture-plan.md @@ -0,0 +1,1363 @@ +# Scriptoria Flow DSL 设计与实施计划(门控 + Agent + 循环) + +## 0. 文档目的 + +本文件汇总并固化本次讨论的完整方案,目标是让 Scriptoria 支持复杂自动化流程: + +- 将脚本、agent、门控拆成可复用子单元 +- 支持条件分支与循环 +- 支持最大轮次(例如 20 轮)避免无限循环 +- 在开发前先定义完整测试用例,覆盖正常与异常路径 + +本方案面向 CLI 首期落地,后续可扩展到 macOS App 配置界面。 + +## 0.1 当前实现状态(截至 2026-03-10) + +以下内容是设计与实施计划,不代表当前仓库已经实现: + +- `scriptoria` CLI 还未注册 `flow` 子命令 +- `Sources/ScriptoriaCore` 还没有 `Flow` 运行时实现 +- `Tests/ScriptoriaCoreTests` 还没有 Flow 专项测试文件 + +本文件中的“验收标准”是目标态,不是当前态。 + +--- + +## 1. 现状与问题 + +### 1.1 当前能力(代码现状) + +当前核心链路是「一次脚本执行 + 一次 agent 触发判断 + 可选一次 agent 执行」,没有循环控制器: + +- 触发模式仅支持 `always` 和 `preScriptTrue` + - `Sources/ScriptoriaCore/Models/Script.swift` +- 触发判断器是一次性布尔决策 + - `Sources/ScriptoriaCore/Execution/AgentTriggerEvaluator.swift` +- CLI 在脚本成功后最多触发一次 agent + - `Sources/ScriptoriaCLI/Commands/RunCommand.swift` +- App 侧同样是一次性链路 + - `Sources/ScriptoriaApp/AppState.swift` + +### 1.2 目标流程需求(用户示例) + +目标流程可抽象为: + +1. 先跑脚本检查是否有 eslint issue。 +2. 若需要修复,唤醒 agent 修复并输出 PR 链接。 +3. 脚本检查 PR 的 CI 和 code review comment。 +4. 若全部通过则结束。 +5. 若未通过则再次唤醒 agent 修复,再回到脚本检查。 +6. 可循环,最大循环次数例如 20 轮。 +7. 20 轮内完成则成功,否则失败退出。 + +--- + +## 2. 核心设计决策 + +## 2.1 DSL 形式:YAML + JSON IR(推荐) + +采用双层结构: + +1. **YAML(人写)** +用于用户配置复杂流程,可读性高,适合代码审查与维护。 + +2. **JSON IR(机器执行)** +由编译器将 YAML 转换为规范化 IR,执行层只依赖 IR,便于校验、审计、回放与兼容升级。 + +不建议首期自研新的“文本语法 DSL”(如 mini-language),原因: + +- 解析器与错误定位成本高 +- 长期兼容和迁移复杂 +- 工具生态(lint/schema/editor support)弱于 YAML/JSON + +## 2.2 流程模型:有限状态机(FSM) + +流程统一表示为状态机,`v1` 必须支持以下状态类型: + +- `gate`:执行门控脚本并产出决策 +- `agent`:唤醒 agent 执行修复或操作 +- `wait`:等待一段时间后继续 +- `script`:执行普通脚本步骤(非门控语义) +- `end`:终止(success/failure) + +循环由“状态跳转 + 计数器”表达,不额外引入复杂循环语法。 + +## 2.3 门控决策统一协议 + +`gate` 决策统一为 4 种: + +- `pass`:通过,进入成功路径 +- `needs_agent`:需要 agent 处理 +- `wait`:暂时不可判定,等待后重试 +- `fail`:业务失败决策,不直接硬退出;必须按 `on.fail` 分支跳转(通常到 `end(status=failure)`) + +补充: + +- `gate` 的 `fail` 是业务分支,不是运行时硬失败 +- 运行时硬失败(超时、进程退出码异常、上限超限等)由引擎直接 non-zero 结束 + +--- + +## 3. 顶层架构 + +## 3.1 组件分层 + +1. **Flow DSL 层** +- `FlowYAMLDefinition` +- `FlowValidator`(语法 + 语义校验) + +2. **Flow Compiler** +- `FlowCompiler`:YAML -> JSON IR +- 负责默认值填充、表达式标准化、错误定位 + +3. **Flow Runtime** +- `FlowEngine`:执行状态机 +- `GateStepRunner`:执行门控脚本并解析输出 +- `AgentStepRunner`:复用 `PostScriptAgentRunner` +- `ScriptStepRunner`:复用 `ScriptRunner` +- `FlowExecutionContext`:保存变量、计数器、上一步结果 + +4. **CLI 接入** +- `scriptoria flow validate` +- `scriptoria flow compile` +- `scriptoria flow run` +- `scriptoria flow dry-run` + +## 3.2 与现有模块关系 + +- 复用脚本执行器:`ScriptRunner` +- 复用 agent 执行器:`PostScriptAgentRunner` +- 新增流程引擎位于 `ScriptoriaCore` +- CLI 子命令位于 `ScriptoriaCLI/Commands` +- 现有 `scriptoria run` 保持兼容,不强制走 flow + +## 3.3 执行器 API 对接前置(v1 必做) + +为使 DSL 字段可落地,`v1` 在实现 Flow 之前必须补齐执行器适配能力: + +1. `ScriptRunner` 侧(供 `GateStepRunner` / `ScriptStepRunner` 使用) +需要支持并暴露: + +- `args`(额外脚本参数) +- `env`(额外环境变量,覆盖同名键) +- `timeout_sec`(超时后终止进程并返回统一错误) +- `workingDirectory`(由流程引擎显式传入,`v1` 固定为“解析后脚本路径的父目录”) + +2. `PostScriptAgentRunner` 侧(供 `AgentStepRunner` 使用) +需要支持并暴露: + +- `timeout_sec`(超时后触发 interrupt;`v1` 固定等待 `10s` grace 窗口后仍未结束则强制失败) +- 统一的中断/超时错误映射(对齐 `flow.agent.interrupted` / `flow.step.timeout`) + +3. `Flow` 适配层职责 + +- `GateStepRunner` / `ScriptStepRunner` 负责将 DSL 的 `run/args/env/timeout_sec/interpreter` 映射到 `ScriptRunner` 输入 +- `AgentStepRunner` 负责将 DSL 的 `model/prompt/timeout_sec` 映射到 `PostScriptAgentRunner` +- `GateStepRunner` / `ScriptStepRunner` 对每次执行均先完成 `run` 路径解析,再将 `workingDirectory` 固定设为该解析后脚本的父目录(与现有 `ScriptRunner` 语义对齐) + +说明:如果不先补齐以上 API,DSL 中的 `args/env/timeout_sec` 只能停留在文档层,无法稳定实现。 + +--- + +## 4. YAML DSL v1 规范 + +## 4.1 顶层字段 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `version` | string | 是 | 固定 `flow/v1` | +| `start` | string | 是 | 起始状态 ID | +| `defaults` | object | 否 | 全局默认策略 | +| `context` | object | 否 | 初始上下文变量 | +| `states` | array | 是 | 有序状态定义列表,元素必须包含唯一 `id` | + +补充: + +- `v1` 不接受 `states` 为 map 的写法(避免顺序语义歧义) +- `states` 中 `id` 必须唯一 + +## 4.2 defaults 建议字段 + +| 字段 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `max_agent_rounds` | int | 20 | agent 最大轮次 | +| `max_wait_cycles` | int | 200 | 全局 wait 进入次数上限(跨所有 wait 状态累计) | +| `max_total_steps` | int | 2000 | 全局状态步数上限(防任意形态死循环) | +| `step_timeout_sec` | int | 1800 | 单步超时(可覆盖) | +| `fail_on_parse_error` | bool | true | gate 输出解析失败是否立即失败 | + +数值约束(`v1` 固定): + +- `max_agent_rounds >= 1` +- `max_wait_cycles >= 1` +- `max_total_steps >= 1` +- `step_timeout_sec >= 1` + +## 4.3 状态类型定义 + +### A. `gate` + +必填: + +- `type: gate` +- `run`(脚本文件路径,必须可由 `ScriptRunner` 执行) +- `on`(必须覆盖 `pass/needs_agent/wait/fail`;其中 `fail` 为业务分支) + +可选: + +- `args`(可包含表达式;字面量允许 `string|number|bool`:`number/bool` 在 `compile` 期按 JSON 标量文本转为字符串,`null/array/object` 直接报 `flow.validate.field_type_error`) +- `env`(同 `args` 规则) +- `interpreter`(`auto|bash|zsh|sh|node|python3|ruby|osascript|binary`,默认 `auto`) +- `timeout_sec` +- `parse`(枚举,`v1` 支持:`json_last_line|json_full_stdout`,默认 `json_last_line`) +- `on.parse_error`(当 `fail_on_parse_error=false` 时必填) + +数值约束: + +- `timeout_sec`(若配置)必须为整数且 `>= 1` + +### B. `agent` + +必填: + +- `type: agent` +- `task` +- `next` + +可选: + +- `model` +- `counter`(计数器名,默认 `agent_round.`) +- `max_rounds`(默认取 `defaults.max_agent_rounds`) +- `prompt`(附加提示) +- `export`(从 agent 输出提取变量到 context) +- `timeout_sec` + +数值约束: + +- `max_rounds`(若配置)必须为整数且 `>= 1` +- `timeout_sec`(若配置)必须为整数且 `>= 1` + +### C. `wait` + +必填: + +- `type: wait` +- `next` + +以下二选一: + +- `seconds` +- `seconds_from`(表达式,从 context 读取) + +可选: + +- `timeout_sec`(覆盖 `defaults.step_timeout_sec`) + +数值约束: + +- `seconds`(若配置)必须为整数且 `>= 0` +- `timeout_sec`(若配置)必须为整数且 `>= 1` + +### D. `script` + +必填: + +- `type: script` +- `run`(脚本文件路径,必须可由 `ScriptRunner` 执行) +- `next` + +可选: + +- `args`(可包含表达式;字面量允许 `string|number|bool`:`number/bool` 在 `compile` 期按 JSON 标量文本转为字符串,`null/array/object` 直接报 `flow.validate.field_type_error`) +- `env`(同 `args` 规则) +- `interpreter`(同 `gate`) +- `timeout_sec` +- `export`(从脚本输出提取变量;需满足 6.3 的结构化输出契约) + +数值约束: + +- `timeout_sec`(若配置)必须为整数且 `>= 1` + +### E. `end` + +必填: + +- `type: end` +- `status`(`success` 或 `failure`) + +可选: + +- `message` + +## 4.4 表达式约定(v1) + +统一采用简化 JSONPath 风格字符串(仅取值,不做复杂计算),并固定作用域: + +- `$.context.*`:全局上下文(可读写) +- `$.counters.*`:计数器(只读) +- `$.state..last.*`:指定状态最近一次输出(只读) +- `$.prev.*`:上一个已完成状态的输出(只读) +- `$.current.*`:当前状态输出(仅在当前状态 `export` 求值时可用;`v1` 标准形状见 6.4) + +示例: + +- `$.context.pr_url` +- `$.state.postcheck.last.retry_after_sec` +- `$.current.final.pr_url` +- `$.state.lint_script.last.stdout_last_line` + +表达式求值失败规则: + +- 若字段是必需值,状态执行失败 +- 若字段是可选值,置空并记录警告 + +`v1` 具体字段必需性见 4.8;未在 4.8 列出的字段不允许使用表达式。 + +## 4.5 gate 解析失败语义(闭环定义) + +`fail_on_parse_error` 的行为在 `v1` 固定如下: + +1. `fail_on_parse_error=true`(默认) +gate 输出不可解析时直接失败终止,错误码 `flow.gate.parse_error`。 + +2. `fail_on_parse_error=false` +gate 输出不可解析时不直接失败,转移到 `on.parse_error`。 + +3. 校验约束 +当 `fail_on_parse_error=false` 时,所有 `gate` 状态必须显式定义 `on.parse_error`,否则 `validate` 失败。 + +4. 不受此开关影响的失败 +`gate` 脚本进程退出码非 0 仍然是直接失败,不进入 `on.parse_error`。 + +## 4.6 counter 与轮次语义(闭环定义) + +`agent` 计数器在 `v1` 固定如下: + +1. 存储位置 +计数器存储在 `$.counters.`。 + +2. 默认命名 +若未显式配置 `counter`,默认名称为 `agent_round.`(每个 agent 状态独立)。 + +3. 同名共享 +多个 agent 状态若显式使用同一 `counter` 名称,则共享同一全局计数器。 + +4. 初始化与重置 +所有计数器在 flow run 开始时初始化为 `0`,运行过程中不自动重置。 + +5. 计数时机 +进入 agent 状态时先计算 `next = current + 1`;若 `next > effective_max_rounds`,直接失败,不执行 agent。 + +6. `effective_max_rounds` 计算 +`effective_max_rounds = min(state.max_rounds, defaults.max_agent_rounds, cli_cap_if_present)`。 + +7. 日志口径(`counter.value`) +运行日志中的 `counter.value` 固定记录“本次 agent 状态生效值”,即第 5 条中的 `next`(增量后值),不是增量前值。 + +## 4.7 parse 枚举与非法值行为(闭环定义) + +`gate.parse` 在 `v1` 只允许以下值: + +- `json_last_line`:解析 stdout 最后一个非空行 JSON +- `json_full_stdout`:解析完整 stdout 为 JSON + +非法值行为: + +- `validate/compile` 阶段直接报错,错误码 `flow.gate.parse_mode_invalid` + +## 4.8 表达式字段必需性矩阵(v1 固定) + +| 状态类型 | 字段 | 是否允许表达式 | 是否必需成功解析 | 失败行为 | +|---|---|---|---|---| +| `gate` | `args[*]` | 是 | 是 | 状态失败(解析失败 `flow.expr.resolve_error`;类型不匹配 `flow.expr.type_error`) | +| `gate` | `env.` | 是 | 是 | 状态失败(解析失败 `flow.expr.resolve_error`;类型不匹配 `flow.expr.type_error`) | +| `wait` | `seconds_from` | 是 | 是 | 状态失败(`flow.wait.seconds_resolve_error`) | +| `script` | `args[*]` | 是 | 是 | 状态失败(解析失败 `flow.expr.resolve_error`;类型不匹配 `flow.expr.type_error`) | +| `script` | `env.` | 是 | 是 | 状态失败(解析失败 `flow.expr.resolve_error`;类型不匹配 `flow.expr.type_error`) | +| `script` | `export.` | 是 | 是 | 状态失败(`flow.script.export_field_missing`) | +| `agent` | `export.` | 是 | 是 | 状态失败(`flow.agent.export_field_missing`) | + +补充: + +- `v1` 没有“可选表达式字段”;因此“可选值置空告警”规则在 `v1` 不触发,仅为未来版本保留。 +- `seconds_from` 解析后必须是整数秒,且 `>= 0`。否则状态失败。 +- `agent.export` / `script.export` 在表达式求值前,先执行结构化输出解析;解析失败分别返回 `flow.agent.output_parse_error` / `flow.script.output_parse_error`。 +- `export` 字段“缺失”与“值为 null”语义不同:缺失时报错;值为 `null` 视为合法值并写入 context。 + +## 4.9 路径解析与存在性规则(v1 固定) + +适用于 `gate.run` 与 `script.run`: + +1. 绝对路径(以 `/` 开头) +直接使用。 + +2. `~` 路径 +先展开为用户 Home 目录,再使用。 + +3. 相对路径(如 `./scripts/a.sh`、`../tools/b.sh`) +统一相对于 **flow YAML 文件所在目录** 解析,不相对于当前 shell cwd。 + +4. 路径字面量判定(消除“路径 vs 命令名”歧义) +`v1` 中,`run` 仅在满足以下任一条件时才被视为“路径字面量”: + +- 以 `/` 开头(绝对路径) +- 以 `~/` 开头(home 路径) +- 以 `./` 或 `../` 开头(显式相对路径) +- 包含 `/`(如 `scripts/check.sh`) + +其他形式均视为“命令名/裸 token”并报 `flow.path.invalid_path_kind`。 +示例:`eslint`、`check.sh`(无 `/`)在 `v1` 都是非法写法;应写成 `./check.sh` 或 `scripts/check.sh`。 + +该规则属于 `v1` 迁移期的显式行为变更;发布文档与迁移指引必须单列说明,避免被误判为回归。 + +5. 一致性要求 +`validate/compile/run` 必须使用同一解析规则,禁止因入口不同而产生路径语义差异。 + +6. 存在性检查 +`validate` 与 `compile` 默认检查解析后的 `run` 文件存在且可读取;可通过 CLI `--no-fs-check` 跳过该检查(用于离线编译或跨机器校验)。 +`run` 阶段始终再次检查,若缺失或不可读则以 `flow.path.not_found` 失败。 + +7. 执行工作目录(`workingDirectory`) +`gate/script` 状态实际执行时,`workingDirectory` 固定为“解析后脚本路径的父目录”,不使用调用命令时的 shell cwd,也不直接使用 flow 文件目录。 +该规则用于保持与现有 `ScriptRunner` 运行语义一致,避免脚本内相对路径行为漂移。 + +## 4.10 `--var` 与表达式类型规则(v1 固定) + +1. `--var` 注入类型 +`--var key=value` 在 `v1` 一律注入为字符串;不做 JSON 自动解析。 + +键名规则(`v1` 固定): + +- `key` 必须匹配正则 `^[A-Za-z_][A-Za-z0-9_]*$` +- 不支持点号/括号等嵌套语法;例如 `--var a.b=1` 直接报错(`flow.cli.var_key_invalid`) +- 同一命令行中重复传入同名 `key` 时,按出现顺序“后者覆盖前者”(last wins) + +2. `args[*]` / `env.` 字面量类型(非表达式) +在 YAML 中直接写字面量时,先按 YAML 解析类型,再应用下列规则(是否加引号会影响结果类型): + +- `string`:原样使用 +- `number`/`bool`:按 JSON 标量文本转换为字符串(示例:`true -> "true"`、`42 -> "42"`) +- `null`/`array`/`object`:`validate/compile` 失败,错误码 `flow.validate.field_type_error` +- 补充:`"42"`、`"true"` 因为本身是字符串,按 `string` 原样使用 + +3. `args[*]` / `env.` 表达式结果类型 +最终必须是字符串,转换规则: + +- 表达式结果为 `string`:原样使用 +- 表达式结果为 `number`/`bool`:按 JSON 标量文本转换为字符串(示例:`true`、`42`、`3.14`) +- 表达式结果为 `null`/`array`/`object`:失败,错误码 `flow.expr.type_error` + +4. `wait.seconds_from` 目标类型 +必须解析为整数秒: + +- `integer number`:直接使用 +- `string` 且匹配十进制整数:解析后使用 +- 其他类型或格式:失败,错误码 `flow.wait.seconds_resolve_error` + +5. `export` 目标类型 +`agent.export` 与 `script.export` 可写入任意 JSON 值到 `context`,不做字符串化。 +若字段存在且值为 `null`,应按合法值写入 `context. = null`;仅“字段不存在”才触发 `*_export_field_missing`。 + +## 4.11 状态超时语义(v1 固定) + +- `gate/agent/wait/script` 均受“单步超时”约束,统一错误码 `flow.step.timeout`。 +- `effective_timeout_sec = state.timeout_sec ?? defaults.step_timeout_sec`。 +- `end` 状态不参与超时判定。 + +`wait` 状态补充规则(`v1` 固定): + +1. 先解析 `wait_seconds`(来自 `seconds` 或 `seconds_from`)。 +2. 若 `wait_seconds > effective_timeout_sec`,立即以 `flow.step.timeout` 失败(不进入实际 sleep)。 +3. 若 `wait_seconds <= effective_timeout_sec`,执行 `wait_seconds` 的 sleep 后按 `next` 跳转。 + +## 4.12 Agent 超时与中断窗口(v1 固定) + +`agent` 状态超时执行规则: + +1. 到达 `effective_timeout_sec`(`state.timeout_sec` 或 `defaults.step_timeout_sec`)时,引擎发送一次 `turn/interrupt`。 +2. 发送 interrupt 后进入固定 `10s` grace 窗口(`v1` 常量,不可配置)。 +3. 若 grace 期内 agent 完成退出,仍按超时处理,错误码 `flow.step.timeout`。 +4. 若 grace 期结束仍未退出,引擎强制终止 provider 进程,并以 `flow.step.timeout` 结束。 +5. 仅“用户主动 interrupt”使用错误码 `flow.agent.interrupted`;超时路径不返回该错误码。 +6. 若 agent 以失败状态结束(非 timeout / 非用户中断 / 非 export 解析失败),统一错误码为 `flow.agent.failed`。 + +--- + +## 5. JSON IR v1 规范(执行层) + +编译后的 IR 目标: + +- 无歧义 +- 默认值已展开 +- 跳转目标全解析 +- 状态 ID 顺序稳定(便于 diff/golden test) + +编译确定性规则(`v1` 固定): + +- `states` 数组顺序严格保持 YAML 中的声明顺序 +- JSON 输出采用固定键顺序与固定缩进(canonical formatting) +- 相同输入内容 + 相同编译参数必须得到字节级一致的 IR 输出 + +`run` 路径在 IR 中的标准化规则(`v1` 固定): + +- 若 YAML 为相对路径输入:输出为“相对于 flow 文件目录”的规范相对路径(去除冗余 `.` 段,统一 `/` 分隔) +- 若 YAML 为绝对路径或 `~/`:输出为规范绝对路径 +- 编译阶段不将“路径标准化”与“文件存在性检查”混为一谈;存在性仍按 `--no-fs-check` 规则独立处理 + +校验策略(`v1` 固定): + +- 不可达状态一律视为校验错误(不是 warning) +- 未知字段默认报错(禁止 silent ignore) +- 所有表达式在 `compile` 阶段做静态可解析性检查(存在性在运行时检查) + +示例(节选): + +```json +{ + "version": "flow-ir/v1", + "start": "precheck", + "defaults": { + "max_agent_rounds": 20, + "max_wait_cycles": 200, + "max_total_steps": 2000, + "step_timeout_sec": 1800, + "fail_on_parse_error": true + }, + "states": [ + { + "id": "precheck", + "kind": "gate", + "exec": { + "run": "scripts/check-eslint-issues.sh", + "args": [], + "env": {}, + "parse": "json_last_line", + "timeout_sec": 1800 + }, + "transitions": { + "pass": "done", + "needs_agent": "fix", + "wait": "pre_wait", + "fail": "done_fail" + } + } + ] +} +``` + +--- + +## 6. 门控与 Agent 输出契约 + +## 6.1 gate 脚本输出契约(强约束) + +`gate` 输出解析契约由 `parse` 决定: + +1. `parse=json_last_line` +要求 stdout 最后一个非空行是 JSON 对象(其余日志可在前面行出现)。 + +2. `parse=json_full_stdout` +要求完整 stdout(trim 后)是单个 JSON 对象(不允许额外非 JSON 文本)。 + +无论哪种模式,最终 JSON 对象最少必须包含: + +```json +{ + "decision": "needs_agent" +} +``` + +规则: + +- `decision` 必填,值只能是 `pass|needs_agent|wait|fail` +- `reason` 建议填,便于日志与排障 +- `retry_after_sec` 仅 `wait` 场景使用(建议提供) +- `pr_url` 可选,可在 precheck 或 agent 后补全 +- `meta` 可选,用于携带调试与统计信息 +- `decision=fail` 时会走 `on.fail` 业务分支,不是引擎硬失败 + +## 6.2 agent 输出契约(v1 规则) + +`agent` 状态分两类: + +1. 未使用 `export` +可接受自由文本最终输出,不强制结构化 JSON。 + +2. 使用 `export` +必须提供结构化最终输出,且 `v1` 约定为“最终消息最后一个非空行是 JSON 对象”。 + +解析成功后,引擎将该 JSON 对象写入 `$.current.final`,供 `agent.export` 表达式读取(例如 `$.current.final.pr_url`)。 + +建议字段(示例): + +- `pr_url` +- `summary` +- `changed_files`(可选) + +失败语义: + +- 若 `export` 存在但最终 JSON 不可解析,状态失败,错误码 `flow.agent.output_parse_error` +- 若 `export` 引用字段不存在,状态失败,错误码 `flow.agent.export_field_missing` +- 若 `export` 引用字段存在但值为 `null`,视为合法并写入 `context`(不报错) + +## 6.3 script 输出契约(v1 规则) + +`script` 状态分两类: + +1. 未使用 `export` +不要求 stdout 结构化;按现有脚本执行语义运行。 + +2. 使用 `export` +要求 stdout 最后一个非空行是 JSON 对象;解析成功后写入 `$.current.final`,供 `script.export` 表达式读取。 + +失败语义: + +- 若 `export` 存在但 stdout 最后一个非空行不可解析为 JSON 对象,状态失败,错误码 `flow.script.output_parse_error` +- 若 `export` 引用字段不存在,状态失败,错误码 `flow.script.export_field_missing` +- 若 `export` 引用字段存在但值为 `null`,视为合法并写入 `context`(不报错) + +## 6.4 `$.current` 标准形状(v1 固定) + +在 `agent.export` 与 `script.export` 求值期间,`$.current` 统一为: + +```json +{ + "final": { "...": "..." } +} +``` + +其中 `final` 是 6.2 / 6.3 定义的“解析后的结构化 JSON 对象”。 +因此 `v1` 中应使用 `$.current.final.` 访问导出字段,避免“根对象 vs final 包装”歧义。 + +--- + +## 7. 循环与计数规则 + +## 7.1 计数模型 + +- 仅对 `agent` 状态增量计数(`counter`) +- `wait` 不增加 agent 轮次 +- 脚本重检也不增加 agent 轮次 +- `max_wait_cycles` 是全局计数(跨所有 wait 状态累计) +- 所有状态进入次数都会计入 `max_total_steps` + +## 7.2 终止条件 + +成功终止: + +- 到达 `end(status=success)` +- 或 gate 返回 `pass` 并流转到成功终点 + +失败终止: + +- 业务失败:仅当流程显式跳转到 `end(status=failure)` +- 运行时硬失败:`agent` 轮次超出 `max_rounds`、wait 超限、总步数超限、状态超时、`gate/script` 进程非零退出(`flow.gate.process_exit_nonzero` / `flow.script.process_exit_nonzero`)、agent 常规失败(`flow.agent.failed`)、`agent/script` 结构化输出解析失败(`flow.agent.output_parse_error` / `flow.script.output_parse_error`)、表达式求值失败(关键字段)、`gate` 解析失败且 `fail_on_parse_error=true`、用户主动中断 agent + +补充(`v1` 固定): + +- 运行时硬失败不会隐式跳转到某个 `failed` 状态,而是直接结束本次 flow run(non-zero) +- `failed` 状态不是必需项;若需要业务可见失败收敛,需通过显式转移到 `end(status=failure)` +- `agent` 超时路径统一归类为 `flow.step.timeout`(内部 interrupt 仅为超时收敛手段) +- `agent interrupted` 在 `v1` 固定指“用户主动中断”,视为运行时硬失败(non-zero,错误码 `flow.agent.interrupted`) + +## 7.3 与用户示例 1~7 的映射 + +1. `precheck(gate)` 判断是否有 issue。 +2. `needs_agent -> fix(agent)` 修复并导出 PR。 +3. `postcheck(gate)` 检查 CI + review comment。 +4. `pass -> done`。 +5. 未通过则回 `fix(agent)`,再回 `postcheck(gate)`。 +6. 20 轮内通过则成功。 +7. 超过 20 轮仍不通过则失败。 + +## 7.4 用户目标示例(完整端到端) + +本节给出与你需求一一对应的完整示例,包含: + +- Flow YAML 示例 +- 两个门控脚本的输出协议示例 +- 一条“20 轮内成功”轨迹 +- 一条“20 轮失败退出”轨迹 + +### 7.4.1 Flow YAML 示例(eslint -> agent -> PR 检查循环) + +```yaml +version: flow/v1 +start: precheck +defaults: + max_agent_rounds: 20 + max_wait_cycles: 200 + max_total_steps: 2000 + step_timeout_sec: 1800 + fail_on_parse_error: false # 示例中显式关闭,演示 on.parse_error 分支 +context: + pr_url: null + repo: "org/repo" + +states: + - id: precheck + type: gate + run: ./scripts/check-eslint-issues.sh + parse: json_last_line + on: + pass: done + needs_agent: fix + wait: pre_wait + fail: done_fail + parse_error: done_fail + + - id: pre_wait + type: wait + seconds: 30 + next: precheck + + - id: fix + type: agent + task: fix-eslint-and-open-pr + model: gpt-5.3-codex + counter: fix_round + max_rounds: 20 + prompt: | + Fix eslint issues only. + If code changes are needed, commit and open/update pull request. + Return final structured JSON with at least: + {"pr_url":"...", "summary":"..."} + export: + pr_url: "$.current.final.pr_url" + fix_summary: "$.current.final.summary" + next: postcheck + + - id: postcheck + type: gate + run: ./scripts/check-pr-ci-review.sh + parse: json_last_line + args: + - "$.context.pr_url" + - "$.context.repo" + on: + pass: done + needs_agent: fix + wait: post_wait + fail: done_fail + parse_error: done_fail + + - id: post_wait + type: wait + seconds_from: "$.state.postcheck.last.retry_after_sec" + next: postcheck + + - id: done + type: end + status: success + message: "CI all green and no blocking code review comments." + + - id: done_fail + type: end + status: failure + message: "Business-level failure branch." +``` + +### 7.4.2 precheck 门控脚本输出示例 + +场景 A:没有 eslint 问题,直接结束 + +```json +{"decision":"pass","reason":"no eslint issues found"} +``` + +场景 B:发现 eslint 问题,需要 agent 修复 + +```json +{"decision":"needs_agent","reason":"12 eslint issues found","meta":{"eslint_issue_count":12}} +``` + +场景 C:检查工具暂时不可用,等待后重试 + +```json +{"decision":"wait","reason":"eslint service warming up","retry_after_sec":20} +``` + +场景 D:脚本无法继续(不可恢复) + +```json +{"decision":"fail","reason":"workspace not found"} +``` + +### 7.4.3 postcheck 门控脚本输出示例 + +场景 A:PR 已通过 + +```json +{ + "decision":"pass", + "reason":"all CI checks passed and no blocking review comments", + "pr_url":"https://github.com/org/repo/pull/123", + "meta":{"ci":"success","blocking_reviews":0} +} +``` + +场景 B:CI 仍在运行,先等待 + +```json +{ + "decision":"wait", + "reason":"2 checks still in progress", + "retry_after_sec":60, + "pr_url":"https://github.com/org/repo/pull/123" +} +``` + +场景 C:CI 失败或存在 `REQUEST_CHANGES`,需要再次修复 + +```json +{ + "decision":"needs_agent", + "reason":"ci failed and 1 blocking review comment", + "pr_url":"https://github.com/org/repo/pull/123", + "meta":{"failed_checks":["lint"],"blocking_reviews":1} +} +``` + +场景 D:`pr_url` 缺失,直接失败 + +```json +{ + "decision":"fail", + "reason":"missing pr_url in context" +} +``` + +### 7.4.4 执行轨迹示例(20 轮内成功) + +示例轨迹: + +1. 进入 `precheck`,返回 `needs_agent`(发现 12 个 issue)。 +2. 进入 `fix` 第 1 轮,agent 输出 `pr_url=https://github.com/org/repo/pull/123`。 +3. 进入 `postcheck`,返回 `wait`(CI 未跑完,`retry_after_sec=60`)。 +4. 进入 `post_wait`,等待 60 秒。 +5. 再进 `postcheck`,返回 `needs_agent`(有 `REQUEST_CHANGES`)。 +6. 进入 `fix` 第 2 轮,agent 修复 review comment 并 push。 +7. 再进 `postcheck`,返回 `pass`。 +8. 跳转到 `done`,流程成功结束(agent 共 2 轮)。 + +### 7.4.5 执行轨迹示例(超过 20 轮失败) + +示例轨迹: + +1. `precheck` 返回 `needs_agent`。 +2. `fix` 与 `postcheck` 在 `needs_agent` 间反复跳转。 +3. 当 `fix_round` 从 20 准备进入 21 时,触发 `max_rounds` 保护。 +4. 引擎直接以运行时硬失败结束(non-zero),错误原因为 `flow.agent.rounds_exceeded`。 + +### 7.4.6 dry-run 夹具示例(用于测试) + +可用 fixture 模拟每一步 gate/agent 结果,以便不连接真实 GitHub/CI 即可验证流程: + +```json +{ + "states": { + "precheck": [ + {"decision":"needs_agent","reason":"12 eslint issues found"} + ], + "fix": [ + {"status":"completed","final":{"pr_url":"https://github.com/org/repo/pull/123","summary":"round1 fix"}}, + {"status":"completed","final":{"pr_url":"https://github.com/org/repo/pull/123","summary":"round2 fix"}} + ], + "postcheck": [ + {"decision":"wait","retry_after_sec":1,"reason":"checks pending"}, + {"decision":"needs_agent","reason":"request changes"}, + {"decision":"pass","reason":"all green"} + ] + } +} +``` + +说明: + +- 该夹具应触发“wait -> needs_agent -> pass”的分支覆盖。 +- 对应测试可断言最终为 `success` 且 `fix_round == 2`。 + +## 7.5 统一错误码规范(v1) + +为保证实现与测试断言稳定,`v1` 统一错误码如下: + +| 分类 | 错误码 | 触发条件 | +|---|---|---| +| validate/compile/runtime-preflight | `flow.validate.schema_error` | 基础 schema 不合法 | +| validate/compile/runtime-preflight | `flow.validate.unknown_field` | 出现未知字段 | +| validate/compile/runtime-preflight | `flow.validate.unreachable_state` | 存在不可达状态 | +| validate/compile/runtime-preflight | `flow.validate.numeric_range_error` | 数值字段越界或类型不符 | +| validate/compile/runtime-preflight | `flow.validate.field_type_error` | 字段字面量类型不被允许(如 `args/env` 为 null/object/array) | +| validate/compile/runtime-preflight | `flow.path.invalid_path_kind` | `run` 不是脚本路径(例如命令名/裸 token);`flow run` 预检同样返回该错误 | +| validate/compile/runtime-preflight/runtime | `flow.path.not_found` | `run` 路径解析后文件不存在或不可读(`validate/compile` 仅在未启用 `--no-fs-check` 时检查;`flow run` 预检可触发;进入状态机后执行前复检也可触发) | +| validate/compile/runtime-preflight | `flow.gate.parse_mode_invalid` | `gate.parse` 不是支持枚举 | +| runtime | `flow.expr.resolve_error` | 表达式求值失败(通用) | +| runtime | `flow.expr.type_error` | 表达式值类型不符合目标字段要求 | +| runtime | `flow.gate.parse_error` | gate 输出解析失败且 `fail_on_parse_error=true` | +| runtime | `flow.gate.process_exit_nonzero` | gate 进程退出码非 0 | +| runtime | `flow.script.process_exit_nonzero` | script 进程退出码非 0 | +| runtime | `flow.agent.failed` | agent 以失败状态结束(不含 timeout / interrupted / export 解析失败) | +| runtime | `flow.agent.rounds_exceeded` | 超过生效轮次上限 | +| runtime | `flow.wait.cycles_exceeded` | 超过 `max_wait_cycles` | +| runtime | `flow.steps.exceeded` | 超过 `max_total_steps` | +| runtime | `flow.step.timeout` | 任一步骤超时(含 agent:超时后 interrupt + 10s grace 仍统一按 timeout 失败) | +| runtime | `flow.wait.seconds_resolve_error` | `seconds_from` 解析失败 | +| runtime | `flow.agent.output_parse_error` | agent export 需要 JSON 但输出不可解析 | +| runtime | `flow.script.output_parse_error` | script export 需要 JSON 但输出不可解析 | +| runtime | `flow.agent.export_field_missing` | agent export 引用字段不存在 | +| runtime | `flow.script.export_field_missing` | script export 引用字段不存在 | +| runtime | `flow.agent.interrupted` | 用户主动中断 agent(v1 固定非零失败) | +| runtime-dry-run | `flow.dryrun.fixture_missing_state_data` | dry-run fixture 缺少被执行状态所需数据 | +| runtime-dry-run | `flow.dryrun.fixture_unknown_state` | dry-run fixture 包含未知状态 ID | +| runtime-dry-run | `flow.dryrun.fixture_unconsumed_items` | dry-run 中已执行状态存在未消费的 fixture 条目 | +| cli-warning | `flow.dryrun.fixture_unused_state_data` | dry-run 中未执行状态存在 fixture 条目 | +| cli | `flow.cli.var_key_invalid` | `--var` 键名不符合 `v1` 规则 | +| cli-warning | `flow.cli.command_unused` | flow 结束时仍有未消费的 `--command` | +| runtime | `flow.business_failed` | 到达 `end(status=failure)` | + +## 7.6 `flow run` 预检错误归类口径(v1 固定) + +- `flow run` 启动后先执行与 `flow validate` 等价的 preflight(包含路径类型与默认存在性检查)。 +- preflight 失败时,不进入状态机;退出码非零;错误归类固定为 `runtime-preflight`,错误码沿用表 7.5(不改码)。 +- 因此,7.5 中所有 `validate/compile` 类错误在 `flow run` 路径下都可出现为 `runtime-preflight`。 +- 仅 preflight 通过后才进入 runtime 阶段。 +- 同一错误码可在不同阶段出现(例如 `flow.path.not_found`),日志必须输出 `phase=runtime-preflight|runtime` 以消除歧义。 + +--- + +## 8. CLI 交互设计 + +## 8.1 命令 + +1. `scriptoria flow validate [--no-fs-check]` +- 只做校验,不执行 +- 输出错误位置(状态名、字段、行号) +- 默认执行 `run` 路径存在性检查;可用 `--no-fs-check` 跳过文件系统存在性检查 + +2. `scriptoria flow compile --out [--no-fs-check]` +- 输出规范化 IR +- 用于审计、缓存、回放 +- 默认执行 `run` 路径存在性检查;可用 `--no-fs-check` 跳过文件系统存在性检查 + +3. `scriptoria flow run [--var ...] [--max-agent-rounds ] [--no-steer] [--command ...]` +- 执行完整流程 +- 支持 `--var k=v` 注入上下文 +- 支持 `--max-agent-rounds` 临时覆盖 +- 相对 `run` 路径统一按 flow 文件目录解析(与 shell cwd 无关) +- `run` 阶段始终复检脚本路径存在性(不受 `--no-fs-check` 影响) +- 启动执行前先做与 `flow validate` 等价的 preflight 校验;预检失败直接 non-zero 退出 +- `--no-steer`:禁用运行期交互式 steer 输入 +- `--command`(可重复):在一次 `flow run` 内先进入 FIFO 队列;仅在存在活动 agent turn 时投递;`/interrupt` 在投递成功后触发用户主动中断 +- 参数名与现有 `scriptoria run` 保持一致(统一使用 `--command`,`v1` 不新增 `--agent-command`) + +4. `scriptoria flow dry-run --fixture ` +- 不调用真实 agent/外部系统 +- 用假数据走完整分支,验证状态跳转 + +## 8.2 参数优先级与 dry-run 规则(v1 固定) + +1. `--var` 覆盖优先级 +CLI 传入 `--var key=value` 的优先级高于 YAML `context` 同名键。 + +类型规则:`--var` 在 `v1` 一律作为字符串注入。 + +2. dry-run fixture 严格匹配 +- fixture 中缺少被执行状态所需数据:报错退出(`flow.dryrun.fixture_missing_state_data`) +- fixture 中包含未知状态 ID:报错退出(`flow.dryrun.fixture_unknown_state`) +- 对“已执行状态”,fixture 条目必须被完全消费;若有剩余未消费条目,报错退出(`flow.dryrun.fixture_unconsumed_items`) +- 对“未执行状态”,允许存在 fixture 条目,但会打印 warning(`flow.dryrun.fixture_unused_state_data`,不报错) + +3. `--max-agent-rounds` 覆盖优先级 +CLI 参数是“全局硬上限”,只允许收紧,不允许放宽。 +最终生效值为 `min(state.max_rounds, defaults.max_agent_rounds, cli_cap_if_present)`。 +若 CLI 值大于配置上限,则按配置上限执行并打印 warning。 + +4. `--no-fs-check` 行为边界 +- 仅影响 `flow validate` 与 `flow compile` 的“路径存在性”检查 +- 不影响路径语法/路径类型校验(例如命令名仍报 `flow.path.invalid_path_kind`) +- 不影响 `flow run` 的运行期复检(运行期仍可因路径缺失报 `flow.path.not_found`) + +5. `--var` 键名与重复键 +- 键名必须匹配 `^[A-Za-z_][A-Za-z0-9_]*$`,否则报 `flow.cli.var_key_invalid` +- 不支持 `a.b` 这类嵌套键写法 +- 重复键按“后者覆盖前者”(last wins) + +6. steer/interrupt 行为 +- `--no-steer` 仅关闭交互式 stdin steer,不影响 `--command` +- `--command` 在一次 flow run 内形成“单次消费队列”,生命周期为“run 启动到 run 结束” +- 当“当前无活动 agent turn”时,命令仅保留在队列中等待后续 turn(不丢弃、不排队到进程外、不立即报错) +- 每次 agent turn 激活时,引擎按 FIFO 尝试向该 turn 投递队首命令,直到队列清空或该 turn 结束 +- 只有在命令被当前 turn 成功受理(`turn/steer` 请求成功)后才标记为“已消费”;若 turn 提前结束导致未受理,命令保留在队首,待下一次 agent turn 重试 +- 未消费命令可跨多次 agent turn 延续;已消费命令绝不在后续 turn 自动重放(不复用) +- `--command "/interrupt"` 在投递成功后视为用户主动中断,错误码 `flow.agent.interrupted` +- 若流程从未进入任何 agent turn,所有 `--command` 视为未消费 +- 如果流程结束时队列仍有未消费命令,打印 warning `flow.cli.command_unused`(不改变退出码) + +## 8.3 运行日志字段规范(v1 固定) + +- preflight 失败日志必须包含:`phase=runtime-preflight`、`error_code`、`error_message`、`flow_path` +- runtime 每步日志必须包含:`phase=runtime`、`run_id`、`state_id`、`state_type`、`attempt`、`counter`、`decision`、`transition`、`duration` +- `attempt` 取值规则:当前 `state_id` 在本次 run 内的 1-based 进入序号(所有状态都必须给出整数值) +- `counter` 取值规则:`agent` 状态填对象 `{"name":"","value":,"effective_max":}`;其中 `value` 固定为本次生效值(增量后,等于 4.6 中的 `next`);`gate/script/wait/end` 固定填 `null` +- `decision` 取值规则:`gate` 状态填 `pass|needs_agent|wait|fail|parse_error`;`agent/script/wait/end` 固定填 `null` +- `transition` 取值规则:存在后继跳转时填目标 `state_id`;流程在当前步终止(例如 `end` 或运行时硬失败)时填 `null` +- runtime 失败时,除上述字段外还必须包含:`error_code`、`error_message` +- dry-run 日志口径:`flow dry-run` 的失败日志使用 `phase=runtime-dry-run`,并沿用与 runtime 相同的字段集合与取值规则(含 `attempt/counter/decision/transition` 规则)。 + +--- + +## 9. 开发前测试计划(测试先行) + +## 9.1 测试分层 + +1. 单元测试:解析、校验、编译、表达式求值、门控解析 +2. 引擎测试:状态机执行和循环策略 +3. CLI 集成测试:命令行为与退出码 +4. Provider E2E:codex/claude/kimi 全链路 +5. 回归测试:现有 run/agent 逻辑不破坏 + +## 9.2 详细测试用例清单 + +### A. YAML 解析与语义校验 + +- `TC-Y01` 最小合法流程可通过 +- `TC-Y02` 缺少 `version` 报错 +- `TC-Y03` `version` 非 `flow/v1` 报错 +- `TC-Y04` 缺少 `start` 报错 +- `TC-Y05` `start` 指向不存在状态报错 +- `TC-Y06` 状态类型非法报错 +- `TC-Y07` `gate.on` 缺少 `pass` 报错 +- `TC-Y08` `gate.on` 缺少 `needs_agent` 报错 +- `TC-Y09` `gate.on` 缺少 `wait` 报错 +- `TC-Y10` `gate.on` 缺少 `fail` 报错 +- `TC-Y11` `wait` 同时存在 `seconds` 和 `seconds_from` 报错 +- `TC-Y12` `wait` 的 `seconds` 与 `seconds_from` 两者都缺失时报错 +- `TC-Y13` `end.status` 非法值报错 +- `TC-Y14` 跳转目标不存在报错 +- `TC-Y15` 发现不可达状态报错(`v1` 固定),错误码 `flow.validate.unreachable_state` +- `TC-Y16` `max_agent_rounds <= 0` 报错 +- `TC-Y17` 当 `fail_on_parse_error=false` 且某 gate 缺少 `on.parse_error` 时报错 +- `TC-Y18` `script` 状态缺 `run/next` 时报错 +- `TC-Y19` `run` 不是脚本路径(而是内联命令)时报错 +- `TC-Y20` `states` 使用 map 结构时报错(`v1` 仅接受数组) +- `TC-Y21` 存在重复 `state.id` 时报错 +- `TC-Y22` `gate.parse` 非法枚举值时报错,错误码 `flow.gate.parse_mode_invalid` +- `TC-Y23` 未知字段报错(禁止 silent ignore),错误码 `flow.validate.unknown_field` +- `TC-Y24` `max_wait_cycles <= 0` 报错 +- `TC-Y25` `max_total_steps <= 0` 报错 +- `TC-Y26` `step_timeout_sec <= 0` 报错 +- `TC-Y27` `agent.max_rounds <= 0` 报错 +- `TC-Y28` 任意状态 `timeout_sec <= 0` 报错 +- `TC-Y29` `wait.seconds < 0` 报错 +- `TC-Y30` `wait.seconds` 非整数字面量时报错 +- `TC-Y31` `gate.args` 字面量为 `null/array/object` 时报 `flow.validate.field_type_error` +- `TC-Y32` `gate.env` 字面量为 `null/array/object` 时报 `flow.validate.field_type_error` +- `TC-Y33` `script.args/env` 字面量为 `null/array/object` 时报 `flow.validate.field_type_error` +- `TC-Y34` `wait.timeout_sec <= 0` 时报 `flow.validate.numeric_range_error` +- `TC-Y35` 顶层 schema 结构损坏(如 `states` 非数组且字段类型整体不匹配)时报 `flow.validate.schema_error` + +### B. YAML -> JSON IR 编译测试 + +- `TC-C01` 编译产物字段完整 +- `TC-C02` 默认值自动注入 +- `TC-C03` 状态顺序稳定(确定性输出) +- `TC-C04` 表达式字段保留并标准化 +- `TC-C05` 错误信息包含状态名与字段路径 +- `TC-C06` golden 文件比对一致 +- `TC-C07` canonical 输出键顺序稳定(字节级一致) +- `TC-C08` YAML 中状态声明顺序在 IR 中完全保留 +- `TC-C09` 表达式语法非法时在 `compile` 阶段报错 +- `TC-C10` 表达式引用作用域前缀非法时报错 +- `TC-C11` `run` 路径在 IR 中按规则标准化(相对输入 -> 规范相对路径;绝对输入 -> 规范绝对路径) +- `TC-C12` 相对 `run` 路径按 flow 文件目录解析并写入 IR +- `TC-C13` `run` 为命令名(非路径)时报 `flow.path.invalid_path_kind` +- `TC-C14` `run` 路径不存在且未启用 `--no-fs-check` 时报 `flow.path.not_found` +- `TC-C15` 路径不存在但启用 `--no-fs-check` 时仍可编译成功并产出 IR +- `TC-C16` 启用 `--no-fs-check` 时,`run` 为命令名/裸 token(如 `eslint`、`check.sh`)仍报 `flow.path.invalid_path_kind` +- `TC-C17` 相同 flow 内容在不同 shell cwd 下编译,IR 字节级一致(相对 `run` 场景) +- `TC-C18` `args/env` 字面量为 `number/bool` 时在 IR 中按规则字符串化 +- `TC-C19` `wait.timeout_sec` 配置存在时应写入 IR 并参与运行时 `effective_timeout_sec` 计算 + +### C. 门控输出解析测试 + +- `TC-GP01` 合法 `pass` 输出 +- `TC-GP02` 合法 `needs_agent` 输出 +- `TC-GP03` 合法 `wait` + `retry_after_sec` +- `TC-GP04` 合法 `fail` 输出 +- `TC-GP05` 非 JSON 行为 +- `TC-GP06` JSON 缺 `decision` +- `TC-GP07` `decision` 未知值 +- `TC-GP08` `retry_after_sec` 非数字 +- `TC-GP09` gate 脚本 exit code 非 0,错误码应为 `flow.gate.process_exit_nonzero` +- `TC-GP10` 解析失败 + `fail_on_parse_error=true` -> 直接失败,错误码 `flow.gate.parse_error` +- `TC-GP11` 解析失败 + `fail_on_parse_error=false` -> 走 `on.parse_error` +- `TC-GP12` `parse=json_full_stdout` 且 stdout 是合法 JSON +- `TC-GP13` `parse=json_full_stdout` 且 stdout 非法 JSON + +### D. 引擎状态机测试 + +- `TC-E01` precheck=pass 直接成功,不启动 agent +- `TC-E02` precheck=needs_agent, postcheck=pass,1轮成功 +- `TC-E03` 连续 needs_agent,第 N(<20) 轮成功 +- `TC-E04` 连续 needs_agent,第 21 轮失败 +- `TC-E05` postcheck=wait,进入等待后重检 +- `TC-E06` wait 不增加 agent 轮次 +- `TC-E07` wait 循环超限失败(`max_wait_cycles`) +- `TC-E08` agent 常规失败立即失败并返回 `flow.agent.failed` +- `TC-E09` 用户主动 interrupt agent 视为运行时硬失败(non-zero,`flow.agent.interrupted`) +- `TC-E10` 表达式求值失败(关键字段)失败退出,错误码 `flow.expr.resolve_error` +- `TC-E11` `max_rounds == 20` 且第 20 轮通过时应成功 +- `TC-E12` `seconds_from` 缺失值时失败 +- `TC-E13` `seconds_from = 0` 时不 sleep 且继续跳转 +- `TC-E14` `seconds_from < 0` 时失败 +- `TC-E15` `step_timeout_sec` 超时行为(含 gate/agent/wait/script) +- `TC-E16` `script` 状态成功路径 +- `TC-E17` `script` 状态脚本非 0 退出失败并返回 `flow.script.process_exit_nonzero` +- `TC-E18` `gate <-> script` 环在无 agent/wait 时被 `max_total_steps` 终止 +- `TC-E19` `max_wait_cycles` 为全局累计而非按状态独立计数 +- `TC-E20` 两个 agent 状态默认计数器互不影响(`agent_round.`) +- `TC-E21` 两个 agent 状态显式同名 `counter` 时共享计数 +- `TC-E22` 运行时硬失败不会隐式跳转到 `end(status=failure)` 状态 +- `TC-E23` 使用 `export` 且 agent 最终输出非 JSON 时失败,错误码 `flow.agent.output_parse_error` +- `TC-E24` 使用 `export` 且字段缺失时失败,错误码 `flow.agent.export_field_missing` +- `TC-E25` `script.export` 引用字段缺失时失败,错误码 `flow.script.export_field_missing` +- `TC-E26` `gate.decision=fail` 必须走 `on.fail` 业务分支(不是硬失败) +- `TC-E27` `max_wait_cycles` 超限返回 `flow.wait.cycles_exceeded` +- `TC-E28` `max_total_steps` 超限返回 `flow.steps.exceeded` +- `TC-E29` 任一步骤超时返回 `flow.step.timeout` +- `TC-E30` 超过 agent 轮次上限返回 `flow.agent.rounds_exceeded` +- `TC-E31` 到达 `end(status=failure)` 返回 `flow.business_failed` +- `TC-E32` `max_wait_cycles == limit` 时第 `limit` 次 wait 仍允许,第 `limit+1` 次失败 +- `TC-E33` `max_total_steps == limit` 时第 `limit` 步仍允许,第 `limit+1` 步失败 +- `TC-E34` `args/env` 表达式结果为 `null/array/object` 时失败(`flow.expr.type_error`) +- `TC-E35` `seconds_from` 解析为非整数时失败(`flow.wait.seconds_resolve_error`) +- `TC-E36` `args/env` 表达式结果为 `number/bool` 时按规则字符串化 +- `TC-E37` 当 `fail_on_parse_error=false` 且 gate 输出不可解析时,状态机必须走 `on.parse_error` 跳转 +- `TC-E38` `run` 前校验通过但执行时脚本路径缺失/不可读时,返回 `flow.path.not_found` +- `TC-E39` agent 超时后应发送一次 interrupt,并进入固定 `10s` grace 窗口 +- `TC-E40` agent 在 grace 窗口内自行结束时,仍返回 `flow.step.timeout` +- `TC-E41` `agent.export` 字段存在且值为 `null` 时应成功写入 `context`(值为 null) +- `TC-E42` `script.export` 字段存在且值为 `null` 时应成功写入 `context`(值为 null) +- `TC-E43` `wait.seconds > effective_timeout_sec` 时立即返回 `flow.step.timeout`(不进入实际 sleep) +- `TC-E44` `wait.seconds_from` 解析后若大于 `effective_timeout_sec`,返回 `flow.step.timeout` +- `TC-E45` `wait.seconds == effective_timeout_sec` 时允许完成等待并继续跳转 +- `TC-E46` 使用 `script.export` 且脚本最后一个非空 stdout 行非 JSON 对象时失败(`flow.script.output_parse_error`) +- `TC-E47` gate 进程非 0 退出时失败并返回 `flow.gate.process_exit_nonzero` + +### E. CLI 集成测试 + +- `TC-CLI01` `flow validate` 成功返回 0 +- `TC-CLI02` `flow validate` 非法文件返回非 0 +- `TC-CLI03` `flow compile` 成功产出 JSON +- `TC-CLI04` `flow run` 成功路径返回 0 +- `TC-CLI05` `flow run` 超轮次返回非 0 +- `TC-CLI06` `flow run --max-agent-rounds` 生效并收紧上限 +- `TC-CLI07` `flow dry-run` 使用 fixture 正确跳转 +- `TC-CLI08` `--var` 覆盖优先级(CLI > YAML context) +- `TC-CLI09` `dry-run` fixture 缺失状态数据时报错,错误码 `flow.dryrun.fixture_missing_state_data` +- `TC-CLI10` `dry-run` fixture 含未知状态数据时报错,错误码 `flow.dryrun.fixture_unknown_state` +- `TC-CLI11` `dry-run` 已执行状态存在剩余未消费条目时报错,错误码 `flow.dryrun.fixture_unconsumed_items` +- `TC-CLI12` `dry-run` 未执行状态存在条目时仅 warning,不报错,warning 码 `flow.dryrun.fixture_unused_state_data` +- `TC-CLI13` `flow run` 单步超时返回非 0 并包含超时状态 ID +- `TC-CLI14` `--max-agent-rounds` 大于配置上限时只告警且不放宽限制 +- `TC-CLI15` 业务失败路径退出码非 0 且错误码为 `flow.business_failed` +- `TC-CLI16` `max_total_steps` 超限错误码为 `flow.steps.exceeded` +- `TC-CLI17` `max_wait_cycles` 超限错误码为 `flow.wait.cycles_exceeded` +- `TC-CLI18` `--var key=1` 注入后在表达式中应为字符串 `"1"`(非数字) +- `TC-CLI19` 在不同 cwd 下,`run` 相对路径解析结果一致(相对 flow 文件目录) +- `TC-CLI20` `flow validate --no-fs-check` 在脚本路径缺失时不因存在性检查失败 +- `TC-CLI21` `flow compile --no-fs-check` 在脚本路径缺失时仍可产出 IR +- `TC-CLI22` `flow run` 在运行期路径缺失时返回 `flow.path.not_found` +- `TC-CLI23` `--var a.b=1` 因键名非法返回 `flow.cli.var_key_invalid` +- `TC-CLI24` 同一命令行重复 `--var key=...` 时按“后者覆盖前者”生效 +- `TC-CLI25` `flow validate --no-fs-check` 时,`run` 为命令名/裸 token 仍返回 `flow.path.invalid_path_kind` +- `TC-CLI26` `flow run` 中 `script` 非 0 退出返回 `flow.script.process_exit_nonzero` +- `TC-CLI27` `flow run` 中 agent 常规失败返回 `flow.agent.failed` +- `TC-CLI28` `flow run --no-steer` 不应进入交互式 stdin 读取 +- `TC-CLI29` `flow run --command \"/interrupt\"` 返回 `flow.agent.interrupted` +- `TC-CLI30` `flow run` 预检遇到 `run` 裸 token 时返回 `flow.path.invalid_path_kind` 且不执行状态机 +- `TC-CLI31` `flow run --command` 在“无 agent turn”场景下输出 `flow.cli.command_unused` warning +- `TC-CLI32` 多轮 agent 场景下 `--command` 队列按 FIFO 单次消费,不自动重放 +- `TC-CLI33` `--command` 在无活动 turn 阶段不丢失,首个 agent turn 激活后开始投递 +- `TC-CLI34` 多次进入 agent 状态时,未消费命令可跨 turn 延续;已消费命令不会在后续 turn 自动复用 +- `TC-CLI35` 活动 turn 在命令受理前结束时,该命令保持队首并在下一次 agent turn 重试投递 +- `TC-CLI36` 流程从未进入 agent 状态时,所有 `--command` 最终都以 `flow.cli.command_unused` warning 输出 +- `TC-CLI37` `flow run` 在 `wait` 状态超时时返回 `flow.step.timeout` +- `TC-CLI38` preflight 失败日志必须带 `phase=runtime-preflight`,且不得出现 runtime 步骤字段 +- `TC-CLI39` runtime 正常步骤日志必须包含 `phase=runtime` + `run_id/state_id/state_type/attempt/counter/decision/transition/duration`,且非 gate 状态 `decision=null`、非 agent 状态 `counter=null` +- `TC-CLI40` runtime 失败日志必须包含 `phase=runtime` + `error_code/error_message`,且 `state_id` 对齐失败状态;若当前步未产生后继跳转则 `transition=null` +- `TC-CLI41` `flow run` 中 gate 进程非 0 退出返回 `flow.gate.process_exit_nonzero` +- `TC-CLI42` `flow run` preflight 命中路径缺失时返回 `flow.path.not_found`,并输出 `phase=runtime-preflight`(不得进入状态机) +- `TC-CLI43` `flow dry-run` fixture 校验失败日志使用 `phase=runtime-dry-run`,且错误码与 8.2 对应(例如 `flow.dryrun.fixture_missing_state_data`) +- `TC-CLI44` agent 状态日志中 `counter.value` 必须等于本次生效轮次(增量后值),首轮应为 `1` + +### F. GitHub PR 门控场景测试(假数据/模拟) + +- `TC-PR01` CI 全 success + 无阻塞评论 -> `pass` +- `TC-PR02` CI 有 failure -> `needs_agent` +- `TC-PR03` CI pending -> `wait` +- `TC-PR04` 存在 `REQUEST_CHANGES` -> `needs_agent` +- `TC-PR05` PR URL 缺失 -> `fail` + +### G. Provider E2E(按项目要求) + +约束:本组用例必须使用 `scriptoria flow run ...` 路径执行,不能用 `scriptoria run ...` 代替。 + +- `TC-P01` `flow run` + codex 端到端跑通 +- `TC-P02` `flow run` + claude adapter 端到端跑通 +- `TC-P03` `flow run` + kimi adapter 端到端跑通 +- `TC-P04` `flow run` streaming 输出可见 +- `TC-P05` `flow run` 运行中可通过交互式 steer 或 `--command` 投递 steer 指令 +- `TC-P06` `flow run --command "/interrupt"` 后应以 `flow.agent.interrupted` 非零退出 +- `TC-P07` `flow run` task memory 写入 + workspace summarize 正常 + +### H. 回归测试 + +- `TC-R01` 现有 `scriptoria run --skip-agent` 行为不变 +- `TC-R02` 现有 `preScriptTrue` 触发逻辑不变 +- `TC-R03` 既有 schedule/logs/kill 相关命令无回归 +- `TC-R04` `flow` 中 `gate/script` 的 `interpreter` 映射到 `ScriptRunner` 输入且行为正确 +- `TC-R05` `flow` 中 `gate/script` 实际 `workingDirectory` 固定为“解析后脚本路径父目录” +- `TC-R06` 在不同 shell cwd 下执行同一 flow,`workingDirectory` 行为一致(与 shell cwd 解耦) + +--- + +## 10. 目录与代码落位建议 + +建议新增目录: + +- `Sources/ScriptoriaCore/Flow/` + - `FlowDefinition.swift` + - `FlowValidator.swift` + - `FlowCompiler.swift` + - `FlowIR.swift` + - `FlowEngine.swift` + - `GateStepRunner.swift` + - `AgentStepRunner.swift` + - `ScriptStepRunner.swift` + - `ExpressionEvaluator.swift` +- `Sources/ScriptoriaCLI/Commands/FlowCommand.swift` +- `Tests/ScriptoriaCoreTests/Flow/` + - `FlowYAMLValidationTests.swift` + - `FlowCompileTests.swift` + - `FlowEngineTests.swift` + - `GateOutputParserTests.swift` + - `FlowCLITests.swift` + - `Fixtures/` + `Golden/` + +--- + +## 11. 分阶段实施计划 + +实施顺序强约束(v1 固定): + +- `M0` 是 `M1~M7` 的硬前置;`M0` 未完成不得开始 `M1` 及后续阶段实现。 +- `M0` 完成判定:3.3 中两类执行器 API 与行为全部落地,且对应回归测试为绿灯。 + +## M0 执行器前置改造(对应 3.3,先做) + +- 为 `ScriptRunner` 补齐 `args/env/timeout_sec/workingDirectory` 能力 +- 为 `PostScriptAgentRunner` 补齐 `timeout_sec + interrupt grace` 行为与错误映射 +- 增加前置回归测试,确保现有 `scriptoria run` 行为不回归 +- 前置测试必须显式覆盖:`interpreter` 映射正确、`workingDirectory=脚本父目录`、与 shell cwd 解耦 + +## M1 规范冻结 + +- 完成 YAML v1、IR v1、错误码规范 +- 定义门控脚本输出协议 +- 完成示例流程模板 + +## M2 测试骨架先行(先红) + +- 写完第 9 节全部关键测试 +- 跑测试确认失败点符合预期 + +## M3 编译链路 + +- 实现 `validate/compile` +- 让 YAML/IR 相关测试先全绿 + +## M4 运行时引擎 + +- 实现 `gate/agent/wait/script/end` +- 打通循环与轮次限制 + +## M5 CLI 对接 + +- `flow validate/compile/run/dry-run` +- 输出统一日志结构 + +## M6 集成与回归 + +- provider E2E +- 既有命令回归 + +## M7 文档与发布 + +- 用户文档与模板 +- 迁移指引(从一次性 run 到 flow) +- 显式标注路径字面量策略变更:`run: check.sh`/`run: eslint` 在 `v1` 非法,必须改写为 `./check.sh` 或 `scripts/check.sh` + +--- + +## 12. 验收标准 + +1. 示例流程可在 20 轮内成功结束(exit code 0)。 +2. 超过 20 轮自动失败退出(non-zero exit code)。 +3. 每轮都有清晰日志(状态、决策、跳转、耗时、原因)。 +4. `flow compile` 产物可重复生成且稳定。 +5. 所有新增测试通过,既有测试无回归。 +6. CLI 中出现 `flow` 子命令且通过基础集成测试。 + +--- + +## 13. 风险与应对 + +1. **风险:gate 脚本输出格式不稳定** +应对:强制 JSON 契约 + 解析错误即失败(可配置)。 + +2. **风险:agent 最终输出难以稳定抽取 PR URL** +应对:要求 agent 输出结构化 JSON,`export` 显式映射。 + +3. **风险:外部系统(CI/GitHub)短期波动导致误判** +应对:`wait` 分支 + `retry_after_sec` + `max_wait_cycles`。 + +4. **风险:流程定义复杂导致排障困难** +应对:`validate`、`compile`、`dry-run` 三件套和结构化日志。 + +--- + +## 14. 业界可借鉴模式(参考) + +本方案参考以下成熟编排思想: + +- AWS Step Functions 的 `Choice + Wait + Retry/Catch` +- Temporal 的 durable workflow + timer + signal/interrupt +- LangGraph 的递归上限(防无限循环) +- GitHub API 的 PR 检查状态与 review 结果作为 gate 输入 + +--- + +## 15. 首期交付边界(建议) + +首期(v1)只做最小闭环: + +- 状态类型:`gate/agent/wait/script/end` +- 表达式:只支持字段取值(不支持复杂计算) +- 执行存储:先使用现有 run/agentRun 记录 + flow 运行日志 +- 恢复能力:先不做 `resume`(次期) + +## 15.1 迁移注意事项(v1 必须提示) + +- `flow` 的 `run` 字段在 `v1` 只接受“路径字面量”;`check.sh`、`eslint` 这类无 `/` 的裸 token 会报 `flow.path.invalid_path_kind`。 +- 迁移时请统一改写为显式路径:`./check.sh`、`../tools/check.sh` 或 `scripts/check.sh`。 +- 本条是设计层面的行为变更,不应按回归缺陷处理。 + +--- + +## 16. 下一步执行项 + +1. 在当前分支先实现 M0:补齐 3.3 执行器前置 API 与行为。 +2. 再做 M1:冻结 YAML v1 与 IR v1 schema。 +3. 先写 M2 测试骨架(覆盖第 9 节所有关键用例),红灯确认后再开始 M3/M4 功能实现。 diff --git a/docs/flow-dsl-v1.md b/docs/flow-dsl-v1.md new file mode 100644 index 0000000..dd109a1 --- /dev/null +++ b/docs/flow-dsl-v1.md @@ -0,0 +1,194 @@ +# Scriptoria Flow DSL v1 Guide + +This guide describes the currently implemented `flow/v1` behavior in Scriptoria CLI. + +## Commands + +```bash +scriptoria flow validate [--no-fs-check] +scriptoria flow compile --out [--no-fs-check] +scriptoria flow run [--var ...] [--max-agent-rounds ] [--no-steer] [--command ...] +scriptoria flow dry-run --fixture +``` + +Notes: + +- `flow run` performs preflight validation before runtime. +- `--no-fs-check` only applies to `validate/compile`. +- `--var` keys must match `^[A-Za-z_][A-Za-z0-9_]*$`. +- Repeated `--var key=...` uses last value. + +Example files are available at: + +- `docs/examples/flow-v1/local-gate-script/` +- `docs/examples/flow-v1/pr-loop/` + +## YAML Shape (`flow/v1`) + +Top-level fields: + +- `version`: must be `flow/v1` +- `start`: start state id +- `defaults`: optional global limits and policies +- `context`: optional initial context object +- `states`: required array of state objects + +Supported state types: + +- `gate` +- `agent` +- `wait` +- `script` +- `end` + +`run` path rules for `gate/script`: + +- Allowed: absolute (`/a/b.sh`), home (`~/a.sh`), explicit relative (`./a.sh`, `../a.sh`), or any token containing `/` (for example `scripts/check.sh`). +- Not allowed: bare command-like tokens (`eslint`, `check.sh`). +- Relative paths are resolved against the flow YAML directory, not shell cwd. + +## Minimal Example + +```yaml +version: flow/v1 +start: precheck +defaults: + max_agent_rounds: 20 + max_wait_cycles: 200 + max_total_steps: 2000 + step_timeout_sec: 1800 + fail_on_parse_error: true +context: + pr_url: null +states: + - id: precheck + type: gate + run: ./scripts/precheck.sh + on: + pass: done + needs_agent: fix + wait: wait_ci + fail: done_fail + - id: fix + type: agent + task: fix-lint-and-open-pr + export: + pr_url: "$.current.final.pr_url" + next: precheck + - id: wait_ci + type: wait + seconds: 30 + next: precheck + - id: done + type: end + status: success + - id: done_fail + type: end + status: failure +``` + +## Expressions + +Supported roots: + +- `$.context.*` +- `$.counters.*` +- `$.state..last.*` +- `$.prev.*` +- `$.current.*` (during `export` evaluation only) + +Scalar conversion rules: + +- `string` is used as-is. +- `number/bool` become JSON scalar text. +- `null/array/object` are rejected where scalar is required (`args/env` expression targets). + +## Runtime Logs + +Preflight failure log includes: + +- `phase=runtime-preflight` +- `error_code` +- `error_message` +- `flow_path` + +Per-step runtime log includes: + +- `phase` +- `run_id` +- `state_id` +- `state_type` +- `attempt` +- `counter` +- `decision` +- `transition` +- `duration` + +Runtime error steps also include: + +- `error_code` +- `error_message` + +## Dry-Run Fixture + +Fixture file format: + +```json +{ + "states": { + "precheck": [ + {"decision": "needs_agent"} + ], + "fix": [ + {"status": "completed", "final": {"pr_url": "https://example.com/pr/1"}} + ] + } +} +``` + +Strictness: + +- Missing data for an executed state: `flow.dryrun.fixture_missing_state_data` (error) +- Unknown state in fixture: `flow.dryrun.fixture_unknown_state` (error) +- Unconsumed entries for an executed state: `flow.dryrun.fixture_unconsumed_items` (error) +- Unused entries for non-executed states: `flow.dryrun.fixture_unused_state_data` (warning) + +## Common Error Codes + +- `flow.validate.schema_error` +- `flow.validate.unknown_field` +- `flow.validate.unreachable_state` +- `flow.validate.numeric_range_error` +- `flow.validate.field_type_error` +- `flow.path.invalid_path_kind` +- `flow.path.not_found` +- `flow.gate.parse_mode_invalid` +- `flow.gate.parse_error` +- `flow.gate.process_exit_nonzero` +- `flow.script.process_exit_nonzero` +- `flow.agent.failed` +- `flow.agent.rounds_exceeded` +- `flow.wait.cycles_exceeded` +- `flow.steps.exceeded` +- `flow.step.timeout` +- `flow.wait.seconds_resolve_error` +- `flow.agent.output_parse_error` +- `flow.script.output_parse_error` +- `flow.agent.export_field_missing` +- `flow.script.export_field_missing` +- `flow.agent.interrupted` +- `flow.business_failed` +- `flow.cli.var_key_invalid` +- `flow.cli.command_unused` (warning) + +## TC Mapping Maintenance + +To re-generate TC-to-test mapping documentation: + +```bash +scripts/generate-flow-tc-mapping.sh +``` + +Generated file: + +- `docs/flow-tc-mapping.md` diff --git a/docs/flow-migration-v1.md b/docs/flow-migration-v1.md new file mode 100644 index 0000000..3e71b55 --- /dev/null +++ b/docs/flow-migration-v1.md @@ -0,0 +1,49 @@ +# Flow v1 Migration Notes + +This note helps migrate from one-shot `scriptoria run` workflows to `scriptoria flow`. + +## When to Migrate + +Use `flow` when you need: + +- Looping gate -> agent -> gate behavior +- Global limits (`max_agent_rounds`, `max_wait_cycles`, `max_total_steps`) +- Deterministic dry-run fixture replay +- Per-state structured logs and transitions + +Keep using `scriptoria run` for simple one-shot script execution with optional agent stage. + +## Breaking Behavior in `flow/v1` + +`run` in `gate/script` only accepts path literals. + +Examples: + +- Invalid in `flow/v1`: `run: check.sh`, `run: eslint` +- Valid in `flow/v1`: `run: ./check.sh`, `run: ../tools/check.sh`, `run: scripts/check.sh` + +If not converted, validation/preflight fails with `flow.path.invalid_path_kind`. + +## Path Resolution Rules + +- Relative `run` paths are resolved from the flow YAML directory. +- Runtime still re-checks file existence/readability even if compile-time checks are skipped. +- Actual script working directory is the resolved script file's parent directory. + +## Suggested Migration Steps + +1. Start from current script entrypoint and split it into `gate/script/agent/wait/end` states. +2. Put all existing runtime variables into flow `context`. +3. Add explicit loop boundaries in `defaults`: + `max_agent_rounds`, `max_wait_cycles`, `max_total_steps`. +4. Validate and compile first: + `scriptoria flow validate ...`, `scriptoria flow compile ...`. +5. Add a fixture and run `flow dry-run` to validate branch behavior. +6. Roll out with `flow run` and monitor per-step logs. + +## Command Mapping + +- `scriptoria run