diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md new file mode 100644 index 000000000..e410cb1f5 --- /dev/null +++ b/.github/agents/copilot-instructions.md @@ -0,0 +1,29 @@ +# main Development Guidelines + +Auto-generated from all feature plans. Last updated: 2026-03-08 + +## Active Technologies + +- C# latest on .NET 10.0 primary, with existing multi-target support for .NET 8.0 and .NET 9.0 + FastEndpoints, Elsa.Api.Common abstractions, CShells, CShells.FastEndpoints.Abstractions, Refit client contracts, xUnit component test infrastructure (001-shell-reload-api) + +## Project Structure + +```text +src/ +tests/ +``` + +## Commands + +# Add commands for C# latest on .NET 10.0 primary, with existing multi-target support for .NET 8.0 and .NET 9.0 + +## Code Style + +C# latest on .NET 10.0 primary, with existing multi-target support for .NET 8.0 and .NET 9.0: Follow standard conventions + +## Recent Changes + +- 001-shell-reload-api: Added C# latest on .NET 10.0 primary, with existing multi-target support for .NET 8.0 and .NET 9.0 + FastEndpoints, Elsa.Api.Common abstractions, CShells, CShells.FastEndpoints.Abstractions, Refit client contracts, xUnit component test infrastructure + + + diff --git a/specs/001-shell-reload-api/checklists/requirements.md b/specs/001-shell-reload-api/checklists/requirements.md new file mode 100644 index 000000000..c4b8ae8fa --- /dev/null +++ b/specs/001-shell-reload-api/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Shell Reload API Endpoints + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-08 +**Feature**: [spec.md](/specs/001-shell-reload-api/spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation completed on 2026-03-08 against the current spec. +- The spec intentionally records the temporary compatibility behavior for targeted reload in outcome-oriented terms without prescribing implementation details. diff --git a/specs/001-shell-reload-api/contracts/shell-reload-api.yaml b/specs/001-shell-reload-api/contracts/shell-reload-api.yaml new file mode 100644 index 000000000..7ed97b912 --- /dev/null +++ b/specs/001-shell-reload-api/contracts/shell-reload-api.yaml @@ -0,0 +1,128 @@ +openapi: 3.1.0 +info: + title: Shell Reload API + version: 0.1.0 + description: Administrative endpoints for reloading shell-backed configuration. +paths: + /actions/shells/reload: + post: + summary: Reload all shells + operationId: reloadAllShells + tags: + - Shells + responses: + '200': + description: Reload completed or partially completed. + content: + application/json: + schema: + $ref: '#/components/schemas/ShellReloadResponse' + '409': + description: Another reload is already in progress. + content: + application/json: + schema: + $ref: '#/components/schemas/ShellReloadResponse' + '503': + description: Shell configuration could not be loaded from the current provider. + content: + application/json: + schema: + $ref: '#/components/schemas/ShellReloadResponse' + /shells/{shellId}/reload: + post: + summary: Reload a specific shell + operationId: reloadShellById + tags: + - Shells + parameters: + - name: shellId + in: path + required: true + schema: + type: string + description: Case-insensitive shell identifier. + responses: + '200': + description: The fallback reload completed and the requested shell refreshed successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/ShellReloadResponse' + '404': + description: The requested shell ID is unknown. + '409': + description: Another reload is already in progress. + content: + application/json: + schema: + $ref: '#/components/schemas/ShellReloadResponse' + '422': + description: The fallback reload did not refresh the requested shell successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/ShellReloadResponse' + '503': + description: Shell configuration could not be loaded from the current provider. + content: + application/json: + schema: + $ref: '#/components/schemas/ShellReloadResponse' +components: + schemas: + ShellReloadResponse: + type: object + required: + - status + - shells + - reloadedAt + properties: + status: + $ref: '#/components/schemas/ShellReloadStatus' + requestedShellId: + type: + - string + - 'null' + reloadedAt: + type: string + format: date-time + shells: + type: array + items: + $ref: '#/components/schemas/ShellReloadItemResult' + ShellReloadStatus: + type: string + enum: + - Completed + - Partial + - Failed + - Busy + - NotFound + - RequestedShellFailed + ShellReloadItemResult: + type: object + required: + - shellId + - outcome + - requested + properties: + shellId: + type: string + outcome: + $ref: '#/components/schemas/ShellReloadItemOutcome' + requested: + type: boolean + message: + type: + - string + - 'null' + ShellReloadItemOutcome: + type: string + enum: + - Reloaded + - Unchanged + - Removed + - InvalidConfiguration + - Unknown + - Skipped diff --git a/specs/001-shell-reload-api/data-model.md b/specs/001-shell-reload-api/data-model.md new file mode 100644 index 000000000..fb71b4c14 --- /dev/null +++ b/specs/001-shell-reload-api/data-model.md @@ -0,0 +1,78 @@ +# Data Model: Shell Reload API Endpoints + +## Entity: Shell Reload Command + +- **Purpose**: Represents an administrative request to refresh shell runtime state. +- **Fields**: + - `Scope`: `All` or `Targeted`. + - `RequestedShellId`: optional shell identifier; required when scope is `Targeted`. + - `RequestedAt`: execution timestamp captured for result reporting and diagnostics. +- **Validation rules**: + - `RequestedShellId` is required for targeted commands. + - Shell identifiers are matched case-insensitively to align with `ShellId` semantics in CShells. + +## Entity: Shell Reload Result + +- **Purpose**: Represents the top-level outcome returned by either reload endpoint. +- **Fields**: + - `Status`: one of `Completed`, `Partial`, `Failed`, `Busy`, `NotFound`, or `RequestedShellFailed`. + - `RequestedShellId`: optional shell identifier echoed for targeted requests. + - `Shells`: collection of `Shell Reload Item Result` records for each affected shell. + - `ReloadedAt`: timestamp indicating when the orchestration completed. +- **Validation rules**: + - `Shells` must be populated for `Completed`, `Partial`, and `RequestedShellFailed` outcomes. + - `RequestedShellId` must be populated for `Targeted` requests. + +## Entity: Shell Reload Item Result + +- **Purpose**: Describes the shell-level result used to explain a full or targeted reload outcome. +- **Fields**: + - `ShellId`: shell identifier. + - `Outcome`: one of `Reloaded`, `Unchanged`, `Removed`, `InvalidConfiguration`, `Unknown`, or `Skipped`. + - `Requested`: boolean indicating whether this item is the caller’s explicitly requested shell. + - `Message`: optional human-readable detail describing why the shell remained unchanged or failed. +- **Validation rules**: + - Each `ShellId` appears at most once per response. + - `Requested` is true for at most one item. + +## Entity: Shell Reload Scope Snapshot + +- **Purpose**: Internal planning concept representing the latest provider settings compared with the current cache before orchestration runs. +- **Fields**: + - `CurrentShellIds`: current in-memory shell identifiers from the settings cache. + - `LatestShellSettings`: latest shell settings from the provider. + - `MissingShellIds`: current shells absent from the latest provider view. +- **Why it matters**: The orchestration needs this snapshot to distinguish updates, additions, removals, unknown shell requests, and unchanged shells. + +## State Transitions + +### Full Reload Command + +`Pending` → `Completed` +when all affected shells refresh successfully. + +`Pending` → `Partial` +when at least one shell refreshes successfully and at least one shell remains unchanged because of invalid configuration or a shell-specific failure. + +`Pending` → `Busy` +when another reload is already in progress. + +`Pending` → `Failed` +when the shell settings provider cannot supply usable configuration for the operation. + +### Targeted Reload Command + +`Pending` → `NotFound` +when the requested shell ID does not exist in the authoritative configuration view. + +`Pending` → `Busy` +when another reload is already in progress. + +`Pending` → `Completed` +when the current full-reload fallback completes and the requested shell refreshes successfully. + +`Pending` → `RequestedShellFailed` +when the fallback full reload updates some shells but the requested shell remains unchanged because its updated configuration could not be applied. + +`Pending` → `Failed` +when the shell settings provider cannot supply usable configuration for the operation. diff --git a/specs/001-shell-reload-api/plan.md b/specs/001-shell-reload-api/plan.md new file mode 100644 index 000000000..c2335ee6f --- /dev/null +++ b/specs/001-shell-reload-api/plan.md @@ -0,0 +1,91 @@ +# Implementation Plan: Shell Reload API Endpoints + +**Branch**: `001-shell-reload-api` | **Date**: 2026-03-08 | **Spec**: `./spec.md` +**Input**: Feature specification from `/specs/001-shell-reload-api/spec.md` + +## Summary + +Add two administrative shell reload endpoints and a matching typed API client surface so operators can refresh shell-backed feature configuration without restarting the host. The implementation will use a dedicated shell reload orchestration service in `Elsa.Workflows.Api` to validate shell IDs, reject concurrent reloads, wrap the current full-reload fallback for targeted requests, and return detailed per-shell outcomes that satisfy the clarified spec. + +## Technical Context + +**Language/Version**: C# latest on .NET 10.0 primary, with existing multi-target support for .NET 8.0 and .NET 9.0 +**Primary Dependencies**: FastEndpoints, Elsa.Api.Common abstractions, CShells, CShells.FastEndpoints.Abstractions, Refit client contracts, xUnit component test infrastructure +**Storage**: No new persistent storage; uses the existing CShells shell settings provider and in-memory shell settings cache +**Testing**: xUnit component tests in `Elsa.Workflows.ComponentTests`; no existing dedicated `Elsa.Workflows.Api` unit test project +**Target Platform**: ASP.NET Core modular server host exposing Elsa REST APIs through shell-aware FastEndpoints +**Project Type**: Modular .NET library feature plus first-party API client contract +**Performance Goals**: Keep shell reload as a synchronous admin operation within the existing API client timeout budget of 1 minute; do not add background polling or queued work +**Constraints**: Preserve existing shell-aware endpoint discovery, keep the feature scoped to current modules, reject concurrent reload requests, return per-shell outcomes, and keep targeted reload on full-reload fallback semantics until upstream CShells adds a selective reload API +**Scale/Scope**: Two new endpoints, one shared response contract, one orchestration service, one API client resource, and focused component-test coverage in the existing server fixture + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +**Pre-Phase 0 Gate Review** + +- **I. Modular Architecture**: PASS. Changes stay inside existing bounded areas: `Elsa.Workflows.Api`, `Elsa.Api.Client`, and `Elsa.Workflows.ComponentTests`. +- **II. Composition & Extensibility**: PASS. A dedicated orchestration contract isolates current fallback behavior from future upstream targeted reload support. +- **III. Convention-Driven Design**: PASS. Endpoints will remain one-class-per-endpoint with collocated request/response models and standard permission configuration. +- **IV. Async & Pipeline Execution**: PASS. Endpoint and orchestration flows remain async-only and use existing middleware/endpoint infrastructure. +- **V. Testing Discipline**: PASS. Feature verification will use the existing component-test host and endpoint-style tests already used for REST APIs. +- **VI. Trunk-Based Development**: PASS. The change is one concern and includes API contract artifacts for downstream client work. +- **VII. Simplicity & Focus**: PASS. No new module is introduced; one orchestration service is the minimum additional abstraction needed to satisfy busy-state handling, fallback semantics, and detailed outcomes. + +**Post-Phase 1 Design Review** + +- PASS. The design keeps the concern localized, introduces no extra module boundary, uses collocated endpoint models, and adds only one new service abstraction because direct endpoint-to-`IShellManager` calls cannot satisfy partial-success reporting, busy rejection, and requested-shell strictness together. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-shell-reload-api/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── shell-reload-api.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +src/ +├── modules/ +│ └── Elsa.Workflows.Api/ +│ ├── Contracts/ +│ ├── Services/ +│ └── Endpoints/ +│ └── Shells/ +│ ├── Reload/ +│ └── ReloadAll/ +└── clients/ + └── Elsa.Api.Client/ + └── Resources/ + └── Shells/ + ├── Contracts/ + ├── Requests/ + ├── Responses/ + └── Models/ + +test/ +└── component/ + └── Elsa.Workflows.ComponentTests/ + └── Scenarios/ + └── RestApis/ + └── Endpoints/ + └── Shells/ +``` + +**Structure Decision**: Extend the existing workflow API module and first-party API client instead of adding a new shell-management module. This aligns with the repository’s established pattern where administrative REST endpoints and typed client contracts evolve together while component tests validate behavior through the hosted server fixture. + +Endpoint-specific request and response models remain collocated under `Endpoints/Shells/Reload/` and `Endpoints/Shells/ReloadAll/`. Shared orchestration result types belong under `Contracts/` so endpoint model colocation is preserved. + +## Complexity Tracking + +No constitutional violations requiring justification. diff --git a/specs/001-shell-reload-api/quickstart.md b/specs/001-shell-reload-api/quickstart.md new file mode 100644 index 000000000..7b7197494 --- /dev/null +++ b/specs/001-shell-reload-api/quickstart.md @@ -0,0 +1,40 @@ +# Quickstart: Shell Reload API Endpoints + +## Goal + +Implement and verify two administrative endpoints that refresh shell-backed configuration: + +- reload all shells +- reload one requested shell while still using the current full-reload fallback + +## Implementation Steps + +1. Add the API module dependency required to consume `IShellManager`, `IShellSettingsProvider`, and `IShellSettingsCache`. +2. Add a shell reload orchestration contract and implementation under `src/modules/Elsa.Workflows.Api/Contracts` and `src/modules/Elsa.Workflows.Api/Services`. +3. Add endpoint folders under `src/modules/Elsa.Workflows.Api/Endpoints/Shells/ReloadAll` and `src/modules/Elsa.Workflows.Api/Endpoints/Shells/Reload` with collocated request/response models. +4. Use the orchestration service to enforce: + - busy rejection + - unknown-shell validation + - targeted requested-shell strictness + - detailed per-shell results + - current full-reload fallback semantics for the targeted endpoint +5. Add a new client resource under `src/clients/Elsa.Api.Client/Resources/Shells` so first-party consumers can call both endpoints. +6. Add component tests under `test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/Shells`. + +## Verification Scenarios + +1. Full reload returns `Completed` when every affected shell refreshes successfully. +2. Full reload returns `Partial` and shell-level detail when at least one shell remains unchanged because of invalid configuration. +3. Targeted reload returns `404` when the requested shell is unknown. + The `404` response does not include a JSON body. +4. Targeted reload returns `422` with shell-level detail when the requested shell does not refresh successfully during the fallback full reload. +5. Either endpoint returns `409` while another reload is already in progress. +6. Either endpoint returns `503` when the configuration provider cannot supply usable shell settings. + +## Suggested Validation Commands + +```bash +dotnet test test/component/Elsa.Workflows.ComponentTests/ --filter "FullyQualifiedName~Scenarios.RestApis.Endpoints.Shells" -p:CollectCoverage=false +``` + +If the component suite is too broad during iteration, run the project with a test filter targeting the new shell reload scenario names. \ No newline at end of file diff --git a/specs/001-shell-reload-api/research.md b/specs/001-shell-reload-api/research.md new file mode 100644 index 000000000..a2e3fe9f0 --- /dev/null +++ b/specs/001-shell-reload-api/research.md @@ -0,0 +1,57 @@ +# Phase 0 Research: Shell Reload API Endpoints + +## Decision 1: Keep the feature in the existing API and client modules + +- **Decision**: Implement the endpoints in `Elsa.Workflows.Api` and add the typed client surface in `Elsa.Api.Client`. +- **Rationale**: The repository already places administrative REST endpoints in `Elsa.Workflows.Api`, and component tests exercise those endpoints through the hosted API fixture. A matching client resource keeps first-party consumers aligned with the new API surface. +- **Alternatives considered**: + - Add the endpoints to `Elsa.Tenants`: rejected because the capability is broader than tenant CRUD and the requested change is centered on the existing API surface used by Elsa clients. + - Create a new shell-administration module: rejected as unnecessary module growth for a two-endpoint feature. + +## Decision 2: Introduce a shell reload orchestration service in the API module + +- **Decision**: Add a dedicated orchestration contract and service in `Elsa.Workflows.Api` that the endpoints call instead of embedding reload logic directly inside endpoint classes. +- **Rationale**: The spec requires busy-state rejection, unknown-shell validation, per-shell result reporting, and a future upgrade path from full-reload fallback to selective reload. Encapsulating that logic in one service keeps endpoints conventionally thin and localizes future upstream changes. +- **Alternatives considered**: + - Inject `IShellManager` directly into each endpoint: rejected because the same branching, validation, and result-mapping logic would be duplicated. + - Put the orchestration service in `src/common/`: rejected because the behavior is API-facing, request-oriented, and not shared elsewhere yet. + +## Decision 3: Use `IShellSettingsProvider` and `IShellSettingsCache` as the shell identity inputs + +- **Decision**: Validate shell IDs and derive per-shell reload scope from the authoritative shell settings provider plus the current shell settings cache. +- **Rationale**: Upstream CShells uses `IShellSettingsProvider.GetShellSettingsAsync` as the source for `ReloadAllShellsAsync`, and `IShellSettingsCache` provides the current in-memory view of known shells. Using both lets Elsa compare requested/current/latest state without guessing. +- **Alternatives considered**: + - Validate only against the current cache: rejected because removed or newly added shells would be misclassified relative to the latest configuration source. + - Validate against shell host instances only: rejected because shell host state is runtime-focused and not the authoritative source for latest configuration. + +## Decision 4: Implement the current fallback as a full reconciliation flow, not a blind call to `ReloadAllShellsAsync` + +- **Decision**: Plan the current implementation around a full shell reconciliation flow that can still represent the current full-reload fallback semantics while producing detailed outcomes and partial application. +- **Rationale**: Upstream `DefaultShellManager.ReloadAllShellsAsync` updates the shell settings cache and publishes `ShellsReloaded`, and the ASP.NET Core notification handler re-registers endpoints for all shells. That path does not expose per-shell success/failure details, requested-shell strictness, or partial application behavior required by the clarified spec. A reconciliation-oriented orchestration can compose existing manager operations (`AddShellAsync`, `UpdateShellAsync`, `RemoveShellAsync`) and preserve the current full-reload fallback for targeted requests. +- **Alternatives considered**: + - Call `ReloadAllShellsAsync` directly for both endpoints: rejected because it cannot distinguish requested-shell failure from overall success and does not support the spec’s partial-success response model. + - Implement a targeted one-shell update now: rejected because the user explicitly wants the targeted endpoint to remain on full-reload fallback behavior until upstream support exists. + +## Decision 5: Use a shared response model with top-level status plus per-shell outcomes + +- **Decision**: Both endpoints will return the same logical result shape: a top-level reload status, optional requested shell ID, and a collection of per-shell outcome records. +- **Rationale**: The spec now requires detailed shell-level reporting for both full and targeted requests. A shared response model keeps the API and client simpler and makes targeted fallback behavior transparent when other shells are also affected. +- **Alternatives considered**: + - Minimal boolean success responses: rejected because they cannot represent partial success or requested-shell strict failures. + - Different response schemas per endpoint: rejected because both endpoints expose the same operational data with different scopes. + +## Decision 6: Keep endpoint request/response models collocated and move shared orchestration types into contracts + +- **Decision**: Keep endpoint request/response models collocated with each endpoint and place only shared orchestration result types in `Elsa.Workflows.Api/Contracts`. +- **Rationale**: This preserves the repository’s single-endpoint-per-class convention while still avoiding duplication for shared internal result types consumed by both endpoints. +- **Alternatives considered**: + - Put all reload models in a shared `Endpoints/Shells/Models.cs`: rejected because it weakens endpoint colocation and drifts from existing API conventions. + - Duplicate all result types per endpoint: rejected because both endpoints share the same outcome model and would diverge unnecessarily. + +## Decision 7: Cover the feature with component tests, not a new unit-test project + +- **Decision**: Add component tests under `Elsa.Workflows.ComponentTests` for the new endpoints and their observable runtime behavior. +- **Rationale**: There is no existing dedicated `Elsa.Workflows.Api` unit-test project, and the repository already validates REST endpoint behavior through the hosted `WorkflowServer` fixture with authenticated HTTP clients. Component tests can verify routes, permissions, status handling, and the effect on active shell-backed behavior in one place. +- **Alternatives considered**: + - Create a brand-new `Elsa.Workflows.Api.UnitTests` project: rejected as out of scope for this feature and unnecessary for the first increment. + - Rely only on integration tests against lower-level services: rejected because the feature’s primary value is the external API behavior. diff --git a/specs/001-shell-reload-api/spec.md b/specs/001-shell-reload-api/spec.md new file mode 100644 index 000000000..0994a43c2 --- /dev/null +++ b/specs/001-shell-reload-api/spec.md @@ -0,0 +1,120 @@ +# Feature Specification: Shell Reload API Endpoints + +**Feature Branch**: `001-shell-reload-api` +**Created**: 2026-03-08 +**Status**: Draft +**Input**: User description: "Add API endpoints to reload all shells and reload a specific shell so administrators can apply updated feature enablement and configuration without restarting the application. Until selective reload is supported by the shell platform, the specific-shell action should temporarily perform a full reload." + +## Clarifications + +### Session 2026-03-08 + +- Q: How should a full reload behave when one shell configuration is invalid while others remain valid? → A: Partial apply: valid shells reload, invalid shells stay on their previous configuration, and the API reports partial success. +- Q: How should the API behave when a reload request arrives while another reload is already in progress? → A: Reject: return a conflict or busy outcome while a reload is already in progress, and require the caller to retry. +- Q: For a targeted reload request, should the API succeed only if the requested shell itself refreshed successfully? → A: Requested-shell strict: if the requested shell's new configuration is invalid, the targeted reload returns a non-success outcome even if other shells reload successfully. +- Q: Should reload responses include detailed per-shell outcome information or only an overall outcome? → A: Detailed results: reload responses include per-shell outcome details for affected shells, including which shells succeeded and which failed. +- Q: Is first-party API client support part of this feature scope? → A: Yes, the first-party API client is part of the feature scope and should expose both shell reload actions. +- Q: Is the observability timing target a hard acceptance gate? → A: No, the feature must make updated configuration observable after a successful reload, but the prior 1-minute value is guidance and not an enforceable acceptance threshold. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Reload all shells after configuration changes (Priority: P1) + +An administrator updates shared or per-shell configuration that affects enabled features or feature settings and needs the running application to pick up those changes immediately without restarting the host. + +**Why this priority**: Reloading all shells is the existing operational fallback and is required to make changed configuration effective across the system. + +**Independent Test**: Can be fully tested by changing shell configuration for one or more shells, invoking the full reload action, and verifying that subsequent requests use the updated configuration without an application restart. + +**Acceptance Scenarios**: + +1. **Given** one or more shells have configuration changes waiting in the active configuration source, **When** an authorized administrator triggers the full reload action, **Then** all running shells are reloaded from the latest configuration and begin using the updated feature state and settings. +2. **Given** the full reload action completes successfully, **When** the administrator uses functionality affected by the configuration change, **Then** the behavior reflects the updated configuration without requiring a host restart. +3. **Given** a full reload completes with a mix of successful and unsuccessful shell refreshes, **When** the API returns its result, **Then** the response identifies which shells refreshed successfully and which shells did not. + +--- + +### User Story 2 - Request reload for one shell (Priority: P2) + +An administrator changes configuration for a single shell and wants to target that shell explicitly through the API so operational tooling can express intent at the shell level. + +**Why this priority**: Targeted reload is the desired long-term operational model and should be available in the API contract now, even if the underlying platform initially fulfills it with a broader reload. + +**Independent Test**: Can be fully tested by changing configuration for a known shell, invoking the targeted reload action with that shell identifier, and verifying that the requested shell reflects the updated configuration after the action completes. + +**Acceptance Scenarios**: + +1. **Given** a shell with a valid identifier has pending configuration changes, **When** an authorized administrator triggers the targeted reload action for that identifier, **Then** the system completes a reload operation that results in the requested shell reflecting the latest configuration. +2. **Given** the platform does not yet support selective shell reload, **When** the administrator triggers the targeted reload action, **Then** the request still succeeds by using the currently supported reload behavior while ensuring the requested shell reflects the updated configuration. +3. **Given** the administrator specifies a shell identifier that does not correspond to a known shell, **When** the targeted reload action is invoked, **Then** the system rejects the request with a clear not-found outcome and does not report success. +4. **Given** a targeted reload falls back to a full reload and the requested shell's updated configuration is invalid while other shells can still reload, **When** the targeted reload action is invoked, **Then** the request returns a non-success outcome for the caller and the requested shell keeps its previous active configuration. +5. **Given** a targeted reload action completes, **When** the API returns its result, **Then** the response includes outcome detail for the requested shell and any other affected shells. + +--- + +### User Story 3 - Handle failed reload attempts safely (Priority: P3) + +An administrator needs clear failure behavior when reload cannot complete because configuration is invalid, unavailable, or the system is already processing a conflicting reload request. + +**Why this priority**: Operators need predictable failure handling so they do not assume configuration changes are active when they are not. + +**Independent Test**: Can be fully tested by making the configuration source unavailable or invalid, invoking either reload action, and verifying that the API communicates failure without falsely indicating that shells were refreshed. + +**Acceptance Scenarios**: + +1. **Given** the configuration source is unavailable or cannot provide any usable shell configuration, **When** either reload action is invoked, **Then** the request fails with a clear error outcome and shells continue using their last known active configuration. +2. **Given** a reload operation is already in progress, **When** another reload action is invoked, **Then** the system rejects the new request with a conflict or busy outcome and requires the caller to retry later. +3. **Given** a full reload encounters invalid configuration for one shell while configuration for other shells remains valid, **When** the full reload action is invoked, **Then** valid shells adopt their updated configuration, invalid shells keep their previous active configuration, and the API reports a partial-success outcome. + +### Edge Cases + +- A targeted reload request is made for a shell identifier that existed previously but has since been removed from the current configuration source. +- Configuration for one shell is invalid while other shells remain valid during a full reload request, resulting in partial success rather than a full rollback. +- A reload is requested while affected shells are actively serving traffic. +- Multiple administrators or automation jobs issue reload requests within a short time window. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST provide an authenticated and authorized API action that triggers a reload of all shells. +- **FR-002**: The system MUST provide an authenticated and authorized API action that accepts a shell identifier and triggers a reload request for that specific shell. +- **FR-003**: The targeted reload action MUST validate that the supplied shell identifier refers to a known shell before reporting success. +- **FR-004**: When the full reload action succeeds, the system MUST rebuild running shell state from the latest available shell configuration source. +- **FR-005**: When the targeted reload action succeeds, the requested shell MUST reflect the latest available configuration by the time the action reports success. +- **FR-006**: Until selective shell reload is supported by the underlying shell platform, the targeted reload action MUST use the currently supported reload behavior while preserving the targeted API contract. +- **FR-007**: If a targeted reload request references an unknown shell identifier, the system MUST return a not-found outcome and MUST NOT report the request as successful. +- **FR-008**: If reload cannot begin because the configuration source is unavailable or cannot provide any usable shell configuration, the system MUST return a failure outcome and MUST leave the last active shell configuration in effect. +- **FR-009**: The system MUST provide responses that let callers distinguish successful reload completion from partial success, validation errors, authorization failures, busy rejections, and reload execution failures. +- **FR-010**: Successful reload operations MUST cause changed feature enablement and changed feature configuration to be reflected in subsequent interactions with affected shells without restarting the host application. +- **FR-011**: If a full reload encounters invalid configuration for only a subset of shells, the system MUST reload shells with valid configuration, MUST keep affected invalid shells on their last active configuration, and MUST report the operation as partial success. +- **FR-012**: If a reload request is received while another reload is already in progress, the system MUST reject the new request with a conflict or busy outcome and MUST NOT queue it automatically. +- **FR-013**: The targeted reload action MUST return a non-success outcome if the requested shell does not refresh successfully, even if the underlying fallback reload updates other shells successfully. +- **FR-014**: Reload responses MUST include per-shell outcome details for affected shells so callers can identify which shells refreshed successfully and which shells remained unchanged. +- **FR-015**: The system MUST expose first-party API client support for both shell reload actions using request and response shapes aligned with the server API. + +### Key Entities *(include if feature involves data)* + +- **Shell**: A runtime-isolated application context identified uniquely within the host and configured with a set of enabled features and feature-specific settings. +- **Reload Request**: An administrative action asking the system to refresh shell runtime state either for all shells or for one named shell. +- **Reload Outcome**: The result of a reload request, including the top-level status and per-shell details showing whether each affected shell refreshed successfully, remained unchanged, was invalid, was unknown, or was not refreshed because the request was rejected. + +## Assumptions + +- Shell identifiers are unique and stable enough for administrators and automation to target the intended shell. +- Only privileged operational users or automation are expected to invoke shell reload actions. +- The current shell platform supports full reloads today, while selective per-shell reload will become available later. + +## Dependencies + +- The long-term optimization for truly selective reload depends on future shell-platform support for reloading a single shell without reloading all shells. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Administrators can apply configuration changes across all shells with a single API call and without restarting the host application. +- **SC-002**: In validation scenarios for successful reload requests, updated feature enablement and feature configuration are observable in affected shells without restarting the host application. +- **SC-003**: 100% of targeted reload requests for unknown shell identifiers return a non-success outcome. +- **SC-004**: 100% of reload attempts where the configuration source is unavailable return a failure outcome rather than a false success, and 100% of partial full reloads caused by shell-specific invalid configuration report partial success. +- **SC-005**: 100% of partial-success and targeted reload responses include shell-level outcome details for every affected shell. diff --git a/specs/001-shell-reload-api/tasks.md b/specs/001-shell-reload-api/tasks.md new file mode 100644 index 000000000..6ca66b564 --- /dev/null +++ b/specs/001-shell-reload-api/tasks.md @@ -0,0 +1,178 @@ +# Tasks: Shell Reload API Endpoints + +**Input**: Design documents from `/specs/001-shell-reload-api/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md + +**Tests**: Tests are required for this feature by the repository constitution, so each user story includes component-test coverage. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Add the minimum project references and client resource scaffolding required by the feature. + +- [X] T001 Update `src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj` to reference the `CShells` package needed for `IShellManager`, `IShellSettingsProvider`, and `IShellSettingsCache` +- [X] T002 [P] Create the shell API client contract scaffold in `src/clients/Elsa.Api.Client/Resources/Shells/Contracts/IShellsApi.cs` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Establish shared orchestration infrastructure and shared non-endpoint result types used by all shell reload stories. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [X] T003 Create shared orchestration result contracts in `src/modules/Elsa.Workflows.Api/Contracts/ShellReloadStatus.cs`, `src/modules/Elsa.Workflows.Api/Contracts/ShellReloadItemResult.cs`, and `src/modules/Elsa.Workflows.Api/Contracts/ShellReloadResult.cs` +- [X] T004 [P] Create shared client-side reload DTOs in `src/clients/Elsa.Api.Client/Resources/Shells/Models/ShellReloadItemResult.cs`, `src/clients/Elsa.Api.Client/Resources/Shells/Models/ShellReloadStatus.cs`, and `src/clients/Elsa.Api.Client/Resources/Shells/Responses/ShellReloadResponse.cs` +- [X] T005 Create the reload orchestration contract and base implementation in `src/modules/Elsa.Workflows.Api/Contracts/IShellReloadOrchestrator.cs` and `src/modules/Elsa.Workflows.Api/Services/ShellReloadOrchestrator.cs` +- [X] T006 Register the shell reload orchestrator in `src/modules/Elsa.Workflows.Api/ShellFeatures/WorkflowsApiFeature.cs` + +**Checkpoint**: Shared DTOs and orchestration infrastructure are ready for story implementation. + +--- + +## Phase 3: User Story 1 - Reload all shells after configuration changes (Priority: P1) 🎯 MVP + +**Goal**: Let operators trigger a full shell reload and receive detailed per-shell results. + +**Independent Test**: Change shell configuration for one or more shells, call the reload-all endpoint, and verify the updated behavior is active without restarting the host. + +### Tests for User Story 1 + +- [X] T007 [P] [US1] Add component tests for successful full-shell reload results in `test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/Shells/ReloadAllTests.cs` + +### Implementation for User Story 1 + +- [X] T008 [US1] Implement full-reload snapshotting and successful per-shell result mapping around the current full-reload fallback in `src/modules/Elsa.Workflows.Api/Services/ShellReloadOrchestrator.cs` +- [X] T009 [P] [US1] Implement the reload-all endpoint and collocated response handling in `src/modules/Elsa.Workflows.Api/Endpoints/Shells/ReloadAll/Endpoint.cs` and `src/modules/Elsa.Workflows.Api/Endpoints/Shells/ReloadAll/Models.cs` +- [X] T010 [P] [US1] Add the reload-all client method to `src/clients/Elsa.Api.Client/Resources/Shells/Contracts/IShellsApi.cs` + +**Checkpoint**: User Story 1 is independently functional through the API and client surface. + +--- + +## Phase 4: User Story 2 - Request reload for one shell (Priority: P2) + +**Goal**: Expose a targeted shell reload endpoint that validates the shell ID and enforces requested-shell strict success semantics while still using the current full-reload fallback. + +**Independent Test**: Call the targeted reload endpoint for a known shell and verify the requested shell refreshes; call it for an unknown shell and verify a not-found outcome. + +### Tests for User Story 2 + +- [X] T011 [P] [US2] Add component tests for targeted reload success, unknown shell handling, and requested-shell strict outcomes in `test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/Shells/ReloadTests.cs` + +### Implementation for User Story 2 + +- [X] T012 [US2] Extend targeted reload validation and requested-shell strict result handling in `src/modules/Elsa.Workflows.Api/Services/ShellReloadOrchestrator.cs` +- [X] T013 [P] [US2] Implement the targeted reload endpoint and collocated models in `src/modules/Elsa.Workflows.Api/Endpoints/Shells/Reload/Endpoint.cs` and `src/modules/Elsa.Workflows.Api/Endpoints/Shells/Reload/Models.cs` +- [X] T014 [P] [US2] Add the targeted reload client method to `src/clients/Elsa.Api.Client/Resources/Shells/Contracts/IShellsApi.cs` + +**Checkpoint**: User Story 2 works independently for known and unknown shell IDs. + +--- + +## Phase 5: User Story 3 - Handle failed reload attempts safely (Priority: P3) + +**Goal**: Return clear busy, unavailable, and partial-success outcomes without falsely reporting that shells refreshed. + +**Independent Test**: Simulate concurrent reloads, unavailable shell settings, and invalid configuration for one shell, then verify the API returns the required error or partial-success responses with shell-level detail. + +### Tests for User Story 3 + +- [X] T015 [P] [US3] Add component tests for busy rejection, unavailable configuration, and partial-success reload behavior in `test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/Shells/ShellReloadFailureTests.cs` + +### Implementation for User Story 3 + +- [X] T016 [US3] Implement busy-state rejection and provider-unavailable failure handling in `src/modules/Elsa.Workflows.Api/Services/ShellReloadOrchestrator.cs` +- [X] T017 [US3] Implement partial-success reconciliation for invalid shell configurations in `src/modules/Elsa.Workflows.Api/Services/ShellReloadOrchestrator.cs` +- [X] T018 [US3] Map busy, partial-success, requested-shell failure, and provider-failure outcomes to HTTP responses in `src/modules/Elsa.Workflows.Api/Endpoints/Shells/ReloadAll/Endpoint.cs` and `src/modules/Elsa.Workflows.Api/Endpoints/Shells/Reload/Endpoint.cs` + +**Checkpoint**: All failure and partial-success behaviors are independently testable through the API. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Bring implementation, client surface, and planning artifacts into final alignment. + +- [X] T019 [P] Update `specs/001-shell-reload-api/contracts/shell-reload-api.yaml` to match the final implemented routes, statuses, and response payloads +- [X] T020 Run the quickstart validation against `specs/001-shell-reload-api/quickstart.md` using `test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj` and fix any mismatches in `src/modules/Elsa.Workflows.Api/Endpoints/Shells/` and `src/clients/Elsa.Api.Client/Resources/Shells/` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies; start immediately. +- **Foundational (Phase 2)**: Depends on Phase 1; blocks all user story work. +- **User Story 1 (Phase 3)**: Depends on Phase 2 only. +- **User Story 2 (Phase 4)**: Depends on Phase 2 only. +- **User Story 3 (Phase 5)**: Depends on Phase 2 only. +- **Polish (Phase 6)**: Depends on the user stories you intend to ship. + +### User Story Dependencies + +- **US1**: No dependency on other user stories; this is the MVP. +- **US2**: No functional dependency on US1, but it reuses the shared orchestrator and response models from Phase 2. +- **US3**: No functional dependency on US1 or US2, but it extends the same orchestrator and endpoint files, so coordinate file ownership if multiple developers work in parallel. + +### Within Each User Story + +- Write the component tests first and confirm they fail before implementation. +- Implement orchestration logic before endpoint wiring. +- Add API client methods after the corresponding endpoint contract is stable. +- Validate the story through the hosted component-test server before moving on. + +### Parallel Opportunities + +- `T002` can run in parallel with `T001`. +- `T004` can run in parallel with `T003` once Phase 1 is complete. +- `T007`, `T009`, and `T010` can proceed in parallel after `T008` starts stabilizing the shared orchestration shape. +- `T011`, `T013`, and `T014` can proceed in parallel after `T012` defines the targeted behavior. +- `T015` can run in parallel with `T016` because tests are in a separate file. +- `T019` can run in parallel with final cleanup before `T020`. + +--- + +## Parallel Example: User Story 1 + +```bash +# After T008 defines the US1 orchestration behavior: +Task: "Add component tests for successful full-shell reload results in test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/Shells/ReloadAllTests.cs" +Task: "Implement the reload-all endpoint and collocated response handling in src/modules/Elsa.Workflows.Api/Endpoints/Shells/ReloadAll/Endpoint.cs and src/modules/Elsa.Workflows.Api/Endpoints/Shells/ReloadAll/Models.cs" +Task: "Add the reload-all client method to src/clients/Elsa.Api.Client/Resources/Shells/Contracts/IShellsApi.cs" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational. +3. Complete Phase 3: User Story 1. +4. Validate the reload-all endpoint and client flow with the component tests. + +### Incremental Delivery + +1. Deliver US1 to provide immediate operational value. +2. Add US2 so automation can target one shell explicitly while still using the full-reload fallback. +3. Add US3 to complete the failure-handling and partial-success contract. +4. Finish with Phase 6 to ensure the generated contract and quickstart remain accurate. + +### Parallel Team Strategy + +1. One developer completes Phase 1 and Phase 2. +2. Then split work by story, with coordination around `src/modules/Elsa.Workflows.Api/Services/ShellReloadOrchestrator.cs` and `src/clients/Elsa.Api.Client/Resources/Shells/Contracts/IShellsApi.cs` because those files are shared touchpoints. +3. Rejoin for Phase 6 validation and contract alignment. + +--- + +## Notes + +- `[P]` tasks are limited to work that can proceed without editing the same files concurrently. +- Story labels map directly to the prioritized stories in `spec.md`. +- The task list assumes the design decision to keep this feature inside `Elsa.Workflows.Api`, `Elsa.Api.Client`, and `Elsa.Workflows.ComponentTests`. +- `T020` is the final verification checkpoint before handing the feature off for implementation completion. \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs index f846e499e..1d4c8f926 100644 --- a/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs +++ b/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs @@ -9,6 +9,7 @@ using Elsa.Api.Client.Resources.Identity.Contracts; using Elsa.Api.Client.Resources.IncidentStrategies.Contracts; using Elsa.Api.Client.Resources.LogPersistenceStrategies; using Elsa.Api.Client.Resources.Resilience.Contracts; +using Elsa.Api.Client.Resources.Shells.Contracts; using Elsa.Api.Client.Resources.Scripting.Contracts; using Elsa.Api.Client.Resources.StorageDrivers.Contracts; using Elsa.Api.Client.Resources.Tasks.Contracts; @@ -82,6 +83,7 @@ public static class DependencyInjectionExtensions services.AddApi(builderOptions); services.AddApi(builderOptions); services.AddApi(builderOptions); + services.AddApi(builderOptions); services.AddApi(builderOptions); services.AddApi(builderOptions); services.AddApi(builderOptions); @@ -127,7 +129,7 @@ public static class DependencyInjectionExtensions /// The service collection. /// An options object that can be used to configure the HTTP client builder. /// The type representing the API. - public static IServiceCollection AddApi(this IServiceCollection services, ElsaClientBuilderOptions? httpClientBuilderOptions = default) where T : class + public static IServiceCollection AddApi(this IServiceCollection services, ElsaClientBuilderOptions? httpClientBuilderOptions = null) where T : class { return services.AddApi(typeof(T), httpClientBuilderOptions); } @@ -138,7 +140,7 @@ public static class DependencyInjectionExtensions /// The service collection. /// The type representing the API /// An options object that can be used to configure the HTTP client builder. - public static IServiceCollection AddApi(this IServiceCollection services, Type apiType, ElsaClientBuilderOptions? httpClientBuilderOptions = default) + public static IServiceCollection AddApi(this IServiceCollection services, Type apiType, ElsaClientBuilderOptions? httpClientBuilderOptions = null) { var builder = services.AddRefitClient(apiType, sp => CreateRefitSettings(sp, httpClientBuilderOptions?.ConfigureJsonSerializerOptions), apiType.Name).ConfigureHttpClient(ConfigureElsaApiHttpClient); httpClientBuilderOptions?.ConfigureHttpClientBuilder(builder); @@ -152,7 +154,7 @@ public static class DependencyInjectionExtensions /// The service collection. /// An options object that can be used to configure the HTTP client builder. /// The type representing the API. - public static void AddApiWithoutRetryPolicy(this IServiceCollection services, ElsaClientBuilderOptions? httpClientBuilderOptions = default) where T : class + public static void AddApiWithoutRetryPolicy(this IServiceCollection services, ElsaClientBuilderOptions? httpClientBuilderOptions = null) where T : class { var builder = services .AddRefitClient(sp => CreateRefitSettings(sp), typeof(T).Name) diff --git a/src/clients/Elsa.Api.Client/Resources/Shells/Contracts/IShellsApi.cs b/src/clients/Elsa.Api.Client/Resources/Shells/Contracts/IShellsApi.cs new file mode 100644 index 000000000..a75ffd84e --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/Shells/Contracts/IShellsApi.cs @@ -0,0 +1,15 @@ +using Elsa.Api.Client.Resources.Shells.Responses; +using JetBrains.Annotations; +using Refit; + +namespace Elsa.Api.Client.Resources.Shells.Contracts; + +[PublicAPI] +public interface IShellsApi +{ + [Post("/actions/shells/reload")] + Task> ReloadAllAsync(CancellationToken cancellationToken = default); + + [Post("/shells/{shellId}/reload")] + Task> ReloadAsync(string shellId, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/Shells/Models/ShellReloadItemResult.cs b/src/clients/Elsa.Api.Client/Resources/Shells/Models/ShellReloadItemResult.cs new file mode 100644 index 000000000..078b0503d --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/Shells/Models/ShellReloadItemResult.cs @@ -0,0 +1,19 @@ +namespace Elsa.Api.Client.Resources.Shells.Models; + +public class ShellReloadItemResult +{ + public string ShellId { get; init; } = default!; + public ShellReloadItemOutcome Outcome { get; init; } + public bool Requested { get; init; } + public string? Message { get; init; } +} + +public enum ShellReloadItemOutcome +{ + Reloaded, + Unchanged, + Removed, + InvalidConfiguration, + Unknown, + Skipped +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/Shells/Models/ShellReloadStatus.cs b/src/clients/Elsa.Api.Client/Resources/Shells/Models/ShellReloadStatus.cs new file mode 100644 index 000000000..7fb892334 --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/Shells/Models/ShellReloadStatus.cs @@ -0,0 +1,11 @@ +namespace Elsa.Api.Client.Resources.Shells.Models; + +public enum ShellReloadStatus +{ + Completed, + Partial, + Failed, + Busy, + NotFound, + RequestedShellFailed +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/Shells/Responses/ShellReloadResponse.cs b/src/clients/Elsa.Api.Client/Resources/Shells/Responses/ShellReloadResponse.cs new file mode 100644 index 000000000..072382092 --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/Shells/Responses/ShellReloadResponse.cs @@ -0,0 +1,11 @@ +using Elsa.Api.Client.Resources.Shells.Models; + +namespace Elsa.Api.Client.Resources.Shells.Responses; + +public class ShellReloadResponse +{ + public ShellReloadStatus Status { get; init; } + public string? RequestedShellId { get; init; } + public DateTimeOffset ReloadedAt { get; init; } + public IReadOnlyCollection Shells { get; init; } = []; +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Contracts/IShellReloadOrchestrator.cs b/src/modules/Elsa.Workflows.Api/Contracts/IShellReloadOrchestrator.cs new file mode 100644 index 000000000..c4e25c4a3 --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Contracts/IShellReloadOrchestrator.cs @@ -0,0 +1,7 @@ +namespace Elsa.Workflows.Api.Contracts; + +internal interface IShellReloadOrchestrator +{ + Task ReloadAllAsync(CancellationToken cancellationToken = default); + Task ReloadAsync(string shellId, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Contracts/ShellReloadItemResult.cs b/src/modules/Elsa.Workflows.Api/Contracts/ShellReloadItemResult.cs new file mode 100644 index 000000000..2a251dcf6 --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Contracts/ShellReloadItemResult.cs @@ -0,0 +1,19 @@ +namespace Elsa.Workflows.Api.Contracts; + +internal class ShellReloadItemResult +{ + public string ShellId { get; init; } = default!; + public ShellReloadItemOutcome Outcome { get; init; } + public bool Requested { get; init; } + public string? Message { get; init; } +} + +internal enum ShellReloadItemOutcome +{ + Reloaded, + Unchanged, + Removed, + InvalidConfiguration, + Unknown, + Skipped +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Contracts/ShellReloadResult.cs b/src/modules/Elsa.Workflows.Api/Contracts/ShellReloadResult.cs new file mode 100644 index 000000000..ce92bd676 --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Contracts/ShellReloadResult.cs @@ -0,0 +1,9 @@ +namespace Elsa.Workflows.Api.Contracts; + +internal class ShellReloadResult +{ + public ShellReloadStatus Status { get; init; } + public string? RequestedShellId { get; init; } + public DateTimeOffset ReloadedAt { get; init; } + public IReadOnlyCollection Shells { get; init; } = []; +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Contracts/ShellReloadStatus.cs b/src/modules/Elsa.Workflows.Api/Contracts/ShellReloadStatus.cs new file mode 100644 index 000000000..ae9e349ae --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Contracts/ShellReloadStatus.cs @@ -0,0 +1,11 @@ +namespace Elsa.Workflows.Api.Contracts; + +internal enum ShellReloadStatus +{ + Completed, + Partial, + Failed, + Busy, + NotFound, + RequestedShellFailed +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj b/src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj index f36f1fdcc..3b35e6b04 100644 --- a/src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj +++ b/src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj @@ -8,6 +8,7 @@ + diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/Shells/Reload/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/Shells/Reload/Endpoint.cs new file mode 100644 index 000000000..835bc448e --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Endpoints/Shells/Reload/Endpoint.cs @@ -0,0 +1,47 @@ +using Elsa.Abstractions; +using Elsa.Workflows.Api.Contracts; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; +using Elsa.Workflows; + +namespace Elsa.Workflows.Api.Endpoints.Shells.Reload; + +[PublicAPI] +internal class Reload(IShellReloadOrchestrator shellReloadOrchestrator, IApiSerializer apiSerializer) : ElsaEndpointWithoutRequest +{ + public override void Configure() + { + Post("/shells/{shellId}/reload"); + ConfigurePermissions("actions:shells:reload"); + } + + public override async Task HandleAsync(CancellationToken cancellationToken) + { + var shellId = Route("shellId")!; + var result = await shellReloadOrchestrator.ReloadAsync(shellId, cancellationToken); + + if (result.Status == ShellReloadStatus.NotFound) + { + await Send.NotFoundAsync(cancellationToken); + return; + } + + var serializerOptions = apiSerializer.GetOptions(); + var response = Elsa.Workflows.Api.Endpoints.Shells.Reload.Response.FromResult(result); + var statusCode = result.Status switch + { + ShellReloadStatus.Busy => StatusCodes.Status409Conflict, + ShellReloadStatus.RequestedShellFailed => StatusCodes.Status422UnprocessableEntity, + ShellReloadStatus.Failed => StatusCodes.Status503ServiceUnavailable, + _ => StatusCodes.Status200OK + }; + + await SendJsonAsync(response, statusCode, serializerOptions, cancellationToken); + } + + private async Task SendJsonAsync(Response response, int statusCode, System.Text.Json.JsonSerializerOptions serializerOptions, CancellationToken cancellationToken) + { + HttpContext.Response.StatusCode = statusCode; + await HttpContext.Response.WriteAsJsonAsync(response, serializerOptions, cancellationToken); + } +} diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/Shells/Reload/Models.cs b/src/modules/Elsa.Workflows.Api/Endpoints/Shells/Reload/Models.cs new file mode 100644 index 000000000..371dd113d --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Endpoints/Shells/Reload/Models.cs @@ -0,0 +1,33 @@ +using Elsa.Workflows.Api.Contracts; + +namespace Elsa.Workflows.Api.Endpoints.Shells.Reload; + +internal class Response +{ + public ShellReloadStatus Status { get; init; } + public string? RequestedShellId { get; init; } + public DateTimeOffset ReloadedAt { get; init; } + public IReadOnlyCollection Shells { get; init; } = []; + + public static Response FromResult(ShellReloadResult result) => new() + { + Status = result.Status, + RequestedShellId = result.RequestedShellId, + ReloadedAt = result.ReloadedAt, + Shells = result.Shells.Select(x => new ShellResponse + { + ShellId = x.ShellId, + Outcome = x.Outcome, + Requested = x.Requested, + Message = x.Message + }).ToArray() + }; +} + +internal class ShellResponse +{ + public string ShellId { get; init; } = null!; + public ShellReloadItemOutcome Outcome { get; init; } + public bool Requested { get; init; } + public string? Message { get; init; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/Shells/ReloadAll/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/Shells/ReloadAll/Endpoint.cs new file mode 100644 index 000000000..f1d86dd6a --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Endpoints/Shells/ReloadAll/Endpoint.cs @@ -0,0 +1,35 @@ +using Elsa.Abstractions; +using Elsa.Workflows.Api.Contracts; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; + +namespace Elsa.Workflows.Api.Endpoints.Shells.ReloadAll; + +[PublicAPI] +internal class ReloadAll(IShellReloadOrchestrator shellReloadOrchestrator) : ElsaEndpointWithoutRequest +{ + public override void Configure() + { + Post("/actions/shells/reload"); + ConfigurePermissions("actions:shells:reload"); + } + + public override async Task HandleAsync(CancellationToken cancellationToken) + { + var result = await shellReloadOrchestrator.ReloadAllAsync(cancellationToken); + var response = Elsa.Workflows.Api.Endpoints.Shells.ReloadAll.Response.FromResult(result); + + switch (result.Status) + { + case ShellReloadStatus.Busy: + await Send.ResponseAsync(response, StatusCodes.Status409Conflict, cancellationToken); + break; + case ShellReloadStatus.Failed: + await Send.ResponseAsync(response, StatusCodes.Status503ServiceUnavailable, cancellationToken); + break; + default: + await Send.OkAsync(response, cancellationToken); + break; + } + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/Shells/ReloadAll/Models.cs b/src/modules/Elsa.Workflows.Api/Endpoints/Shells/ReloadAll/Models.cs new file mode 100644 index 000000000..714bf48b0 --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Endpoints/Shells/ReloadAll/Models.cs @@ -0,0 +1,33 @@ +using Elsa.Workflows.Api.Contracts; + +namespace Elsa.Workflows.Api.Endpoints.Shells.ReloadAll; + +internal class Response +{ + public ShellReloadStatus Status { get; init; } + public string? RequestedShellId { get; init; } + public DateTimeOffset ReloadedAt { get; init; } + public IReadOnlyCollection Shells { get; init; } = []; + + public static Response FromResult(ShellReloadResult result) => new() + { + Status = result.Status, + RequestedShellId = result.RequestedShellId, + ReloadedAt = result.ReloadedAt, + Shells = result.Shells.Select(x => new ShellResponse + { + ShellId = x.ShellId, + Outcome = x.Outcome, + Requested = x.Requested, + Message = x.Message + }).ToArray() + }; +} + +internal class ShellResponse +{ + public string ShellId { get; init; } = default!; + public ShellReloadItemOutcome Outcome { get; init; } + public bool Requested { get; init; } + public string? Message { get; init; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Features/WorkflowsApiFeature.cs b/src/modules/Elsa.Workflows.Api/Features/WorkflowsApiFeature.cs index fd625fc05..577093fd2 100644 --- a/src/modules/Elsa.Workflows.Api/Features/WorkflowsApiFeature.cs +++ b/src/modules/Elsa.Workflows.Api/Features/WorkflowsApiFeature.cs @@ -4,6 +4,7 @@ using Elsa.Features.Attributes; using Elsa.Features.Services; using Elsa.SasTokens.Features; using Elsa.Workflows.Api.Constants; +using Elsa.Workflows.Api.Contracts; using Elsa.Workflows.Api.Requirements; using Elsa.Workflows.Api.Serialization; using Elsa.Workflows.Api.Services; @@ -42,5 +43,6 @@ public class WorkflowsApiFeature(IModule module) : FeatureBase(module) options.AddPolicy(AuthorizationPolicies.NotReadOnlyPolicy, policy => policy.AddRequirements(new NotReadOnlyRequirement())); }); Services.AddScoped(); + Services.AddSingleton(); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Services/ShellReloadOrchestrator.cs b/src/modules/Elsa.Workflows.Api/Services/ShellReloadOrchestrator.cs new file mode 100644 index 000000000..ebb1a866d --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Services/ShellReloadOrchestrator.cs @@ -0,0 +1,259 @@ +using CShells; +using CShells.Configuration; +using CShells.Management; +using Elsa.Workflows.Api.Contracts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Elsa.Workflows.Api.Services; + +internal class ShellReloadOrchestrator(IServiceProvider serviceProvider, ILogger logger) : IShellReloadOrchestrator, IDisposable +{ + private static readonly StringComparer ShellIdComparer = StringComparer.OrdinalIgnoreCase; + private readonly SemaphoreSlim _reloadLock = new(1, 1); + private bool _disposed; + + public Task ReloadAllAsync(CancellationToken cancellationToken = default) => + ReloadInternalAsync(null, cancellationToken); + + public Task ReloadAsync(string shellId, CancellationToken cancellationToken = default) => + ReloadInternalAsync(shellId, cancellationToken); + + private async Task ReloadInternalAsync(string? requestedShellId, CancellationToken cancellationToken) + { + var requested = string.IsNullOrWhiteSpace(requestedShellId) ? null : requestedShellId; + + if (!await _reloadLock.WaitAsync(0, cancellationToken)) + return CreateBusyResult(requested); + + try + { + var shellManager = serviceProvider.GetService(); + var shellSettingsProvider = serviceProvider.GetService(); + var shellSettingsCache = serviceProvider.GetService(); + + if (shellManager == null || shellSettingsProvider == null || shellSettingsCache == null) + { + logger.LogWarning("Shell reload was requested, but the current host does not provide CShells management services."); + return CreateFailureResult(requested, "Shell reload is not available in the current host."); + } + + IReadOnlyDictionary currentShells; + IReadOnlyDictionary latestShells; + + try + { + currentShells = shellSettingsCache.GetAll().Select(Clone).ToDictionary(x => x.Id.Name, ShellIdComparer); + latestShells = (await shellSettingsProvider.GetShellSettingsAsync(cancellationToken)).Select(Clone).ToDictionary(x => x.Id.Name, ShellIdComparer); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Failed to load shell settings from the provider."); + return CreateFailureResult(requested, exception.Message); + } + + if (requested != null && !latestShells.ContainsKey(requested)) + return CreateNotFoundResult(requested); + + var itemResults = new List(); + + foreach (var currentShell in currentShells.Values + .Where(x => !latestShells.ContainsKey(x.Id.Name)) + .OrderBy(x => x.Id.Name, ShellIdComparer)) + { + var result = await RemoveShellAsync(shellManager, currentShell.Id, requested, cancellationToken); + itemResults.Add(result); + } + + foreach (var latestShell in latestShells.Values.OrderBy(x => x.Id.Name, ShellIdComparer)) + { + currentShells.TryGetValue(latestShell.Id.Name, out var previousShell); + var result = await UpsertShellAsync(shellManager, latestShell, previousShell, requested, cancellationToken); + itemResults.Add(result); + } + + return CreateResult(requested, itemResults); + } + finally + { + _reloadLock.Release(); + } + } + + private async Task RemoveShellAsync(IShellManager shellManager, ShellId shellId, string? requestedShellId, CancellationToken cancellationToken) + { + try + { + await shellManager.RemoveShellAsync(shellId, cancellationToken); + + return new ShellReloadItemResult + { + ShellId = shellId.Name, + Outcome = ShellReloadItemOutcome.Removed, + Requested = IsRequested(shellId.Name, requestedShellId) + }; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Failed to remove shell '{ShellId}' during reload.", shellId.Name); + + return new ShellReloadItemResult + { + ShellId = shellId.Name, + Outcome = ShellReloadItemOutcome.InvalidConfiguration, + Requested = IsRequested(shellId.Name, requestedShellId), + Message = exception.Message + }; + } + } + + private async Task UpsertShellAsync(IShellManager shellManager, ShellSettings latestShell, ShellSettings? previousShell, string? requestedShellId, CancellationToken cancellationToken) + { + try + { + if (previousShell == null) + await shellManager.AddShellAsync(Clone(latestShell), cancellationToken); + else + await shellManager.UpdateShellAsync(Clone(latestShell), cancellationToken); + + return new ShellReloadItemResult + { + ShellId = latestShell.Id.Name, + Outcome = ShellReloadItemOutcome.Reloaded, + Requested = IsRequested(latestShell.Id.Name, requestedShellId) + }; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Failed to reload shell '{ShellId}'.", latestShell.Id.Name); + var message = exception.Message; + + try + { + await shellManager.RemoveShellAsync(latestShell.Id, cancellationToken); + + if (previousShell != null) + await shellManager.AddShellAsync(Clone(previousShell), cancellationToken); + } + catch (Exception restoreException) + { + logger.LogWarning(restoreException, "Failed to restore the previous configuration for shell '{ShellId}'.", latestShell.Id.Name); + message = $"{message} Previous configuration could not be restored: {restoreException.Message}"; + } + + return new ShellReloadItemResult + { + ShellId = latestShell.Id.Name, + Outcome = ShellReloadItemOutcome.InvalidConfiguration, + Requested = IsRequested(latestShell.Id.Name, requestedShellId), + Message = message + }; + } + } + + private static ShellReloadResult CreateBusyResult(string? requestedShellId) + { + var shells = requestedShellId == null ? Array.Empty() : + [ + new ShellReloadItemResult + { + ShellId = requestedShellId, + Outcome = ShellReloadItemOutcome.Skipped, + Requested = true, + Message = "A shell reload is already in progress." + } + ]; + + return new ShellReloadResult + { + Status = ShellReloadStatus.Busy, + RequestedShellId = requestedShellId, + ReloadedAt = DateTimeOffset.UtcNow, + Shells = shells + }; + } + + private static ShellReloadResult CreateFailureResult(string? requestedShellId, string message) + { + var shells = requestedShellId == null ? Array.Empty() : + [ + new ShellReloadItemResult + { + ShellId = requestedShellId, + Outcome = ShellReloadItemOutcome.Skipped, + Requested = true, + Message = message + } + ]; + + return new ShellReloadResult + { + Status = ShellReloadStatus.Failed, + RequestedShellId = requestedShellId, + ReloadedAt = DateTimeOffset.UtcNow, + Shells = shells + }; + } + + private static ShellReloadResult CreateNotFoundResult(string requestedShellId) => + new() + { + Status = ShellReloadStatus.NotFound, + RequestedShellId = requestedShellId, + ReloadedAt = DateTimeOffset.UtcNow, + Shells = + [ + new ShellReloadItemResult + { + ShellId = requestedShellId, + Outcome = ShellReloadItemOutcome.Unknown, + Requested = true, + Message = "The requested shell was not found in the current configuration source." + } + ] + }; + + private static ShellReloadResult CreateResult(string? requestedShellId, IReadOnlyCollection shellResults) + { + var requestedResult = requestedShellId == null + ? null + : shellResults.FirstOrDefault(x => IsRequested(x.ShellId, requestedShellId)); + var hasFailures = shellResults.Any(x => x.Outcome is ShellReloadItemOutcome.InvalidConfiguration or ShellReloadItemOutcome.Unknown or ShellReloadItemOutcome.Skipped); + var requestedFailed = requestedResult != null && requestedResult.Outcome != ShellReloadItemOutcome.Reloaded; + + return new ShellReloadResult + { + Status = requestedFailed + ? ShellReloadStatus.RequestedShellFailed + : hasFailures ? ShellReloadStatus.Partial : ShellReloadStatus.Completed, + RequestedShellId = requestedShellId, + ReloadedAt = DateTimeOffset.UtcNow, + Shells = shellResults.OrderBy(x => x.ShellId, ShellIdComparer).ToArray() + }; + } + + private static bool IsRequested(string shellId, string? requestedShellId) => + requestedShellId != null && ShellIdComparer.Equals(shellId, requestedShellId); + + private static ShellSettings Clone(ShellSettings source) + { + var clone = new ShellSettings(source.Id, source.EnabledFeatures) + { + ConfigurationData = new Dictionary(source.ConfigurationData, StringComparer.OrdinalIgnoreCase) + }; + + foreach (var configurator in source.FeatureConfigurators) + clone.FeatureConfigurators[configurator.Key] = configurator.Value; + + return clone; + } + + public void Dispose() + { + if (_disposed) + return; + + _reloadLock.Dispose(); + _disposed = true; + } +} diff --git a/src/modules/Elsa.Workflows.Api/ShellFeatures/WorkflowsApiFeature.cs b/src/modules/Elsa.Workflows.Api/ShellFeatures/WorkflowsApiFeature.cs index de155669f..6cf34ca19 100644 --- a/src/modules/Elsa.Workflows.Api/ShellFeatures/WorkflowsApiFeature.cs +++ b/src/modules/Elsa.Workflows.Api/ShellFeatures/WorkflowsApiFeature.cs @@ -2,6 +2,7 @@ using CShells.FastEndpoints.Features; using CShells.Features; using Elsa.Extensions; using Elsa.Workflows.Api.Constants; +using Elsa.Workflows.Api.Contracts; using Elsa.Workflows.Api.Requirements; using Elsa.Workflows.Api.Serialization; using Elsa.Workflows.Api.Services; @@ -42,5 +43,6 @@ public class WorkflowsApiFeature : IFastEndpointsShellFeature options.AddPolicy(AuthorizationPolicies.NotReadOnlyPolicy, policy => policy.AddRequirements(new NotReadOnlyRequirement())); }); services.AddScoped(); + services.AddSingleton(); } } diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs index 595cf3c61..661b01289 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs @@ -316,7 +316,7 @@ public partial class Flowchart if (flowScope.AnyInboundConnectionsFollowed(flowGraph, outboundActivity)) { // This is the first inbound connection followed; schedule the outbound activity - var scheduleResponse = await ScheduleOutboundActivityAsync(flowchartContext, outboundActivity, completionCallback); + var scheduleResponse = await ScheduleOutboundActivityAsync(flowchartContext, completedActivityContext, outboundActivity, completionCallback); // An inbound connection has been followed; cancel remaining inbound activities await CancelRemainingInboundActivitiesAsync(flowchartContext, outboundActivity); diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs index 04ae1027f..0643ad23c 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs @@ -1,4 +1,6 @@ using System.Reflection; +using CShells.Configuration; +using CShells.Management; using Elsa.Alterations.Extensions; using Elsa.Caching; using Elsa.Extensions; @@ -14,6 +16,7 @@ using Elsa.Workflows.ComponentTests.Decorators; using Elsa.Workflows.ComponentTests.Materializers; using Elsa.Workflows.ComponentTests.Scenarios.DistributedLockResilience.Mocks; using Elsa.Workflows.ComponentTests.Scenarios.HostMethodActivities; +using Elsa.Workflows.ComponentTests.Scenarios.RestApis.Endpoints.Shells; using Elsa.Workflows.ComponentTests.WorkflowProviders; using Elsa.Workflows.Management; using Elsa.Workflows.Runtime.Distributed.Extensions; @@ -28,6 +31,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Refit; using static Elsa.Api.Client.RefitSettingsHelper; @@ -146,6 +150,14 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl builder.ConfigureTestServices(services => { + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + // Decorate IDistributedLockProvider with SelectiveMockLockProvider // This allows tests to selectively mock specific locks without affecting background operations services.Decorate(); diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/Shells/ReloadAllTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/Shells/ReloadAllTests.cs new file mode 100644 index 000000000..ec9496c9e --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/Shells/ReloadAllTests.cs @@ -0,0 +1,76 @@ +using System.Net; +using CShells; +using Elsa.Api.Client.Resources.Shells.Models; +using Elsa.Api.Client.Resources.Shells.Responses; +using Elsa.Testing.Shared.Extensions; +using Elsa.Workflows.ComponentTests.Abstractions; +using Elsa.Workflows.ComponentTests.Fixtures; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Workflows.ComponentTests.Scenarios.RestApis.Endpoints.Shells; + +public class ReloadAllTests : AppComponentTest +{ + private readonly TestShellEnvironment _shellEnvironment; + + public ReloadAllTests(App app) : base(app) + { + _shellEnvironment = app.WorkflowServer.Services.GetRequiredService(); + _shellEnvironment.Reset(); + } + + [Fact] + public async Task Post_ShouldReloadAllShellsAndReturnPerShellResults() + { + _shellEnvironment.Reset( + [ + CreateShell("alpha", "v1"), + CreateShell("beta", "v1") + ], + [ + CreateShell("alpha", "v2"), + CreateShell("gamma", "v1") + ]); + + var client = WorkflowServer.CreateHttpClient(); + using var response = await client.PostAsync("actions/shells/reload", content: null); + var model = await response.ReadAsJsonAsync(WorkflowServer.Services); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(ShellReloadStatus.Completed, model.Status); + Assert.Collection(model.Shells.OrderBy(x => x.ShellId), + alpha => + { + Assert.Equal("alpha", alpha.ShellId); + Assert.Equal(ShellReloadItemOutcome.Reloaded, alpha.Outcome); + Assert.False(alpha.Requested); + }, + beta => + { + Assert.Equal("beta", beta.ShellId); + Assert.Equal(ShellReloadItemOutcome.Removed, beta.Outcome); + Assert.False(beta.Requested); + }, + gamma => + { + Assert.Equal("gamma", gamma.ShellId); + Assert.Equal(ShellReloadItemOutcome.Reloaded, gamma.Outcome); + Assert.False(gamma.Requested); + }); + + Assert.Equal("v2", _shellEnvironment.FindCurrent("alpha")!.ConfigurationData["Version"]); + Assert.Null(_shellEnvironment.FindCurrent("beta")); + Assert.Equal("v1", _shellEnvironment.FindCurrent("gamma")!.ConfigurationData["Version"]); + } + + private static ShellSettings CreateShell(string shellId, string version, bool invalid = false) + { + var shell = new ShellSettings(shellId, ["WorkflowsApi"]); + shell.ConfigurationData["Version"] = version; + + if (invalid) + shell.ConfigurationData["Test:IsInvalid"] = true; + + return shell; + } +} \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/Shells/ReloadTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/Shells/ReloadTests.cs new file mode 100644 index 000000000..c0817fab3 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/Shells/ReloadTests.cs @@ -0,0 +1,95 @@ +using System.Net; +using CShells; +using Elsa.Api.Client.Resources.Shells.Models; +using Elsa.Api.Client.Resources.Shells.Responses; +using Elsa.Testing.Shared.Extensions; +using Elsa.Workflows.ComponentTests.Abstractions; +using Elsa.Workflows.ComponentTests.Fixtures; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Workflows.ComponentTests.Scenarios.RestApis.Endpoints.Shells; + +public class ReloadTests : AppComponentTest +{ + private readonly TestShellEnvironment _shellEnvironment; + + public ReloadTests(App app) : base(app) + { + _shellEnvironment = app.WorkflowServer.Services.GetRequiredService(); + _shellEnvironment.Reset(); + } + + [Fact] + public async Task Post_WithKnownShell_ShouldReturnRequestedShellResults() + { + _shellEnvironment.Reset( + [ + CreateShell("alpha", "v1"), + CreateShell("beta", "v1") + ], + [ + CreateShell("alpha", "v2"), + CreateShell("beta", "v2", invalid: true) + ]); + + var client = WorkflowServer.CreateHttpClient(); + using var response = await client.PostAsync("shells/alpha/reload", content: null); + var model = await response.ReadAsJsonAsync(WorkflowServer.Services); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(ShellReloadStatus.Partial, model.Status); + Assert.Equal("alpha", model.RequestedShellId); + Assert.Contains(model.Shells, x => x.ShellId == "alpha" && x.Requested && x.Outcome == ShellReloadItemOutcome.Reloaded); + Assert.Contains(model.Shells, x => x.ShellId == "beta" && !x.Requested && x.Outcome == ShellReloadItemOutcome.InvalidConfiguration); + Assert.Equal("v2", _shellEnvironment.FindCurrent("alpha")!.ConfigurationData["Version"]); + Assert.Equal("v1", _shellEnvironment.FindCurrent("beta")!.ConfigurationData["Version"]); + } + + [Fact] + public async Task Post_WithUnknownShell_ShouldReturnNotFound() + { + _shellEnvironment.Reset([CreateShell("alpha", "v1")], [CreateShell("alpha", "v2")]); + + var client = WorkflowServer.CreateHttpClient(); + using var response = await client.PostAsync("shells/missing/reload", content: null); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(string.Empty, await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Post_WhenRequestedShellFails_ShouldReturnUnprocessableEntity() + { + _shellEnvironment.Reset( + [ + CreateShell("alpha", "v1"), + CreateShell("beta", "v1") + ], + [ + CreateShell("alpha", "v2", invalid: true), + CreateShell("beta", "v2") + ]); + + var client = WorkflowServer.CreateHttpClient(); + using var response = await client.PostAsync("shells/alpha/reload", content: null); + var model = await response.ReadAsJsonAsync(WorkflowServer.Services); + + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + Assert.Equal(ShellReloadStatus.RequestedShellFailed, model.Status); + Assert.Contains(model.Shells, x => x.ShellId == "alpha" && x.Requested && x.Outcome == ShellReloadItemOutcome.InvalidConfiguration); + Assert.Contains(model.Shells, x => x.ShellId == "beta" && x.Outcome == ShellReloadItemOutcome.Reloaded); + Assert.Equal("v1", _shellEnvironment.FindCurrent("alpha")!.ConfigurationData["Version"]); + Assert.Equal("v2", _shellEnvironment.FindCurrent("beta")!.ConfigurationData["Version"]); + } + + private static ShellSettings CreateShell(string shellId, string version, bool invalid = false) + { + var shell = new ShellSettings(shellId, ["WorkflowsApi"]); + shell.ConfigurationData["Version"] = version; + + if (invalid) + shell.ConfigurationData["Test:IsInvalid"] = true; + + return shell; + } +} \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/Shells/ShellReloadFailureTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/Shells/ShellReloadFailureTests.cs new file mode 100644 index 000000000..01c7caef1 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/Shells/ShellReloadFailureTests.cs @@ -0,0 +1,91 @@ +using System.Net; +using CShells; +using Elsa.Api.Client.Resources.Shells.Models; +using Elsa.Api.Client.Resources.Shells.Responses; +using Elsa.Testing.Shared.Extensions; +using Elsa.Workflows.ComponentTests.Abstractions; +using Elsa.Workflows.ComponentTests.Fixtures; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Workflows.ComponentTests.Scenarios.RestApis.Endpoints.Shells; + +public class ShellReloadFailureTests : AppComponentTest +{ + private readonly TestShellEnvironment _shellEnvironment; + + public ShellReloadFailureTests(App app) : base(app) + { + _shellEnvironment = app.WorkflowServer.Services.GetRequiredService(); + _shellEnvironment.Reset(); + } + + [Fact] + public async Task Post_WhenReloadIsBusy_ShouldReturnConflict() + { + _shellEnvironment.Reset([CreateShell("alpha", "v1")], [CreateShell("alpha", "v2")]); + var (gate, entered) = _shellEnvironment.BlockProvider(); + var client = WorkflowServer.CreateHttpClient(); + var firstRequestTask = client.PostAsync("actions/shells/reload", content: null); + + await entered; + + using var secondResponse = await client.PostAsync("actions/shells/reload", content: null); + var secondModel = await secondResponse.ReadAsJsonAsync(WorkflowServer.Services); + gate.TrySetResult(true); + using var firstResponse = await firstRequestTask; + + Assert.Equal(HttpStatusCode.Conflict, secondResponse.StatusCode); + Assert.Equal(ShellReloadStatus.Busy, secondModel.Status); + } + + [Fact] + public async Task Post_WhenProviderIsUnavailable_ShouldReturnServiceUnavailable() + { + _shellEnvironment.Reset([CreateShell("alpha", "v1")], [CreateShell("alpha", "v2")]); + _shellEnvironment.ProviderException = new InvalidOperationException("Shell settings provider is unavailable."); + + var client = WorkflowServer.CreateHttpClient(); + using var response = await client.PostAsync("actions/shells/reload", content: null); + var model = await response.ReadAsJsonAsync(WorkflowServer.Services); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + Assert.Equal(ShellReloadStatus.Failed, model.Status); + Assert.Equal("v1", _shellEnvironment.FindCurrent("alpha")!.ConfigurationData["Version"]); + } + + [Fact] + public async Task Post_WhenOneShellIsInvalid_ShouldReturnPartialAndKeepPreviousConfiguration() + { + _shellEnvironment.Reset( + [ + CreateShell("alpha", "v1"), + CreateShell("beta", "v1") + ], + [ + CreateShell("alpha", "v2"), + CreateShell("beta", "v2", invalid: true) + ]); + + var client = WorkflowServer.CreateHttpClient(); + using var response = await client.PostAsync("actions/shells/reload", content: null); + var model = await response.ReadAsJsonAsync(WorkflowServer.Services); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(ShellReloadStatus.Partial, model.Status); + Assert.Contains(model.Shells, x => x.ShellId == "alpha" && x.Outcome == ShellReloadItemOutcome.Reloaded); + Assert.Contains(model.Shells, x => x.ShellId == "beta" && x.Outcome == ShellReloadItemOutcome.InvalidConfiguration); + Assert.Equal("v2", _shellEnvironment.FindCurrent("alpha")!.ConfigurationData["Version"]); + Assert.Equal("v1", _shellEnvironment.FindCurrent("beta")!.ConfigurationData["Version"]); + } + + private static ShellSettings CreateShell(string shellId, string version, bool invalid = false) + { + var shell = new ShellSettings(shellId, ["WorkflowsApi"]); + shell.ConfigurationData["Version"] = version; + + if (invalid) + shell.ConfigurationData["Test:IsInvalid"] = true; + + return shell; + } +} diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/Shells/TestShellEnvironment.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/Shells/TestShellEnvironment.cs new file mode 100644 index 000000000..da3898756 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/RestApis/Endpoints/Shells/TestShellEnvironment.cs @@ -0,0 +1,144 @@ +using CShells; +using CShells.Configuration; +using CShells.Management; + +namespace Elsa.Workflows.ComponentTests.Scenarios.RestApis.Endpoints.Shells; + +public class TestShellEnvironment : IShellManager, IShellSettingsProvider, IShellSettingsCache +{ + private const string InvalidConfigurationKey = "Test:IsInvalid"; + private readonly object _lock = new(); + private Dictionary _currentShells = new(StringComparer.OrdinalIgnoreCase); + private Dictionary _sourceShells = new(StringComparer.OrdinalIgnoreCase); + private TaskCompletionSource? _providerGate; + private TaskCompletionSource? _providerEntered; + + public Exception? ProviderException { get; set; } + + public void Reset(IEnumerable? currentShells = null, IEnumerable? sourceShells = null) + { + lock (_lock) + { + _currentShells = (currentShells ?? []).Select(Clone).ToDictionary(x => x.Id.Name, StringComparer.OrdinalIgnoreCase); + _sourceShells = (sourceShells ?? currentShells ?? []).Select(Clone).ToDictionary(x => x.Id.Name, StringComparer.OrdinalIgnoreCase); + ProviderException = null; + _providerGate = null; + _providerEntered = null; + } + } + + public (TaskCompletionSource Gate, Task Entered) BlockProvider() + { + lock (_lock) + { + _providerGate = new(TaskCreationOptions.RunContinuationsAsynchronously); + _providerEntered = new(TaskCreationOptions.RunContinuationsAsynchronously); + return (_providerGate, _providerEntered.Task); + } + } + + public ShellSettings? FindCurrent(string shellId) + { + lock (_lock) + return _currentShells.TryGetValue(shellId, out var settings) ? Clone(settings) : null; + } + + public Task> GetShellSettingsAsync(CancellationToken cancellationToken = default) + { + TaskCompletionSource? providerGate; + Exception? providerException; + IEnumerable settings; + + lock (_lock) + { + providerGate = _providerGate; + _providerEntered?.TrySetResult(true); + providerException = ProviderException; + settings = _sourceShells.Values.Select(Clone).ToArray(); + } + + return GetShellSettingsInternalAsync(providerGate, providerException, settings, cancellationToken); + } + + public IReadOnlyCollection GetAll() + { + lock (_lock) + return _currentShells.Values.Select(Clone).ToArray(); + } + + public ShellSettings? GetById(ShellId id) + { + lock (_lock) + return _currentShells.TryGetValue(id.Name, out var settings) ? Clone(settings) : null; + } + + public Task AddShellAsync(ShellSettings settings, CancellationToken cancellationToken = default) + { + lock (_lock) + { + _currentShells[settings.Id.Name] = Clone(settings); + + if (IsInvalid(settings)) + throw new InvalidOperationException($"Shell '{settings.Id.Name}' has invalid configuration."); + } + + return Task.CompletedTask; + } + + public Task RemoveShellAsync(ShellId shellId, CancellationToken cancellationToken = default) + { + lock (_lock) + _currentShells.Remove(shellId.Name); + + return Task.CompletedTask; + } + + public Task UpdateShellAsync(ShellSettings settings, CancellationToken cancellationToken = default) + { + lock (_lock) + { + _currentShells.Remove(settings.Id.Name); + _currentShells[settings.Id.Name] = Clone(settings); + + if (IsInvalid(settings)) + throw new InvalidOperationException($"Shell '{settings.Id.Name}' has invalid configuration."); + } + + return Task.CompletedTask; + } + + public Task ReloadAllShellsAsync(CancellationToken cancellationToken = default) + { + lock (_lock) + _currentShells = _sourceShells.Values.Select(Clone).ToDictionary(x => x.Id.Name, StringComparer.OrdinalIgnoreCase); + + return Task.CompletedTask; + } + + private static bool IsInvalid(ShellSettings settings) => + settings.ConfigurationData.TryGetValue(InvalidConfigurationKey, out var value) && value is true; + + private static async Task> GetShellSettingsInternalAsync(TaskCompletionSource? providerGate, Exception? providerException, IEnumerable settings, CancellationToken cancellationToken) + { + if (providerGate != null) + await providerGate.Task.WaitAsync(cancellationToken); + + if (providerException != null) + throw providerException; + + return settings; + } + + private static ShellSettings Clone(ShellSettings source) + { + var clone = new ShellSettings(source.Id, source.EnabledFeatures) + { + ConfigurationData = new Dictionary(source.ConfigurationData, StringComparer.OrdinalIgnoreCase) + }; + + foreach (var configurator in source.FeatureConfigurators) + clone.FeatureConfigurators[configurator.Key] = configurator.Value; + + return clone; + } +}