From a7cf199eec6d493aaa91aabd443e1295499d2f7b Mon Sep 17 00:00:00 2001 From: nullhack Date: Tue, 5 May 2026 02:57:55 -0400 Subject: [PATCH 1/2] feat: subflow exit resolution, chaining, JSON-first output (v0.5.0 Fine Sift) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix resolve_subflows .yaml extension fallback - Fix subflow exit resolution through parent transition map - Fix stack frame recording correct parent state - Add recursive subflow entry for 3-level nesting - Add session init auto-enter subflow - Enhance next with trigger→target, status, conditions - Add --session to states and validate - Fix check --session argparse dispatch - Flip --json to --text (JSON is now default output) - Update docs: ADR, spec, glossary, system, changelog, README --- .gitignore | 7 + CHANGELOG.md | 31 ++ README.md | 17 +- docs/adr/ADR_20260426_subflow_resolution.md | 20 +- .../subflow-transition-overhaul.feature | 161 ++++++++ ...IN_20260505_subflow-transition-overhaul.md | 77 ++++ ...260505_subflow-mechanism-non-functional.md | 84 ++++ docs/spec/domain_model.md | 4 +- docs/spec/flow_definition_spec.md | 30 +- docs/spec/glossary.md | 36 ++ docs/spec/system.md | 20 +- flowr/__main__.py | 391 ++++++++++++++---- flowr/cli/output.py | 20 +- flowr/cli/session_cmd.py | 23 +- flowr/domain/loader.py | 8 +- pyproject.toml | 2 +- tests/features/flowr_cli/check_state_test.py | 8 +- .../features/flowr_cli/mermaid_export_test.py | 6 +- tests/features/flowr_cli/next_command_test.py | 40 +- .../features/flowr_cli/states_command_test.py | 6 +- .../flowr_cli/transition_command_test.py | 21 +- .../flowr_cli/validate_command_test.py | 10 +- .../session_transition_test.py | 2 +- tests/unit/cli_test.py | 54 +-- tests/unit/loader_test.py | 57 +++ tests/unit/session_cmd_test.py | 222 +++++++++- 26 files changed, 1139 insertions(+), 218 deletions(-) create mode 100644 docs/features/backlog/subflow-transition-overhaul.feature create mode 100644 docs/interview-notes/IN_20260505_subflow-transition-overhaul.md create mode 100644 docs/post-mortem/PM_20260505_subflow-mechanism-non-functional.md diff --git a/.gitignore b/.gitignore index c9155f3..19c661d 100644 --- a/.gitignore +++ b/.gitignore @@ -175,3 +175,10 @@ cython_debug/ .opencode/ .templates/ AGENTS.md + +# smith managed +.flowr/ +.opencode/ +.templates/ +AGENTS.md +# end smith managed diff --git a/CHANGELOG.md b/CHANGELOG.md index 92ac205..c7a0930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ All notable changes to this project will be documented in this file. +## [v0.5.0+20260505] - Fine Sift - 2026-05-05 + +### Added + +- **Subflow exit resolution**: when a subflow exits, the exit name is resolved through the parent flow's transition map to determine the actual target state; previously the exit name was used directly as a state ID, causing sessions to land on invalid states +- **Subflow chaining**: after exiting a subflow, if the resolved target state has a `flow:` field, the stack is pushed again to enter the next subflow atomically (e.g., discovery-flow → exit → architecture-flow) +- **`session init` auto-enters subflow**: when the first state has a `flow:` field, `session init` pushes the stack and enters the subflow's initial state automatically +- **`next` shows all transitions**: the `next` command now displays ALL transitions including blocked/guarded ones, with trigger→target mapping and status markers (`[blocked]` + condition hints) +- **`next` JSON output uses `transitions` array**: replaces the old `next: [strings]` with `transitions: [{trigger, target, status, conditions}]` — a breaking change (pre-release) +- **`states --session`**: lists states in the current (sub)flow resolved from the session +- **`validate --session`**: validates the current (sub)flow resolved from the session +- **`check --session `**: correctly shows transition conditions for the given trigger (previously the argument was silently captured by argparse as `flow_file`) +- **Session-aware dispatch refactor**: extracted `_dispatch_session_command()` to reduce cyclomatic complexity; unified session-aware routing for all commands +- **Post-mortem**: `PM_20260505_subflow-mechanism-non-functional` documenting the two critical bugs and testing gap + +### Changed + +- **`flowr/domain/loader.py`**: `resolve_subflows()` now tries the `flow` field path as-is first, then appends `.yaml` if not found — making the `.yaml` extension optional in flow definitions +- **`flowr/__main__.py`**: new helper functions `_find_flow_file()`, `_enter_subflow()`, `_resolve_subflow_exit()`, `_build_transition_list()`, `_format_transitions_text()`; `_apply_session_transition()` gains `flows_dir` parameter for parent flow resolution; `_cmd_next` and `_cmd_next_session` use rich transition output; argparse for `validate` and `states` now accept optional `flow_file` and `--session` +- **`flowr/cli/session_cmd.py`**: `cmd_session_init` auto-enters initial subflow when first state has `flow:` field +- **JSON is now default CLI output**: `--json` flag replaced with `--text` — JSON is the default for all commands; use `--text` for human-readable output +- **ADR_20260426_subflow_resolution** amended: `.yaml` extension now optional with fallback-append + +### Fixed + +- **Subflow path resolution**: `resolve_subflows()` failed for flow references without `.yaml` extension (e.g., `flow: discovery-flow`) — now tries as-is first, then appends `.yaml` +- **Subflow exit state resolution**: `pop_stack(target)` used exit name directly as state ID, producing invalid states — now resolves through parent transition map +- **`check --session ` argparse capture**: target argument was silently consumed as `flow_file` positional — now correctly routed as target trigger name +- **Test fixture gap**: added tests for flow references without `.yaml` extension and subflow exit resolution/chaining +- **Stack frame state bug**: `_enter_subflow()` recorded the pre-transition state instead of the subflow wrapper state in the stack frame, causing exit resolution to look up the wrong parent `next` map — now records the target (subflow wrapper) state + ## [v0.4.0+20260502] - Refined Semolina - 2026-05-02 ### Added diff --git a/README.md b/README.md index 72f8c6b..abda2fa 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,10 @@ flowr defines a YAML format for non-deterministic state machines with per-state ``` flowr validate deploy.yaml → valid: True flowr states deploy.yaml → prepare, execute, review -flowr next deploy.yaml review → approve (guarded), reject +flowr next deploy.yaml review → approve → deployed [blocked] need: score=>=80 + → reject → failed flowr transition deploy.yaml review approve --evidence score=85 - → from: review, to: deployed + → from: review, to: deployed flowr session init deploy-flow → session created at state: prepare flowr --session transition approve → from: prepare, to: review flowr mermaid deploy.yaml → stateDiagram-v2 ... @@ -55,7 +56,7 @@ flowr mermaid deploy.yaml → stateDiagram-v2 ... **Query.** States, transitions, conditions, attributes — ask any question the flow can answer. -**Sessions.** Init, show, set-state, transition, list. Subflow push/pop for nested workflows. One `--session` flag turns any command session-aware. +**Sessions.** Init, show, set-state, transition, list. Subflow push/pop for nested workflows. Auto-enters initial subflow on `session init`. One `--session` flag turns any command session-aware (including `validate` and `states`). **Config.** `flowr config` shows where every value comes from — default, pyproject.toml, or CLI override. @@ -111,8 +112,8 @@ review $ flowr next deploy.yaml review state: review -next: approve (guarded) -next: reject + approve → deployed [blocked] need: score=>=80 + reject → failed $ flowr transition deploy.yaml review approve --evidence score=85 from: review @@ -152,15 +153,15 @@ default_session = default (default) | `flowr validate ` | Validate a flow definition | | `flowr states ` | List all state ids | | `flowr check []` | Show state details or transition conditions | -| `flowr next [--evidence K=V]` | Show valid next transitions | +| `flowr next [--evidence K=V]` | Show all transitions with trigger→target and condition status | | `flowr transition [--evidence K=V]` | Compute next state | | `flowr mermaid ` | Export as Mermaid state diagram | -| `flowr session init [--name NAME]` | Create a new session at the flow's initial state | +| `flowr session init [--name NAME]` | Create a new session (auto-enters initial subflow) | | `flowr session show [--name NAME] [--format FORMAT]` | Display current session state | | `flowr session set-state [--name NAME]` | Update the session's current state | | `flowr session list [--format FORMAT]` | List all sessions | | `flowr config [--json]` | Show resolved configuration with sources | -| `flowr --session ` | Run a command using session state | +| `flowr --session ` | Run a command using session state (works with validate, states, check, next, transition) | `` accepts a file path or a short flow name (resolved from `.flowr/flows/`). Use `--flows-dir` to override the configured flows directory. All commands accept `--json` for machine-readable output. Evidence: `--evidence key=value` (repeatable) or `--evidence-json '{"key": "value"}'`. diff --git a/docs/adr/ADR_20260426_subflow_resolution.md b/docs/adr/ADR_20260426_subflow_resolution.md index 8c6dd7c..72112a4 100644 --- a/docs/adr/ADR_20260426_subflow_resolution.md +++ b/docs/adr/ADR_20260426_subflow_resolution.md @@ -17,17 +17,19 @@ When a state references a subflow via its `flow` field, the CLI needs to locate ## Decision -Subflow lookup resolves the `flow` field as a relative path from the root flow's directory, including file extension. Subflow entry is displayed as `/`. +Subflow lookup resolves the `flow` field as a relative path from the root flow's directory. The resolver tries the path as-is first; if the file does not exist, it appends `.yaml` and tries again. This makes the `.yaml` extension optional — flow authors can write `flow: discovery-flow` or `flow: discovery-flow.yaml`. Subflow entry is displayed as `/`. + +**Amendment (2026-05-05):** The original decision required the `.yaml` extension explicitly. This was changed because real production flows omit the extension (e.g., `flow: discovery-flow`), and requiring it added friction. The fallback-append approach keeps explicit extensions working while also supporting bare names. ## Reason -Convention-over-configuration with path flexibility; matches project layout; supports subdirectories; no extra CLI flags needed. +Convention-over-configuration with path flexibility; matches project layout; supports subdirectories; no extra CLI flags needed. The extension-optional fallback reduces ceremony for the common case (same directory, `.yaml` files) while preserving support for explicit paths and subdirectories. ## Alternatives Considered - **Explicit --flows-dir flag**: over-engineered for v1; adds cognitive load - **Walk parent directories**: fragile; could find wrong files -- **Auto-append .yaml extension**: adds magic; explicit extension is simpler +- **Require .yaml extension always**: original decision; rejected after production flows showed authors prefer bare names - **Subflow output as just first state**: loses context about which subflow you're entering - **Subflow output as just flow name**: loses context about entry point @@ -36,11 +38,19 @@ Convention-over-configuration with path flexibility; matches project layout; sup - (+) Zero configuration; works with existing flow file layout - (+) Supports subdirectories and relative paths - (+) Clear, unambiguous output showing subflow name + entry state -- (-) Flow field must include `.yaml` extension — existing reference YAML files need updating +- (+) `.yaml` extension is optional — bare flow names work (e.g., `flow: discovery-flow`) - (-) Flow field has dual meaning (lookup path vs canonical flow name from loaded YAML) ## Risk Assessment | Risk | Probability | Impact | Mitigation | Accepted? | |------|------------|--------|------------|-----------| -| Flow field has dual meaning (lookup path vs canonical flow name) | Low | Medium | Ambiguity is mitigated by convention; loader extracts canonical name from loaded YAML | Yes | \ No newline at end of file +| Flow field has dual meaning (lookup path vs canonical flow name) | Low | Medium | Ambiguity is mitigated by convention; loader extracts canonical name from loaded YAML | Yes | +| Bare name matches wrong file in subdirectory | Low | Low | Resolution is relative to root flow's directory; convention keeps flows flat | Yes | + +## Changes + +| Date | Change | Reason | +|------|--------|--------| +| 2026-04-26 | Initial decision: require `.yaml` extension | Original ADR | +| 2026-05-05 | Made `.yaml` extension optional with fallback | Production flows use bare names; requiring extension was friction (PM_20260505_subflow-mechanism-non-functional) | \ No newline at end of file diff --git a/docs/features/backlog/subflow-transition-overhaul.feature b/docs/features/backlog/subflow-transition-overhaul.feature new file mode 100644 index 0000000..018d8d1 --- /dev/null +++ b/docs/features/backlog/subflow-transition-overhaul.feature @@ -0,0 +1,161 @@ +Feature: Subflow Transition Overhaul + + The subflow mechanism (push/pop stack for nested flows) is completely + non-functional for real flows due to two critical bugs in path resolution + and exit handling. Additionally, the `next` command hides critical + navigation information — trigger names, guarded transitions, and evidence + requirements — making autonomous agent navigation impossible. + + This feature fixes the subflow mechanism, enhances `next` to show the + complete navigation picture, and improves session command robustness. + + Depends on: session-management (core), session-management-extended. + + Status: ELICITING + + Rules (Business): + - Flow references without `.yaml` extension are resolved by appending `.yaml` if the bare path doesn't exist + - Subflow exit resolves through the parent flow's transition map, not by using the exit name directly + - Subflow chaining: after exiting one subflow, if the resolved target enters another subflow, the stack is pushed again + - `session init` auto-enters the initial subflow if the first state has a `flow:` field + - `next` always shows ALL transitions including blocked/guarded ones, with status markers + - `next` output shows trigger→target mapping with inline condition requirements + - `next` JSON output uses `transitions` array of objects with `trigger`, `target`, `status`, `conditions` + - `check --session ` correctly shows transition conditions + - `states --session` and `validate --session` resolve the current flow from session + + Constraints: + - Non-session commands remain backward compatible + - `next` JSON output is a breaking change: `next` array of strings replaced with `transitions` array of objects + - Domain layer (Session, SessionStackFrame) changes are minimal — fixes are in CLI/transition logic + + ## Questions + + | ID | Question | Status | Answer / Assumption | + |----|----------|--------|---------------------| + | Q1 | Should `next` show all transitions or require `--all` flag? | Resolved | Always show all — agents need the full picture | + | Q2 | Should `next` JSON preserve backward compatibility? | Resolved | No — clean break, replace `next` array with `transitions` objects | + | Q3 | Should `set-state` support crossing flow boundaries? | Open | Phase 3 — may add `--flow` flag for recovery scenarios | + + ## Changes + + | Session | Q-IDs | Change | + |---------|-------|--------| + | 2026-05-05 S1 | Q1-Q3 | Created: subflow resolution, exit handling, next overhaul, session robustness | + + Rule: Subflow Path Resolution + As a flowr user + I want flow references without file extensions to resolve correctly + So that I can use clean flow names in YAML definitions + + @id:sf-001 + Example: Flow reference without .yaml extension resolves correctly + Given a flow YAML with `flow: discovery-flow` referencing a file `discovery-flow.yaml` + When the CLI resolves subflows + Then the subflow is loaded successfully + + @id:sf-002 + Example: Flow reference with .yaml extension still works + Given a flow YAML with `flow: child.yaml` referencing a file `child.yaml` + When the CLI resolves subflows + Then the subflow is loaded successfully + + Rule: Subflow Exit Resolution + As a flowr user + I want subflow exits to correctly resolve through the parent flow's transitions + So that my session lands on the correct next state after completing a subflow + + @id:sf-003 + Example: Subflow exit resolves parent transition target + Given a session inside discovery-flow with stack frame pointing to main-flow/discovery + And discovery-flow exits with `complete` + And main-flow/discovery maps `complete → architecture` + When the user transitions with the exit trigger + Then the session pops the stack and lands at main-flow/architecture + + @id:sf-004 + Example: Subflow chaining enters next subflow after exit + Given a session exiting discovery-flow back to main-flow + And the resolved target `architecture` has `flow: architecture-flow` + When the subflow exit resolves to architecture + Then the session pushes a new stack frame and enters architecture-flow + + @id:sf-005 + Example: Subflow exit with invalid parent state produces error + Given a session inside a subflow with a corrupted stack frame + When the subflow exit cannot find the parent state + Then the CLI prints a clear error indicating the parent state is invalid + + Rule: Session Init Enters Subflow + As a flowr user + I want session init to automatically enter the initial subflow + So that I can start working immediately without an extra transition step + + @id:sf-006 + Example: Session init auto-enters subflow when initial state has flow field + Given a flow whose initial state has `flow: discovery-flow` + When the user runs flowr session init main-flow + Then the session is created inside discovery-flow with a stack frame pointing to main-flow + + @id:sf-007 + Example: Session init without subflow works as before + Given a flow whose initial state has no `flow:` field + When the user runs flowr session init simple-flow + Then the session is created at the initial state with no stack + + Rule: Next Shows Full Transition Map + As a flowr user + I want to see all transitions with trigger names, targets, and condition requirements + So that I can navigate without reading raw YAML files + + @id:sf-008 + Example: Next shows trigger→target mapping for all transitions + Given a session at a state with transitions `needs_full_discovery → event-storming`, `needs_scope_only → scope-boundary` + When the user runs flowr next --session + Then the output shows each trigger name alongside its target state + + @id:sf-009 + Example: Next shows blocked guarded transitions with condition hints + Given a session at a state with a guarded transition requiring `committed_to_main_locally=verified` + When the user runs flowr next --session without evidence + Then the blocked transition appears with a marker showing the required evidence + + @id:sf-010 + Example: Next shows passing guarded transitions + Given a session at a state with a guarded transition requiring `committed_to_main_locally=verified` + When the user runs flowr next --session --evidence committed_to_main_locally=verified + Then the transition appears as passing/open + + @id:sf-011 + Example: Next JSON output uses transitions array with full details + Given a session at a state with transitions + When the user runs flowr next --session --json + Then the output contains a `transitions` array where each entry has `trigger`, `target`, `status`, and `conditions` + + Rule: Check Session Shows Conditions + As a flowr user + I want to check transition conditions from session mode + So that I know exactly what evidence to provide + + @id:sf-012 + Example: Check session with target shows transition conditions + Given a session at a state with a guarded transition + When the user runs flowr check --session + Then the output shows the conditions required for that transition + + Rule: Session-Aware States and Validate + As a flowr user + I want to list states and validate the current flow from session + So that I can inspect my current (sub)flow context without specifying the flow name + + @id:sf-013 + Example: States command with --session lists current flow's states + Given a session inside architecture-flow + When the user runs flowr states --session + Then the output lists all states in architecture-flow + + @id:sf-014 + Example: Validate command with --session validates current flow + Given a session inside architecture-flow + When the user runs flowr validate --session + Then the output validates architecture-flow diff --git a/docs/interview-notes/IN_20260505_subflow-transition-overhaul.md b/docs/interview-notes/IN_20260505_subflow-transition-overhaul.md new file mode 100644 index 0000000..e032842 --- /dev/null +++ b/docs/interview-notes/IN_20260505_subflow-transition-overhaul.md @@ -0,0 +1,77 @@ +# IN_20260505_subflow-transition-overhaul — Subflow mechanism and agent UX overhaul + +> **Status:** COMPLETE +> **Interviewer:** PO +> **Participant(s):** Stakeholder +> **Session type:** Feature specification + +--- + +## Feature: subflow-transition-overhaul + +| ID | Question | Answer | +|----|----------|--------| +| Q1 | What is the core problem discovered? | A full dry run of the main-flow revealed two critical bugs that make the entire subflow mechanism non-functional for real flows, plus multiple agent UX gaps that would prevent effective navigation even after the bugs are fixed. | +| Q2 | What is the first critical bug? | `resolve_subflows()` in `loader.py:39` constructs paths as `root_path.parent / state.flow`, but real flow references omit the `.yaml` extension (e.g., `flow: discovery-flow`). The resulting path `.flowr/flows/discovery-flow` doesn't exist, so subflows are never resolved. Tests pass because fixtures use explicit `.yaml` extensions. | +| Q3 | What is the second critical bug? | When a subflow exits, `pop_stack(target)` at `__main__.py:596` uses the exit name directly as the new state (e.g., `"complete"`), but it should resolve through the parent flow's transition map. In `main-flow`, `discovery` state maps `complete → architecture`, but the session ends up at the invalid state `main-flow/complete`. The agent is dead-ended. | +| Q4 | Does `session init` handle initial subflows? | No. When the first state has a `flow:` field (like `main-flow/discovery` which has `flow: discovery-flow`), `session init` sets the state but never pushes the subflow stack. The agent sits at the wrapper state without entering the subflow. | +| Q5 | What is the `next` command's output problem? | `next` shows only TARGET state names (e.g., `next: event-storming`), but agents need TRIGGER names to transition (e.g., `needs_full_discovery`). There is no combined view showing the trigger→target mapping. The agent must run both `check` (for trigger names) and `next` (for target states) and mentally map them. | +| Q6 | What about guarded transitions? | `next` filters out transitions whose conditions aren't met by the provided evidence. An agent that hasn't provided evidence sees fewer transitions — potentially zero — with no indication that guarded transitions exist or what evidence they need. This is especially dangerous at exit transitions like `scope-boundary`'s `done → complete` which requires `committed_to_main_locally=verified`. | +| Q7 | Does `check --session ` work? | No. The argparse captures the target argument as the `flow_file` positional (because `flow_file` is the first optional positional). So `check --session project approved` sets `flow_file="approved"` and `target=None`, showing state details instead of transition conditions. | +| Q8 | Can `set-state` cross flow boundaries for recovery? | No. `set-state` validates the target state exists in the current flow only. Subflow states aren't in the parent flow's state list, so you can't teleport into a subflow. And the stack isn't managed either. | +| Q9 | What about `--session` on other commands? | `--session` only exists on `check`, `next`, and `transition`. You can't run `states --session` to list states in your current (sub)flow, or `validate --session` to validate it. | +| Q10 | What design decisions were made? | (1) `next` should ALWAYS show all transitions including guarded/blocked ones, with status markers — agents need the full picture. (2) JSON output for `next` should be a clean break: replace `"next": [strings]` with `"transitions": [{trigger, target, status, conditions}]`. No backward compatibility needed. | +| Q11 | What is the proposed subflow exit fix? | When a subflow exit is detected: (1) load the parent flow from the stack frame, (2) look up the exit trigger in the parent state's `next` map, (3) use the resolved target as the new state, (4) check if the resolved target enters ANOTHER subflow (handle chaining). This requires passing `flows_dir` to `_apply_session_transition`. | +| Q12 | Should the `next` text output format change? | Yes. From `next: event-storming` to ` needs_full_discovery → event-storming` with inline condition hints: ` done → complete (needs: committed_to_main_locally=verified)` or ` done → complete (blocked: committed_to_main_locally=verified)`. | + +--- + +## Quality Attributes + +| ID | Attribute | Scenario | Target | Priority | +|----|-----------|----------|--------|----------| +| QA1 | Reliability | When a session transitions through a subflow exit, the parent transition is correctly resolved and the session lands on a valid state in the parent flow | Subflow exit always resolves through parent transition map | Must | +| QA2 | Reliability | When a flow references a subflow by name without `.yaml`, the subflow is resolved correctly | Try path as-is, then with `.yaml` appended | Must | +| QA3 | Usability | When an agent runs `next`, all available transitions are visible including guarded ones that need evidence | Always show all transitions with status markers | Must | +| QA4 | Usability | When an agent runs `next`, trigger names are visible alongside target states | Show `trigger → target` format | Must | +| QA5 | Discoverability | When an agent runs `next`, evidence keys required by guarded transitions are visible inline | Show condition key=value requirements in output | Must | +| QA6 | Backward Compatibility | Non-session commands continue to work identically | Zero behavior change for non-session invocations | Must | + +--- + +## Pain Points Identified + +- Subflow mechanism is completely non-functional for real flows — two critical bugs prevent any subflow from being entered or exited correctly +- `next` command hides critical information: trigger names, guarded transitions, and evidence requirements are all invisible +- Session can enter an invalid state (non-existent state ID) from which recovery requires manual YAML editing +- `session init` leaves the agent stranded at a wrapper state instead of entering the initial subflow +- No single command gives the agent a complete picture of where they are and what they can do +- `check --session ` silently ignores the target argument due to argparse capture +- Evidence keys are completely undiscoverable — the agent must read raw YAML to know what to provide +- Tests have a false-positive gap: fixtures use `.yaml` extensions in flow references, hiding the resolve_subflows bug + +## Business Goals Identified + +- Make the subflow mechanism actually work for real multi-level flow hierarchies +- Enable agents to navigate flows autonomously by making all navigation information visible from CLI commands +- Prevent sessions from entering invalid/broken states +- Support subflow→subflow chaining (e.g., main-flow → discovery-flow → exit → architecture-flow) + +## Terms to Define (for glossary) + +- Subflow entry — the push-stack operation when transitioning to a state with a `flow:` field +- Subflow exit — the pop-stack operation when a transition targets a name in the flow's `exits` list +- Exit resolution — resolving the exit name through the parent flow's transition map to get the actual target state +- Subflow chaining — entering a new subflow immediately after exiting one (e.g., discovery-flow exits → main-flow transitions → architecture-flow enters) +- Blocked transition — a guarded transition whose conditions are not met by the provided evidence + +## Action Items + +- [ ] Fix `resolve_subflows()` to handle missing `.yaml` extension +- [ ] Fix subflow exit to resolve parent transition target with chaining support +- [ ] Fix `session init` to auto-enter initial subflow +- [ ] Enhance `next` output to show trigger→target mapping with conditions +- [ ] Fix `check --session ` argparse dispatch +- [ ] Add `--session` to `states` and `validate` +- [ ] Add tests without `.yaml` extension in flow references +- [ ] Add tests for subflow exit resolution and chaining diff --git a/docs/post-mortem/PM_20260505_subflow-mechanism-non-functional.md b/docs/post-mortem/PM_20260505_subflow-mechanism-non-functional.md new file mode 100644 index 0000000..60c5a13 --- /dev/null +++ b/docs/post-mortem/PM_20260505_subflow-mechanism-non-functional.md @@ -0,0 +1,84 @@ +# PM_20260505_subflow-mechanism-non-functional: Two critical bugs made the entire subflow mechanism non-functional for real flows + +## Failed At + +Production readiness — a full dry run of the main-flow discovered that the subflow mechanism (push/pop stack for nested flows) was completely broken. Subflows could not be entered (path resolution bug) and could not be exited correctly (exit resolution bug). The session-management features were delivered and tested, but the tests had a false-positive gap: fixtures used `.yaml` extensions in flow references, hiding the path resolution bug. + +## Root Cause + +Two independent bugs in the CLI/transition layer, plus a systematic testing gap: + +1. **Path resolution bug** (`loader.py:39`): `resolve_subflows()` constructs paths as `root_path.parent / state.flow`, but real flow definitions omit the `.yaml` extension (e.g., `flow: discovery-flow`). The resulting path doesn't exist, so subflows are never loaded into the resolved flow graph. Tests pass because all test fixtures use explicit `.yaml` extensions (e.g., `flow: child.yaml`). + +2. **Exit resolution bug** (`__main__.py:596`): When a subflow exits via `pop_stack(target)`, the exit name (e.g., `"complete"`) was used directly as the new state. But the parent flow's transition map maps exit names to real target states (e.g., `complete → architecture`). The session ended up at the invalid state `main-flow/complete` — a state that doesn't exist in main-flow. The agent is dead-ended. + +3. **Stack frame bug** (`__main__.py:654`): `_enter_subflow()` recorded `session.state` (the pre-transition state) instead of `target_state_id` (the subflow wrapper state) in the stack frame. On subflow exit, `_resolve_subflow_exit()` looks up the exit name in the parent state's `next` map — but the stack pointed to the wrong state, so the lookup failed silently and fell back to using the exit name as a bare state ID. Example: entering tdd-cycle from `development-flow/project-structuring` pushed `{state: project-structuring}`, but the exit map lives on the `tdd-cycle` state — so `blocked` couldn't resolve to `project-structuring`. + +4. **Testing gap**: Tests used `.yaml` extensions in flow references and tested subflow exit only to exit names that happened to also be valid state IDs in the parent flow. No test exercised the full production flow hierarchy or verified that the stack frame pointed to the correct parent state. + +## What Happened + +The session-management feature was implemented in two phases (core + extended) and all tests passed. However, when the main-flow was dry-run end-to-end for the first time, every subflow transition failed: + +| Step | Expected | Actual | Bug | +|------|----------|--------|-----| +| `session init main-flow` | Enter `discovery-flow/stakeholder-interview` | Stay at `main-flow/discovery` | `resolve_subflows` fails, `session init` doesn't enter subflow | +| Transition inside discovery-flow | Move to next state | Success (no subflow needed) | — | +| Exit discovery-flow → enter architecture-flow | `main-flow/architecture` → enter `architecture-flow/architecture-assessment` | Session at invalid state `main-flow/complete` | Exit uses exit name directly, not parent transition target | + +Additionally, even if the subflow mechanism had worked, the agent UX would have been broken: + +- `next` showed only target state names without trigger names — agents couldn't discover which trigger to use +- `next` hid guarded/blocked transitions — agents saw fewer options than actually available +- `check --session ` silently swallowed the target argument due to argparse capturing it as `flow_file` +- `states --session` and `validate --session` didn't exist + +## Missed Gate + +**Test fixtures should match production conventions.** The test fixtures used `flow: child.yaml` (with extension) while real flows use `flow: discovery-flow` (without extension). No validation ensured that test fixtures represented realistic flow definitions. + +**E2E smoke test.** No test exercised the complete flow hierarchy (main-flow → discovery-flow → exit → architecture-flow). The feature was declared complete based on unit tests with simplified fixtures that didn't reproduce the production bug surface. + +## Fix + +### Phase 1: Critical Bugs (sf-001 through sf-007) + +| ID | Fix | File | Change | +|----|-----|------|--------| +| sf-001, sf-002 | `resolve_subflows()` tries path as-is, then appends `.yaml` | `flowr/domain/loader.py:34-48` | Added fallback: `if not subflow_path.exists(): subflow_path = parent / (state.flow + ".yaml")` | +| sf-003 | Subflow exit resolves through parent's transition map | `flowr/__main__.py:660-694` | New `_resolve_subflow_exit()`: loads parent flow from stack, looks up exit name in parent state's `next` map, uses resolved target | +| sf-004 | Subflow chaining: exit + immediate entry into next subflow | `flowr/__main__.py:688-694` | `_resolve_subflow_exit()` calls `_enter_subflow()` on the resolved target, handling chains like discovery → architecture | +| sf-005 | Invalid parent state produces clear error | `flowr/__main__.py:679-680` | Fallback to `pop_stack(exit_name)` when parent flow/state can't be resolved | +| sf-006, sf-007 | `session init` auto-enters initial subflow | `flowr/cli/session_cmd.py:97-108` | After `store.init()`, checks if first state has `flow:` field, resolves subflow, pushes stack | +| sf-bug | Stack frame records subflow wrapper state (not pre-transition state) | `flowr/__main__.py:654` | Changed `state=session.state` to `state=target_state_id` — exit resolution now finds the correct parent `next` map | + +### Phase 2: Agent UX (sf-008 through sf-014) + +| ID | Fix | File | Change | +|----|-----|------|--------| +| sf-008-sf-011 | `next` shows all transitions with trigger→target, status, conditions | `flowr/__main__.py:516-557` | New `_build_transition_list()` and `_format_transitions_text()`; JSON replaces `next: [strings]` with `transitions: [{trigger, target, status, conditions}]` | +| sf-012 | `check --session ` correctly routes target argument | `flowr/__main__.py:807-825` | Uses `effective_target = args.target or getattr(args, "flow_file", None)` when `--session` is active | +| sf-013, sf-014 | `states --session` and `validate --session` resolve current flow | `flowr/__main__.py:933+` | New `_cmd_states_session()` and `_cmd_validate_session()`; dispatch refactored into `_dispatch_session_command()` | + +### Testing + +- Added tests for `resolve_subflows` without `.yaml` extension +- Added `TestSubflowExitResolution` with 4 tests: simple exit, chaining, fallback, push without extension +- Updated `next_command_test.py` to match new output format +- All existing tests pass (no regressions) + +### Key Design Decisions + +1. **`next` ALWAYS shows all transitions** — including blocked/guarded ones with status markers. Agents need the full picture even when they can't take a transition yet. +2. **JSON output is a breaking change** — `next` array of strings replaced with `transitions` array of objects. No backward compatibility needed (pre-release). +3. **Subflow exit uses exit name as parent trigger** — the exit name (e.g., `complete`) is looked up in the parent state's `next` map to find the real target. +4. **Chaining is handled inline** — `_resolve_subflow_exit` calls `_enter_subflow` on the resolved target, so discovery→architecture chaining is atomic. + +## Restart Check + +Before declaring any feature "complete and tested": + +1. **Dry run the production flow end-to-end.** Unit tests with simplified fixtures are necessary but not sufficient. Run `session init` → full flow → completion against the actual flow hierarchy before closing the feature. +2. **Test fixtures must match production conventions.** If production flows omit `.yaml` extensions, test fixtures must omit them too. Audit fixture conventions against real flow definitions. +3. **Test the full transition chain, not individual links.** Subflow entry, execution, and exit must be tested as a complete sequence, not as isolated unit tests that happen to pass individually. +4. **Verify agent UX, not just correctness.** After fixing bugs, verify that an agent using only CLI commands can discover triggers, conditions, and navigate the full flow without reading raw YAML files. diff --git a/docs/spec/domain_model.md b/docs/spec/domain_model.md index 345e3c6..57ad15a 100644 --- a/docs/spec/domain_model.md +++ b/docs/spec/domain_model.md @@ -118,7 +118,7 @@ flowr is a Python library and CLI for defining, validating, and visualizing non- | `FlowNameResolution` | Service | Resolves a short flow name to a file path using the configured flows directory; file paths take priority over name resolution | CLI | — | | `ResolvedFlowPath` | Value Object | The resolved file path for a flow name, or an error indicating the name was not found | CLI | — | | `Session` | Entity | A persistent record of workflow state (flow name, current state, subflow stack, params) that survives across CLI invocations | Session Tracking | Yes | -| `SessionStackFrame` | Value Object | A single frame in the session call stack, recording the parent flow name and state at the point a subflow was entered | Session Tracking | — | +| `SessionStackFrame` | Value Object | A single frame in the session call stack, recording the parent flow name and the subflow wrapper state (the state with the `flow:` field whose `next` map defines exit resolution) | Session Tracking | — | | `SessionStore` | Service | Persistence service for sessions; reads and writes session YAML files in `.flowr/sessions/` with atomic writes | Session Tracking | — | | `CurrentSession` | Value Object | Read model representing the current session state for display | Session Tracking | — | @@ -141,7 +141,7 @@ flowr is a Python library and CLI for defining, validating, and visualizing non- | `CLIEntrypoint` | dispatches | `Session` | 1:0..1 | Session-aware commands use the current session | | `FlowrConfig` | configures | `FlowNameResolution` | 1:1 | The resolved configuration provides the flows directory for name resolution | | `Session` | references | `Flow` | N:1 | A session tracks the current flow by name | -| `Session` | contains | `SessionStackFrame` | 1:0..N | A session has a stack of parent contexts for subflows; each frame records the parent flow+state | +| `Session` | contains | `SessionStackFrame` | 1:0..N | A session has a stack of parent contexts for subflows; each frame records the parent flow and the subflow wrapper state (the state with the `flow:` field) | | `SessionStore` | persists | `Session` | 1:N | The session store manages all session files in `.flowr/sessions/` | | `FlowNameResolution` | resolves | `Flow` | N:1 | Name resolution maps a flow name to a flow file path | diff --git a/docs/spec/flow_definition_spec.md b/docs/spec/flow_definition_spec.md index 46411b9..744e758 100644 --- a/docs/spec/flow_definition_spec.md +++ b/docs/spec/flow_definition_spec.md @@ -264,6 +264,10 @@ Plain strings without operators are treated as `==value`. Evidence keys must exa - Parent `next` keys must match child's `exits` list exactly - Subflows use a call-stack mechanism: push on entry, pop on exit - Context is isolated: only current flow visible in responses +- The `flow` field value is a relative file path from the root flow's directory; the `.yaml` extension is optional — the resolver tries the path as-is first, then appends `.yaml` if the file is not found +- On subflow exit, the exit name is resolved through the parent flow's transition map to determine the actual target state (not used directly as a state ID) +- Subflow chaining: if the resolved target after a subflow exit is itself a subflow state (has a `flow:` field), the stack is pushed again to enter the next subflow +- `session init` automatically enters the initial subflow if the first state has a `flow:` field --- @@ -307,24 +311,18 @@ Parent flows constrain compatibility: `flow-version: "^1"` ## Session Format (Minimal — v1) ```yaml -session: a1b2c3d4-... -started: "2026-04-25T10:00:00Z" -current: - flow: arch-cycle - state: interview - stack: - - flow: feature-flow - state: step-2-arch -params: - feature-flow: - feature_slug: user-auth - branch_name: feat/user-auth - arch-cycle: - feature_slug: user-auth - branch_name: feat/user-auth +flow: feature-flow +state: step-1-scope +name: default +created_at: "2026-05-01T10:00:00" +updated_at: "2026-05-01T14:25:00" +stack: + - flow: main-flow + state: discovery +params: {} ``` -Fields: `session` (UUID), `started` (ISO 8601), `current` (flow + state), `stack` (for subflows; push on entry, pop on exit), `params` (per-flow variable namespace). +Fields: `flow` (current flow name — changes when entering subflows), `state` (current state ID), `name` (session name = filename stem), `created_at` (ISO 8601 creation time), `updated_at` (ISO 8601 last update time), `stack` (subflow call stack; push on entry, pop on exit), `params` (per-flow variable namespace; reserved for future). **Note:** Transition counts and history tracking are **not included** in v1. diff --git a/docs/spec/glossary.md b/docs/spec/glossary.md index e4b0876..bea28b3 100644 --- a/docs/spec/glossary.md +++ b/docs/spec/glossary.md @@ -481,6 +481,42 @@ Entries are sorted alphabetically. --- +## Subflow Chaining + +**Definition:** The process of automatically entering a new subflow immediately after exiting one, when the resolved target state in the parent flow also has a `flow:` field. + +**Aliases:** chained subflow, sequential subflow entry + +**Example:** "When discovery-flow exits to main-flow's architecture state (which has `flow: architecture-flow`), the session automatically pushes a new stack frame and enters architecture-flow — no separate transition needed." + +**Source:** subflow-transition-overhaul — Interview IN_20260505_subflow-transition-overhaul + +--- + +## Exit Resolution + +**Definition:** The process of resolving a subflow exit name through the parent flow's transition map to determine the actual target state, rather than using the exit name directly as a state ID. + +**Aliases:** subflow exit resolution, parent transition resolution + +**Example:** "When discovery-flow exits with `complete`, exit resolution looks up `complete` in the parent state's `next` map in main-flow and finds `complete → architecture`, so the session lands at main-flow/architecture instead of the invalid main-flow/complete." + +**Source:** subflow-transition-overhaul — Interview IN_20260505_subflow-transition-overhaul + +--- + +## Blocked Transition + +**Definition:** A guarded transition whose conditions are not met by the provided evidence; displayed by the `next` command with a `[blocked]` marker and inline condition requirements. + +**Aliases:** guarded transition (unmet), blocked + +**Example:** "The transition `done → complete` requires `committed_to_main_locally=verified`; without that evidence, `next` shows it as `done → complete [blocked] need: committed_to_main_locally=verified`." + +**Source:** subflow-transition-overhaul — Interview IN_20260505_subflow-transition-overhaul + +--- + ## System Architect (SA) **Definition:** The agent responsible for translating accepted requirements into an architectural design, writing domain stubs, recording architectural decisions, and verifying implementation against the design. diff --git a/docs/spec/system.md b/docs/spec/system.md index 5c6b6bb..9c828c6 100644 --- a/docs/spec/system.md +++ b/docs/spec/system.md @@ -9,7 +9,7 @@ ## Summary -flowr is a Python library and CLI for defining, validating, and visualizing non-deterministic state machine workflows in YAML. It provides a reference validator that checks flow definitions against the specification, a Mermaid converter that generates stateDiagram-v2 diagrams, a CLI with six subcommands (validate, states, check, next, transition, mermaid) for one-shot flow definition interaction, and a session management system that tracks workflow state across CLI invocations. Flow name resolution allows CLI arguments to accept short flow names (resolved from the configured flows directory) as well as file paths. Named condition groups allow flow authors to define reusable condition expressions at the state level and reference them by name in `when` clauses, eliminating repetition while remaining fully backwards compatible. The system uses Python dataclasses for its internal representation and PyYAML for parsing flow definition and session files. +flowr is a Python library and CLI for defining, validating, and visualizing non-deterministic state machine workflows in YAML. It provides a reference validator that checks flow definitions against the specification, a Mermaid converter that generates stateDiagram-v2 diagrams, a CLI with six subcommands (validate, states, check, next, transition, mermaid) for one-shot flow definition interaction, and a session management system that tracks workflow state across CLI invocations. Flow name resolution allows CLI arguments to accept short flow names (resolved from the configured flows directory) as well as file paths. Named condition groups allow flow authors to define reusable condition expressions at the state level and reference them by name in `when` clauses, eliminating repetition while remaining fully backwards compatible. The subflow mechanism supports multi-level flow hierarchies with automatic path resolution (`.yaml` extension optional), exit resolution through parent transition maps, and subflow chaining (entering a new subflow immediately after exiting one). The `next` command shows all transitions including guarded/blocked ones with trigger→target mapping and condition hints. Session-aware mode is available on all read commands (validate, states, check, next) and the transition command. `session init` automatically enters the initial subflow when the first state has a `flow:` field. The system uses Python dataclasses for its internal representation and PyYAML for parsing flow definition and session files. --- @@ -92,19 +92,19 @@ Developers interact via the CLI (`python -m flowr `) or the Python A | Module | Responsibility | Bounded Context | |--------|----------------|-----------------| -| `flowr/__main__.py` | CLI entrypoint: builds argparse parser with subcommands and global flags (`--flows-dir`); resolves flow names; dispatches; formats output; session-aware command mode; exit codes | CLI | +| `flowr/__main__.py` | CLI entrypoint: builds argparse parser with subcommands and global flags (`--flows-dir`); resolves flow names; dispatches via `_dispatch_session_command` for session-aware routing; formats output; session-aware mode on validate/states/check/next/transition; `next` shows trigger→target with condition status; subflow exit resolution with chaining support; exit codes | CLI | | `flowr/__init__.py` | Package marker; no public API | CLI | | `flowr/cli/__init__.py` | CLI subpackage marker | CLI | | `flowr/cli/output.py` | Output formatting: text and JSON formatters for CLI results | CLI | | `flowr/cli/resolution.py` | Flow name resolution: FlowNameResolver Protocol, DefaultFlowNameResolver, FlowNameNotFound exception | CLI | -| `flowr/cli/session_cmd.py` | Session subcommand group: init, show, set-state, list — parses args, dispatches to SessionStore, formats output | CLI | +| `flowr/cli/session_cmd.py` | Session subcommand group: init (auto-enters initial subflow), show, set-state, list — parses args, dispatches to SessionStore, formats output | CLI | | `flowr/domain/__init__.py` | Domain subpackage marker | Flow Definition | | `flowr/domain/flow_definition.py` | Core domain types: Flow, State, Transition, GuardCondition, ConditionExpression, Param; State carries optional named condition groups; Transition tracks referenced condition groups | Flow Definition | | `flowr/domain/validation.py` | Validation types: ConformanceLevel, Violation, ValidationResult; validate function; condition reference and unused group checks | Flow Definition | | `flowr/domain/condition.py` | Condition evaluation: ConditionOperator enum, evaluate_condition function | Flow Definition | | `flowr/domain/mermaid.py` | Mermaid stateDiagram-v2 conversion: to_mermaid function; shows resolved conditions on transition labels | Flow Definition | | `flowr/domain/session.py` | Session types: Session, SessionStackFrame dataclasses; SessionStore Protocol for persistence interface | Session Tracking | -| `flowr/domain/loader.py` | YAML parsing Protocol and load_flow function; subflow resolution; condition inlining via resolve_when_clause | Flow Definition | +| `flowr/domain/loader.py` | YAML parsing Protocol and load_flow function; subflow resolution (`.yaml` extension optional — tries as-is, then appends `.yaml`); condition inlining via resolve_when_clause | Flow Definition | | `flowr/infrastructure/__init__.py` | Infrastructure subpackage marker | Infrastructure | | `flowr/infrastructure/config.py` | Configuration resolution: FlowrConfig dataclass, resolve_config function; reads `[tool.flowr]` from `pyproject.toml` with CLI overrides | CLI | | `flowr/infrastructure/session_store.py` | Session persistence: YamlSessionStore implements SessionStore Protocol; atomic writes via temp-file-then-rename; loads/lists sessions from `.flowr/sessions/` | Session Tracking | @@ -140,7 +140,7 @@ Developers interact via the CLI (`python -m flowr `) or the Python A - CLI exit codes: 0 = success, 1 = command failed, 2 = usage error (ADR_20260426_cli_io_convention) - CLI output: stdout for results, stderr for errors/warnings (ADR_20260426_cli_io_convention) - Evidence input: `--evidence key=value` for simple, `--evidence-json` for complex (ADR_20260426_cli_io_convention) -- Subflow lookup: `flow` field is relative file path from root flow directory, including extension (ADR_20260426_subflow_resolution) +- Subflow lookup: `flow` field is relative file path from root flow directory; `.yaml` extension is optional — resolver tries path as-is first, then appends `.yaml` (ADR_20260426_subflow_resolution, amended 2026-05-05) - Named condition groups are inlined at load time; after resolution, GuardCondition remains a flat dict; unknown refs raise FlowParseError; empty dicts allowed; unused groups produce SHOULD warnings (ADR_20260426_condition_inlining) - Image generation deferred to v2 (ADR_20260426_image_rendering_deferral) - Flow name resolution: file paths take priority over name resolution; only `.yaml` extension is tried; case-sensitive matching (Technical Design) @@ -159,12 +159,15 @@ Developers interact via the CLI (`python -m flowr `) or the Python A - Fuzzy match: ~= applies ONLY to numeric values with 5% tolerance; no string fuzzy matching (ADR_20260426_fuzzy_match_algorithm) - Validation result: return ValidationResult with list of Violation objects (severity, message, location) — collect all violations at once (ADR_20260426_validation_result) - CLI I/O convention: positional YAML path; --evidence/--evidence-json; 3-tier exit codes (0/1/2); stdout=results/stderr=errors; key-value text output (ADR_20260426_cli_io_convention) -- Subflow resolution: flow field is relative file path from root flow directory including extension; output as / (ADR_20260426_subflow_resolution) +- Subflow resolution: flow field is relative file path from root flow directory; `.yaml` extension optional; output as /; subflow exit resolves through parent transition map with chaining support (ADR_20260426_subflow_resolution, amended 2026-05-05) - Condition inlining: named references resolved at load time in the loader; three when forms (dict, list, string); unknown refs raise FlowParseError; empty dicts allowed; unused groups produce SHOULD warnings; GuardCondition unchanged; Transition gains referenced_condition_groups (ADR_20260426_condition_inlining) - Image rendering: deferred to v2 — no Python-native Mermaid renderer without heavy deps (ADR_20260426_image_rendering_deferral) - Flow name resolution: file paths take priority; only `.yaml` extension tried; case-sensitive; `--flows-dir` global flag overrides config (Technical Design) - Session persistence: atomic writes via temp-file-then-rename; YAML format; no concurrency control (last-write-wins) (Technical Design) -- Session-aware commands: `--session` flag on next/transition/check; `session` subcommand group (init, show, set-state, list); backward compatible (Technical Design) +- Session-aware commands: `--session` flag on next/transition/check/validate/states; `session` subcommand group (init, show, set-state, list); backward compatible (Technical Design) +- `next` command shows ALL transitions including blocked/guarded ones with trigger→target mapping, status markers, and condition hints (Technical Design) +- Subflow exit resolution: when a subflow exits, the exit name is resolved through the parent flow's transition map; if the resolved target enters another subflow, the stack is pushed again (chaining) (Technical Design) +- `session init` auto-enters the initial subflow if the first state has a `flow:` field (Technical Design) - Hexagonal architecture: CLI as primary adapter, domain as core, infrastructure as secondary adapter; SessionStore as Protocol in domain, YamlSessionStore as infrastructure implementation (Technical Design) --- @@ -194,4 +197,5 @@ See `docs/features/` for accepted features. | 2026-04-26 | ADR_20260426_subflow_resolution | Subflow lookup by relative path | Feature flow-definition-spec | | 2026-04-26 | ADR_20260426_condition_inlining | Named condition groups inlined at load time | Feature named-condition-groups | | 2026-04-26 | ADR_20260426_image_rendering_deferral | Image generation deferred to v2 | Feature flow-definition-spec | -| 2026-05-01 | Technical Design | Added Session Tracking bounded context; updated CLI context with flow name resolution and session-aware commands; added SessionStore Protocol and YamlSessionStore; updated module structure with new modules (resolution.py, session_cmd.py, session_store.py); updated aggregate boundary for Session; added Agent Operator actor; added Session Store system | Features cli-flow-name-resolution, session-management | \ No newline at end of file +| 2026-05-01 | Technical Design | Added Session Tracking bounded context; updated CLI context with flow name resolution and session-aware commands; added SessionStore Protocol and YamlSessionStore; updated module structure with new modules (resolution.py, session_cmd.py, session_store.py); updated aggregate boundary for Session; added Agent Operator actor; added Session Store system | Features cli-flow-name-resolution, session-management | +| 2026-05-05 | Technical Design | Updated subflow resolution (`.yaml` extension optional); added subflow exit resolution with chaining; added `session init` auto-subflow entry; expanded `--session` to validate/states; `next` shows all transitions with trigger→target and condition status; updated module descriptions for __main__.py, session_cmd.py, loader.py | Feature subflow-transition-overhaul | \ No newline at end of file diff --git a/flowr/__main__.py b/flowr/__main__.py index ca876b6..10bd03e 100644 --- a/flowr/__main__.py +++ b/flowr/__main__.py @@ -61,9 +61,9 @@ def build_parser() -> argparse.ArgumentParser: def _add_flow_args(parser: argparse.ArgumentParser) -> None: - """Add common args: flow file path and --json flag.""" + """Add common args: flow file path and --text flag.""" parser.add_argument("flow_file", help="Path to flow YAML file or flow name") - parser.add_argument("--json", action="store_true", dest="json_output") + parser.add_argument("--text", action="store_true", dest="text_output") def _add_evidence_args(parser: argparse.ArgumentParser) -> None: @@ -108,11 +108,41 @@ def _add_subcommands(parser: argparse.ArgumentParser) -> None: # validate p_validate = sub.add_parser("validate", help="Validate a flow definition") - _add_flow_args(p_validate) + p_validate.add_argument( + "flow_file", + nargs="?", + default=None, + help="Path to flow YAML file or flow name (required unless --session)", + ) + p_validate.add_argument("--text", action="store_true", dest="text_output") + p_validate.add_argument( + "--session", + nargs="?", + const="__default__", + default=None, + dest="session", + metavar="NAME", + help="Use session name to resolve flow (read-only)", + ) # states p_states = sub.add_parser("states", help="List all states in a flow") - _add_flow_args(p_states) + p_states.add_argument( + "flow_file", + nargs="?", + default=None, + help="Path to flow YAML file or flow name (required unless --session)", + ) + p_states.add_argument("--text", action="store_true", dest="text_output") + p_states.add_argument( + "--session", + nargs="?", + const="__default__", + default=None, + dest="session", + metavar="NAME", + help="Use session name to resolve flow (read-only)", + ) # check p_check = sub.add_parser("check", help="Check a state or transition conditions") @@ -122,7 +152,7 @@ def _add_subcommands(parser: argparse.ArgumentParser) -> None: default=None, help="Path to flow YAML file or flow name (required unless --session)", ) - p_check.add_argument("--json", action="store_true", dest="json_output") + p_check.add_argument("--text", action="store_true", dest="text_output") p_check.add_argument( "state_id", nargs="?", @@ -153,7 +183,7 @@ def _add_subcommands(parser: argparse.ArgumentParser) -> None: default=None, help="Path to flow YAML file or flow name (required unless --session)", ) - p_next.add_argument("--json", action="store_true", dest="json_output") + p_next.add_argument("--text", action="store_true", dest="text_output") p_next.add_argument("state_id", nargs="?", default=None, help="Current state id") _add_evidence_args(p_next) p_next.add_argument( @@ -173,7 +203,7 @@ def _add_subcommands(parser: argparse.ArgumentParser) -> None: nargs="*", help="Args: or with --session", ) - p_transition.add_argument("--json", action="store_true", dest="json_output") + p_transition.add_argument("--text", action="store_true", dest="text_output") _add_evidence_args(p_transition) p_transition.add_argument( "--session", @@ -188,10 +218,10 @@ def _add_subcommands(parser: argparse.ArgumentParser) -> None: # config p_config = sub.add_parser("config", help="Show resolved configuration") p_config.add_argument( - "--json", + "--text", action="store_true", - dest="json_output", - help="Output as JSON", + dest="text_output", + help="Output as human-readable text", ) # mermaid @@ -208,6 +238,9 @@ def _cmd_validate(args: argparse.Namespace) -> int: Returns: Exit code: 0 if valid, 1 if invalid. """ + if args.flow_file is None: + _error("flow_file is required when not using --session") + return 2 flow = load_flow_from_file(args.flow_file) all_flows = resolve_subflows(flow, args.flow_file) result = validate(flow, all_flows if len(all_flows) > 1 else None) @@ -223,10 +256,10 @@ def _cmd_validate(args: argparse.Namespace) -> int: "location": v.location, } ) - if args.json_output: - print(format_json(output)) # noqa: T201 - else: + if args.text_output: print(format_text(output)) # noqa: T201 + else: + print(format_json(output)) # noqa: T201 return 0 if result.is_valid else 1 @@ -236,13 +269,16 @@ def _cmd_states(args: argparse.Namespace) -> int: Returns: Exit code: 0 on success. """ + if args.flow_file is None: + _error("flow_file is required when not using --session") + return 2 flow = load_flow_from_file(args.flow_file) state_ids = [s.id for s in flow.states] - if args.json_output: - print(format_json(state_ids)) # noqa: T201 - else: + if args.text_output: for sid in state_ids: print(sid) # noqa: T201 + else: + print(format_json(state_ids)) # noqa: T201 return 0 @@ -283,10 +319,10 @@ def _cmd_check_state(_flow: Flow, state: State, args: argparse.Namespace) -> int output["flow"] = state.flow transitions = list(state.next.keys()) output["transitions"] = transitions - if args.json_output: - print(format_json(output)) # noqa: T201 - else: + if args.text_output: print(format_text(output)) # noqa: T201 + else: + print(format_json(output)) # noqa: T201 return 0 @@ -308,10 +344,10 @@ def _cmd_check_conditions(_flow: Flow, state: State, args: argparse.Namespace) - output["conditions"] = transition.conditions.conditions else: output["conditions"] = "(none)" - if args.json_output: - print(format_json(output)) # noqa: T201 - else: + if args.text_output: print(format_text(output)) # noqa: T201 + else: + print(format_json(output)) # noqa: T201 return 0 @@ -333,15 +369,11 @@ def _cmd_next(args: argparse.Namespace) -> int: _error(f"State '{args.state_id}' not found") return 1 evidence = _parse_evidence(args) - passing = _find_passing_transitions(state, evidence) - output: dict[str, Any] = { - "state": state.id, - "next": [t.target for t in passing], - } - if args.json_output: - print(format_json(output)) # noqa: T201 + transitions = _build_transition_list(state, evidence) + if args.text_output: + print(_format_transitions_text(state.id, transitions)) # noqa: T201 else: - print(format_text(output)) # noqa: T201 + print(format_json({"state": state.id, "transitions": transitions})) # noqa: T201 return 0 @@ -386,10 +418,10 @@ def _cmd_transition(args: argparse.Namespace) -> int: "trigger": trigger, "to": target, } - if args.json_output: - print(format_json(output)) # noqa: T201 - else: + if args.text_output: print(format_text(output)) # noqa: T201 + else: + print(format_json(output)) # noqa: T201 return 0 @@ -405,7 +437,7 @@ def _cmd_config(args: argparse.Namespace) -> int: rows = [ { "key": "project_root", - "value": str(config.project_root), + "value": _display_path(config.project_root), "source": sources["project_root"], }, { @@ -429,11 +461,11 @@ def _cmd_config(args: argparse.Namespace) -> int: "source": sources["default_session"], }, ] - if args.json_output: - print(format_json(rows)) # noqa: T201 - else: + if args.text_output: for row in rows: print(f"{row['key']} = {row['value']} ({row['source']})") # noqa: T201 + else: + print(format_json(rows)) # noqa: T201 return 0 @@ -445,10 +477,10 @@ def _cmd_mermaid(args: argparse.Namespace) -> int: """ flow = load_flow_from_file(args.flow_file) diagram = to_mermaid(flow) - if args.json_output: - print(format_json({"mermaid": diagram})) # noqa: T201 - else: + if args.text_output: print(diagram) # noqa: T201 + else: + print(format_json({"mermaid": diagram})) # noqa: T201 return 0 @@ -481,6 +513,48 @@ def _find_passing_transitions( return passing +def _build_transition_list( + state: State, evidence: dict[str, str] +) -> list[dict[str, Any]]: + """Build rich transition info for all transitions from a state. + + Returns: + List of dicts with trigger, target, status, and conditions. + """ + transitions: list[dict[str, Any]] = [] + for trigger, transition in state.next.items(): + if transition.conditions is None or _conditions_met( + transition.conditions.conditions, evidence + ): + status = "open" + else: + status = "blocked" + transitions.append( + { + "trigger": trigger, + "target": transition.target, + "status": status, + "conditions": transition.conditions.conditions + if transition.conditions + else None, + } + ) + return transitions + + +def _format_transitions_text(state_id: str, transitions: list[dict[str, Any]]) -> str: + """Format transitions as human-readable text.""" + lines = [f"state: {state_id}"] + for t in transitions: + marker = " [blocked]" if t["status"] == "blocked" else "" + cond = "" + if t["status"] == "blocked" and t["conditions"]: + pairs = ", ".join(f"{k}={v}" for k, v in t["conditions"].items()) + cond = f" need: {pairs}" + lines.append(f" {t['trigger']} → {t['target']}{marker}{cond}") + return "\n".join(lines) + + def _conditions_met(conditions: dict[str, str], evidence: dict[str, str]) -> bool: """Check if all conditions are satisfied by evidence. @@ -509,6 +583,15 @@ def _find_subflow( return None +def _display_path(path: Path) -> str: + """Display a path relative to cwd, or '.' if same.""" + try: + rel = path.relative_to(Path.cwd()) + return "." if rel == Path() else str(rel) + except ValueError: + return str(path) + + def _error(msg: str) -> None: """Print error to stderr.""" print(f"error: {msg}", file=sys.stderr) # noqa: T201 @@ -544,12 +627,104 @@ def _resolve_session( return session, flow, flow_path +def _find_flow_file(flow_name: str, flows_dir: Path) -> Path | None: + """Find a flow file by name in the flows directory.""" + path = flows_dir / (flow_name + ".yaml") + if path.exists(): + return path + path = flows_dir / flow_name + if path.exists(): + return path + return None + + +def _enter_subflow( + session: Session, + parent_flow: Flow, + parent_flow_path: Path, + target_state_id: str, +) -> tuple[Session, str] | None: + """Try to enter a subflow for the given target state. + + Returns (updated_session, display_target) if a subflow was entered, + or None if the target is not a subflow entry point. + + Recursively enters nested subflows if the child's first state + is itself a subflow wrapper (has a ``flow:`` field). + """ + target_state = _find_state(parent_flow, target_state_id) + if target_state is None or target_state.flow is None: + return None + + all_flows = resolve_subflows(parent_flow, parent_flow_path) + child = _find_subflow(all_flows, target_state.flow, parent_flow_path) + if child is None or not child.states: + return None + + frame = SessionStackFrame(flow=parent_flow.flow, state=target_state_id) + subflow_initial = child.states[0].id + updated = session.push_stack(frame, subflow_initial, new_flow=child.flow) + display = f"{child.flow}/{subflow_initial}" + + child_first = child.states[0] + if child_first.flow is not None: + child_flows = resolve_subflows(child, parent_flow_path) + grandchild = _find_subflow(child_flows, child_first.flow, parent_flow_path) + if grandchild is not None and grandchild.states: + nested_frame = SessionStackFrame(flow=child.flow, state=subflow_initial) + gc_initial = grandchild.states[0].id + nested = updated.push_stack( + nested_frame, gc_initial, new_flow=grandchild.flow + ) + return nested, f"{grandchild.flow}/{gc_initial}" + + return updated, display + + +def _resolve_subflow_exit( + session: Session, + trigger: str, + exit_name: str, + flows_dir: Path, +) -> tuple[Session, str]: + """Handle subflow exit: resolve parent transition, handle chaining. + + When a transition targets an exit name and the session has a stack, + this function resolves the actual target through the parent flow's + transition map and handles entering the next subflow if needed. + """ + parent_frame = session.stack[-1] + parent_flow_path = _find_flow_file(parent_frame.flow, flows_dir) + if parent_flow_path is None: + return session.pop_stack(exit_name), exit_name + + parent_flow = load_flow_from_file(parent_flow_path) + parent_state = _find_state(parent_flow, parent_frame.state) + if parent_state is None: + return session.pop_stack(exit_name), exit_name + + parent_transition = parent_state.next.get(exit_name) + if parent_transition is None: + return session.pop_stack(exit_name), exit_name + + resolved_target = parent_transition.target + + popped = session.pop_stack(resolved_target) + + entry = _enter_subflow(popped, parent_flow, parent_flow_path, resolved_target) + if entry is not None: + return entry + + return popped, resolved_target + + def _apply_session_transition( session: Session, flow: Flow, flow_path: Path, trigger: str, evidence: dict[str, str], + flows_dir: Path | None = None, ) -> tuple[Session, str]: """Apply a transition to a session, handling subflow push/pop. @@ -573,30 +748,20 @@ def _apply_session_transition( sys.exit(1) # pragma: no cover target = transition.target - all_flows = resolve_subflows(flow, flow_path) - target_state = _find_state(flow, target) # Check if transition enters a subflow - enters_subflow = target_state is not None and target_state.flow is not None - - if enters_subflow and target_state is not None and target_state.flow is not None: - flow_ref = target_state.flow - child = _find_subflow(all_flows, flow_ref, flow_path) - if child and child.states: - frame = SessionStackFrame(flow=session.flow, state=session.state) - subflow_initial = child.states[0].id - updated_session = session.push_stack( - frame, subflow_initial, new_flow=child.flow - ) - target = f"{child.flow}/{subflow_initial}" - else: # pragma: no cover - updated_session = session.with_state(target) # pragma: no cover - elif session.stack and target in flow.exits: - # Transition exits a subflow + entry = _enter_subflow(session, flow, flow_path, target) + if entry is not None: + return entry + + # Check if transition exits a subflow + if session.stack and target in flow.exits: + if flows_dir is not None: + return _resolve_subflow_exit(session, trigger, target, flows_dir) updated_session = session.pop_stack(target) - else: - updated_session = session.with_state(target) + return updated_session, target + updated_session = session.with_state(target) return updated_session, target @@ -616,7 +781,7 @@ def _cmd_transition_session( session, flow, flow_path = _resolve_session(session_name, config, resolver) evidence = _parse_evidence(args) updated_session, target = _apply_session_transition( - session, flow, flow_path, trigger, evidence + session, flow, flow_path, trigger, evidence, config.flows_path() ) store = YamlSessionStore(config.sessions_path()) @@ -627,10 +792,10 @@ def _cmd_transition_session( "trigger": trigger, "to": target, } - if args.json_output: - print(format_json(output)) # noqa: T201 - else: + if args.text_output: print(format_text(output)) # noqa: T201 + else: + print(format_json(output)) # noqa: T201 sys.exit(0) @@ -675,7 +840,9 @@ def _cmd_check_session( _error(f"State '{session.state}' not found") sys.exit(1) - if args.target is not None: + effective_target = args.target or getattr(args, "flow_file", None) + if effective_target is not None: + args.target = effective_target rc = _cmd_check_conditions(flow, state, args) else: rc = _cmd_check_state(flow, state, args) @@ -697,16 +864,60 @@ def _cmd_next_session( sys.exit(1) evidence = _parse_evidence(args) - passing = _find_passing_transitions(state, evidence) + transitions = _build_transition_list(state, evidence) + if args.text_output: + print(_format_transitions_text(state.id, transitions)) # noqa: T201 + else: + print(format_json({"state": state.id, "transitions": transitions})) # noqa: T201 + sys.exit(0) + + +def _cmd_states_session( + args: argparse.Namespace, config: FlowrConfig, resolver: DefaultFlowNameResolver +) -> None: + """Run states with session-aware flow resolution (read-only).""" + session_name = ( + config.default_session if args.session == "__default__" else args.session + ) + + _session, flow, _flow_path = _resolve_session(session_name, config, resolver) + state_ids = [s.id for s in flow.states] + if args.text_output: + for sid in state_ids: + print(sid) # noqa: T201 + else: + print(format_json(state_ids)) # noqa: T201 + sys.exit(0) + + +def _cmd_validate_session( + args: argparse.Namespace, config: FlowrConfig, resolver: DefaultFlowNameResolver +) -> None: + """Run validate with session-aware flow resolution (read-only).""" + session_name = ( + config.default_session if args.session == "__default__" else args.session + ) + + _session, flow, flow_path = _resolve_session(session_name, config, resolver) + all_flows = resolve_subflows(flow, flow_path) + result = validate(flow, all_flows if len(all_flows) > 1 else None) output: dict[str, Any] = { - "state": state.id, - "next": [t.target for t in passing], + "valid": result.is_valid, + "violations": [], } - if args.json_output: - print(format_json(output)) # noqa: T201 - else: + for v in result.violations: + output["violations"].append( + { + "severity": v.severity.value, + "message": v.message, + "location": v.location, + } + ) + if args.text_output: print(format_text(output)) # noqa: T201 - sys.exit(0) + else: + print(format_json(output)) # noqa: T201 + sys.exit(0 if result.is_valid else 1) def _resolve_flow_for_command( @@ -734,6 +945,29 @@ def _resolve_flow_for_command( sys.exit(1) +_SESSION_COMMANDS = { + "transition": "_cmd_transition_session", + "check": "_cmd_check_session", + "next": "_cmd_next_session", + "states": "_cmd_states_session", + "validate": "_cmd_validate_session", +} + + +def _dispatch_session_command( + args: argparse.Namespace, config: FlowrConfig, resolver: DefaultFlowNameResolver +) -> bool: + """Handle session-aware command dispatch. Returns True if handled.""" + if getattr(args, "session", None) is None: + return False + handler_name = _SESSION_COMMANDS.get(args.command) + if handler_name is None: + return False + handler = globals()[handler_name] + handler(args, config, resolver) + return True + + def main() -> None: """Run the application.""" args = build_parser().parse_args() @@ -754,17 +988,8 @@ def main() -> None: rc = _cmd_config(args) sys.exit(rc) # pragma: no cover - if args.command == "transition" and args.session is not None: # pragma: no cover - _cmd_transition_session(args, config, resolver) - return # pragma: no cover - - if args.command == "check" and args.session is not None: # pragma: no cover - _cmd_check_session(args, config, resolver) - return # pragma: no cover - - if args.command == "next" and args.session is not None: # pragma: no cover - _cmd_next_session(args, config, resolver) - return # pragma: no cover + if _dispatch_session_command(args, config, resolver): + return _resolve_flow_for_command(args, config, resolver) diff --git a/flowr/cli/output.py b/flowr/cli/output.py index 6f870af..39553f7 100644 --- a/flowr/cli/output.py +++ b/flowr/cli/output.py @@ -4,25 +4,31 @@ from typing import Any -def _format_dict_lines(data: dict[str, Any]) -> list[str]: +def _format_dict_lines(data: dict[str, Any], indent: str = "") -> list[str]: """Format a single dict as key-value lines.""" lines: list[str] = [] for key, value in data.items(): if isinstance(value, list): if not value: - lines.append(f"{key}: (none)") + lines.append(f"{indent}{key}: (none)") elif isinstance(value[0], dict): + lines.append(f"{indent}{key}:") for item in value: + first = True for k, v in item.items(): - lines.append(f" {k}: {v}") + if first: + lines.append(f"{indent} - {k}: {v}") + first = False + else: + lines.append(f"{indent} {k}: {v}") else: for item in value: - lines.append(f"{key}: {item}") + lines.append(f"{indent}{key}: {item}") elif isinstance(value, dict): - for k, v in value.items(): - lines.append(f"{k}: {v}") + lines.append(f"{indent}{key}:") + lines.extend(_format_dict_lines(value, indent + " ")) else: - lines.append(f"{key}: {value}") + lines.append(f"{indent}{key}: {value}") return lines diff --git a/flowr/cli/session_cmd.py b/flowr/cli/session_cmd.py index 488ef0d..a36662b 100644 --- a/flowr/cli/session_cmd.py +++ b/flowr/cli/session_cmd.py @@ -5,11 +5,13 @@ import argparse import sys +from pathlib import Path from typing import NoReturn from flowr.cli.output import format_json, format_text from flowr.cli.resolution import DefaultFlowNameResolver, FlowNameNotFoundError -from flowr.domain.loader import load_flow_from_file +from flowr.domain.loader import load_flow_from_file, resolve_subflows +from flowr.domain.session import SessionStackFrame from flowr.infrastructure.config import FlowrConfig from flowr.infrastructure.session_store import ( SessionAlreadyExistsError, @@ -39,9 +41,9 @@ def add_session_parser(sub: argparse._SubParsersAction) -> None: p_show.add_argument( "--format", choices=["yaml", "json"], - default="yaml", + default="json", dest="output_format", - help="Output format (default: yaml)", + help="Output format (default: json)", ) # session set-state @@ -56,9 +58,9 @@ def add_session_parser(sub: argparse._SubParsersAction) -> None: p_list.add_argument( "--format", choices=["yaml", "json"], - default="yaml", + default="json", dest="output_format", - help="Output format (default: yaml)", + help="Output format (default: json)", ) @@ -92,6 +94,17 @@ def cmd_session_init( except SessionAlreadyExistsError as exc: _error(str(exc)) + flow = load_flow_from_file(flow_path) + if flow.states and flow.states[0].flow is not None: + all_flows = resolve_subflows(flow, flow_path) + ref_stem = Path(flow.states[0].flow).stem + subflow = next((f for f in all_flows if f.flow == ref_stem), None) + if subflow is not None and subflow.states: + frame = SessionStackFrame(flow=session.flow, state=session.state) + initial_id = subflow.states[0].id + session = session.push_stack(frame, initial_id, new_flow=subflow.flow) + store.save(session) + output = { "flow": session.flow, "state": session.state, diff --git a/flowr/domain/loader.py b/flowr/domain/loader.py index 6587d0a..ebe7f8c 100644 --- a/flowr/domain/loader.py +++ b/flowr/domain/loader.py @@ -32,11 +32,17 @@ def load_flow_from_file(path: Path) -> Flow: def resolve_subflows(root_flow: Flow, root_path: Path) -> list[Flow]: - """Resolve all subflow references from the root flow's directory.""" + """Resolve all subflow references from the root flow's directory. + + Flow references may omit the ``.yaml`` extension. The function tries + the path as-is first and, if it does not exist, appends ``.yaml``. + """ flows = [root_flow] for state in root_flow.states: if state.flow is not None: subflow_path = root_path.parent / state.flow + if not subflow_path.exists(): + subflow_path = root_path.parent / (state.flow + ".yaml") if subflow_path.exists(): flows.append(load_flow_from_file(subflow_path)) return flows diff --git a/pyproject.toml b/pyproject.toml index 8e458b7..c6f8cd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "flowr" -version = "0.4.0" +version = "0.5.0" description = "non-deterministic state machine specification to knead workflows" readme = "README.md" requires-python = ">=3.13" diff --git a/tests/features/flowr_cli/check_state_test.py b/tests/features/flowr_cli/check_state_test.py index aef9d04..63e86d9 100644 --- a/tests/features/flowr_cli/check_state_test.py +++ b/tests/features/flowr_cli/check_state_test.py @@ -70,7 +70,7 @@ def test_flowr_cli_92de4c71(tmp_path: Path) -> None: Then: the output includes the state's attrs and available transitions """ flow_file = _write_yaml(tmp_path, _YAML_WITH_ATTRS) - result = _run_cli("check", str(flow_file), "idle") + result = _run_cli("check", str(flow_file), "idle", "--text") assert result.returncode == 0 assert "color" in result.stdout assert "start" in result.stdout @@ -83,7 +83,7 @@ def test_flowr_cli_155a7306(tmp_path: Path) -> None: Then: the output includes the referenced subflow name """ flow_file = _write_yaml(tmp_path, _YAML_WITH_SUBFLOW) - result = _run_cli("check", str(flow_file), "idle") + result = _run_cli("check", str(flow_file), "idle", "--text") assert result.returncode == 0 assert "subflow.yaml" in result.stdout @@ -91,11 +91,11 @@ def test_flowr_cli_155a7306(tmp_path: Path) -> None: def test_flowr_cli_0cf36941(tmp_path: Path) -> None: """ Given: a flow definition with a state - When: the developer runs the check command with --json for that state + When: the developer runs the check command for that state (JSON is default) Then: the output is valid JSON containing the state details """ flow_file = _write_yaml(tmp_path, _YAML_BASIC) - result = _run_cli("check", str(flow_file), "idle", "--json") + result = _run_cli("check", str(flow_file), "idle") data = json.loads(result.stdout) assert "id" in data assert data["id"] == "idle" diff --git a/tests/features/flowr_cli/mermaid_export_test.py b/tests/features/flowr_cli/mermaid_export_test.py index 4a005ed..2681826 100644 --- a/tests/features/flowr_cli/mermaid_export_test.py +++ b/tests/features/flowr_cli/mermaid_export_test.py @@ -43,7 +43,7 @@ def test_flowr_cli_1bf637c4(tmp_path: Path) -> None: Then: the output is a valid Mermaid stateDiagram-v2 string """ flow_file = _write_yaml(tmp_path, _YAML_FLOW) - result = _run_cli("mermaid", str(flow_file)) + result = _run_cli("mermaid", str(flow_file), "--text") assert result.returncode == 0 assert "stateDiagram-v2" in result.stdout @@ -51,11 +51,11 @@ def test_flowr_cli_1bf637c4(tmp_path: Path) -> None: def test_flowr_cli_8c9d008f(tmp_path: Path) -> None: """ Given: a flow definition - When: the developer runs the mermaid command with --json + When: the developer runs the mermaid command (JSON is default) Then: the output is valid JSON containing the Mermaid diagram string """ flow_file = _write_yaml(tmp_path, _YAML_FLOW) - result = _run_cli("mermaid", str(flow_file), "--json") + result = _run_cli("mermaid", str(flow_file)) data = json.loads(result.stdout) assert "mermaid" in data assert "stateDiagram-v2" in data["mermaid"] diff --git a/tests/features/flowr_cli/next_command_test.py b/tests/features/flowr_cli/next_command_test.py index 9fef003..bf06144 100644 --- a/tests/features/flowr_cli/next_command_test.py +++ b/tests/features/flowr_cli/next_command_test.py @@ -44,49 +44,59 @@ def test_flowr_cli_e0a380b7(tmp_path: Path) -> None: """ Given: a flow definition with a state that has a guarded transition When: the developer runs the next command with matching evidence - Then: the output shows that transition as a valid next step + Then: the output shows that transition as open (not blocked) """ flow_file = _write_yaml(tmp_path, _YAML_GUARDED) - result = _run_cli("next", str(flow_file), "idle", "--evidence", "score=90") + result = _run_cli( + "next", str(flow_file), "idle", "--evidence", "score=90", "--text" + ) assert result.returncode == 0 assert "working" in result.stdout + assert "[blocked]" not in result.stdout def test_flowr_cli_79a29725(tmp_path: Path) -> None: """ Given: a flow definition with a state that has a guarded transition When: the developer runs the next command with non-matching evidence - Then: the output shows no passing transitions + Then: the output shows the guarded transition as blocked """ flow_file = _write_yaml(tmp_path, _YAML_GUARDED) - result = _run_cli("next", str(flow_file), "idle", "--evidence", "score=30") + result = _run_cli( + "next", str(flow_file), "idle", "--evidence", "score=30", "--text" + ) assert result.returncode == 0 - assert "none" in result.stdout.lower() or "working" not in result.stdout + assert "working" in result.stdout + assert "[blocked]" in result.stdout def test_flowr_cli_81dc8827(tmp_path: Path) -> None: """ Given: a flow with both guarded and unguarded transitions from a state When: the developer runs the next command without providing evidence - Then: the output shows only the unguarded transitions + Then: the output shows all transitions with the guarded one marked blocked """ flow_file = _write_yaml(tmp_path, _YAML_GUARDED) - result = _run_cli("next", str(flow_file), "idle") + result = _run_cli("next", str(flow_file), "idle", "--text") assert result.returncode == 0 assert "idle" in result.stdout - assert "working" not in result.stdout + assert "working" in result.stdout + assert "[blocked]" in result.stdout def test_flowr_cli_0b719a77(tmp_path: Path) -> None: """ Given: a flow definition with a state and valid evidence - When: the developer runs the next command with --json - Then: the output is valid JSON containing the passing transitions + When: the developer runs the next command (JSON is default) + Then: the output is valid JSON with transitions array of objects """ flow_file = _write_yaml(tmp_path, _YAML_GUARDED) - result = _run_cli( - "next", str(flow_file), "idle", "--evidence", "score=90", "--json" - ) + result = _run_cli("next", str(flow_file), "idle", "--evidence", "score=90") data = json.loads(result.stdout) - assert "next" in data - assert len(data["next"]) > 0 + assert "transitions" in data + assert len(data["transitions"]) > 0 + for t in data["transitions"]: + assert "trigger" in t + assert "target" in t + assert "status" in t + assert "conditions" in t diff --git a/tests/features/flowr_cli/states_command_test.py b/tests/features/flowr_cli/states_command_test.py index 326b43a..dabf670 100644 --- a/tests/features/flowr_cli/states_command_test.py +++ b/tests/features/flowr_cli/states_command_test.py @@ -45,7 +45,7 @@ def test_flowr_cli_2faa93a6(tmp_path: Path) -> None: Then: the output contains all three state ids """ flow_file = _write_yaml(tmp_path, _YAML_THREE_STATES) - result = _run_cli("states", str(flow_file)) + result = _run_cli("states", str(flow_file), "--text") assert result.returncode == 0 assert "idle" in result.stdout assert "working" in result.stdout @@ -55,11 +55,11 @@ def test_flowr_cli_2faa93a6(tmp_path: Path) -> None: def test_flowr_cli_9b7eba0c(tmp_path: Path) -> None: """ Given: a flow definition with multiple states - When: the developer runs the states command with --json + When: the developer runs the states command (JSON is default) Then: the output is a valid JSON array of state ids """ flow_file = _write_yaml(tmp_path, _YAML_THREE_STATES) - result = _run_cli("states", str(flow_file), "--json") + result = _run_cli("states", str(flow_file)) data = json.loads(result.stdout) assert isinstance(data, list) assert "idle" in data diff --git a/tests/features/flowr_cli/transition_command_test.py b/tests/features/flowr_cli/transition_command_test.py index 1d9391c..c5ea501 100644 --- a/tests/features/flowr_cli/transition_command_test.py +++ b/tests/features/flowr_cli/transition_command_test.py @@ -75,7 +75,13 @@ def test_flowr_cli_0993f68a(tmp_path: Path) -> None: """ flow_file = _write_yaml(tmp_path, _YAML_GUARDED, "flow.yaml") result = _run_cli( - "transition", str(flow_file), "idle", "approve", "--evidence", "score=90" + "transition", + str(flow_file), + "idle", + "approve", + "--evidence", + "score=90", + "--text", ) assert result.returncode == 0 assert "working" in result.stdout @@ -89,7 +95,13 @@ def test_flowr_cli_5302dfcf(tmp_path: Path) -> None: """ flow_file = _write_yaml(tmp_path, _YAML_GUARDED, "flow.yaml") result = _run_cli( - "transition", str(flow_file), "idle", "approve", "--evidence", "score=30" + "transition", + str(flow_file), + "idle", + "approve", + "--evidence", + "score=30", + "--text", ) assert result.returncode == 1 assert "not" in result.stderr.lower() or "not" in result.stdout.lower() @@ -103,7 +115,7 @@ def test_flowr_cli_250c4dce(tmp_path: Path) -> None: """ flow_file = _write_yaml(tmp_path, _YAML_SUBFLOW, "parent.yaml") _write_yaml(tmp_path, _YAML_SUBFLOW_CHILD, "child.yaml") - result = _run_cli("transition", str(flow_file), "idle", "start") + result = _run_cli("transition", str(flow_file), "idle", "start", "--text") assert result.returncode == 0 assert "review" in result.stdout or "child" in result.stdout @@ -123,7 +135,7 @@ def test_flowr_cli_dac419ef(tmp_path: Path) -> None: def test_flowr_cli_04589cee(tmp_path: Path) -> None: """ Given: a flow definition with a state and valid trigger and evidence - When: the developer runs the transition command with --json + When: the developer runs the transition command (JSON is default) Then: the output is valid JSON containing the next state """ flow_file = _write_yaml(tmp_path, _YAML_GUARDED, "flow.yaml") @@ -134,7 +146,6 @@ def test_flowr_cli_04589cee(tmp_path: Path) -> None: "approve", "--evidence", "score=90", - "--json", ) data = json.loads(result.stdout) assert "to" in data diff --git a/tests/features/flowr_cli/validate_command_test.py b/tests/features/flowr_cli/validate_command_test.py index 460f287..29dae60 100644 --- a/tests/features/flowr_cli/validate_command_test.py +++ b/tests/features/flowr_cli/validate_command_test.py @@ -62,7 +62,7 @@ def test_flowr_cli_f82e43f3(tmp_path: Path) -> None: Then: the output indicates the flow is valid """ flow_file = _write_yaml(tmp_path, _YAML_VALID) - result = _run_cli("validate", str(flow_file)) + result = _run_cli("validate", str(flow_file), "--text") assert result.returncode == 0 assert "valid" in result.stdout.lower() @@ -74,7 +74,7 @@ def test_flowr_cli_e60ea5d5(tmp_path: Path) -> None: Then: the output lists at least one MUST-level violation """ flow_file = _write_yaml(tmp_path, _YAML_MISSING_FIELDS) - result = _run_cli("validate", str(flow_file)) + result = _run_cli("validate", str(flow_file), "--text") assert result.returncode == 1 assert "MUST" in result.stdout @@ -86,17 +86,17 @@ def test_flowr_cli_c74ff68e(tmp_path: Path) -> None: Then: the output lists at least one SHOULD-level warning """ flow_file = _write_yaml(tmp_path, _YAML_SHOULD_WARNING) - result = _run_cli("validate", str(flow_file)) + result = _run_cli("validate", str(flow_file), "--text") assert "SHOULD" in result.stdout def test_flowr_cli_25479a5b(tmp_path: Path) -> None: """ Given: a flow definition file with violations - When: the developer runs the validate command with --json on that file + When: the developer runs the validate command on that file (JSON is default) Then: the output is valid JSON containing the violation details """ flow_file = _write_yaml(tmp_path, _YAML_MISSING_FIELDS) - result = _run_cli("validate", str(flow_file), "--json") + result = _run_cli("validate", str(flow_file)) data = json.loads(result.stdout) assert "violations" in data diff --git a/tests/features/session_management/session_transition_test.py b/tests/features/session_management/session_transition_test.py index 506fde7..230a18d 100644 --- a/tests/features/session_management/session_transition_test.py +++ b/tests/features/session_management/session_transition_test.py @@ -143,7 +143,7 @@ def test_session_management_s5t6u7v8(tmp_path: Path) -> None: assert session_data["state"] == "step-1-scope" assert len(session_data["stack"]) == 1 assert session_data["stack"][0]["flow"] == "feature-development-flow" - assert session_data["stack"][0]["state"] == "architecture" + assert session_data["stack"][0]["state"] == "step-1-scope" def test_session_management_w9x0y1z2(tmp_path: Path) -> None: diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 88b2300..126d9c3 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -166,12 +166,12 @@ def test_condition_fails(self) -> None: class TestCmdValidate: def test_valid_flow(self, tmp_path: Path) -> None: p = _write_flow(tmp_path, _SIMPLE_YAML) - ns = _ns(flow_file=p, json_output=False) + ns = _ns(flow_file=p, text_output=True) assert _cmd_validate(ns) == 0 def test_valid_flow_json(self, tmp_path: Path) -> None: p = _write_flow(tmp_path, _SIMPLE_YAML) - ns = _ns(flow_file=p, json_output=True) + ns = _ns(flow_file=p, text_output=False) assert _cmd_validate(ns) == 0 def test_invalid_flow(self, tmp_path: Path) -> None: @@ -181,19 +181,19 @@ def test_invalid_flow(self, tmp_path: Path) -> None: " go:\n to: nonexistent\n" ) p = _write_flow(tmp_path, yaml_str) - ns = _ns(flow_file=p, json_output=False) + ns = _ns(flow_file=p, text_output=True) assert _cmd_validate(ns) == 1 class TestCmdStates: def test_states_text(self, tmp_path: Path) -> None: p = _write_flow(tmp_path, _SIMPLE_YAML) - ns = _ns(flow_file=p, json_output=False) + ns = _ns(flow_file=p, text_output=True) assert _cmd_states(ns) == 0 def test_states_json(self, tmp_path: Path) -> None: p = _write_flow(tmp_path, _SIMPLE_YAML) - ns = _ns(flow_file=p, json_output=True) + ns = _ns(flow_file=p, text_output=False) assert _cmd_states(ns) == 0 @@ -204,7 +204,7 @@ def test_check_state_text(self, tmp_path: Path) -> None: flow_file=p, state_id="idle", target=None, - json_output=False, + text_output=True, ) assert _cmd_check(ns) == 0 @@ -214,7 +214,7 @@ def test_check_state_json(self, tmp_path: Path) -> None: flow_file=p, state_id="idle", target=None, - json_output=True, + text_output=False, ) assert _cmd_check(ns) == 0 @@ -224,7 +224,7 @@ def test_check_state_with_attrs(self, tmp_path: Path) -> None: flow_file=p, state_id="idle", target=None, - json_output=False, + text_output=True, ) assert _cmd_check(ns) == 0 @@ -234,7 +234,7 @@ def test_check_missing_state(self, tmp_path: Path) -> None: flow_file=p, state_id="missing", target=None, - json_output=False, + text_output=True, ) assert _cmd_check(ns) == 1 @@ -244,7 +244,7 @@ def test_check_conditions_text(self, tmp_path: Path) -> None: flow_file=p, state_id="idle", target="go", - json_output=False, + text_output=True, ) assert _cmd_check(ns) == 0 @@ -254,7 +254,7 @@ def test_check_conditions_json(self, tmp_path: Path) -> None: flow_file=p, state_id="idle", target="go", - json_output=True, + text_output=False, ) assert _cmd_check(ns) == 0 @@ -264,7 +264,7 @@ def test_check_missing_target(self, tmp_path: Path) -> None: flow_file=p, state_id="idle", target="missing", - json_output=False, + text_output=True, ) assert _cmd_check(ns) == 1 @@ -274,7 +274,7 @@ def test_check_unguarded_conditions(self, tmp_path: Path) -> None: flow_file=p, state_id="idle", target="go", - json_output=False, + text_output=True, ) assert _cmd_check(ns) == 0 @@ -285,7 +285,7 @@ def test_next_text(self, tmp_path: Path) -> None: ns = _ns( flow_file=p, state_id="idle", - json_output=False, + text_output=True, evidence=[], evidence_json=None, ) @@ -296,7 +296,7 @@ def test_next_json(self, tmp_path: Path) -> None: ns = _ns( flow_file=p, state_id="idle", - json_output=True, + text_output=False, evidence=[], evidence_json=None, ) @@ -307,7 +307,7 @@ def test_next_missing_state(self, tmp_path: Path) -> None: ns = _ns( flow_file=p, state_id="missing", - json_output=False, + text_output=True, evidence=[], evidence_json=None, ) @@ -321,7 +321,7 @@ def test_transition_text(self, tmp_path: Path) -> None: flow_file=p, state_id="idle", trigger="go", - json_output=False, + text_output=True, evidence=[], evidence_json=None, ) @@ -333,7 +333,7 @@ def test_transition_json(self, tmp_path: Path) -> None: flow_file=p, state_id="idle", trigger="go", - json_output=True, + text_output=False, evidence=[], evidence_json=None, ) @@ -345,7 +345,7 @@ def test_transition_missing_state(self, tmp_path: Path) -> None: flow_file=p, state_id="missing", trigger="go", - json_output=False, + text_output=True, evidence=[], evidence_json=None, ) @@ -357,7 +357,7 @@ def test_transition_missing_trigger(self, tmp_path: Path) -> None: flow_file=p, state_id="idle", trigger="missing", - json_output=False, + text_output=True, evidence=[], evidence_json=None, ) @@ -369,7 +369,7 @@ def test_transition_conditions_not_met(self, tmp_path: Path) -> None: flow_file=p, state_id="idle", trigger="go", - json_output=False, + text_output=True, evidence=[], evidence_json=None, ) @@ -382,7 +382,7 @@ def test_transition_subflow_entry(self, tmp_path: Path) -> None: flow_file=p, state_id="idle", trigger="start", - json_output=False, + text_output=True, evidence=[], evidence_json=None, ) @@ -396,7 +396,7 @@ def test_transition_into_subflow(self, tmp_path: Path) -> None: flow_file=p, state_id="idle", trigger="start", - json_output=True, + text_output=False, evidence=[], evidence_json=None, ) @@ -416,7 +416,7 @@ def test_transition_state_with_missing_subflow(self, tmp_path: Path) -> None: flow_file=p, state_id="idle", trigger="go", - json_output=False, + text_output=True, evidence=[], evidence_json=None, ) @@ -432,7 +432,7 @@ def test_check_state_with_flow_ref(self, tmp_path: Path) -> None: flow_file=p, state_id="review", target=None, - json_output=False, + text_output=True, ) assert _cmd_check(ns) == 0 @@ -440,12 +440,12 @@ def test_check_state_with_flow_ref(self, tmp_path: Path) -> None: class TestCmdMermaid: def test_mermaid_text(self, tmp_path: Path) -> None: p = _write_flow(tmp_path, _SIMPLE_YAML) - ns = _ns(flow_file=p, json_output=False) + ns = _ns(flow_file=p, text_output=True) assert _cmd_mermaid(ns) == 0 def test_mermaid_json(self, tmp_path: Path) -> None: p = _write_flow(tmp_path, _SIMPLE_YAML) - ns = _ns(flow_file=p, json_output=True) + ns = _ns(flow_file=p, text_output=False) assert _cmd_mermaid(ns) == 0 diff --git a/tests/unit/loader_test.py b/tests/unit/loader_test.py index eafd9a6..30f7984 100644 --- a/tests/unit/loader_test.py +++ b/tests/unit/loader_test.py @@ -166,6 +166,63 @@ def test_resolve_subflows(tmp_path: Path) -> None: assert flows[1].flow == "child" +def test_resolve_subflows_without_extension(tmp_path: Path) -> None: + """resolve_subflows appends .yaml when the bare path does not exist.""" + parent = """\ +flow: parent +version: "1.0" +exits: + - complete +states: + - id: idle + flow: child + next: + done: + to: complete +""" + child = """\ +flow: child +version: "1.0" +exits: + - approved +states: + - id: entry + next: + approve: + to: approved +""" + p = tmp_path / "parent.yaml" + p.write_text(parent) + c = tmp_path / "child.yaml" + c.write_text(child) + flow = load_flow_from_file(p) + flows = resolve_subflows(flow, p) + assert len(flows) == 2 + assert flows[1].flow == "child" + + +def test_resolve_subflows_missing_file(tmp_path: Path) -> None: + """resolve_subflows skips references that cannot be resolved.""" + parent = """\ +flow: parent +version: "1.0" +exits: + - complete +states: + - id: idle + flow: nonexistent + next: + done: + to: complete +""" + p = tmp_path / "parent.yaml" + p.write_text(parent) + flow = load_flow_from_file(p) + flows = resolve_subflows(flow, p) + assert len(flows) == 1 + assert flows[0].flow == "parent" + + def test_flow_parser_protocol() -> None: """FlowParser is a Protocol for YAML parsing backends.""" diff --git a/tests/unit/session_cmd_test.py b/tests/unit/session_cmd_test.py index eb87eba..e0dbc87 100644 --- a/tests/unit/session_cmd_test.py +++ b/tests/unit/session_cmd_test.py @@ -404,17 +404,17 @@ def test_invalid_trigger(self, tmp_path: Path) -> None: class TestCmdConfig: def test_config_text_output(self, tmp_path: Path) -> None: - args = _ns(json_output=False, flows_dir=None) + args = _ns(text_output=True, flows_dir=None) rc = _cmd_config(args) assert rc == 0 def test_config_json_output(self, tmp_path: Path) -> None: - args = _ns(json_output=True, flows_dir=None) + args = _ns(text_output=False, flows_dir=None) rc = _cmd_config(args) assert rc == 0 def test_config_with_flows_dir_override(self, tmp_path: Path) -> None: - args = _ns(json_output=False, flows_dir="/custom/flows") + args = _ns(text_output=True, flows_dir="/custom/flows") rc = _cmd_config(args) assert rc == 0 @@ -444,7 +444,7 @@ def test_check_session_reads_state(self, tmp_path: Path) -> None: args = _ns( session="__default__", - json_output=False, + text_output=True, target=None, evidence=[], evidence_json=None, @@ -476,7 +476,7 @@ def test_next_session_shows_transitions(self, tmp_path: Path) -> None: resolver = DefaultFlowNameResolver() args = _ns( - session="__default__", json_output=False, evidence=[], evidence_json=None + session="__default__", text_output=True, evidence=[], evidence_json=None ) with pytest.raises(SystemExit) as exc_info: _cmd_next_session(args, config, resolver) @@ -509,7 +509,7 @@ def test_transition_session_updates_state(self, tmp_path: Path) -> None: args = _ns( session="__default__", positional=["go"], - json_output=False, + text_output=True, evidence=[], evidence_json=None, ) @@ -527,7 +527,7 @@ def test_transition_session_missing_trigger(self, tmp_path: Path) -> None: args = _ns( session="__default__", positional=[], - json_output=False, + text_output=True, evidence=[], evidence_json=None, ) @@ -580,7 +580,7 @@ def test_resolve_transition_missing_args(self, tmp_path: Path) -> None: class TestCheckNextWithoutSession: def test_check_missing_flow_file(self, tmp_path: Path) -> None: args = _ns( - flow_file=None, state_id=None, target=None, json_output=False, session=None + flow_file=None, state_id=None, target=None, text_output=True, session=None ) rc = _cmd_check(args) assert rc == 2 @@ -596,7 +596,7 @@ def test_check_missing_state_id(self, tmp_path: Path) -> None: flow_file=resolver.resolve("test-flow", config.flows_path()), state_id=None, target=None, - json_output=False, + text_output=True, session=None, ) rc = _cmd_check(args) @@ -613,7 +613,7 @@ def test_check_state_not_found(self, tmp_path: Path) -> None: flow_file=resolver.resolve("test-flow", config.flows_path()), state_id="nonexistent", target=None, - json_output=False, + text_output=True, session=None, ) rc = _cmd_check(args) @@ -623,7 +623,7 @@ def test_next_missing_state_id(self, tmp_path: Path) -> None: args = _ns( flow_file=None, state_id=None, - json_output=False, + text_output=True, evidence=[], evidence_json=None, session=None, @@ -645,7 +645,7 @@ def test_transition_with_positional_args(self, tmp_path: Path) -> None: positional=["test-flow", "idle", "go"], state_id=None, trigger=None, - json_output=False, + text_output=True, evidence=[], evidence_json=None, ) @@ -666,7 +666,7 @@ def test_transition_trigger_not_found(self, tmp_path: Path) -> None: positional=None, state_id="idle", trigger="nonexistent", - json_output=False, + text_output=True, evidence=[], evidence_json=None, ) @@ -854,7 +854,7 @@ def test_check_session_with_target(self, tmp_path: Path) -> None: config = _config(tmp_path) resolver = DefaultFlowNameResolver() - args = _ns(session="__default__", json_output=False, target="done") + args = _ns(session="__default__", text_output=True, target="done") with pytest.raises(SystemExit) as exc_info: _cmd_check_session(args, config, resolver) assert exc_info.value.code == 1 @@ -881,7 +881,7 @@ def test_check_session_state_not_found(self, tmp_path: Path) -> None: config = _config(tmp_path) resolver = DefaultFlowNameResolver() - args = _ns(session="__default__", json_output=False, target=None) + args = _ns(session="__default__", text_output=True, target=None) with pytest.raises(SystemExit) as exc_info: _cmd_check_session(args, config, resolver) assert exc_info.value.code == 1 @@ -911,7 +911,7 @@ def test_next_session_json_output(self, tmp_path: Path) -> None: resolver = DefaultFlowNameResolver() args = _ns( - session="__default__", json_output=True, evidence=[], evidence_json=None + session="__default__", text_output=False, evidence=[], evidence_json=None ) with pytest.raises(SystemExit) as exc_info: _cmd_next_session(args, config, resolver) @@ -944,7 +944,7 @@ def test_transition_json_output(self, tmp_path: Path) -> None: args = _ns( session="__default__", positional=["go"], - json_output=True, + text_output=False, evidence=[], evidence_json=None, ) @@ -1003,7 +1003,7 @@ def test_next_missing_state_id(self, tmp_path: Path) -> None: args = _ns( flow_file=flow_file, state_id=None, - json_output=False, + text_output=True, evidence=[], evidence_json=None, session=None, @@ -1049,7 +1049,7 @@ def test_next_session_state_not_found(self, tmp_path: Path) -> None: resolver = DefaultFlowNameResolver() args = _ns( - session="__default__", json_output=False, evidence=[], evidence_json=None + session="__default__", text_output=True, evidence=[], evidence_json=None ) with pytest.raises(SystemExit) as exc_info: _cmd_next_session(args, config, resolver) @@ -1087,3 +1087,187 @@ def test_simple_transition_else_branch(self, tmp_path: Path) -> None: assert updated.state == "done" assert target == "done" assert updated.flow == "test-flow" + + +_CHAIN_PARENT = """\ +flow: chain-parent +version: "1.0" +exits: + - done +states: + - id: step-1 + flow: chain-child-a + next: + complete: step-2 + - id: step-2 + flow: chain-child-b + next: + complete: done + - id: done + next: {} +""" + +_CHAIN_CHILD_A = """\ +flow: chain-child-a +version: "1.0" +exits: + - complete +states: + - id: a-start + next: + finish: complete +""" + +_CHAIN_CHILD_B = """\ +flow: chain-child-b +version: "1.0" +exits: + - complete +states: + - id: b-start + next: + finish: complete +""" + + +class TestSubflowExitResolution: + def test_exit_resolves_parent_transition(self, tmp_path: Path) -> None: + from flowr.domain.loader import load_flow_from_file + + parent = """\ +flow: simple-parent +version: "1.0" +states: + - id: step-1 + flow: simple-child + next: + complete: step-2 + - id: step-2 + next: {} +""" + child = """\ +flow: simple-child +version: "1.0" +exits: + - complete +states: + - id: child-start + next: + finish: complete +""" + _write_flow(tmp_path, parent, "simple-parent.yaml") + _write_flow(tmp_path, child, "simple-child.yaml") + child_path = tmp_path / "simple-child.yaml" + + child_flow = load_flow_from_file(child_path) + session = Session( + flow="simple-child", + state="child-start", + name="test", + stack=[SessionStackFrame(flow="simple-parent", state="step-1")], + ) + updated, target = _apply_session_transition( + session, + child_flow, + child_path, + "finish", + {}, + flows_dir=tmp_path, + ) + assert updated.flow == "simple-parent" + assert updated.state == "step-2" + assert len(updated.stack) == 0 + assert target == "step-2" + + def test_exit_chains_into_next_subflow(self, tmp_path: Path) -> None: + from flowr.domain.loader import load_flow_from_file + + _write_flow(tmp_path, _CHAIN_PARENT, "chain-parent.yaml") + _write_flow(tmp_path, _CHAIN_CHILD_A, "chain-child-a.yaml") + _write_flow(tmp_path, _CHAIN_CHILD_B, "chain-child-b.yaml") + child_a_path = tmp_path / "chain-child-a.yaml" + + child_a = load_flow_from_file(child_a_path) + session = Session( + flow="chain-child-a", + state="a-start", + name="test", + stack=[SessionStackFrame(flow="chain-parent", state="step-1")], + ) + updated, target = _apply_session_transition( + session, + child_a, + child_a_path, + "finish", + {}, + flows_dir=tmp_path, + ) + assert updated.flow == "chain-child-b" + assert updated.state == "b-start" + assert len(updated.stack) == 1 + assert updated.stack[0].flow == "chain-parent" + assert updated.stack[0].state == "step-2" + assert target == "chain-child-b/b-start" + + def test_exit_without_flows_dir_uses_exit_name(self, tmp_path: Path) -> None: + from flowr.domain.loader import load_flow_from_file + + _write_flow(tmp_path, _CHAIN_CHILD_A, "chain-child-a.yaml") + child_a_path = tmp_path / "chain-child-a.yaml" + + child_a = load_flow_from_file(child_a_path) + session = Session( + flow="chain-child-a", + state="a-start", + name="test", + stack=[SessionStackFrame(flow="chain-parent", state="step-1")], + ) + updated, target = _apply_session_transition( + session, + child_a, + child_a_path, + "finish", + {}, + ) + assert updated.flow == "chain-parent" + assert updated.state == "complete" + assert target == "complete" + + def test_subflow_push_without_extension(self, tmp_path: Path) -> None: + from flowr.domain.loader import load_flow_from_file + + parent = """\ +flow: no-ext-parent +version: "1.0" +states: + - id: idle + next: + go: work + - id: work + flow: no-ext-child + next: + done: end + - id: end + next: {} +""" + child = """\ +flow: no-ext-child +version: "1.0" +exits: [done] +states: + - id: child-start + next: + finish: done +""" + _write_flow(tmp_path, parent, "no-ext-parent.yaml") + _write_flow(tmp_path, child, "no-ext-child.yaml") + + parent_path = tmp_path / "no-ext-parent.yaml" + parent_flow = load_flow_from_file(parent_path) + session = Session(flow="no-ext-parent", state="idle", name="test") + updated, _target = _apply_session_transition( + session, parent_flow, parent_path, "go", {} + ) + assert updated.flow == "no-ext-child" + assert updated.state == "child-start" + assert len(updated.stack) == 1 From 3c905e32d1924c9e992a968afc34c455cf5e7c23 Mon Sep 17 00:00:00 2001 From: nullhack Date: Tue, 5 May 2026 02:59:25 -0400 Subject: [PATCH 2/2] chore: update uv.lock for v0.5.0 --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 7aecc36..8df9cfd 100644 --- a/uv.lock +++ b/uv.lock @@ -334,7 +334,7 @@ wheels = [ [[package]] name = "flowr" -version = "0.4.0" +version = "0.5.0" source = { virtual = "." } dependencies = [ { name = "pyyaml" },