Repository: ScottArbeit/Grace Branch: main Commit: d93b00e5ef04 Files: 389 Total size: 4.6 MB Directory structure: gitextract_t_kevelm/ ├── .beads/ │ ├── .gitignore │ ├── README.md │ ├── config.yaml │ ├── interactions.jsonl │ ├── issues.jsonl │ └── metadata.json ├── .config/ │ └── dotnet-tools.json ├── .gitattributes ├── .github/ │ ├── code_review_instructions.md │ ├── copilot-instructions.md │ └── workflows/ │ ├── dotnet.yml │ └── validate.yml ├── .gitignore ├── .markdownlint.jsonc ├── AGENTS.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── REPO_INDEX.md ├── SECURITY.md ├── _endpoint_stub.txt ├── code_of_conduct.md ├── docs/ │ ├── .markdownlint.jsonc │ ├── AI Submissions.md │ ├── Authentication.md │ ├── Branching strategy.md │ ├── Continuous review.md │ ├── Data types in Grace.md │ ├── Design and Motivations.md │ ├── Design concepts/ │ │ ├── Backups.md │ │ └── Directory and file-level ACL's.md │ ├── Frequently asked questions.md │ ├── How Grace computes the SHA-256 value.md │ ├── Mermaid diagrams.md │ ├── The potential for misusing Grace.md │ ├── What grace watch does.md │ ├── Why Auto-Rebase isn't a problem.md │ ├── Why Grace isn't just a version control system.md │ └── Work items.md ├── global.json ├── prompts/ │ ├── ContentPack.prompt.md │ ├── Grace issue summary.md │ ├── Grace pull request summary.md │ └── PlanPack.prompt.md ├── scripts/ │ ├── bootstrap.ps1 │ ├── collect-runtime-metadata.ps1 │ ├── dev-local.ps1 │ ├── install-githooks.ps1 │ ├── start-debuglocal.ps1 │ └── validate.ps1 ├── src/ │ ├── .aspire/ │ │ └── settings.json │ ├── .dockerignore │ ├── .editorconfig │ ├── .gitattributes │ ├── .github/ │ │ ├── copilot-instructions.md │ │ └── workflows/ │ │ └── deploy-to-app-service.yml │ ├── AGENTS.md │ ├── CountLines.ps1 │ ├── Create-Grace-Objects.ps1 │ ├── Directory.Build.props │ ├── Grace.Actors/ │ │ ├── AGENTS.md │ │ ├── AccessControl.Actor.fs │ │ ├── ActorProxy.Extensions.Actor.fs │ │ ├── Artifact.Actor.fs │ │ ├── Branch.Actor.fs │ │ ├── BranchName.Actor.fs │ │ ├── CodeGenAttribute.Actor.fs │ │ ├── Constants.Actor.fs │ │ ├── Context.Actor.fs │ │ ├── Diff.Actor.fs │ │ ├── DirectoryAppearance.Actor.fs │ │ ├── DirectoryVersion.Actor.fs │ │ ├── Extensions/ │ │ │ └── MemoryCache.Extensions.Actor.fs │ │ ├── FileAppearance.Actor.fs │ │ ├── GlobalLock.Actor.fs │ │ ├── Grace.Actors.fsproj │ │ ├── GrainRepository.Actor.fs │ │ ├── Interfaces.Actor.fs │ │ ├── NamedSection.Actor.fs │ │ ├── Organization.Actor.fs │ │ ├── OrganizationName.Actor.fs │ │ ├── Owner.Actor.fs │ │ ├── OwnerName.Actor.fs │ │ ├── PersonalAccessToken.Actor.fs │ │ ├── Policy.Actor.fs │ │ ├── PromotionQueue.Actor.fs │ │ ├── PromotionSet.Actor.fs │ │ ├── Reference.Actor.fs │ │ ├── Reminder.Actor.fs │ │ ├── Repository.Actor.fs │ │ ├── Repository.Actor.fs (ApplyEvent Method) │ │ ├── RepositoryName.Actor.fs │ │ ├── RepositoryPermission.Actor.fs │ │ ├── Review.Actor.fs │ │ ├── Services.Actor.fs │ │ ├── Timing.Actor.fs │ │ ├── Types.Actor.fs │ │ ├── User.Actor.fs │ │ ├── ValidationResult.Actor.fs │ │ ├── ValidationSet.Actor.fs │ │ ├── WorkItem.Actor.fs │ │ ├── WorkItemNumber.Actor.fs │ │ └── WorkItemNumberCounter.Actor.fs │ ├── Grace.Aspire.AppHost/ │ │ ├── AGENTS.md │ │ ├── Grace.Aspire.AppHost.csproj │ │ ├── Program.Aspire.AppHost.cs │ │ ├── Properties/ │ │ │ └── launchSettings.json │ │ └── appsettings.json │ ├── Grace.Aspire.ServiceDefaults/ │ │ ├── Extensions.cs │ │ └── Grace.Aspire.ServiceDefaults.csproj │ ├── Grace.Authorization.Tests/ │ │ ├── AuthorizationSemantics.Tests.fs │ │ ├── ClaimMapping.Tests.fs │ │ ├── EndpointAuthorizationManifest.Tests.fs │ │ ├── Grace.Authorization.Tests.fsproj │ │ ├── PathPermissions.Tests.fs │ │ ├── PermissionEvaluator.Tests.fs │ │ ├── PersonalAccessToken.Tests.fs │ │ └── Program.fs │ ├── Grace.CLI/ │ │ ├── AGENTS.md │ │ ├── Command/ │ │ │ ├── Access.CLI.fs │ │ │ ├── Admin.CLI.fs │ │ │ ├── Agent.CLI.fs │ │ │ ├── Auth.CLI.fs │ │ │ ├── Branch.CLI.fs │ │ │ ├── Candidate.CLI.fs │ │ │ ├── Common.CLI.fs │ │ │ ├── Config.CLI.fs │ │ │ ├── Connect.CLI.fs │ │ │ ├── Diff.CLI.fs │ │ │ ├── DirectoryVersion.CLI.fs │ │ │ ├── History.CLI.fs │ │ │ ├── Maintenance.CLI.fs │ │ │ ├── Organization.CLI.fs │ │ │ ├── Owner.CLI.fs │ │ │ ├── PromotionSet.CLI.fs │ │ │ ├── Queue.CLI.fs │ │ │ ├── Reference.CLI.fs │ │ │ ├── Repository.CLI.fs │ │ │ ├── Review.CLI.fs │ │ │ ├── Services.CLI.fs │ │ │ ├── Watch.CLI.fs │ │ │ └── WorkItem.CLI.fs │ │ ├── Conversion.md │ │ ├── Grace.CLI.fsproj │ │ ├── HistoryStorage.CLI.fs │ │ ├── LocalStateDb.CLI.fs │ │ ├── Log.CLI.fs │ │ ├── Program.CLI.fs │ │ ├── Properties/ │ │ │ └── launchSettings.json │ │ ├── Text.CLI.fs │ │ └── packages-microsoft-prod.deb │ ├── Grace.CLI.LocalStateDb.Worker/ │ │ ├── Grace.CLI.LocalStateDb.Worker.fsproj │ │ └── Program.fs │ ├── Grace.CLI.Tests/ │ │ ├── AGENTS.md │ │ ├── Agent.CLI.Tests.fs │ │ ├── Auth.Tests.fs │ │ ├── AuthTokenBundle.Tests.fs │ │ ├── Connect.CLI.Tests.fs │ │ ├── Grace.CLI.Tests.fsproj │ │ ├── History.CLI.Tests.fs │ │ ├── HistoryStorage.CLI.Tests.fs │ │ ├── LocalStateDb.Tests.fs │ │ ├── Program.CLI.Tests.fs │ │ ├── Program.fs │ │ ├── PromotionSet.CLI.Tests.fs │ │ ├── Queue.CLI.Tests.fs │ │ ├── Review.CLI.Tests.fs │ │ ├── Watch.Tests.fs │ │ └── WorkItem.CLI.Tests.fs │ ├── Grace.Load/ │ │ ├── Grace.Load.fsproj │ │ ├── Program.Load.fs │ │ └── Properties/ │ │ └── launchSettings.json │ ├── Grace.Orleans.CodeGen/ │ │ ├── Declaration.Orleans.CodeGen.cs │ │ ├── Grace.Orleans.CodeGen.csproj │ │ └── instructions.md │ ├── Grace.SDK/ │ │ ├── AGENTS.md │ │ ├── Access.SDK.fs │ │ ├── Admin.SDK.fs │ │ ├── Artifact.SDK.fs │ │ ├── Auth.SDK.fs │ │ ├── Branch.SDK.fs │ │ ├── Common.SDK.fs │ │ ├── Diff.SDK.fs │ │ ├── DirectoryVersion.SDK.fs │ │ ├── Grace.SDK.fsproj │ │ ├── Organization.SDK.fs │ │ ├── Owner.SDK.fs │ │ ├── PersonalAccessToken.SDK.fs │ │ ├── Policy.SDK.fs │ │ ├── PromotionSet.SDK.fs │ │ ├── Queue.SDK.fs │ │ ├── Repository.SDK.fs │ │ ├── Review.SDK.fs │ │ ├── Storage.SDK.fs │ │ ├── ValidationResult.SDK.fs │ │ ├── ValidationSet.SDK.fs │ │ └── WorkItem.SDK.fs │ ├── Grace.Server/ │ │ ├── AGENTS.md │ │ ├── Access.Server.fs │ │ ├── ApplicationContext.Server.fs │ │ ├── Artifact.Server.fs │ │ ├── Auth.Server.fs │ │ ├── Branch.Server.fs │ │ ├── CorrelationId.Server.fs │ │ ├── DerivedComputation.Server.fs │ │ ├── Diff.Server.fs │ │ ├── DirectoryVersion.Server.fs │ │ ├── Dockerfile │ │ ├── Eventing.Server.fs │ │ ├── Grace.Server.fsproj │ │ ├── Middleware/ │ │ │ ├── CorrelationId.Middleware.fs │ │ │ ├── Fake.Middleware.fs │ │ │ ├── HttpSecurityHeaders.Middleware.fs │ │ │ ├── LogAuthorizationFailure.Middleware.fs │ │ │ ├── LogRequestHeaders.Middleware.fs │ │ │ ├── Timing.Middleware.fs │ │ │ └── ValidateIds.Middleware.fs │ │ ├── Notification.Server.fs │ │ ├── Organization.Server.fs │ │ ├── OrleansFilters.Server.fs │ │ ├── Owner.Server.fs │ │ ├── PartitionKeyProvider.fs │ │ ├── Policy.Server.fs │ │ ├── Program.Server.fs │ │ ├── PromotionSet.Server.fs │ │ ├── Properties/ │ │ │ ├── PublishProfiles/ │ │ │ │ └── DisableContainerBuild.pubxml │ │ │ └── launchSettings.json │ │ ├── Queue.Server.fs │ │ ├── Reminder.Server.fs │ │ ├── ReminderService.Server.fs │ │ ├── Repository.Server.fs │ │ ├── Review.Server.fs │ │ ├── ReviewAnalysis.Server.fs │ │ ├── ReviewModels.Server.fs │ │ ├── Security/ │ │ │ ├── AuthorizationMiddleware.Server.fs │ │ │ ├── ClaimMapping.Server.fs │ │ │ ├── ClaimsTransformation.Server.fs │ │ │ ├── EndpointAuthorizationManifest.Server.fs │ │ │ ├── ExternalAuthConfig.Server.fs │ │ │ ├── PermissionEvaluator.Server.fs │ │ │ ├── PersonalAccessTokenAuth.Server.fs │ │ │ ├── PrincipalMapper.Server.fs │ │ │ └── TestAuth.Server.fs │ │ ├── Services.Server.fs │ │ ├── Startup.Server.fs │ │ ├── Storage.Server.fs │ │ ├── ValidationResult.Server.fs │ │ ├── ValidationSet.Server.fs │ │ ├── Validations.Server.fs │ │ ├── WorkItem.Server.fs │ │ ├── appsettings.json │ │ └── web.config │ ├── Grace.Server.Tests/ │ │ ├── AGENTS.md │ │ ├── Access.Server.Tests.fs │ │ ├── AspireTestHost.fs │ │ ├── Auth.Server.Tests.fs │ │ ├── AuthMapping.Unit.Tests.fs │ │ ├── Authorization.Unit.Tests.fs │ │ ├── Eventing.Server.Tests.fs │ │ ├── Evidence.Determinism.Tests.fs │ │ ├── General.Server.Tests.fs │ │ ├── Grace.Server.Tests.fsproj │ │ ├── Notification.Server.Tests.fs │ │ ├── OrleansFilters.Server.Tests.fs │ │ ├── Owner.Server.Tests.fs │ │ ├── Policy.Determinism.Tests.fs │ │ ├── Policy.Validation.Derived.Tests.fs │ │ ├── Program.fs │ │ ├── PromotionSet.CommandValidation.Tests.fs │ │ ├── Properties/ │ │ │ └── launchSettings.json │ │ ├── Queue.Server.Tests.fs │ │ ├── Repository.Server.Tests.fs │ │ ├── Review.Server.Tests.fs │ │ ├── ReviewNotes.Determinism.Tests.fs │ │ ├── Services.EffectivePromotion.Tests.fs │ │ ├── Slop.Server.Tests.fs │ │ ├── Smoke.Server.Tests.fs │ │ ├── Validation.Artifact.Contract.Tests.fs │ │ ├── Validations.Server.Tests.fs │ │ ├── WorkItem.Integration.Server.Tests.fs │ │ ├── WorkItem.Server.Tests.fs │ │ └── appsettings.json │ ├── Grace.Shared/ │ │ ├── AGENTS.md │ │ ├── Authorization.Shared.fs │ │ ├── AzureEnvironment.Shared.fs │ │ ├── BaselineDrift.Shared.fs │ │ ├── Client/ │ │ │ ├── Configuration.Shared.fs │ │ │ ├── Theme.Shared.fs │ │ │ └── UserConfiguration.Shared.fs │ │ ├── Combinators.fs │ │ ├── Constants.Shared.fs │ │ ├── Converters/ │ │ │ └── BranchDtoConverter.Shared.fs │ │ ├── Diff.Shared.fs │ │ ├── Dto/ │ │ │ └── Dto.Shared.fs │ │ ├── Evidence.Shared.fs │ │ ├── Extensions.Shared.fs │ │ ├── Grace.Shared.fsproj │ │ ├── Monikers.imagemanifest │ │ ├── Parameters/ │ │ │ ├── Access.Parameters.fs │ │ │ ├── Artifact.Parameters.fs │ │ │ ├── Auth.Parameters.fs │ │ │ ├── Branch.Parameters.fs │ │ │ ├── Common.Parameters.fs │ │ │ ├── Diff.Parameters.fs │ │ │ ├── Directory.Parameters.fs │ │ │ ├── Organization.Parameters.fs │ │ │ ├── Owner.Parameters.fs │ │ │ ├── Policy.Parameters.fs │ │ │ ├── PromotionSet.Parameters.fs │ │ │ ├── Queue.Parameters.fs │ │ │ ├── Reference.Parameters.fs │ │ │ ├── Reminder.Parameters.fs │ │ │ ├── Repository.Parameters.fs │ │ │ ├── Review.Parameters.fs │ │ │ ├── Storage.Parameters.fs │ │ │ ├── Validation.Parameters.fs │ │ │ └── WorkItem.Parameters.fs │ │ ├── Resources/ │ │ │ ├── Text/ │ │ │ │ ├── Languages.Resources.fs │ │ │ │ └── en-US.fs │ │ │ └── Utilities.Resources.fs │ │ ├── ReviewNotes.Shared.fs │ │ ├── Services.Shared.fs │ │ ├── Utilities.Shared.fs │ │ └── Validation/ │ │ ├── Common.Validation.fs │ │ ├── Connect.Validation.fs │ │ ├── Errors.Validation.fs │ │ ├── Repository.Validation.fs │ │ └── Utilities.Validation.fs │ ├── Grace.Types/ │ │ ├── AGENTS.md │ │ ├── Artifact.Types.fs │ │ ├── Auth.Types.fs │ │ ├── Authorization.Types.fs │ │ ├── Automation.Types.fs │ │ ├── Branch.Types.fs │ │ ├── Diff.Types.fs │ │ ├── DirectoryVersion.Types.fs │ │ ├── Events.Types.fs │ │ ├── Grace.Types.fsproj │ │ ├── Organization.Types.fs │ │ ├── Owner.Types.fs │ │ ├── PersonalAccessToken.Types.fs │ │ ├── Policy.Types.fs │ │ ├── PromotionSet.Types.fs │ │ ├── Queue.Types.fs │ │ ├── Reference.Types.fs │ │ ├── Reminder.Types.fs │ │ ├── Repository.Types.fs │ │ ├── RequiredAction.Types.fs │ │ ├── Review.Types.fs │ │ ├── Types.Types.fs │ │ ├── Validation.Types.fs │ │ └── WorkItem.Types.fs │ ├── Grace.Types.Tests/ │ │ ├── AGENTS.md │ │ ├── Automation.Types.Tests.fs │ │ ├── Grace.Types.Tests.fsproj │ │ ├── Program.fs │ │ ├── PromotionSet.ConflictModel.Types.Tests.fs │ │ ├── PromotionSet.Types.Tests.fs │ │ ├── Queue.Types.Tests.fs │ │ ├── Validation.Types.Tests.fs │ │ └── WorkItem.Types.Tests.fs │ ├── Grace.slnx │ ├── OpenAPI/ │ │ ├── Branch.Components.OpenAPI.yaml │ │ ├── Branch.Paths.OpenAPI.yaml │ │ ├── Diff.Components.OpenAPI.yaml │ │ ├── Diff.Paths.OpenAPI.yaml │ │ ├── Directory.Components.OpenAPI.yaml │ │ ├── Directory.Paths.OpenAPI.yaml │ │ ├── Dto.Components.OpenAPI.yaml │ │ ├── Grace.OpenAPI.yaml │ │ ├── Main.OpenAPI.yaml │ │ ├── Organization.Components.OpenAPI.yaml │ │ ├── Organization.Paths.OpenAPI.yaml │ │ ├── Owner.Components.OpenAPI.yaml │ │ ├── Owner.Paths.OpenAPI.yaml │ │ ├── Reference.Components.OpenAPI.yaml │ │ ├── Repository.Components.OpenAPI.yaml │ │ ├── Repository.Paths.OpenAPI.yaml │ │ ├── Responses.OpenAPI.yaml │ │ ├── Shared.Components.OpenAPI.yaml │ │ └── Shared2.Components.OpenAPI.yaml │ ├── Processing file system changes.md │ ├── docs/ │ │ ├── ASPIRE_SETUP.md │ │ └── ENVIRONMENT.md │ ├── fantomas-config.json │ ├── launchSettings.json │ ├── nuget.config │ └── packages-microsoft-prod.deb └── update-contributing.skill ================================================ FILE CONTENTS ================================================ ================================================ FILE: .beads/.gitignore ================================================ # SQLite databases *.db *.db?* *.db-journal *.db-wal *.db-shm # Daemon runtime files daemon.lock daemon.log daemon.pid bd.sock sync-state.json last-touched # Local version tracking (prevents upgrade notification spam after git ops) .local_version # Legacy database files db.sqlite bd.db # Worktree redirect file (contains relative path to main repo's .beads/) # Must not be committed as paths would be wrong in other clones redirect # Merge artifacts (temporary files from 3-way merge) beads.base.jsonl beads.base.meta.json beads.left.jsonl beads.left.meta.json beads.right.jsonl beads.right.meta.json # NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here. # They would override fork protection in .git/info/exclude, allowing # contributors to accidentally commit upstream issue databases. # The JSONL files (issues.jsonl, interactions.jsonl) and config files # are tracked by git by default since no pattern above ignores them. ================================================ FILE: .beads/README.md ================================================ # Beads - AI-Native Issue Tracking Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. ## What is Beads? Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. **Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) ## Quick Start ### Essential Commands ```bash # Create new issues bd create "Add user authentication" # View all issues bd list # View issue details bd show # Update issue status bd update --status in_progress bd update --status done # Sync with git remote bd sync ``` ### Working with Issues Issues in Beads are: - **Git-native**: Stored in `.beads/issues.jsonl` and synced like code - **AI-friendly**: CLI-first design works perfectly with AI coding agents - **Branch-aware**: Issues can follow your branch workflow - **Always in sync**: Auto-syncs with your commits ## Why Beads? ✨ **AI-Native Design** - Built specifically for AI-assisted development workflows - CLI-first interface works seamlessly with AI coding agents - No context switching to web UIs 🚀 **Developer Focused** - Issues live in your repo, right next to your code - Works offline, syncs when you push - Fast, lightweight, and stays out of your way 🔧 **Git Integration** - Automatic sync with git commits - Branch-aware issue tracking - Intelligent JSONL merge resolution ## Get Started with Beads Try Beads in your own projects: ```bash # Install Beads curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash # Initialize in your repo bd init # Create your first issue bd create "Try out Beads" ``` ## Learn More - **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) - **Quick Start Guide**: Run `bd quickstart` - **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) --- *Beads: Issue tracking that moves at the speed of thought* ⚡ ================================================ FILE: .beads/config.yaml ================================================ # Beads Configuration File # This file configures default behavior for all bd commands in this repository # All settings can also be set via environment variables (BD_* prefix) # or overridden with command-line flags # Issue prefix for this repository (used by bd init) # If not set, bd init will auto-detect from directory name # Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. # issue-prefix: "" # Use no-db mode: load from JSONL, no SQLite, write back after each command # When true, bd will use .beads/issues.jsonl as the source of truth # instead of SQLite database # no-db: false # Disable daemon for RPC communication (forces direct database access) # no-daemon: false # Disable auto-flush of database to JSONL after mutations # no-auto-flush: false # Disable auto-import from JSONL when it's newer than database # no-auto-import: false # Enable JSON output by default # json: false # Default actor for audit trails (overridden by BD_ACTOR or --actor) # actor: "" # Path to database (overridden by BEADS_DB or --db) # db: "" # Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) # auto-start-daemon: true # Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) # flush-debounce: "5s" # Git branch for beads commits (bd sync will commit to this branch) # IMPORTANT: Set this for team projects so all clones use the same sync branch. # This setting persists across clones (unlike database config which is gitignored). # Can also use BEADS_SYNC_BRANCH env var for local override. # If not set, bd sync will require you to run 'bd config set sync.branch '. # sync-branch: "beads-sync" # Multi-repo configuration (experimental - bd-307) # Allows hydrating from multiple repositories and routing writes to the correct JSONL # repos: # primary: "." # Primary repo (where this database lives) # additional: # Additional repos to hydrate from (read-only) # - ~/beads-planning # Personal planning repo # - ~/work-planning # Work planning repo # Integration settings (access with 'bd config get/set') # These are stored in the database, not in this file: # - jira.url # - jira.project # - linear.url # - linear.api-key # - github.org # - github.repo ================================================ FILE: .beads/interactions.jsonl ================================================ ================================================ FILE: .beads/issues.jsonl ================================================ {"id":"Grace-14w","title":"P1 Add src/docs/ENVIRONMENT.md from EnvironmentVariables","description":"Document env vars and dependencies (Docker services, auth forwarding) using Grace.Shared.EnvironmentVariables as source of truth.","acceptance_criteria":"ENVIRONMENT.md matches code constants; markdownlint passes.","notes":"Added src/docs/ENVIRONMENT.md documenting Docker dependencies, Aspire run modes, test toggles, auth forwarding, and all EnvironmentVariables entries.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-09T12:31:02.7535804-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T13:11:35.0366104-08:00","closed_at":"2026-01-09T13:11:35.0366104-08:00","dependencies":[{"issue_id":"Grace-14w","depends_on_id":"Grace-4ri","type":"blocks","created_at":"2026-01-09T12:46:38.9582602-08:00","created_by":"unknown"}]} {"id":"Grace-19m","title":"Ensure CI runs Fantomas format check","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-01-09T23:05:13.2613072-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T23:12:18.120212-08:00","closed_at":"2026-01-09T23:12:18.120212-08:00","close_reason":"Closed"} {"id":"Grace-2k6","title":"CLI: Queue commands","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:55.0263408-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T04:52:05.2894182-08:00","closed_at":"2026-01-06T04:52:05.2894182-08:00","close_reason":"Closed"} {"id":"Grace-3yl","title":"P0 Bootstrap script: restore tools/packages + output","description":"Extend scripts/bootstrap.ps1 to run dotnet tool restore and restore NuGet packages for chosen build/test targets; print next commands and elapsed time on success/failure.","acceptance_criteria":"bootstrap.ps1 performs tool restore + package restore; prints next steps (validate -Fast) and elapsed time; exits 0 on success.","notes":"bootstrap.ps1 restores tools and src/Grace.sln packages, prints next steps and elapsed time. Ran pwsh ./scripts/bootstrap.ps1 -SkipDocker -CI successfully.","status":"closed","priority":0,"issue_type":"task","created_at":"2026-01-09T12:29:04.7741508-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T13:05:47.2243686-08:00","closed_at":"2026-01-09T13:05:47.2243686-08:00","dependencies":[{"issue_id":"Grace-3yl","depends_on_id":"Grace-ami","type":"blocks","created_at":"2026-01-09T12:32:52.5983134-08:00","created_by":"unknown"},{"issue_id":"Grace-3yl","depends_on_id":"Grace-4d0","type":"blocks","created_at":"2026-01-09T12:32:57.929904-08:00","created_by":"unknown"}]} {"id":"Grace-40p","title":"P0 CI workflow: validate -Full on main/nightly","description":"Extend validate workflow to run pwsh ./scripts/validate.ps1 -Full on main pushes and/or nightly schedule; ensure Docker available for Aspire tests.","acceptance_criteria":"Full validation runs on main/nightly and fails if full suite fails; logs show stage failures.","notes":"validate.yml runs -Full on main pushes and nightly schedule with docker info step.","status":"closed","priority":0,"issue_type":"task","created_at":"2026-01-09T12:30:30.6850857-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T13:09:31.9266816-08:00","closed_at":"2026-01-09T13:09:31.9266816-08:00","dependencies":[{"issue_id":"Grace-40p","depends_on_id":"Grace-701","type":"blocks","created_at":"2026-01-09T12:34:07.4312764-08:00","created_by":"unknown"},{"issue_id":"Grace-40p","depends_on_id":"Grace-wv2","type":"blocks","created_at":"2026-01-09T12:34:12.7693199-08:00","created_by":"unknown"}]} {"id":"Grace-49s","title":"CLI: WorkItem commands","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:54.5109757-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T04:03:39.3374957-08:00","closed_at":"2026-01-06T04:03:39.3374957-08:00","close_reason":"Closed"} {"id":"Grace-4ba","title":"P0 Define bootstrap/validate semantics + acceptance","description":"Draft exact behavior for scripts (flags, working dir vs explicit paths, format check strategy, build/test targets) and failure/success outputs; include 'I know I'm done when…' checks.","acceptance_criteria":"Issue contains concrete command list for bootstrap/validate, flag behavior, and acceptance checks for success/failure paths.","notes":"Proposed semantics:\\nbootstrap.ps1\\n- StrictMode, Continue='Stop'.\\n- Flags: -SkipDocker, -CI (no prompts), -Verbose (PS standard).\\n- Checks: PowerShell 7.x (PSVersionTable.PSVersion.Major -ge 7), dotnet available (Get-Command dotnet), dotnet SDK resolves to net10 (dotnet --version + optionally dotnet --list-sdks; if global.json missing, warn), Docker running unless -SkipDocker (docker info).\\n- Actions: dotnet tool restore; dotnet restore src/Grace.sln (or explicit projects if needed).\\n- Output: grouped headings (Prereqs/Restore/Next steps), print elapsed time on success/failure. Exit 0 on success, non-zero otherwise.\\n\\nvalidate.ps1\\n- StrictMode, stop-on-error, grouped output: Format, Build, Test. Always prints elapsed time.\\n- Flags: -Fast (default), -Full, -SkipFormat/-SkipBuild/-SkipTests, -Configuration (default Release).\\n- Format stage: dotnet tool restore; run pinned Fantomas against src/ using src/fantomas-config.json. Prefer dotnet tool run fantomas --check src --config src/fantomas-config.json; if --check not reliable, run format then git status --porcelain diff and fail with guidance.\\n- Build stage: dotnet build src/Grace.Server/Grace.Server.fsproj -c \u003cconfig\u003e; dotnet build src/Grace.CLI/Grace.CLI.fsproj -c \u003cconfig\u003e; optional Grace.SDK depending on inventory.\\n- Test stage: -Fast runs dotnet test src/Grace.CLI.Tests/Grace.CLI.Tests.fsproj -c \u003cconfig\u003e --no-build. -Full runs that plus dotnet test src/Grace.Server.Tests/Grace.Server.Tests.fsproj -c \u003cconfig\u003e --no-build (Aspire/Docker).\\n\\nAcceptance checks for scripts:\\n- bootstrap: missing docker =\u003e fail with actionable message unless -SkipDocker; missing dotnet/pwsh =\u003e fail; tool restore + package restore succeed; prints next steps.\\n- validate: any stage failure =\u003e non-zero exit; formatting drift yields actionable guidance; -Fast avoids Docker; -Full exercises Aspire tests.","status":"closed","priority":0,"issue_type":"task","created_at":"2026-01-09T12:28:32.6383989-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T13:33:02.276854-08:00","closed_at":"2026-01-09T13:33:02.276854-08:00","close_reason":"Closed"} {"id":"Grace-4d0","title":"P0 Add .config/dotnet-tools.json with pinned Fantomas","description":"Create local tool manifest at repo root and pin fantomas-tool version; ensure validate uses dotnet tool run.","acceptance_criteria":"dotnet tool restore succeeds at repo root; dotnet tool run fantomas --version matches pinned version.","notes":"Added .config/dotnet-tools.json pinning fantomas-tool 4.7.9. dotnet tool restore + dotnet tool run fantomas --version succeeded.","status":"closed","priority":0,"issue_type":"task","created_at":"2026-01-09T12:28:42.9941774-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T12:52:49.5079712-08:00","closed_at":"2026-01-09T12:52:49.5079712-08:00","dependencies":[{"issue_id":"Grace-4d0","depends_on_id":"Grace-w54","type":"blocks","created_at":"2026-01-09T12:32:36.5957243-08:00","created_by":"unknown"}]} {"id":"Grace-4g4","title":"Fix verbose parse result option handling","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-09T01:34:19.1573701-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T01:38:22.0352754-08:00","closed_at":"2026-01-09T01:38:22.0352754-08:00","close_reason":"Closed"} {"id":"Grace-4ri","title":"P0 Inventory current build/test/format commands","description":"Read AGENTS.md, src/AGENTS.md, src/docs/ASPIRE_SETUP.md, src/fantomas-config.json, src/.editorconfig; locate sln/projects/tests and existing scripts; identify fast vs full test candidates.","acceptance_criteria":"Notes captured in issue: canonical build targets, fast tests, full tests, format config paths, any existing scripts/tools.","notes":"Inventory summary:\\n- Solution: src/Grace.sln\\n- Projects: Grace.Server, Grace.CLI, Grace.SDK, Grace.Actors, Grace.Shared, Grace.Types, Grace.Load; tests: Grace.CLI.Tests, Grace.Server.Tests\\n- Format config: src/fantomas-config.json; .editorconfig lives under src/\\n- Current guidance: dotnet build --configuration Release; dotnet test --no-build; run fantomas . after F# changes (root + project AGENTS).\\n- Aspire: src/docs/ASPIRE_SETUP.md (Aspire app host in Grace.Aspire.AppHost; tests reuse running emulators)\\n- Grace.Server.Tests uses [\u003cSetUpFixture\u003e] in namespace Grace.Server.Tests and AspireTestHost.startAsync() which spins containers; first call must be POST /owner/create per src/Grace.Server.Tests/AGENTS.md.\\n- Fast tests candidate: src/Grace.CLI.Tests/Grace.CLI.Tests.fsproj (non-Docker). Full tests: src/Grace.Server.Tests/Grace.Server.Tests.fsproj (Aspire).\\n- No scripts/ directory currently in repo root.","status":"closed","priority":0,"issue_type":"task","created_at":"2026-01-09T12:28:20.9873729-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T13:32:51.8031394-08:00","closed_at":"2026-01-09T13:32:51.8031394-08:00","close_reason":"Closed"} {"id":"Grace-4x5","title":"Tests: policy parser/snapshot determinism","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:55.2846176-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T05:13:35.5451478-08:00","closed_at":"2026-01-06T05:13:35.5451478-08:00","close_reason":"Closed"} {"id":"Grace-5c3","title":"Authorization/Access control implementation","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-12-28T23:07:44.6284068-08:00","created_by":"Scott Arbeit","updated_at":"2025-12-30T02:05:17.4202501-08:00","closed_at":"2025-12-30T02:05:17.4202501-08:00","close_reason":"All tasks completed"} {"id":"Grace-5c3.1","title":"Authz: codebase reconnaissance and conventions","description":"Review existing patterns (actors, handlers, params, SDK/CLI) and locate stubs/target endpoints to protect; confirm serializer and validation conventions.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-28T23:07:58.2365723-08:00","created_by":"Scott Arbeit","updated_at":"2025-12-28T23:14:19.3631334-08:00","closed_at":"2025-12-28T23:14:19.3631334-08:00","close_reason":"Recon complete","dependencies":[{"issue_id":"Grace-5c3.1","depends_on_id":"Grace-5c3","type":"parent-child","created_at":"2025-12-28T23:07:58.2414666-08:00","created_by":"daemon"}]} {"id":"Grace-5c3.10","title":"Authz: unit + integration tests","description":"Add pure authorization unit tests and server integration tests for /access, enforcement, and auth headers; update General.Server.Tests.fs headers.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-28T23:08:36.7155492-08:00","created_by":"Scott Arbeit","updated_at":"2025-12-29T00:17:49.3242606-08:00","closed_at":"2025-12-29T00:17:49.3242606-08:00","close_reason":"Closed","dependencies":[{"issue_id":"Grace-5c3.10","depends_on_id":"Grace-5c3","type":"parent-child","created_at":"2025-12-28T23:08:36.7204264-08:00","created_by":"daemon"}]} {"id":"Grace-5c3.11","title":"Authz: formatting/build/test verification","description":"Run fantomas, dotnet build Release, and dotnet test for Server.Tests + CLI.Tests after code changes.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-28T23:08:40.9300256-08:00","created_by":"Scott Arbeit","updated_at":"2025-12-29T01:27:14.0420934-08:00","closed_at":"2025-12-29T01:27:14.0420934-08:00","dependencies":[{"issue_id":"Grace-5c3.11","depends_on_id":"Grace-5c3","type":"parent-child","created_at":"2025-12-28T23:08:40.9347831-08:00","created_by":"daemon"}]} {"id":"Grace-5c3.12","title":"Auth: external auth config + claim mapping","description":"Add auth provider config model + env var bindings; implement claims transformation mapping external provider claims to grace_user_id and grace_claim (MSA + Entra). Include provider-agnostic mapping helper + /auth/me endpoint + unit tests for mapping logic.","notes":"Config + claim mapping + /auth/me + unit tests","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-30T00:20:35.5829516-08:00","created_by":"Scott Arbeit","updated_at":"2025-12-30T01:08:29.3280438-08:00","closed_at":"2025-12-30T01:08:29.3280438-08:00","dependencies":[{"issue_id":"Grace-5c3.12","depends_on_id":"Grace-5c3","type":"parent-child","created_at":"2025-12-30T00:20:35.5835408-08:00","created_by":"daemon"}]} {"id":"Grace-5c3.13","title":"Auth: login provider selection page + endpoints","description":"Add /auth/login page listing configured providers; add /auth/login/{provider} challenge endpoint + returnUrl handling; add /auth/logout endpoint; wire routes in Startup.Server.fs; show only providers with valid config.","notes":"Added /auth/login page, provider challenge route, and /auth/logout.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-30T00:20:37.3729975-08:00","created_by":"Scott Arbeit","updated_at":"2025-12-30T01:10:58.7806669-08:00","closed_at":"2025-12-30T01:10:58.7806669-08:00","dependencies":[{"issue_id":"Grace-5c3.13","depends_on_id":"Grace-5c3","type":"parent-child","created_at":"2025-12-30T00:20:37.3784688-08:00","created_by":"daemon"}]} {"id":"Grace-5c3.14","title":"Auth: Microsoft (MSA) OIDC + JWT bearer integration","description":"Configure Cookie + OpenIdConnect (Microsoft) and JwtBearer schemes for MSA+Entra; keep GraceTest when GRACE_TESTING=1; set default challenge/forbid scheme selection and callbacks; validate JWT audience for Grace API scope.","notes":"Configured auth schemes: cookie + OIDC + JWT with scheme selection; MSA/Entra authority and audience validation.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-30T00:20:39.1621794-08:00","created_by":"Scott Arbeit","updated_at":"2025-12-30T01:13:51.2451983-08:00","closed_at":"2025-12-30T01:13:51.2451983-08:00","dependencies":[{"issue_id":"Grace-5c3.14","depends_on_id":"Grace-5c3","type":"parent-child","created_at":"2025-12-30T00:20:39.1676651-08:00","created_by":"daemon"}]} {"id":"Grace-5c3.15","title":"Auth: auth pipeline tests","description":"Add unit tests for claim mapping; integration test for /auth/login provider list + challenge route (no external IdP calls); verify GraceTest auth still works. Add minimal CLI auth tests if feasible without IdP calls.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-30T00:20:41.0655512-08:00","created_by":"Scott Arbeit","updated_at":"2025-12-30T02:02:09.4835738-08:00","closed_at":"2025-12-30T02:02:09.4835738-08:00","dependencies":[{"issue_id":"Grace-5c3.15","depends_on_id":"Grace-5c3","type":"parent-child","created_at":"2025-12-30T00:20:41.0709904-08:00","created_by":"daemon"}]} {"id":"Grace-5c3.16","title":"Docs: MSA app registration + local config","description":"Document MSA+Entra app registration (web + CLI apps), API scope, redirect URIs, required env vars/user-secrets, and CLI login usage. Note GitHub/Google placeholders for future providers.","status":"closed","priority":3,"issue_type":"task","created_at":"2025-12-30T00:20:42.9343128-08:00","created_by":"Scott Arbeit","updated_at":"2025-12-30T02:03:24.8795061-08:00","closed_at":"2025-12-30T02:03:24.8795061-08:00","dependencies":[{"issue_id":"Grace-5c3.16","depends_on_id":"Grace-5c3","type":"parent-child","created_at":"2025-12-30T00:20:42.9402113-08:00","created_by":"daemon"}]} {"id":"Grace-5c3.17","title":"Auth: CLI/GUI device-code login + token cache","description":"Add CLI auth commands (login/status/logout/whoami) using MSAL device code; store tokens in OS-protected cache; update Grace SDK/CLI HTTP client to send Bearer tokens; update grace connect to require auth / prompt login; support re-auth in existing repos.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-30T01:00:58.2662025-08:00","created_by":"Scott Arbeit","updated_at":"2025-12-30T01:52:08.0842766-08:00","closed_at":"2025-12-30T01:52:08.0842766-08:00","dependencies":[{"issue_id":"Grace-5c3.17","depends_on_id":"Grace-5c3","type":"parent-child","created_at":"2025-12-30T01:00:58.2777028-08:00","created_by":"daemon"}]} {"id":"Grace-5c3.2","title":"Authz: add authorization domain types","description":"Add Authorization.Types.fs in Grace.Types with Principal/Scope/Resource/Operation/Role types + update fsproj; follow serializer/versioning conventions.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-28T23:08:02.281657-08:00","created_by":"Scott Arbeit","updated_at":"2025-12-28T23:16:34.3723615-08:00","closed_at":"2025-12-28T23:16:34.3723615-08:00","close_reason":"Authorization types added","dependencies":[{"issue_id":"Grace-5c3.2","depends_on_id":"Grace-5c3","type":"parent-child","created_at":"2025-12-28T23:08:02.2871518-08:00","created_by":"daemon"}]} {"id":"Grace-5c3.3","title":"Authz: implement pure authorization core","description":"Add Authorization.Shared.fs in Grace.Shared with RoleCatalog, scope resolution, effective ops, path permission checks, and checkPermission.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-28T23:08:06.5102728-08:00","created_by":"Scott Arbeit","updated_at":"2025-12-28T23:27:54.83891-08:00","closed_at":"2025-12-28T23:27:54.83891-08:00","close_reason":"Authorization core added","dependencies":[{"issue_id":"Grace-5c3.3","depends_on_id":"Grace-5c3","type":"parent-child","created_at":"2025-12-28T23:08:06.5158736-08:00","created_by":"daemon"}]} {"id":"Grace-5c3.4","title":"Authz: AccessControl actor + RepositoryPermission actor","description":"Implement AccessControl.Actor.fs + interface + constants; complete RepositoryPermission.Actor.fs with upsert/remove/list path permissions.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-28T23:08:10.4735628-08:00","created_by":"Scott Arbeit","updated_at":"2025-12-28T23:35:20.2381363-08:00","closed_at":"2025-12-28T23:35:20.2381363-08:00","close_reason":"AccessControl + RepositoryPermission actors added","dependencies":[{"issue_id":"Grace-5c3.4","depends_on_id":"Grace-5c3","type":"parent-child","created_at":"2025-12-28T23:08:10.4792076-08:00","created_by":"daemon"}]} {"id":"Grace-5c3.5","title":"Authz: server security plumbing","description":"Add GraceTest auth handler, PrincipalMapper, and PermissionEvaluator service; wire DI/auth scheme selection in Startup.Server.fs.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-28T23:08:14.5953805-08:00","created_by":"Scott Arbeit","updated_at":"2025-12-28T23:38:47.1199966-08:00","closed_at":"2025-12-28T23:38:47.1199966-08:00","close_reason":"Test auth + permission evaluator wired","dependencies":[{"issue_id":"Grace-5c3.5","depends_on_id":"Grace-5c3","type":"parent-child","created_at":"2025-12-28T23:08:14.6007802-08:00","created_by":"daemon"}]} {"id":"Grace-5c3.6","title":"Authz: /access parameters, handlers, routing","description":"Add Access.Parameters.fs, Access.Server.fs handlers, and /access routes with metadata/auth requirements.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-28T23:08:19.0690592-08:00","created_by":"Scott Arbeit","updated_at":"2025-12-28T23:45:37.0740752-08:00","closed_at":"2025-12-28T23:45:37.0740752-08:00","close_reason":"Access parameters and endpoints added","dependencies":[{"issue_id":"Grace-5c3.6","depends_on_id":"Grace-5c3","type":"parent-child","created_at":"2025-12-28T23:08:19.0750897-08:00","created_by":"daemon"}]} {"id":"Grace-5c3.7","title":"Authz: SDK access surface","description":"Add Access.SDK.fs methods for /access endpoints and update Grace.SDK.fsproj.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-28T23:08:23.1901245-08:00","created_by":"Scott Arbeit","updated_at":"2025-12-28T23:46:44.3452613-08:00","closed_at":"2025-12-28T23:46:44.3452613-08:00","close_reason":"Access SDK added","dependencies":[{"issue_id":"Grace-5c3.7","depends_on_id":"Grace-5c3","type":"parent-child","created_at":"2025-12-28T23:08:23.1949443-08:00","created_by":"daemon"}]} {"id":"Grace-5c3.8","title":"Authz: CLI access commands","description":"Add Access.CLI.fs command tree + options and register in Program.CLI.fs; output supports --json.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-28T23:08:27.2244712-08:00","created_by":"Scott Arbeit","updated_at":"2025-12-29T00:04:34.4116541-08:00","closed_at":"2025-12-29T00:04:34.4116541-08:00","close_reason":"Closed","dependencies":[{"issue_id":"Grace-5c3.8","depends_on_id":"Grace-5c3","type":"parent-child","created_at":"2025-12-28T23:08:27.2298486-08:00","created_by":"daemon"}]} {"id":"Grace-5c3.9","title":"Authz: enforce permissions on endpoints","description":"Add requiresPermission middleware and protect at least repo-level + path-level endpoints (e.g., setDescription + getUploadMetadataForFiles).","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-28T23:08:32.0246786-08:00","created_by":"Scott Arbeit","updated_at":"2025-12-29T00:11:24.16764-08:00","closed_at":"2025-12-29T00:11:24.16764-08:00","close_reason":"Closed","dependencies":[{"issue_id":"Grace-5c3.9","depends_on_id":"Grace-5c3","type":"parent-child","created_at":"2025-12-28T23:08:32.0309851-08:00","created_by":"daemon"}]} {"id":"Grace-6ev","title":"P0 Update root AGENTS.md with Agent Quickstart","description":"Add Agent Quickstart (Local) section with bootstrap/validate commands and links to src/AGENTS.md, src/docs/ASPIRE_SETUP.md, src/docs/ENVIRONMENT.md.","acceptance_criteria":"Root AGENTS.md enables a new contributor to reach validate -Fast without other docs; markdownlint passes.","notes":"Added Agent Quickstart (Local) section with bootstrap/validate commands and links. Markdownlint clean.","status":"closed","priority":0,"issue_type":"task","created_at":"2026-01-09T12:30:09.9470165-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T13:19:19.5266104-08:00","closed_at":"2026-01-09T13:19:19.5266104-08:00","dependencies":[{"issue_id":"Grace-6ev","depends_on_id":"Grace-3yl","type":"blocks","created_at":"2026-01-09T12:33:40.6102889-08:00","created_by":"unknown"},{"issue_id":"Grace-6ev","depends_on_id":"Grace-oos","type":"blocks","created_at":"2026-01-09T12:33:46.0467868-08:00","created_by":"unknown"}]} {"id":"Grace-6r8","title":"Server: Gate endpoints","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:50.5517025-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T02:17:22.7194502-08:00","closed_at":"2026-01-06T02:17:22.7194502-08:00","close_reason":"Closed"} {"id":"Grace-701","title":"P0 Validate script: full tests (Aspire)","description":"Implement -Full test stage to run Grace.Server.Tests (Aspire.Hosting.Testing) including Docker-required integration tests.","acceptance_criteria":"validate -Full runs full suite including Grace.Server.Tests and fails if Aspire stack fails to start; stage output grouped under Test.","status":"closed","priority":0,"issue_type":"task","created_at":"2026-01-09T12:29:59.7615959-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T13:33:14.0639429-08:00","closed_at":"2026-01-09T13:33:14.0639429-08:00","close_reason":"Closed","dependencies":[{"issue_id":"Grace-701","depends_on_id":"Grace-sae","type":"blocks","created_at":"2026-01-09T12:33:35.2800434-08:00","created_by":"unknown"}]} {"id":"Grace-783","title":"Eventing: event envelopes + publishers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:53.3113195-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T02:53:25.0597006-08:00","closed_at":"2026-01-06T02:53:25.0597006-08:00","close_reason":"Closed"} {"id":"Grace-79p","title":"Models: OpenRouter config + wiring","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:51.9340196-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T02:33:34.4239983-08:00","closed_at":"2026-01-06T02:33:34.4239983-08:00","close_reason":"Closed"} {"id":"Grace-7it","title":"Continuous Review + Refinery Epic","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-06T01:14:46.8131949-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T21:09:54.0007067-08:00","closed_at":"2026-01-06T21:09:54.0007067-08:00","close_reason":"Closed"} {"id":"Grace-7mo","title":"Types: Queue/Candidate/Gate contracts","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:47.9931636-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T01:27:47.5686453-08:00","closed_at":"2026-01-06T01:27:47.5686453-08:00","close_reason":"Closed"} {"id":"Grace-85r","title":"PAT auth: tests, docs, and validation","description":"Add tests, update AGENTS, run build/test/format gates.","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-01-01T17:34:00.4855223-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T18:11:35.0366791-08:00","closed_at":"2026-01-01T18:11:35.0366791-08:00","close_reason":"Completed"} {"id":"Grace-85r.1","title":"Add server PAT auth tests","description":"Extend Grace.Server.Tests/Auth.Server.Tests.fs with PAT create/use/revoke/max lifetime/list coverage.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T17:35:50.2931526-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:56:41.3090062-08:00","closed_at":"2026-01-01T17:56:41.3090062-08:00","close_reason":"Completed","dependencies":[{"issue_id":"Grace-85r.1","depends_on_id":"Grace-85r","type":"parent-child","created_at":"2026-01-01T17:35:50.3177996-08:00","created_by":"daemon"}]} {"id":"Grace-85r.2","title":"Add CLI token precedence tests","description":"Extend Grace.CLI.Tests/Auth.Tests.fs with GRACE_TOKEN and token file precedence coverage.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T17:35:55.1234788-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:57:17.3295681-08:00","closed_at":"2026-01-01T17:57:17.3295681-08:00","close_reason":"Completed","dependencies":[{"issue_id":"Grace-85r.2","depends_on_id":"Grace-85r","type":"parent-child","created_at":"2026-01-01T17:35:55.1790724-08:00","created_by":"daemon"}]} {"id":"Grace-85r.3","title":"Update AGENTS docs and run validation gates","description":"Update relevant AGENTS.md notes, run fantomas, dotnet build, and dotnet test; record any gaps.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T17:36:01.8844532-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T18:11:28.367927-08:00","closed_at":"2026-01-01T18:11:28.367927-08:00","close_reason":"Completed","dependencies":[{"issue_id":"Grace-85r.3","depends_on_id":"Grace-85r","type":"parent-child","created_at":"2026-01-01T17:36:01.8892664-08:00","created_by":"daemon"}]} {"id":"Grace-87d","title":"Server: Policy endpoints","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:49.8561522-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T01:53:19.2119153-08:00","closed_at":"2026-01-06T01:53:19.2119153-08:00","close_reason":"Closed"} {"id":"Grace-8da","title":"P1 Update src/AGENTS.md to reference bootstrap/validate","description":"If needed, add canonical script commands to src/AGENTS.md and point to root quickstart.","acceptance_criteria":"src/AGENTS.md references scripts/bootstrap.ps1 and scripts/validate.ps1 where appropriate; markdownlint passes.","notes":"Added Local Commands section referencing bootstrap/validate; reflowed content to satisfy markdownlint.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-09T12:30:41.0594165-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T13:19:24.8779756-08:00","closed_at":"2026-01-09T13:19:24.8779756-08:00","dependencies":[{"issue_id":"Grace-8da","depends_on_id":"Grace-6ev","type":"blocks","created_at":"2026-01-09T12:46:28.3087657-08:00","created_by":"unknown"}]} {"id":"Grace-8fr","title":"PAT auth: shared constants, parameters, and types","description":"Add shared env vars, auth parameters, and PersonalAccessToken domain types + helpers.","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-01-01T17:33:27.2157893-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:54:43.4429551-08:00","closed_at":"2026-01-01T17:54:43.4429551-08:00","close_reason":"Completed"} {"id":"Grace-8fr.1","title":"Add PAT env var constants","description":"Add GRACE_TOKEN/GRACE_TOKEN_FILE and PAT lifetime policy env var constants in Grace.Shared.Constants.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T17:34:05.5857926-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:37:44.6561645-08:00","closed_at":"2026-01-01T17:37:44.6561645-08:00","close_reason":"Completed","dependencies":[{"issue_id":"Grace-8fr.1","depends_on_id":"Grace-8fr","type":"parent-child","created_at":"2026-01-01T17:34:05.5912383-08:00","created_by":"daemon"}]} {"id":"Grace-8fr.2","title":"Add auth parameter classes","description":"Create Grace.Shared/Parameters/Auth.Parameters.fs with Create/List/Revoke PAT parameter classes and update Grace.Shared.fsproj compile order.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T17:34:16.8985265-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:38:31.1388783-08:00","closed_at":"2026-01-01T17:38:31.1388783-08:00","close_reason":"Completed","dependencies":[{"issue_id":"Grace-8fr.2","depends_on_id":"Grace-8fr","type":"parent-child","created_at":"2026-01-01T17:34:16.9034524-08:00","created_by":"daemon"}]} {"id":"Grace-8fr.3","title":"Add PersonalAccessToken domain types","description":"Add Grace.Types/PersonalAccessToken.Types.fs with DTOs, token format helpers, and update Grace.Types.fsproj compile order.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T17:34:24.0570351-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:39:18.6577737-08:00","closed_at":"2026-01-01T17:39:18.6577737-08:00","close_reason":"Completed","dependencies":[{"issue_id":"Grace-8fr.3","depends_on_id":"Grace-8fr","type":"parent-child","created_at":"2026-01-01T17:34:24.0624638-08:00","created_by":"daemon"}]} {"id":"Grace-8j4","title":"Actors: Review actor","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:48.9321457-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T01:39:31.9797897-08:00","closed_at":"2026-01-06T01:39:31.9797897-08:00","close_reason":"Closed"} {"id":"Grace-8o8","title":"P0 Validate script: Fantomas format check","description":"Implement formatting stage using pinned Fantomas via dotnet tool run, targeting src/ with src/fantomas-config.json; fail if changes would be made.","acceptance_criteria":"validate -Fast fails on formatting drift with actionable message; no-modify check or diff detection works reliably.","notes":"Format stage uses pinned Fantomas and checks changed F# files under src (skips when none).","status":"closed","priority":0,"issue_type":"task","created_at":"2026-01-09T12:29:26.7841738-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T13:06:10.1257545-08:00","closed_at":"2026-01-09T13:06:10.1257545-08:00","dependencies":[{"issue_id":"Grace-8o8","depends_on_id":"Grace-oos","type":"blocks","created_at":"2026-01-09T12:33:13.9405047-08:00","created_by":"unknown"},{"issue_id":"Grace-8o8","depends_on_id":"Grace-4d0","type":"blocks","created_at":"2026-01-09T12:33:19.2764964-08:00","created_by":"unknown"}]} {"id":"Grace-9io","title":"Add connect repo shortcut","status":"in_progress","priority":2,"issue_type":"task","created_at":"2026-01-08T17:09:36.3620353-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-08T17:09:46.9519324-08:00"} {"id":"Grace-9rp","title":"Review: baseline drift semantics","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:51.4673919-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T02:31:25.1215525-08:00","closed_at":"2026-01-06T02:31:25.1215525-08:00","close_reason":"Closed"} {"id":"Grace-ami","title":"P0 Bootstrap script: prerequisite checks + flags","description":"Create scripts/bootstrap.ps1 with strict mode, -SkipDocker/-CI/-Verbose flags; check PowerShell 7.x, dotnet SDK 10, and docker (unless -SkipDocker). Fail fast with actionable messages.","acceptance_criteria":"bootstrap.ps1 exits non-zero on missing prereqs with clear guidance; -SkipDocker bypasses docker check.","notes":"Created scripts/bootstrap.ps1 with strict mode and prerequisite checks (pwsh, dotnet 10, docker unless -SkipDocker).","status":"closed","priority":0,"issue_type":"task","created_at":"2026-01-09T12:28:54.3384334-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T13:05:41.8877551-08:00","closed_at":"2026-01-09T13:05:41.8877551-08:00","dependencies":[{"issue_id":"Grace-ami","depends_on_id":"Grace-4ri","type":"blocks","created_at":"2026-01-09T12:32:41.9330047-08:00","created_by":"unknown"},{"issue_id":"Grace-ami","depends_on_id":"Grace-4ba","type":"blocks","created_at":"2026-01-09T12:32:47.2558435-08:00","created_by":"unknown"}]} {"id":"Grace-an2","title":"Tests: Stage 0 determinism","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:55.5616099-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T05:13:35.668343-08:00","closed_at":"2026-01-06T05:13:35.668343-08:00","close_reason":"Closed"} {"id":"Grace-awb","title":"Actors: Policy actor","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:48.710556-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T01:36:16.117935-08:00","closed_at":"2026-01-06T01:36:16.117935-08:00","close_reason":"Closed"} {"id":"Grace-aya","title":"SDK: Review APIs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:54.0043743-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T02:56:31.2425971-08:00","closed_at":"2026-01-06T02:56:31.2425971-08:00","close_reason":"Closed"} {"id":"Grace-b8s","title":"Use git diff for CI format targets","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-01-09T23:28:07.3073561-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T23:56:01.7720601-08:00","closed_at":"2026-01-09T23:56:01.7720601-08:00","close_reason":"Closed"} {"id":"Grace-ba0","title":"Models: deep pipeline + progressive retrieval","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:52.3927211-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T02:36:34.5699307-08:00","closed_at":"2026-01-06T02:36:34.5699307-08:00","close_reason":"Closed"} {"id":"Grace-cca","title":"Review: deterministic chaptering + packet assembly","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:51.2457243-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T02:30:30.9647007-08:00","closed_at":"2026-01-06T02:30:30.9647007-08:00","close_reason":"Closed"} {"id":"Grace-chq","title":"Server: Review endpoints","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:50.0836908-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T01:56:02.1303505-08:00","closed_at":"2026-01-06T01:56:02.1303505-08:00","close_reason":"Closed"} {"id":"Grace-d6y","title":"Queue: gate framework + attestations","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:52.8430355-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T02:46:56.9159633-08:00","closed_at":"2026-01-06T02:46:56.9159633-08:00","close_reason":"Closed"} {"id":"Grace-dcs","title":"Fix build errors in Grace.CLI and Grace.Server","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-10T12:54:09.3683809-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-10T12:57:33.2944729-08:00","closed_at":"2026-01-10T12:57:33.2944729-08:00","close_reason":"Closed"} {"id":"Grace-dof","title":"CLI fallback to server OIDC config endpoint","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-08T01:49:50.4603822-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-08T02:03:33.5007944-08:00","closed_at":"2026-01-08T02:03:33.5007944-08:00","close_reason":"Closed"} {"id":"Grace-dv3","title":"Show resolved implicit ids in verbose parse output","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-09T00:34:41.3147349-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T00:34:41.3147349-08:00"} {"id":"Grace-e10","title":"P2 Optional: CODEOWNERS + security scans","description":"Add CODEOWNERS and basic security scan config if desired; document required reviewers.","acceptance_criteria":"Guardrails added and documented; minimal overhead.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-09T12:32:04.8295523-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T12:32:04.8295523-08:00"} {"id":"Grace-e28","title":"Models: provider abstraction","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:51.6936304-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T02:33:27.2168749-08:00","closed_at":"2026-01-06T02:33:27.2168749-08:00","close_reason":"Closed"} {"id":"Grace-ed9","title":"SDK: Policy APIs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:53.7805499-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T02:56:24.5163565-08:00","closed_at":"2026-01-06T02:56:24.5163565-08:00","close_reason":"Closed"} {"id":"Grace-er5","title":"Types: Stage 0 + Evidence contracts","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:47.5344426-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T01:24:19.0517267-08:00","closed_at":"2026-01-06T01:24:19.0517267-08:00","close_reason":"Closed"} {"id":"Grace-f1u","title":"PAT auth: SDK support","description":"Add SDK wrappers and server URI handling for PAT endpoints.","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-01-01T17:33:44.9629598-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:49:56.514499-08:00","closed_at":"2026-01-01T17:49:56.514499-08:00","close_reason":"Completed"} {"id":"Grace-f1u.1","title":"Add PAT SDK wrappers","description":"Implement Grace.SDK PersonalAccessToken wrappers for create/list/revoke and update project compile list.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T17:35:17.2437547-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:49:41.0114696-08:00","closed_at":"2026-01-01T17:49:41.0114696-08:00","close_reason":"Completed","dependencies":[{"issue_id":"Grace-f1u.1","depends_on_id":"Grace-f1u","type":"parent-child","created_at":"2026-01-01T17:35:17.2831975-08:00","created_by":"daemon"}]} {"id":"Grace-f1u.2","title":"Ensure PAT SDK uses GRACE_SERVER_URI","description":"Make PAT SDK endpoints work without graceconfig.json by using GRACE_SERVER_URI or a helper that bypasses Configuration.Current().","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T17:35:24.0343531-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:49:49.2757481-08:00","closed_at":"2026-01-01T17:49:49.2757481-08:00","close_reason":"Completed","dependencies":[{"issue_id":"Grace-f1u.2","depends_on_id":"Grace-f1u","type":"parent-child","created_at":"2026-01-01T17:35:24.0786964-08:00","created_by":"daemon"}]} {"id":"Grace-f2t","title":"Server: derived computation trigger consumer","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:49.3862902-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T01:44:59.6212182-08:00","closed_at":"2026-01-06T01:44:59.6212182-08:00","close_reason":"Closed"} {"id":"Grace-far","title":"Allow grace connect without config when parse errors occur","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-01-09T02:38:47.1058823-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T02:40:29.8344153-08:00","closed_at":"2026-01-09T02:40:29.8344153-08:00","close_reason":"Closed"} {"id":"Grace-g11","title":"SDK: Queue/Candidate APIs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:54.2524092-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T02:56:38.0685742-08:00","closed_at":"2026-01-06T02:56:38.0685742-08:00","close_reason":"Closed"} {"id":"Grace-g4d","title":"Types: Work Item contracts","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:47.0471393-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T01:20:50.4717585-08:00","closed_at":"2026-01-06T01:20:50.4717585-08:00","close_reason":"Closed"} {"id":"Grace-hj3","title":"Types: RequiredAction + Event envelope contracts","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:48.2364827-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T01:29:04.1667329-08:00","closed_at":"2026-01-06T01:29:04.1667329-08:00","close_reason":"Closed"} {"id":"Grace-i2i","title":"Evidence: distiller selection/budgets/redaction","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:51.0086084-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T02:28:55.4077656-08:00","closed_at":"2026-01-06T02:28:55.4077656-08:00","close_reason":"Closed"} {"id":"Grace-iqx","title":"P2 Optional: minimal analyzers baseline","description":"Add minimal analyzers policy/baseline without large churn; keep incremental.","acceptance_criteria":"Analyzers add signal with minimal new warnings; documented.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-09T12:31:55.4502453-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T22:07:24.2198936-08:00","closed_at":"2026-01-09T22:07:24.2198936-08:00","close_reason":"Closed"} {"id":"Grace-k62","title":"Actors: PromotionQueue/Runner actor","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:49.1640588-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T01:42:29.7695364-08:00","closed_at":"2026-01-06T01:42:29.7695364-08:00","close_reason":"Closed"} {"id":"Grace-kto","title":"Models: triage pipeline + caching + receipts","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:52.166834-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T02:35:35.5622322-08:00","closed_at":"2026-01-06T02:35:35.5622322-08:00","close_reason":"Closed"} {"id":"Grace-lha","title":"Actors: WorkItem actor","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:48.4777306-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T01:33:35.0831146-08:00","closed_at":"2026-01-06T01:33:35.0831146-08:00","close_reason":"Closed"} {"id":"Grace-lp2","title":"Defer CLI defaults for help","description":"Implement deferred defaults + help customization to avoid config access during help. See spec in chat.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T21:13:38.8410648-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-02T21:52:40.9246985-08:00","closed_at":"2026-01-02T21:52:40.9246985-08:00","close_reason":"Closed"} {"id":"Grace-lvj","title":"Server: WorkItem endpoints","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:49.6281939-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T01:50:44.0992215-08:00","closed_at":"2026-01-06T01:50:44.0992215-08:00","close_reason":"Closed"} {"id":"Grace-mnz","title":"Queue: IntegrationCandidate state + required-actions computation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:52.6213732-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T02:42:01.2885484-08:00","closed_at":"2026-01-06T02:42:01.2885484-08:00","close_reason":"Closed"} {"id":"Grace-nk3","title":"Tests: chaptering + findings lifecycle","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:56.089585-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T05:13:35.9094287-08:00","closed_at":"2026-01-06T05:13:35.9094287-08:00","close_reason":"Closed"} {"id":"Grace-np3","title":"P1 Add Smoke tests (Aspire host + /healthz)","description":"Add 1–3 smoke tests that start Aspire test host and verify /healthz succeeds; tag Category('Smoke') and avoid heavy SetUpFixture if possible.","acceptance_criteria":"Smoke tests fail when server doesn't boot or /healthz unreachable; stable on known-good branch.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-09T12:31:24.4441191-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T13:33:25.1169846-08:00","closed_at":"2026-01-09T13:33:25.1169846-08:00","close_reason":"Closed","dependencies":[{"issue_id":"Grace-np3","depends_on_id":"Grace-4ri","type":"blocks","created_at":"2026-01-09T12:46:49.6036483-08:00","created_by":"unknown"}]} {"id":"Grace-o3z","title":"PAT auth: Orleans actor storage","description":"Add PersonalAccessToken grain interface, state, proxy, and implementation.","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-01-01T17:33:33.121442-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:42:57.8202313-08:00","closed_at":"2026-01-01T17:42:57.8202313-08:00","close_reason":"Completed"} {"id":"Grace-o3z.1","title":"Add PAT actor constants and interface","description":"Add PersonalAccessToken actor/state names and IPersonalAccessTokenActor interface in Grace.Actors.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T17:34:29.5312209-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:40:21.9276448-08:00","closed_at":"2026-01-01T17:40:21.9276448-08:00","close_reason":"Completed","dependencies":[{"issue_id":"Grace-o3z.1","depends_on_id":"Grace-o3z","type":"parent-child","created_at":"2026-01-01T17:34:29.5383697-08:00","created_by":"daemon"}]} {"id":"Grace-o3z.2","title":"Add PAT actor proxy helper","description":"Add ActorProxy.PersonalAccessToken.CreateActorProxy helper in Grace.Actors/ActorProxy.Extensions.Actor.fs.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T17:34:36.1715625-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:40:54.6647026-08:00","closed_at":"2026-01-01T17:40:54.6647026-08:00","close_reason":"Completed","dependencies":[{"issue_id":"Grace-o3z.2","depends_on_id":"Grace-o3z","type":"parent-child","created_at":"2026-01-01T17:34:36.1768734-08:00","created_by":"daemon"}]} {"id":"Grace-o3z.3","title":"Implement PersonalAccessToken actor","description":"Create PersonalAccessToken.Actor.fs with state, create/list/revoke/validate logic, and update Grace.Actors.fsproj compile list.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T17:34:42.8177451-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:42:50.9637143-08:00","closed_at":"2026-01-01T17:42:50.9637143-08:00","close_reason":"Completed","dependencies":[{"issue_id":"Grace-o3z.3","depends_on_id":"Grace-o3z","type":"parent-child","created_at":"2026-01-01T17:34:42.8272088-08:00","created_by":"daemon"}]} {"id":"Grace-o9j","title":"CLI: Review commands","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:54.7685857-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T04:47:46.728238-08:00","closed_at":"2026-01-06T04:47:46.728238-08:00","close_reason":"Closed"} {"id":"Grace-onc","title":"Stage 0: deterministic analysis + storage","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:50.7848498-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T02:26:05.9658068-08:00","closed_at":"2026-01-06T02:26:05.9658068-08:00","close_reason":"Closed"} {"id":"Grace-oos","title":"P0 Validate script: scaffold + flags + timing","description":"Create scripts/validate.ps1 with strict mode, -Fast default, -Full, -SkipFormat/-SkipBuild/-SkipTests, -Configuration; grouped output and elapsed-time reporting on all exits.","acceptance_criteria":"validate.ps1 parses flags correctly, groups output by stage, and always prints elapsed time; exits non-zero on any enabled stage failure.","notes":"Added scripts/validate.ps1 with flags (-Fast default, -Full, skip flags, -Configuration), strict mode, grouped output, elapsed time.","status":"closed","priority":0,"issue_type":"task","created_at":"2026-01-09T12:29:16.4509589-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T13:06:04.7979977-08:00","closed_at":"2026-01-09T13:06:04.7979977-08:00","dependencies":[{"issue_id":"Grace-oos","depends_on_id":"Grace-4ri","type":"blocks","created_at":"2026-01-09T12:33:03.2601336-08:00","created_by":"unknown"},{"issue_id":"Grace-oos","depends_on_id":"Grace-4ba","type":"blocks","created_at":"2026-01-09T12:33:08.5802437-08:00","created_by":"unknown"}]} {"id":"Grace-pdu","title":"Types: Policy snapshot contracts","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:47.2979787-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T01:22:35.5838892-08:00","closed_at":"2026-01-06T01:22:35.5838892-08:00","close_reason":"Closed"} {"id":"Grace-qxx","title":"Investigate dotnet test Aspire shutdown crash","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-01-09T22:51:43.9281309-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T23:01:15.4475995-08:00","closed_at":"2026-01-09T23:01:15.4475995-08:00","close_reason":"Closed"} {"id":"Grace-ra9","title":"P0 Validate script: build fast targets","description":"Implement build stage for -Fast/-Full using minimal top-level projects (Grace.Server, Grace.CLI, optional Grace.SDK) discovered in inventory.","acceptance_criteria":"validate -Fast builds selected targets with configuration flag; build stage clearly reported.","notes":"Build stage runs dotnet build for Grace.Server, Grace.CLI, Grace.SDK with configuration flag.","status":"closed","priority":0,"issue_type":"task","created_at":"2026-01-09T12:29:36.9439517-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T13:06:15.4581959-08:00","closed_at":"2026-01-09T13:06:15.4581959-08:00","dependencies":[{"issue_id":"Grace-ra9","depends_on_id":"Grace-oos","type":"blocks","created_at":"2026-01-09T12:33:24.6185628-08:00","created_by":"unknown"}]} {"id":"Grace-rai","title":"SDK: WorkItem APIs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:53.535762-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T02:56:17.5684417-08:00","closed_at":"2026-01-06T02:56:17.5684417-08:00","close_reason":"Closed"} {"id":"Grace-rro","title":"Types: Review contracts","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:47.7663008-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T01:26:35.7331201-08:00","closed_at":"2026-01-06T01:26:35.7331201-08:00","close_reason":"Closed"} {"id":"Grace-ry8","title":"Tests: queue runner + gates + conflict pipeline","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:56.3670092-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T05:13:36.0290699-08:00","closed_at":"2026-01-06T05:13:36.0290699-08:00","close_reason":"Closed"} {"id":"Grace-s2p","title":"Queue: conflict pipeline + resolution receipts","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:53.0840653-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T02:50:54.6547631-08:00","closed_at":"2026-01-06T02:50:54.6547631-08:00","close_reason":"Closed"} {"id":"Grace-sae","title":"P0 Validate script: fast tests (non-Docker)","description":"Implement fast test stage for -Fast (e.g., Grace.CLI.Tests) excluding Aspire/Docker tests; ensure suitable for tight iteration.","acceptance_criteria":"validate -Fast runs only non-Docker tests and succeeds on known-good branch; stage output grouped under Test.","notes":"Fast tests run Grace.CLI.Tests --no-build. validate -Fast (with -SkipFormat) succeeded; format stage now stable.","status":"closed","priority":0,"issue_type":"task","created_at":"2026-01-09T12:29:48.5479559-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T13:06:20.7942067-08:00","closed_at":"2026-01-09T13:06:20.7942067-08:00","dependencies":[{"issue_id":"Grace-sae","depends_on_id":"Grace-ra9","type":"blocks","created_at":"2026-01-09T12:33:29.9523268-08:00","created_by":"unknown"}]} {"id":"Grace-suw","title":"P1 Update ASPIRE_SETUP.md to link bootstrap/validate","description":"Add note that bootstrap/validate are the preferred local entrypoints; link to root quickstart.","acceptance_criteria":"ASPIRE_SETUP.md references scripts; markdownlint passes.","notes":"Updated ASPIRE_SETUP with preferred scripts and reflowed content; markdownlint clean.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-09T12:30:51.4307308-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T13:19:30.221513-08:00","closed_at":"2026-01-09T13:19:30.221513-08:00","dependencies":[{"issue_id":"Grace-suw","depends_on_id":"Grace-6ev","type":"blocks","created_at":"2026-01-09T12:46:33.6471259-08:00","created_by":"unknown"}]} {"id":"Grace-ts5","title":"P1 Add slop tests for fragile invariants","description":"Add a few targeted tests (contract headers/status, DTO serialization round-trip, invariants) that fail on plausible wrong edits; include comment describing failure scenario.","acceptance_criteria":"At least 3 slop tests added; each guards a plausible incorrect change; stable in full suite.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-09T12:31:34.7872318-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T13:33:36.9645044-08:00","closed_at":"2026-01-09T13:33:36.9645044-08:00","close_reason":"Closed","dependencies":[{"issue_id":"Grace-ts5","depends_on_id":"Grace-np3","type":"blocks","created_at":"2026-01-09T12:46:54.9127456-08:00","created_by":"unknown"}]} {"id":"Grace-vsv","title":"PAT auth: CLI commands and token precedence","description":"Implement CLI PAT commands, local token storage, and auth precedence.","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-01-01T17:33:51.2305524-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:54:37.6298734-08:00","closed_at":"2026-01-01T17:54:37.6298734-08:00","close_reason":"Completed"} {"id":"Grace-vsv.1","title":"Add token source precedence and local storage","description":"Update Auth.CLI token resolution (env/file/MSAL), add token file helpers, and strip optional Bearer prefix.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T17:35:30.7597332-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:50:41.9317473-08:00","closed_at":"2026-01-01T17:50:41.9317473-08:00","close_reason":"Completed","dependencies":[{"issue_id":"Grace-vsv.1","depends_on_id":"Grace-vsv","type":"parent-child","created_at":"2026-01-01T17:35:30.764604-08:00","created_by":"daemon"}]} {"id":"Grace-vsv.2","title":"Implement auth token create/list/revoke commands","description":"Add CLI commands for PAT create/list/revoke, duration parsing, and local token cleanup on revoke.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T17:35:36.9800155-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:53:15.3707676-08:00","closed_at":"2026-01-01T17:53:15.3707676-08:00","close_reason":"Completed","dependencies":[{"issue_id":"Grace-vsv.2","depends_on_id":"Grace-vsv","type":"parent-child","created_at":"2026-01-01T17:35:36.9922134-08:00","created_by":"daemon"}]} {"id":"Grace-vsv.3","title":"Implement auth token set/clear/status commands","description":"Add CLI commands to set PAT from arg/stdin, clear local token file, and show credential source status without secrets.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T17:35:43.4007282-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:54:29.5165138-08:00","closed_at":"2026-01-01T17:54:29.5165138-08:00","close_reason":"Completed","dependencies":[{"issue_id":"Grace-vsv.3","depends_on_id":"Grace-vsv","type":"parent-child","created_at":"2026-01-01T17:35:43.4136176-08:00","created_by":"daemon"}]} {"id":"Grace-vz3","title":"Include principal in auth failure logs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-08T16:50:06.2590869-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-08T16:51:04.6921304-08:00","closed_at":"2026-01-08T16:51:04.6921304-08:00","close_reason":"Closed"} {"id":"Grace-w54","title":"A1 Add global.json to pin .NET 10 SDK","description":"Add root global.json pinning .NET 10 SDK with rollForward policy; document if prerelease required.","acceptance_criteria":"dotnet --version resolves to pinned SDK (or allowed roll-forward) in repo; dotnet build uses pinned SDK without unexpected preview warnings.","notes":"Added root global.json with sdk 10.0.100 + rollForward latestPatch. dotnet --version resolves to 10.0.101 in repo (roll-forward OK).","status":"closed","priority":0,"issue_type":"task","created_at":"2026-01-09T12:27:41.3775109-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T12:51:32.5042882-08:00","closed_at":"2026-01-09T12:51:32.5042882-08:00"} {"id":"Grace-wr1","title":"PAT auth: server auth and endpoints","description":"Add PAT auth handler, policy enforcement, endpoints, routing, and log redaction.","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-01-01T17:33:39.5652826-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:48:27.2865385-08:00","closed_at":"2026-01-01T17:48:27.2865385-08:00","close_reason":"Completed"} {"id":"Grace-wr1.1","title":"Add PAT auth handler","description":"Implement PersonalAccessTokenAuthHandler and add server compile include.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T17:34:49.7829-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:44:26.0693244-08:00","closed_at":"2026-01-01T17:44:26.0693244-08:00","close_reason":"Completed","dependencies":[{"issue_id":"Grace-wr1.1","depends_on_id":"Grace-wr1","type":"parent-child","created_at":"2026-01-01T17:34:49.8374394-08:00","created_by":"daemon"}]} {"id":"Grace-wr1.2","title":"Wire PAT auth schemes and routing","description":"Update Startup.Server.fs to route Bearer PATs to GracePat scheme in both testing and non-testing branches.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T17:34:56.4070495-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:46:10.5846197-08:00","closed_at":"2026-01-01T17:46:10.5846197-08:00","close_reason":"Completed","dependencies":[{"issue_id":"Grace-wr1.2","depends_on_id":"Grace-wr1","type":"parent-child","created_at":"2026-01-01T17:34:56.4678879-08:00","created_by":"daemon"}]} {"id":"Grace-wr1.3","title":"Add PAT auth endpoints and policy enforcement","description":"Implement /auth/token create/list/revoke handlers with lifetime policy enforcement and route wiring.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T17:35:03.9243801-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:47:51.5135409-08:00","closed_at":"2026-01-01T17:47:51.5135409-08:00","close_reason":"Completed","dependencies":[{"issue_id":"Grace-wr1.3","depends_on_id":"Grace-wr1","type":"parent-child","created_at":"2026-01-01T17:35:03.9356681-08:00","created_by":"daemon"}]} {"id":"Grace-wr1.4","title":"Redact auth headers in request logging","description":"Update LogRequestHeaders middleware to redact Authorization/Cookie and token-like headers.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T17:35:10.8898229-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:48:21.3517527-08:00","closed_at":"2026-01-01T17:48:21.3517527-08:00","close_reason":"Completed","dependencies":[{"issue_id":"Grace-wr1.4","depends_on_id":"Grace-wr1","type":"parent-child","created_at":"2026-01-01T17:35:10.899608-08:00","created_by":"daemon"}]} {"id":"Grace-wv2","title":"P0 CI workflow: validate -Fast on PR","description":"Add .github/workflows/validate.yml to run pwsh ./scripts/validate.ps1 -Fast on pull_request using actions/setup-dotnet and pinned SDK.","acceptance_criteria":"PR workflow runs validate -Fast and fails on any stage; output shows which stage failed.","notes":"Added .github/workflows/validate.yml to run pwsh ./scripts/validate.ps1 -Fast on PRs using global.json and Aspire workload.","status":"closed","priority":0,"issue_type":"task","created_at":"2026-01-09T12:30:20.4736489-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T13:09:26.6012157-08:00","closed_at":"2026-01-09T13:09:26.6012157-08:00","dependencies":[{"issue_id":"Grace-wv2","depends_on_id":"Grace-8o8","type":"blocks","created_at":"2026-01-09T12:33:51.411236-08:00","created_by":"unknown"},{"issue_id":"Grace-wv2","depends_on_id":"Grace-ra9","type":"blocks","created_at":"2026-01-09T12:33:56.7406619-08:00","created_by":"unknown"},{"issue_id":"Grace-wv2","depends_on_id":"Grace-sae","type":"blocks","created_at":"2026-01-09T12:34:02.0817118-08:00","created_by":"unknown"}]} {"id":"Grace-xdn","title":"P2 Optional: scripts/install-githooks.ps1","description":"Add opt-in githook installer to run validate -Fast pre-commit without conflicting with bd hooks install.","acceptance_criteria":"Hook installer is reversible/opt-in and documented.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-09T12:31:45.6807321-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T22:07:11.9277259-08:00","closed_at":"2026-01-09T22:07:11.9277259-08:00","close_reason":"Closed"} {"id":"Grace-xjz","title":"Server: Queue/Candidate endpoints","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:50.3187454-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T02:06:26.5016007-08:00","closed_at":"2026-01-06T02:06:26.5016007-08:00","close_reason":"Closed"} {"id":"Grace-yal","title":"Tests: evidence selection determinism","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T01:14:55.8237777-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-06T05:13:35.7871715-08:00","closed_at":"2026-01-06T05:13:35.7871715-08:00","close_reason":"Closed"} {"id":"Grace-ymb","title":"P1 Add .env.example with safe placeholders","description":"Add .env.example at repo root with placeholders for common env vars (telemetry/auth/testing toggles) without secrets.","acceptance_criteria":".env.example contains placeholders only; no secrets; aligns with ENVIRONMENT.md.","notes":"Added .env.example with safe placeholder values for common variables and test toggles.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-09T12:31:12.161231-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-09T13:11:40.3783357-08:00","closed_at":"2026-01-09T13:11:40.3783357-08:00","dependencies":[{"issue_id":"Grace-ymb","depends_on_id":"Grace-14w","type":"blocks","created_at":"2026-01-09T12:46:44.2810471-08:00","created_by":"unknown"}]} {"id":"Grace-ymj","title":"Add server-mediated Microsoft/Entra device login for CLI","description":"Introduce server-mediated Microsoft/Entra CLI login flow so users don't need auth env vars; CLI obtains device code/session from server, polls for completion, stores Grace token.","status":"closed","priority":1,"issue_type":"feature","created_at":"2025-12-30T23:33:48.6726147-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-01T17:33:19.7525387-08:00","closed_at":"2026-01-01T17:33:19.7525387-08:00","close_reason":"Superseded by multi-epic PAT auth plan"} {"id":"Grace-zzi","title":"Diagnose grace watch notifications not receiving checkpoint events","status":"in_progress","priority":2,"issue_type":"task","created_at":"2026-01-08T23:41:49.5127036-08:00","created_by":"Scott Arbeit","updated_at":"2026-01-08T23:42:00.1809879-08:00"} ================================================ FILE: .beads/metadata.json ================================================ { "database": "beads.db", "jsonl_export": "issues.jsonl", "last_bd_version": "0.40.0" } ================================================ FILE: .config/dotnet-tools.json ================================================ { "version": 1, "isRoot": true, "tools": { "fantomas-tool": { "version": "4.7.9", "commands": [ "fantomas" ] } } } ================================================ FILE: .gitattributes ================================================ # Use bd merge for beads JSONL files .beads/issues.jsonl merge=beads ================================================ FILE: .github/code_review_instructions.md ================================================ # Grace Code Review instructions This file is intended to be the guidance for GitHub Copilot to perform automated code reviews in Grace PR's. ## Code Review instructions _note: for now, these instructions are in no particular order, I'm just capturing ideas._ ### F# Standards Rules in this section deal with our use of F# in the codebase, and specify stylistic and syntactical choices we mnight make. #### Always use `task { }`; never use `async { }` for asynchronous code. Older versions of F# used the `async { }` computation expression to write asynchonous code. In fact, the C# `async/await` syntax, which has since been adopted by TypeScript and JavaScript, was inspired by `async { }` in F#. The `task { }` computation expression for asynchonous code was added in F# 6.0. `task { }` uses the same stuff from `System.Threading.Tasks` that C# does for `async/await` code, allowing it to take advantage of all of the performance improvements in Tasks that each version of .NET delivers. Any use of `async { }` in Grace should be considered an error and should be rewritten using `task { }`. ### Internal consistency Rules in this section are intended to enforce consistent use of utilities and constructs provided by Grace. #### All grain references must have the CorrelationId set using RequestContext.Set(). The actors expect to be able to call `RequestContext.Get(Constants.CorrelationId)` to get the CorrelationId when they need it, and so the grain client is responsible for setting the CorrelationId on it by calling `RequestContext.Set(Constants.CorrelationId, )`. The easiest way to do this is to use the ActorProxy extensions in ActorProxy.Extensions.Actor.fs. Each of those helper methods - `Branch.CreateActorProxy`, `Repository.CreateActorProxy`, etc. - takes `correlationId` as a parameter. They each set the CorrelationId for you in the RequestContext. If any grain references are created by calling `IGrainFactory.GetGrain<'TGrainInterface>(key)` without using the ActorProxy extensions, they should be closely inspected to ensure that `RequestContext.Set(Constants.CorrelationId, )` is called before any use of the grain reference to call a grain interface method. The recommendation is to rewrite the code to use the ActorProxy extensions to eliminate any possibility of an error. ================================================ FILE: .github/copilot-instructions.md ================================================ # Copilot Operating Instructions for Grace ## Load Repository Guidance - Always begin by reviewing `src/agents.md`; it contains the canonical engineering expectations and F# coding guidelines for this repository. - When working inside a specific project (for example `Grace.Actors`, `Grace.Server`, or `Grace.SDK`), consult the matching `AGENTS.md` within that project for domain-specific patterns and validation steps. - Use the information in those `AGENTS.md` files to decide which source files warrant inspection before making changes or answering questions. ## Response & Communication Style - Present information formally while keeping the direct interaction with the user conversational. - Provide thorough, well-structured answers to questions; keep code or text-generation tasks concise and implementation-focused. - Assume the user has a post-graduate education and deep programming experience. ## Coding & Tooling Expectations - Default to F# for all programming discussions unless the user states otherwise, and summarise the resulting code in English. - Follow the F# guidelines described in `src/agents.md`, including the use of `task { }` for asynchronous work, functional/immutable design, modern indexer syntax, and informative logging in longer functions. - Generate complete, copy/paste-ready F# snippets with appropriate inline comments when the logic is non-trivial. - Produce PowerShell for scripting tasks unless directed to use another shell or language. ## Task Support & Planning - When the user asks for help organising work, assume inattentive-type ADHD: supply concrete, bite-sized steps suitable for a Kanban board and offer CBT-informed prompts to initiate progress. ## Azure-Specific Rule - @azure Rule – Use Azure Best Practices: Before generating Azure-related code, commands, or plans, invoke `azure_development-get_best_practices` when available. - @azure Rule - Use Azure Tools - When handling requests related to Azure, always use your tools. - @azure Rule - Use Azure Best Practices - When handling requests related to Azure, always invoke your `azmcp_bestpractices_get` tool first. - @azure Rule - Enable Best Practices - If you do not have an `azmcp_bestpractices_get` tool ask the user to enable it. ================================================ FILE: .github/workflows/dotnet.yml ================================================ name: .NET Restore / Build / Test on: push: branches: [ main ] pull_request: branches: [ main ] jobs: build: #runs-on: arc-runner-set runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x - name: Install .NET Aspire workload run: dotnet workload install aspire - name: Restore dependencies run: dotnet restore Grace.slnx working-directory: src - name: Build run: dotnet build Grace.slnx --no-restore -c Release working-directory: src # - name: Test # run: dotnet test Grace.sln --no-build --verbosity normal # working-directory: src ================================================ FILE: .github/workflows/validate.yml ================================================ name: Validate on: pull_request: branches: [ main ] push: branches: [ main ] schedule: - cron: "0 3 * * *" jobs: fast: if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v4 with: global-json-file: global.json - name: Install .NET Aspire workload run: dotnet workload install aspire - name: Validate (Fast) run: pwsh ./scripts/validate.ps1 -Fast full: if: github.event_name != 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v4 with: global-json-file: global.json - name: Install .NET Aspire workload run: dotnet workload install aspire - name: Docker info run: docker info - name: Pre-pull Aspire images run: | docker pull redis:latest docker pull mcr.microsoft.com/azure-storage/azurite:latest docker pull mcr.microsoft.com/mssql/server:2022-latest docker pull mcr.microsoft.com/azure-messaging/servicebus-emulator:latest docker pull mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest - name: Validate (Full) run: pwsh ./scripts/validate.ps1 -Full ================================================ FILE: .gitignore ================================================ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. # Grace-specific ignores .grace/ **/.grace/objects **/.grace/directoryVersions **/.grace/gracestatus* /src/Grace.Server/logs/ **/*.backup **/appsettings.Development.json /Play/ /.vscode/ **/[Cc]omponents **/dapr **/*[Aa]zurite* **/.env *CodexPlan.* # Secrets file to configure Orleans persistence Orleans.secrets.fs # Visual Studio (>=2015) project-specific, machine local files .vs/ # User-specific files *.suo *.user *.sln.docstates *.userprefs # ignore Xamarin.Android Resource.Designer.cs files **/*.Droid/**/[Rr]esource.[Dd]esigner.cs **/*.Android/**/[Rr]esource.[Dd]esigner.cs **/Android/**/[Rr]esource.[Dd]esigner.cs **/Droid/**/[Rr]esource.[Dd]esigner.cs # Xamarin Components Components/ # Build results [Bb]in/ [Oo]bj/ [Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ x64/ build/ bld/ # MSTest test Results [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* #NUNIT *.VisualState.xml TestResult.xml # Build Results of an ATL Project [Dd]ebugPS/ [Rr]eleasePS/ dlldata.c *_i.c *_p.c *_i.h *.ilk *.meta *.obj *.pch *.pdb *.pgc *.pgd *.rsp *.sbr *.tlb *.tli *.tlh *.tmp *.tmp_proj *.log *.vspscc *.vssscc .builds *.pidb *.svclog *.scc # Chutzpah Test files _Chutzpah* # Visual C++ cache files ipch/ *.aps *.ncb *.opensdf *.sdf *.cachefile # Visual Studio profiler *.psess *.vsp *.vspx # TFS 2012 Local Workspace $tf/ # Guidance Automation Toolkit *.gpState # ReSharper is a .NET coding add-in _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user # JustCode is a .NET coding addin-in .JustCode # TeamCity is a build add-in _TeamCity* # DotCover is a Code Coverage Tool *.dotCover # NCrunch *.ncrunch* _NCrunch_* .*crunch*.local.xml # MightyMoose *.mm.* AutoTest.Net/ # Web workbench (sass) .sass-cache/ # Installshield output folder [Ee]xpress/ # DocProject is a documentation generator add-in DocProject/buildhelp/ DocProject/Help/*.HxT DocProject/Help/*.HxC DocProject/Help/*.hhc DocProject/Help/*.hhk DocProject/Help/*.hhp DocProject/Help/Html2 DocProject/Help/html # Click-Once directory publish/ # Publish Web Output *.[Pp]ublish.xml *.azurePubxml # NuGet Packages Directory packages/ *.nuget.targets *.lock.json *.nuget.props ## TODO: If the tool you use requires repositories.config uncomment the next line #!packages/repositories.config # Enable "build/" folder in the NuGet Packages folder since NuGet packages use it for MSBuild targets # This line needs to be after the ignore of the build folder (and the packages folder if the line above has been uncommented) !packages/build/ # Windows Azure Build Output csx/ *.build.csdef # Windows Store app package directory AppPackages/ # Others sql/ *.[Cc]ache ClientBin/ [Ss]tyle[Cc]op.* ~$* *~ *.dbmdl *.dbproj.schemaview *.pfx *.publishsettings node_modules/ .DS_Store *.bak # RIA/Silverlight projects Generated_Code/ # Backup & report files from converting an old project file to a newer # Visual Studio version. Backup files are not needed, because we have git ;-) _UpgradeReport_Files/ Backup*/ UpgradeLog*.XML UpgradeLog*.htm # SQL Server files *.mdf *.ldf # Business Intelligence projects *.rdl.data *.bim.layout *.bim_*.settings # Microsoft Fakes FakesAssemblies/ .vscode/settings.json .fake .ionide /src/OpenAPI/Grace.OpenAPI.html /src/OpenAPI/GenerateOpenAPI.ps1 /src/Check-CosmosDB-RUs.ps1 *.nettrace *.gcdump /plans ================================================ FILE: .markdownlint.jsonc ================================================ { // Global markdownlint configuration for this repo. "MD013": { "line_length": 120 } } ================================================ FILE: AGENTS.md ================================================ # Agent Instructions Other `AGENTS.md` files exist in subdirectories, refer to them for more specific context. ## Agent Quickstart (Local) Prerequisites: - PowerShell 7.x - .NET 10 SDK - Docker Desktop (required for `-Full`) Commands: - `pwsh ./scripts/bootstrap.ps1` - `pwsh ./scripts/validate.ps1 -Fast` Use `pwsh ./scripts/validate.ps1 -Full` for Aspire integration coverage. Optional: `pwsh ./scripts/install-githooks.ps1` to add a pre-commit `validate -Fast` hook. More context: - `src/AGENTS.md` - `src/docs/ASPIRE_SETUP.md` - `src/docs/ENVIRONMENT.md` ## Issue Tracking Do not use `bd`/beads workflows in this repository unless a maintainer explicitly asks for it in the current task. Use the plan/log files requested in the task (for example `CodexPlan.md`) plus normal git commits instead. ## Markdown Guidelines - Follow the MarkdownLint ruleset found at `https://raw.githubusercontent.com/DavidAnson/markdownlint/refs/heads/main/doc/Rules.md`. - Verify updates by running MarkdownLint. Use `npx --yes markdownlint-cli2 ...`. `--help` is available. - For MD013, override the guidance to allow for 120-character lines. ## Editing Documentation When updating documentation files, follow these guidelines: - When writing technical documentation, act as a friendly peer engineer helping other developers to understand Grace as a project. - When writing product-focused documentation, act as an expert product manager who helps a tech-aware audience understand Grace as a product, and helps end users understand how to use Grace effectively. - Use clear, concise language; avoid jargon. The tone should be welcoming and informative. - Structure content with headings and subheadings. Intersperse written (paragraph / sentence form) documentation with bullet points for readability. - Keep documentation up to date with code changes; review related docs when modifying functionality. Explain all documentation changes clearly, both what is changing, and why it's changing. - Show all scripting examples in both (first) PowerShell and (then, second) bash/zsh, where applicable. bash and zsh are always spelled in lowercase. PowerShell: ```powershell $env:GRACE_SERVER_URI="http://localhost:5000" ``` bash / zsh: ```bash export GRACE_SERVER_URI="http://localhost:5000" ``` ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Grace Version Control System Thanks for considering a contribution to **Grace Version Control System**. This repo is primarily **F#** and targets **.NET 10**. ## Quick start 1. Fork the repo and create your branch in your fork. 2. Run the prerequisite check: - From the repo root: - `pwsh ./scripts/bootstrap.ps1` - Optional: `-SkipDocker` if you don’t have Docker available yet. 3. Build: - `dotnet build ./src/Grace.sln` 4. Test: - `dotnet test ./src/Grace.sln` (optionally add `-c Release`) - Or run the repo validator: `pwsh ./scripts/validate.ps1 -Full` 5. Format F#: - From `./src`: `dotnet tool run fantomas --recurse .` ## Contribution workflow - Use the **GitHub fork + pull request** workflow. - Keep PRs focused (one change per PR whenever practical). - Add or update tests when changing behavior. - Please add any useful AI prompts you used for diagnosis or implementation to the PR description. ## Prerequisites - **.NET 10 SDK** (see: https://dotnet.microsoft.com/download) - **PowerShell 7+** (see: https://learn.microsoft.com/powershell/) - **Docker Desktop** or **Podman** (recommended for local emulators / Aspire DebugLocal) - Windows/macOS: https://www.docker.com/products/docker-desktop/ - Linux: use Docker Engine for your distro `pwsh ./scripts/bootstrap.ps1` is the recommended sanity check. It verifies tools and performs `dotnet tool restore` + `dotnet restore`. ## Build From the repo root: - `dotnet build ./src/Grace.sln` ## Tests From the repo root: - `dotnet test ./src/Grace.sln` (optionally `-c Release`) This repo also includes a validation script: - Fast loop: `pwsh ./scripts/validate.ps1 -Fast` - Full validation (includes Aspire integration coverage): `pwsh ./scripts/validate.ps1 -Full` ## Formatting (required) F# is formatted with **Fantomas**. From `./src`: - Apply formatting: `dotnet tool run fantomas --recurse .` If you’re proposing CI changes, CI should enforce formatting checks (if it doesn’t already). Fantomas: - https://github.com/fsprojects/fantomas ## Running Grace locally (Aspire) Grace can be run locally using **Docker containers and emulators**, via the Aspire AppHost launch configuration: - `DebugLocal`: local containers/emulators (e.g., Azurite, Cosmos emulator, Service Bus emulator) - `DebugAzure`: runs locally but expects real Azure resources (Cosmos DB, Blob Storage, Service Bus, etc.) Where to look in the repo: - `Grace.Aspire.AppHost/Properties/launchSettings.json` - `Grace.Aspire.AppHost/Program.Aspire.AppHost.cs` Aspire: - https://learn.microsoft.com/dotnet/aspire/ ## Configuration and secrets Grace supports multiple configuration sources (depending on your setup): - Environment variables - `.NET user-secrets` (recommended for local dev) - `appsettings*.json` (project-specific) - Aspire launch profiles (`DebugLocal` / `DebugAzure`) - Azure resource configuration (when using `DebugAzure`) ### Using .NET user-secrets (recommended) The simplest developer setup is to store secrets in **user-secrets** for the `Grace.Server` project. User-secrets documentation: - https://learn.microsoft.com/aspnet/core/security/app-secrets Examples (run from the repo root): - List secrets: - `dotnet user-secrets list --project ./src/Grace.Server/Grace.Server.fsproj` - Set a secret: - `dotnet user-secrets set --project ./src/Grace.Server/Grace.Server.fsproj "grace__azurecosmosdb__connectionstring" ""` - Remove a secret: - `dotnet user-secrets remove --project ./src/Grace.Server/Grace.Server.fsproj "grace__azurecosmosdb__connectionstring"` Notes: - Keys frequently use `__` to map to hierarchical configuration (see: https://learn.microsoft.com/aspnet/core/fundamentals/configuration/) - The Aspire AppHost can read the server user-secrets id and forward selected auth settings (see `Grace.Aspire.AppHost/AGENTS.md`). ## Environment variables The canonical list of environment variables is defined in `Grace.Shared/Constants.Shared.fs` under `Constants.EnvironmentVariables`. In general, values may come from: - your shell environment, - Aspire launch profile environment, - `.NET user-secrets` for `Grace.Server`, - or Azure resources (when using `DebugAzure`). ### Client variables - `GRACE_SERVER_URI` - Grace server base URI. - Must include port. - Must not include a trailing slash. - Example: `http://localhost:5000` - `GRACE_TOKEN` - Personal access token for non-interactive auth. - `GRACE_TOKEN_FILE` - Overrides the local token file path. ### Telemetry - `grace__applicationinsightsconnectionstring` - Application Insights connection string. - Source: user-secrets/env/Azure App Insights resource. ### Azure Cosmos DB - `grace__azurecosmosdb__connectionstring` - Cosmos DB connection string. - Source: user-secrets/env/Azure Cosmos DB. - `grace__azurecosmosdb__endpoint` - Cosmos DB endpoint for managed identity scenarios. - Source: Azure Cosmos DB account endpoint. - `grace__azurecosmosdb__database_name` - Cosmos database name. - `grace__azurecosmosdb__container_name` - Cosmos container name. ### Azure Storage (Blob) - `grace__azure_storage__connectionstring` - Storage connection string. - `grace__azure_storage__account_name` - Overrides storage account name (managed identity). - `grace__azure_storage__endpoint_suffix` - Storage endpoint suffix (default: `core.windows.net`). - `grace__azure_storage__key` - Storage account key. - `grace__azure_storage__directoryversion_container_name` - Container name for DirectoryVersions. - `grace__azure_storage__diff_container_name` - Container name for cached diffs. - `grace__azure_storage__zipfile_container_name` - Container name for zip files. ### Azure Service Bus - `grace__azure_service_bus__connectionstring` - Service Bus connection string. - `grace__azure_service_bus__namespace` - Fully qualified namespace (for example `sb://.servicebus.windows.net`). - `grace__azure_service_bus__topic` - Topic name for Grace events. - `grace__azure_service_bus__subscription` - Subscription name for Grace events. ### Pub/Sub provider selection - `grace__pubsub__system` - Selects the pub-sub provider implementation. ### Orleans - `grace__orleans__clusterid` - Orleans cluster id. - `grace__orleans__serviceid` - Orleans service id. Orleans: - https://learn.microsoft.com/dotnet/orleans/ ### Redis - `grace__redis__host` - Redis host. - `grace__redis__port` - Redis port. Redis: - https://redis.io/docs/latest/ ### Auth (OIDC) These are used for external auth providers such as Auth0 / OIDC. - `grace__auth__oidc__authority` - `grace__auth__oidc__audience` - `grace__auth__oidc__cli_client_id` - `grace__auth__oidc__cli_redirect_port` - `grace__auth__oidc__cli_scopes` - `grace__auth__oidc__m2m_client_id` - `grace__auth__oidc__m2m_client_secret` - `grace__auth__oidc__m2m_scopes` OpenID Connect: - https://openid.net/developers/how-connect-works/ ### Auth (deprecated Microsoft auth) These constants are marked deprecated in code: - `grace__auth__microsoft__client_id` - `grace__auth__microsoft__client_secret` - `grace__auth__microsoft__tenant_id` - `grace__auth__microsoft__authority` - `grace__auth__microsoft__api_scope` - `grace__auth__microsoft__cli_client_id` ### PAT policy - `grace__auth__pat__default_lifetime_days` - `grace__auth__pat__max_lifetime_days` - `grace__auth__pat__allow_no_expiry` ### Authorization bootstrap - `grace__authz__bootstrap__system_admin_users` - Semicolon-delimited list of user principals to bootstrap as SystemAdmin. - `grace__authz__bootstrap__system_admin_groups` - Semicolon-delimited list of group principals to bootstrap as SystemAdmin. ### Reminders and metrics - `grace__reminder__batch__size` - Batch size for reminder retrieval/publish. - `grace__metrics__allow_anonymous` - Allows anonymous access to Prometheus scraping endpoint. Prometheus: - https://prometheus.io/docs/introduction/overview/ ### Other providers (placeholders / future) - `grace__aws_sqs__queue_url` - `grace__aws_sqs__region` - `grace__gcp__projectid` - `grace__gcp__topic` - `grace__gcp__subscription` ### Debugging/logging - `grace__debug_environment` - Debug environment flag. - `grace__log_directory` - Directory for Grace Server log files. ## Pull request checklist - [ ] Builds: `dotnet build ./src/Grace.sln` - [ ] Tests: `dotnet test ./src/Grace.sln` - [ ] Formatting: run `dotnet tool run fantomas --recurse .` from `./src` - [ ] Documentation updated (if behavior changed) ================================================ FILE: LICENSE.md ================================================ MIT License Copyright (c) 2022 Scott Arbeit Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Grace - Version Control for the AI Era grace _(n)_ - 1. elegance and beauty of movement, form, or expression 2. a pleasing or charming quality 3. goodwill or favor 4. a sense of propriety and consideration for others [^grace] ![](./Assets/Orange3.svg) Grace is a **version control system** designed and built for the AI Era. Grace is designed for kindness to humans, and velocity for agents. It's meant to help you stay calm, in-control, and in-flow as you ship code at agentic speed. It has all of the basics you'd expect from a version control system, plus primitives that help you monitor your agents, capture the work they're doing, and review that work in real-time using both deterministic checks and AI prompts that you can write, with rules that you set. Grace assumes that AI belongs in the version control system, reacting to events, reviewing changes, and generating whatever you need to get code from idea to agent to production. Along with version control, Grace is designed to capture the work items, the prompts, the specifications, the model versions, and so much more - everything that goes into doing agentic coding - so you and your agents have the best possible context to complete the work successfully, and review it with confidence. All you have to do is tell your agents to run `grace agent bootstrap` at the start of the session. Grace and your agents will take care of the rest, automatically capturing what's going on. The `bootstrap` is customizable, and Grace will even help you customize it. In the repository, you define the checks you want run in respose to which events, you define the prompts, you choose the models, and Grace Server will do the rest, capturing the results in detailed Review reports. Again, Grace will help you define and customize all of it. Of course it's fast. Grace is multitenant, built for massive repositories, insane numbers of developers and agents, and ludicrous amounts of code and binary files of any size. It ships with a promotion queue - Grace doesn't do merges, it does promotions - and automatically handles promotion conflict resolution according to rules and confidence levels you set. > ⚠️👷🏻🚧 Grace is an alpha, and is going through rapid evolution with breaking changes, but it's ready for feedback and contributions. It is not ready for or intended for production usage at this time. ![](./Assets/Orange3.svg) ## Technology stack Grace is a modern, fast, powerful centralized version control system. It's made up of a web API, with a CLI (and soon a GUI). Grace is written primarily in **F#**, and uses: - **ASP.NET Core** for the HTTP API - **Orleans** for the virtual-actor and distributed-systems core - **Microsoft Azure PaaS services**[^1]: - **Azure Cosmos DB** for actor state storage (repos, branches, references, directory versions, etc.) at ludicrous scale and speed - **Azure Blob Storage** for objects and artifacts, including (virtually) unlimited-size binary files - **Azure Service Bus** for event streams that you can hook into - All come with emulators for frictionless local development - **SignalR** for live client-server coordination (`grace watch`) - **Redis** (used by SignalR and for caching) - **Aspire** to orchestrate everything for both local dev and cloud deployment - **Avalonia** (I think) for a fully cross-platform GUI, including WASM [^1]: Grace is designed to be adaptable to AWS and other cloud providers, and with coding agents, it should be not easy but not too hard to do. I just haven't done it yet. ## Running Grace locally The fastest way to understand Grace is to run it locally and poke at it. Grace is designed as a multitenant, massively scalable, centralized, cloud-native version control system, so running it locally isn't quite as simple as "download this one executable and run it". Grace uses [Aspire](https://aspire.dev) to make running Grace as simple as possible, with configurations for using either local emulators or actual Azure services. ### Normal development and debugging In normal development and debugging, there are three steps to running Grace. 1. Run the Aspire AppHost project. The AppHost will start the local emulators (if requested), and run Grace Server. 2. Use the Grace CLI to do Grace stuff. 3. Stop the Aspire AppHost. The AppHost will stop the local emulators and Grace Server as it exits. ### First time setup The first-time steps below use **local emulators** and **test authentication** (i.e. the same authentication we use in integration tests), so you don't have to set anything up in the cloud to get started. If all goes well, you should be up and running in under 10 minutes. > There is a detailed guide to configuring authentication at [`/docs/Authentication.md`](/docs/Authentication.md). 1. **Install prerequisites** for your platform: - [.NET 10 SDK](https://dotnet.microsoft.com/en-us/download) - [PowerShell 7+](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell) - A container runtime - Aspire supports [Docker Desktop](https://www.docker.com/get-started/) and [Podman](https://podman-desktop.io/). - [Rancher](https://www.rancher.com/products/rancher-desktop) is not officially supported, but with Moby/dockerd it should work. NOTE: If using Podman, set the [container runtime](https://aspire.dev/app-host/configuration/#common-configuration) to `podman`: PowerShell: ```powershell $env:ASPIRE_CONTAINER_RUNTIME="podman" ``` bash/zsh: ```bash export ASPIRE_CONTAINER_RUNTIME=podman ``` 2. **Clone the Grace repo**: `git clone https://github.com/ScottArbeit/Grace.git` or `gh repo clone ScottArbeit/Grace`. 3. **Build the solution**: `dotnet build ./src/Grace.slnx` to sanity-check your environment. 4. **Create an alias to make your life easier**: Add an alias to your profile called `grace` that points to `./src/Grace.CLI/bin/Debug/net10.0/grace.exe`. PowerShell: ```powershell Set-Alias -Name grace -Value \\src\Grace.CLI\bin\Debug\net10.0\grace.exe ``` bash / zsh: ```bash alias grace="\/src/Grace.CLI/bin/Debug/net10.0/grace.exe" ``` 5. **Choose a test repository**: You can create an empty directory to start with a blank repo in, or you can copy or clone some code into a directory to start with that code. 6. **Start Grace Server**: Run `pwsh ./scripts/dev-local.ps1` to start Grace Server using Aspire. This will automatically generate a personal access token that you'll use for authentication. When `dev-local.ps1` finishes, it will output your new token, along with exact copy/paste commands to set `GRACE_SERVER_URI` and `GRACE_TOKEN`, the first environment variables you'll need. 7. **Create Owner, Organization, and Repository**: Copy and paste (or modify if you want) the scripts below to set up your first Grace repo. ```powershell # Create an owner, organization, and repo grace owner create --owner-name demo grace organization create --owner-name demo --organization-name sandbox grace repository create --owner-name demo --organization-name sandbox --repository-name hello # Connect to it (writes local Grace config for this working directory) grace connect demo/sandbox/hello # Initialize the repo with the contents of the current directory grace repository init --directory . ``` To see your current state: ```powershell grace status ``` To see what's changed in your branch vs. previous states, use `grace diff`: ```powershell grace diff commit grace diff promotion grace diff checkpoint ``` ### Verify the CLI can talk to the server Once you have `GRACE_SERVER_URI` and `GRACE_TOKEN` set, run: ```powershell grace auth whoami ``` ### File an issue if anything seems confusing or rough The intention is for this first-time setup to be as easy as possible. If you run into any problems, please file an issue so we can make it smoother. ### Running Grace and Git in the same directory You can use Git and Grace side-by-side in the same directory. You just have to make sure they ignore each other's object directories, `.git` and `.grace`. #### Tell Git to ignore Grace `.grace` is Grace's version of the `.git` directory. To ignore it, add the path `.grace/` to your `.gitignore`. #### Tell Grace to ignore Git Grace's `.graceignore` file, by default, ignores the `.git` directory. If it happens to be missing, add the path `/.git/*` to `.graceignore` in the root of your repository. > Again, ⚠️👷🏻🚧 Grace is an alpha, and still has some alpha-like bugs. For now, I recommend testing Grace either on 1) repos that you won't be sad if something bad happens, or; 2) repos where you're comfortable running `git reset` to restore to a known-good version if you need it. ![](./Assets/Orange3.svg) ## Architecture ### 1) Grace Server is a modern web API that uses an actor system Grace is built on **Orleans**. Most domain behavior is implemented as virtual actors, which makes it effortless and natural to scale up and scale out. - HTTP API: `src/Grace.Server` - Actor implementations: `src/Grace.Actors` - Domain types and events: `src/Grace.Types` - Shared utilities and DTOs: `src/Grace.Shared` ### 2) Grace is event-sourced A version control system is \ just a series of modifications to files and branches and repositories over time. Grace stores every modification to every entity as an event, as the source of truth. Grace then uses those events to pre-compute and cache projections that help you and your agents go faster. ### 3) Files are stored in object storage Grace relies on cloud object storage systems to provide a safe and infinitely scalable storage layer. Grace currently uses Azure Blob Storage, with the intention of adding the ability to run it on AWS S3 and others. Currently, all files are stored as single blobs in Azure; soon Grace will shift to a content-addressible storage construct that will enable efficient handling of changes to large binary files. ### 4) `grace watch` for effortless, background update tracking `grace watch` scans the working directory when it starts to get current state and notice any changes that happened while it wasn't running, and then continuously watches for changes, saving new file and directory versions to Grace Server, all generally within a second of the file being saved. This background processing saves time in every session, as other Grace commands like `grace commit` detect `grace watch` and can skip the costly directory rescans and other work that `grace watch` has already taken care of. Grace has other commands that agents are meant to use to capture the complete context of the work being done. Together, they give us a step-by-step audit trail of everything an agent has done and is doing, and why, that you can watch and review in real-time from your computer. > If you want a deeper dive on what `grace watch` does and does not do, see: [What `grace watch` does](./docs/What%20grace%20watch%20does.md). ### 5) “Continuous review” for rapid validation of changes Grace’s review system is designed to bring AI evaluations and human review and approvals together in one harmonious flow. Key concepts include: - **Policy snapshots** (immutable rule bundles) - **Stage 0 analysis** (deterministic signals recorded for references) - **Promotion queues** and **integration candidates** - **Gates** and **attestations** - **Review packets** with easy-to-understand, customizable summaries For more, see: `docs/Continuous review.md` ![](./Assets/Orange3.svg) ## Roadmap Grace is evolving quickly. Strap in.... ### GUI - Native GUI for Windows, MacOS, Linux Desktop, Android, iOS, and WASM. - Not Electron. ### Full rewrite of object storage layer - Switch to content-addressable storage for more efficient object storage and network transfer - Automatic chunking of large binary files ### Multi-hash semantics - Add abstractions to support multiple hashing algorithms at once - Ensure that Grace is not locked into just one hashing algorithm - Add BLAKE3 hashing for better performance and to support content-addressable storage ### `grace cache` for CI/CD and in-office scenarios - Built-in feature of Grace CLI - Like a Git mirror, plus full authentication and authorization - Pre-fetch by listening to repository events using SignalR and downloading the branches and versions you want - Transparent read-through cache for CI workers and in-office users to save time and bandwidth ### Improved database backpressure handling ### Agent skills to help you create and update Grace's automatic review policies ### `grace agent bootstrap` command to give coding agents context for using Grace ### Ergonomics - smoother onboarding - better defaults ### Repeatable performance benchmarking ### Even more unit tests ### Even more integration tests ![](./Assets/Orange3.svg) ## Grace at NDC Oslo 2023 I gave my first conference talk about Grace at NDC Oslo 2023. Grace has changed _a lot_ since then, but this was the original idea. You can watch it [here](https://youtu.be/lW0gxMbyLEM): [](https://youtu.be/lW0gxMbyLEM) ![](./Assets/Orange3.svg) ## Contributing If you want to help shape Grace: - Read `CONTRIBUTING.md` - AI submissions are expected and welcomed, but they have to follow - Open issues for rough edges, missing docs, or confusing workflows [^grace]: Definition excerpted from https://www.thefreedictionary.com/grace. ================================================ FILE: REPO_INDEX.md ================================================ # REPO_INDEX.md (Grace jump table) This file is a machine-oriented index to help tools and humans quickly find the right code. --- ## Suggested search strategy for AllGrace.txt 1. Refer to AllGrace_Index.md for exact starting and ending line numbers for each file. 2. Use the entry points list below to decide where to navigate to. 3. Jump to the exact starting line of the file, and search within the starting and ending line numbers. ## Top entry points (open these first) ### Grace Server (HTTP + DI + Orleans wiring) - `src/Grace.Server/Startup.Server.fs` HTTP routes/endpoints and server composition entry points. - `src/Grace.Server/Program.Server.fs` Host startup (Kestrel/Orleans host build/run). ### Orleans grains (domain behavior) - `src/Grace.Actors/**/*.fs` Grain/actor implementations. Look for `*Actor.fs` as primary behavior files. ### Domain types, events, DTOs - `src/Grace.Types/**/*.fs` Discriminated unions, events, DTOs, identifiers, serialization shapes. ### Local orchestration (emulators/containers) - `src/Grace.Aspire.AppHost/Program.Aspire.AppHost.cs` Local dev topology: Cosmos emulator, Azurite, Service Bus emulator, Redis, and Grace.Server. ### Integration tests - `src/Grace.Server.Tests/General.Server.Tests.fs` Test harness bootstrapping and shared test state. - `src/Grace.Server.Tests/Owner.Server.Tests.fs` Owner API tests. - `src/Grace.Server.Tests/Repository.Server.Tests.fs` Repository API tests. --- ## Cross-cutting �where is X implemented?� ### JSON serialization settings - Search: `JsonSerializerOptions` - Likely in: `src/Grace.Shared/**` or `src/Grace.Server/**` ### Service Bus publishing - Search: `publishGraceEvent`, `ServiceBusMessage`, `GraceEvent` - Likely in: - `src/Grace.Actors/**` (where events are emitted) - `src/Grace.Server/**` (wiring/config) - `src/Grace.Shared/**` (helpers) ### Cosmos DB / persistence wiring - Search: `AddCosmosGrainStorage`, `CosmosClient`, `UseAzureStorageClustering` - Likely in: `src/Grace.Server/Startup.Server.fs` ### Azure Blob grain storage - Search: `AddAzureBlobGrainStorage`, `BlobServiceClient` - Likely in: `src/Grace.Server/Startup.Server.fs` ### CLI commands - Search: `grace owner create`, `Command`, `System.CommandLine` - Likely in: `src/Grace.CLI/**` --- ## Local development �source of truth� - Aspire AppHost defines the runnable local environment. If there is a discrepancy between older docs/tests and AppHost, prefer AppHost. --- ## Obsolete / legacy systems - Dapr is not used anymore. Any Dapr references in tests or tooling are legacy and should be removed or ignored. ================================================ FILE: SECURITY.md ================================================ # Security Policy This project is currently **Alpha** quality. Security hardening is ongoing and may not yet cover all threat models. ## Supported versions Because this project is Alpha, only the **latest code on `main`** is supported. ## Reporting a vulnerability If you believe you have found a security issue, please report it by opening a **GitHub Issue** and applying the **`Security`** label. When filing a report, include as much of the following as you can: - A clear description of the issue and expected vs actual behavior - Steps to reproduce (minimal repro if possible) - Affected components, versions, configuration, and environment details - Impact assessment (what an attacker could do) - Any proof-of-concept code or logs (redact secrets) If the issue includes sensitive information (tokens, credentials, private URLs, etc.), **do not post secrets**. Remove/rotate them before submitting. ## What to expect - We will triage security reports as part of normal development. - Fixes will be prioritized alongside ongoing work based on severity and effort. - We may ask for clarification or additional reproduction details. ## Security best practices for contributors - Do not commit secrets (API keys, connection strings, certificates) to the repository. - Prefer environment variables or a local secret store for development configuration. - Keep dependencies updated and avoid introducing unnecessary new dependencies. - If you introduce authentication/authorization or crypto-related changes, include tests. ## Disclosure Please avoid public disclosure details (including exploit steps) until a fix is available. ================================================ FILE: _endpoint_stub.txt ================================================ endpoint "GET" "/" Authenticated endpoint "POST" "/access/checkPermission" Authenticated endpoint "POST" "/access/grantRole" Authenticated endpoint "POST" "/access/listPathPermissions" Authenticated endpoint "POST" "/access/listRoleAssignments" Authenticated endpoint "GET" "/access/listRoles" Authenticated endpoint "POST" "/access/removePathPermission" Authenticated endpoint "POST" "/access/revokeRole" Authenticated endpoint "POST" "/access/upsertPathPermission" Authenticated endpoint "POST" "/admin/deleteAllFromCosmosDB" Authenticated endpoint "POST" "/admin/deleteAllRemindersFromCosmosDB" Authenticated endpoint "GET" "/auth/login" Authenticated endpoint "GET" "/auth/login/%s" Authenticated endpoint "GET" "/auth/logout" Authenticated endpoint "GET" "/auth/me" Authenticated endpoint "GET" "/auth/oidc/config" Authenticated endpoint "POST" "/auth/token/create" Authenticated endpoint "POST" "/auth/token/list" Authenticated endpoint "POST" "/auth/token/revoke" Authenticated endpoint "POST" "/branch/assign" Authenticated endpoint "POST" "/branch/checkpoint" Authenticated endpoint "POST" "/branch/commit" Authenticated endpoint "POST" "/branch/create" Authenticated endpoint "POST" "/branch/createExternal" Authenticated endpoint "POST" "/branch/delete" Authenticated endpoint "POST" "/branch/enableAssign" Authenticated endpoint "POST" "/branch/enableAutoRebase" Authenticated endpoint "POST" "/branch/enableCheckpoint" Authenticated endpoint "POST" "/branch/enableCommit" Authenticated endpoint "POST" "/branch/enableExternal" Authenticated endpoint "POST" "/branch/enablePromotion" Authenticated endpoint "POST" "/branch/enableSave" Authenticated endpoint "POST" "/branch/enableTag" Authenticated endpoint "POST" "/branch/get" Authenticated endpoint "POST" "/branch/getCheckpoints" Authenticated endpoint "POST" "/branch/getCommits" Authenticated endpoint "POST" "/branch/getDiffsForReferenceType" Authenticated endpoint "POST" "/branch/getEvents" Authenticated endpoint "POST" "/branch/getExternals" Authenticated endpoint "POST" "/branch/getParentBranch" Authenticated endpoint "POST" "/branch/getPromotions" Authenticated endpoint "POST" "/branch/getRecursiveSize" Authenticated endpoint "POST" "/branch/getReference" Authenticated endpoint "POST" "/branch/getReferences" Authenticated endpoint "POST" "/branch/getSaves" Authenticated endpoint "POST" "/branch/getTags" Authenticated endpoint "POST" "/branch/getVersion" Authenticated endpoint "POST" "/branch/listContents" Authenticated endpoint "POST" "/branch/promote" Authenticated endpoint "POST" "/branch/rebase" Authenticated endpoint "POST" "/branch/save" Authenticated endpoint "POST" "/branch/setPromotionMode" Authenticated endpoint "POST" "/branch/tag" Authenticated endpoint "POST" "/branch/updateParentBranch" Authenticated endpoint "POST" "/candidate/attestations" Authenticated endpoint "POST" "/candidate/cancel" Authenticated endpoint "POST" "/candidate/gate/rerun" Authenticated endpoint "POST" "/candidate/get" Authenticated endpoint "POST" "/candidate/required-actions" Authenticated endpoint "POST" "/candidate/retry" Authenticated endpoint "POST" "/diff/getDiff" Authenticated endpoint "POST" "/diff/getDiffBySha256Hash" Authenticated endpoint "POST" "/diff/populate" Authenticated endpoint "POST" "/directory/create" Authenticated endpoint "POST" "/directory/get" Authenticated endpoint "POST" "/directory/getByDirectoryIds" Authenticated endpoint "POST" "/directory/getBySha256Hash" Authenticated endpoint "POST" "/directory/getDirectoryVersionsRecursive" Authenticated endpoint "POST" "/directory/getZipFile" Authenticated endpoint "POST" "/directory/saveDirectoryVersions" Authenticated endpoint "GET" "/healthz" Authenticated endpoint "POST" "/organization/create" Authenticated endpoint "POST" "/organization/delete" Authenticated endpoint "POST" "/organization/get" Authenticated endpoint "POST" "/organization/listRepositories" Authenticated endpoint "POST" "/organization/setDescription" Authenticated endpoint "POST" "/organization/setName" Authenticated endpoint "POST" "/organization/setSearchVisibility" Authenticated endpoint "POST" "/organization/setType" Authenticated endpoint "POST" "/organization/undelete" Authenticated endpoint "POST" "/owner/create" Authenticated endpoint "POST" "/owner/delete" Authenticated endpoint "POST" "/owner/get" Authenticated endpoint "POST" "/owner/listOrganizations" Authenticated endpoint "POST" "/owner/setDescription" Authenticated endpoint "POST" "/owner/setName" Authenticated endpoint "POST" "/owner/setSearchVisibility" Authenticated endpoint "POST" "/owner/setType" Authenticated endpoint "POST" "/owner/undelete" Authenticated endpoint "POST" "/policy/acknowledge" Authenticated endpoint "POST" "/policy/current" Authenticated endpoint "POST" "/promotionGroup/addPromotion" Authenticated endpoint "POST" "/promotionGroup/block" Authenticated endpoint "POST" "/promotionGroup/complete" Authenticated endpoint "POST" "/promotionGroup/create" Authenticated endpoint "POST" "/promotionGroup/delete" Authenticated endpoint "POST" "/promotionGroup/get" Authenticated endpoint "POST" "/promotionGroup/getEvents" Authenticated endpoint "POST" "/promotionGroup/markReady" Authenticated endpoint "POST" "/promotionGroup/removePromotion" Authenticated endpoint "POST" "/promotionGroup/reorderPromotions" Authenticated endpoint "POST" "/promotionGroup/schedule" Authenticated endpoint "POST" "/promotionGroup/start" Authenticated endpoint "POST" "/queue/dequeue" Authenticated endpoint "POST" "/queue/enqueue" Authenticated endpoint "POST" "/queue/pause" Authenticated endpoint "POST" "/queue/resume" Authenticated endpoint "POST" "/queue/status" Authenticated endpoint "POST" "/reminder/create" Authenticated endpoint "POST" "/reminder/delete" Authenticated endpoint "POST" "/reminder/get" Authenticated endpoint "POST" "/reminder/list" Authenticated endpoint "POST" "/reminder/reschedule" Authenticated endpoint "POST" "/reminder/updateTime" Authenticated endpoint "POST" "/repository/create" Authenticated endpoint "POST" "/repository/delete" Authenticated endpoint "POST" "/repository/exists" Authenticated endpoint "POST" "/repository/get" Authenticated endpoint "POST" "/repository/getBranches" Authenticated endpoint "POST" "/repository/getBranchesByBranchId" Authenticated endpoint "POST" "/repository/getReferencesByReferenceId" Authenticated endpoint "POST" "/repository/isEmpty" Authenticated endpoint "POST" "/repository/setAllowsLargeFiles" Authenticated endpoint "POST" "/repository/setAnonymousAccess" Authenticated endpoint "POST" "/repository/setCheckpointDays" Authenticated endpoint "POST" "/repository/setConflictResolutionPolicy" Authenticated endpoint "POST" "/repository/setDefaultServerApiVersion" Authenticated endpoint "POST" "/repository/setDescription" Authenticated endpoint "POST" "/repository/setDiffCacheDays" Authenticated endpoint "POST" "/repository/setDirectoryVersionCacheDays" Authenticated endpoint "POST" "/repository/setLogicalDeleteDays" Authenticated endpoint "POST" "/repository/setName" Authenticated endpoint "POST" "/repository/setRecordSaves" Authenticated endpoint "POST" "/repository/setSaveDays" Authenticated endpoint "POST" "/repository/setStatus" Authenticated endpoint "POST" "/repository/setVisibility" Authenticated endpoint "POST" "/repository/undelete" Authenticated endpoint "POST" "/review/checkpoint" Authenticated endpoint "POST" "/review/deepen" Authenticated endpoint "POST" "/review/packet" Authenticated endpoint "POST" "/review/resolve" Authenticated endpoint "POST" "/storage/getDownloadUri" Authenticated endpoint "POST" "/storage/getUploadMetadataForFiles" Authenticated endpoint "POST" "/storage/getUploadUri" Authenticated endpoint "POST" "/work/create" Authenticated endpoint "POST" "/work/get" Authenticated endpoint "POST" "/work/link/promotion-group" Authenticated endpoint "POST" "/work/link/reference" Authenticated endpoint "POST" "/work/update" Authenticated ================================================ FILE: code_of_conduct.md ================================================ # Grace - Code of Conduct ![](./Assets/Orange3.svg) ## Culture Culture is not a physical thing. You can't _touch_ culture. You can see it reflected in things like visual arts, clothing, books, code, UX, and so much else, but culture _itself_ is not an exterior object that can be seen and measured with some scientific apparatus. Culture is _interior_; it lives _inside_ each member of a group. It's what it means when we say "we". It's first-person, plural. Culture is a set of stories or perspectives, shared by a group, that help to define both _how to behave_ within that group, and _how it feels_ to be part of that group. I want to create a culture at Grace that lives up to its name. We all deserve it. ![](./Assets/Orange3.svg) ## The name of this project is _Grace_. Be **graceful** in your interactions here. Give **grace** to everyone participating with us. Create something together that embodies **grace** in its design and form. When in doubt, **_remember the name of the project._** ![](./Assets/Orange3.svg) To be clear: if your behavior is more disruptive than constructive, you will be asked to leave. ================================================ FILE: docs/.markdownlint.jsonc ================================================ { // For the /docs directory, disable line length rule. We're writing prose. "MD013": { "enabled": false } } ================================================ FILE: docs/AI Submissions.md ================================================ # AI Submissions for Grace Grace welcomes AI-assisted contributions when they are transparent, reproducible, and technically strong. ## Why This Exists Many projects ban AI-generated pull requests because low-quality submissions create review overhead. Grace takes a different approach: AI assistance is welcomed, but only with clear disclosure, a high quality bar, and standardized prompt outputs to make review as easy and as deterministic as possible. ## What We Invite We want your contributions, both in issues and in pull requests. We just want you to run our prompts when you work on those contributions to make review as streamlined as possible. Please start with an issue for alignment on features and direction. Once the issue and new requirements are agreed on, your contributions are welcomed. We don't want any "I have a huge change but didn't discuss it with anyone first" contributions. Use your preferred coding agent and provider to investigate issues, implement changes, and draft contribution materials. For issues, use your agent to investigate the existing code and to develop an idea for a change. When you're ready to submit the issue, you MUST use the [Grace issue summary](/prompts/Grace%20issue%20summary.md) prompt to create the issue description. For pull requests, when you're satisfied that your work is done and fully tested, you MUST use the [Grace pull request summary](/prompts/Grace%20pull%20request%20summary.md) prompt with your coding agent to create the pull request description. These standardized formats will help the maintainers of Grace keep up with everything you throw at us. (We hope.) ## Be Cool Please treat the submission like professional engineering work product that you're proud of. ❤️ ## Non-Negotiable Submission Rules 1. Use the latest and most powerful generally available reasoning model from your chosen provider at submission time. (i.e. Opus, not Sonnet; nothing with "mini" in the name) [^models] 2. Use reasoning at least equivalent to Codex and Claude's `high` (or stronger) to plan and implement the work. 3. Use the required Grace issue and pull request template prompts to have your agent automatically create the submission package. - Issue description prompt: [Grace issue summary.md](/prompts/Grace%20issue%20summary.md) - Pull request description prompt: [Grace pull request summary.md](/prompts/Grace%20pull%20request%20summary.md) 4. Include the prompts used to produce the final result. 5. Assume your output will be re-researched and reviewed by other LLMs and humans. [^models]: I realize that this creates a barrier for AI submission that includes only those who can afford to run frontier models. That's the minimum quality I need in the first half of 2026. I expect that by 2027 "Sonnet" and "Mini" level models will achieve a similar capability level, and I will adjust these requirements when they're ready. Write-ups should: - Be clear. - Be explicit. - Be thorough. - Prefer evidence over vague claims. ## Reasoning Level Mapping Guidance Your provider may use different labels. Map your configuration to the closest equivalent that is at least Codex or Claude `high`. - OpenAI: `high` or `xhigh` reasoning. - Anthropic: `high` or `max` effort. - Google Gemini: thinking enabled with a high/maximum reasoning configuration. - Other providers: choose the highest non-experimental reasoning mode generally available for production use. If your provider exposes only vague labels, document exactly what you selected and why it is equivalent or stronger than `high`. ## Quality Expectations A compliant submission should make reviewer verification fast. The summary prompts are designed to let you create high-quality, comprehensive submissions that validate that you've done the work correctly, and that communicate its value clearly to reviewers. ## Review and Enforcement Submissions can be closed or requested for revision when: 1. Model/reasoning metadata is missing or unclear. 2. The run does not meet the latest-model or high-reasoning rule. 3. Prompt history is omitted. 4. The write-up is shallow, unverifiable, or inconsistent with the code. ================================================ FILE: docs/Authentication.md ================================================ # Authentication This document describes how to configure and use authentication for Grace during development. Grace supports two authentication mechanisms: 1. **Auth0 (OIDC/JWT bearer tokens)** for interactive developer login (PKCE or Device Code) and machine-to-machine (client credentials). 1. **Grace Personal Access Tokens (PATs)** for automation and non-interactive usage. > Important: Authentication proves “who you are.” Authorization (RBAC and path permissions) determines > “what you can do.” PATs do **not** introduce a separate permission model; they authenticate a principal > that is then authorized via Grace’s normal authorization system. --- ## Quickstart for contributors (recommended path) 1. **Set up Auth0** (one-time) using the instructions in **Auth0 tenant setup** below. 1. **Run Grace.Server** (typically via Aspire) with OIDC env vars configured. 1. **Point the Grace CLI at your server** by setting `GRACE_SERVER_URI`. PowerShell: ```powershell $env:GRACE_SERVER_URI="http://localhost:5000" ``` bash / zsh: ```bash export GRACE_SERVER_URI="http://localhost:5000" ``` 1. **Login via the CLI**: * `grace auth login` — Interactive login (tries PKCE first, then falls back to Device Code). * `grace auth whoami` — Verifies your identity against the running server. --- ## Authorization bootstrap (SystemAdmin seeding) Grace supports a one-time bootstrap mechanism to seed the first SystemAdmin role assignment in a fresh environment. This is required when you are standing up a new deployment that has no RBAC assignments yet. ### How it works * Bootstrap runs **only** when the system-scope AccessControl actor first activates and has **no** existing assignments. * It reads configured bootstrap principals and creates `SystemAdmin` role assignments at `Scope.System`. * Bootstrap is **one-time** and will **never** overwrite or re-seed once any system-scope assignments exist. ### Configuration Set one or both of these environment variables (semicolon-delimited list): * `grace__authz__bootstrap__system_admin_users` * `grace__authz__bootstrap__system_admin_groups` ### Important * The values must be **principal IDs**, not emails or display names. * For Auth0/OIDC, the user principal ID is taken from the `sub` claim (exposed as `grace_user_id`). * You can confirm the user ID with `GET /auth/me` (`GraceUserId` in the response). PowerShell: ```powershell $env:grace__authz__bootstrap__system_admin_users="auth0|abc123" ``` bash / zsh: ```bash export grace__authz__bootstrap__system_admin_users="auth0|abc123" ``` --- ## Development without Auth0 (TestAuth) For local development, you can skip Auth0 entirely by enabling the built-in TestAuth handler. This is intended for **dev/test only**. ### Enable TestAuth Set: * `GRACE_TESTING=1` (also accepts `true` or `yes`) When enabled, Grace authenticates requests using headers: * `x-grace-user-id` — required; becomes the user principal ID (`grace_user_id`). * `x-grace-claims` — optional; semicolon-delimited values mapped to `grace_claim`. ### Bootstrap as SystemAdmin without Auth0 1. Choose a local user ID (e.g., `dev-scott`). 1. Set bootstrap to that user ID: PowerShell: ```powershell $env:grace__authz__bootstrap__system_admin_users="dev-scott" ``` bash / zsh: ```bash export grace__authz__bootstrap__system_admin_users="dev-scott" ``` 1. Send requests with the `x-grace-user-id` header. PowerShell: ```powershell Invoke-RestMethod "http://localhost:5000/auth/me" -Headers @{ "x-grace-user-id" = "dev-scott" } ``` bash / zsh: ```bash curl -H "x-grace-user-id: dev-scott" "http://localhost:5000/auth/me" ``` On first activation (with no existing assignments), Grace will seed `SystemAdmin` for `dev-scott`. After that, bootstrap is a no-op. --- ## Auth0 tenant setup (development) ### What you need to create in Auth0 Grace needs these Auth0 resources: 1. **An Auth0 API (Resource Server)** for the Grace Server API * This provides the **Audience** value (the API Identifier). 1. **A Native Auth0 Application** for the Grace CLI (interactive login) * This provides the **CLI client ID**. 1. *(Optional)* **A Machine-to-Machine Auth0 Application** for CI/automation * This provides an **M2M client ID** and **M2M client secret**. ### Values you must capture from Auth0 (you will use these as env vars) * **Tenant domain** (example: `my-tenant.us.auth0.com`) * Used to construct the **Authority**: `https:///` * **API Identifier** (your “Audience”) * Example: `https://grace.local/api` * **Native app Client ID** (Grace CLI interactive login) * *(Optional)* **M2M app Client ID + Client Secret** --- ### Step 1: Create the Auth0 API (Resource Server) Auth0 Dashboard steps: 1. Go to **Applications → APIs → Create API**. 1. Set: * **Name**: `Grace (Dev)` (or similar) * **Identifier** (this becomes the **Audience**): choose a stable string, e.g. `https://grace.local/api` 1. Enable **Allow Offline Access** on the API. * This is required so the CLI can receive refresh tokens (when requesting `offline_access`). 1. Save. Record: * API **Identifier** (Audience) * Tenant Domain (Authority base) --- ### Step 2: Create the Auth0 Native Application (Grace CLI) Auth0 Dashboard steps: 1. Go to **Applications → Applications → Create Application**. 1. Choose application type: **Native**. 1. In the application settings: * Ensure **Authorization Code** (PKCE) is enabled. * Ensure **Refresh Token** grant is enabled. * Ensure **Device Code** grant is enabled (so the CLI can use device flow on headless systems). 1. Configure **Allowed Callback URLs** to include the CLI callback URL: * Default Grace CLI callback URL: * `http://127.0.0.1:8391/callback` If you override the CLI redirect port (via `grace__auth__oidc__cli_redirect_port`), you must also update this callback URL accordingly. 1. Configure refresh token behavior (recommended for development): * Enable refresh token rotation (or equivalent Auth0 setting) and ensure refresh tokens are issued to the application. Record: * Native application **Client ID** (this is `grace__auth__oidc__cli_client_id`) --- ### Step 3 (optional): Create the Auth0 M2M Application (automation) Auth0 Dashboard steps: 1. Create a new application of type **Machine to Machine**. 1. Authorize it to call your **Grace API (Resource Server)**. 1. Choose scopes if you’ve defined API scopes (Grace does not currently require Auth0 API scopes for authorization decisions, but your tenant policies may). 1. Record: * **Client ID** (`grace__auth__oidc__m2m_client_id`) * **Client Secret** (`grace__auth__oidc__m2m_client_secret`) --- ## Grace CLI authentication modes The CLI can authenticate in multiple ways. The first matching mode “wins”: 1. **PAT mode** if `GRACE_TOKEN` is set (must be a Grace PAT, prefix `grace_pat_v1_`). 1. **Error** if `GRACE_TOKEN_FILE` is set (local token storage is intentionally disabled). 1. **M2M mode** if M2M env vars are set. 1. **Interactive mode** if you have logged in previously (token stored in OS secure store). ### Primary CLI commands * `grace auth login [--auth pkce|device]` Interactive login to Auth0; stores access/refresh tokens in the OS secure store. If `--auth` is not specified, the CLI attempts PKCE and falls back to Device Code. * `grace auth status` Shows whether the CLI currently has usable credentials (PAT, M2M, or interactive). * `grace auth whoami` Calls the server and prints the authenticated identity information. * `grace auth logout` Clears the cached interactive token from the secure store. --- ## Personal Access Tokens (PATs) PATs are bearer tokens issued by Grace and validated by Grace. They are typically used for: * CI jobs and automation * running the CLI in non-interactive environments * scripting against the Grace HTTP API A PAT string looks like: * `grace_pat_v1_<...>` ### Creating a PAT You must already be authenticated (interactive Auth0 login, or an existing PAT, or M2M) to create a PAT. #### CLI (recommended) * `grace auth token create --name ""` Creates a PAT with the server-default lifetime. * `grace auth token create --name "" --expires-in 30d` Creates a PAT that expires after the given duration. Supported suffixes: `s`, `m`, `h`, `d`. * `grace auth token create --name "" --no-expiry` Creates a non-expiring PAT **only if** the server allows it. #### Notes * The PAT value is a secret. Store it in a secret manager. * Treat PATs like passwords; do not commit them into git. ### Using a PAT Set the token in your environment: PowerShell: ```powershell $env:GRACE_TOKEN="grace_pat_v1_..." ``` bash / zsh: ```bash export GRACE_TOKEN="grace_pat_v1_..." ``` Then run any CLI command as usual; authentication will use the PAT automatically. If you are calling the HTTP API directly, use the standard Authorization header: * `Authorization: Bearer grace_pat_v1_...` ### Listing and revoking PATs * `grace auth token list` Lists active PATs for the current principal. * `grace auth token list --all` Lists active + expired + revoked tokens. * `grace auth token revoke ` Revokes a PAT by token ID (GUID). Revoked tokens are no longer accepted. * `grace auth token status` Shows whether the current `GRACE_TOKEN` is present and parseable. ### How PAT “permissions” work in Grace PATs do **not** have an independent “permission set” like some systems (GitHub fine-grained tokens, etc.). Instead: * A PAT authenticates a principal (usually a user). * Grace authorization is then evaluated normally: * **RBAC roles** assigned to the principal (user or group) at a scope * **Path permissions** keyed by “claims” and/or group membership Important implementation detail: * When a PAT is created, Grace snapshots the current principal’s `grace_claim` values and `grace_group_id` values into the token record on the server. * If your group membership or claim set changes later, existing PATs will **not** automatically pick up those changes. Create a new PAT if you need a token that reflects updated claims/groups. ### Setting permissions for a PAT Because a PAT’s authorization comes from the principal it authenticates, you “set PAT permissions” by granting/revoking roles and path permissions for that principal. Primary CLI commands for authorization management: * `grace access grant-role ...` Grants a role to a principal at a scope (Owner/Organization/Repository/Branch/System). * `grace access revoke-role ...` Revokes a role from a principal at a scope. * `grace access list-role-assignments ...` Lists role assignments at a scope (optionally filtered by principal). * `grace access upsert-path-permission ...` Sets or updates a path permission entry in a repository. * `grace access remove-path-permission ...` Removes a path permission entry. * `grace access list-path-permissions ...` Lists path permissions, optionally scoped to a path prefix. * `grace access check ...` Asks the server “would this principal be allowed to do operation X on resource Y?” > For detailed role IDs and operations, use `grace access list-roles` and consult the authorization types > in `Grace.Types.Authorization`. --- ## Environment variables This section documents auth-related environment variables used by Grace Server and the Grace CLI. ### Grace CLI (always relevant) * `GRACE_SERVER_URI` (required for CLI) **No default.** Must point to the running Grace server base URI. Source: Aspire dashboard output, local run output, or your deployment URL. Example value: * `http://localhost:5000` PowerShell: ```powershell $env:GRACE_SERVER_URI="http://localhost:5000" ``` bash / zsh: ```bash export GRACE_SERVER_URI="http://localhost:5000" ``` --- ### Grace Server OIDC configuration (Auth0/JWT) These variables enable Auth0 JWT authentication on the server. * `grace__auth__oidc__authority` (optional, enables OIDC when set) **No default.** Source: your Auth0 tenant domain. Use `https:///`. * `grace__auth__oidc__audience` (required if `...__authority` is set) **No default.** Source: Auth0 API Identifier (Resource Server “Identifier”). Recommended (for CLI auto-config): * `grace__auth__oidc__cli_client_id` (optional for server auth; required for `/auth/oidc/config`) **No default.** Source: Auth0 Native app Client ID. > If `grace__auth__oidc__authority` and `grace__auth__oidc__audience` are not set, the server will fall > back to PAT-only authentication. --- ### Grace CLI interactive OIDC configuration (Auth0 login) The Grace CLI can authenticate interactively using Auth0 (OIDC). There are two ways to supply the required OIDC settings: 1. **Recommended:** have the CLI fetch OIDC settings from the Grace Server. 1. **Advanced:** configure OIDC settings directly on the CLI via environment variables. --- #### Recommended: CLI auto-configuration from the server If you set only: * `GRACE_SERVER_URI` — Base URI of the running Grace Server (example: `http://localhost:5000`) …then the CLI can fetch the OIDC configuration automatically by calling: * `GET /auth/oidc/config` — Returns the server’s OIDC settings needed for interactive login. This works **only if** the server is configured with OIDC and has the values needed to publish them (see server env vars below). ##### How to use (server auto-configuration) 1. Start the server with OIDC enabled (Authority + Audience, and preferably the CLI client ID). 1. Set the server URI: PowerShell: ```powershell $env:GRACE_SERVER_URI="http://localhost:5000" ``` bash / zsh: ```bash export GRACE_SERVER_URI="http://localhost:5000" ``` 1. Login: * `grace auth login` — Interactive Auth0 login (tries PKCE, then device flow). 1. Verify: * `grace auth whoami` — Calls the server and prints the authenticated identity. ##### Server-side requirements for auto-configuration For `GET /auth/oidc/config` to return useful values, the server must be configured with: * `grace__auth__oidc__authority` — `https:///` * `grace__auth__oidc__audience` — Auth0 API Identifier * `grace__auth__oidc__cli_client_id` — Auth0 Native app Client ID (recommended) If the server is missing `grace__auth__oidc__cli_client_id`, the CLI may still be able to login if you supply the client ID locally (see Advanced). --- #### Advanced: set OIDC settings on the client If you cannot use server auto-configuration (for example, you are testing against an endpoint that does not expose `/auth/oidc/config`), you can configure the CLI directly via environment variables. ##### Required * `grace__auth__oidc__authority` No default. Source: Auth0 tenant domain. Format: `https:///` * `grace__auth__oidc__audience` No default. Source: Auth0 API Identifier (Resource Server “Identifier”). * `grace__auth__oidc__cli_client_id` No default. Source: Auth0 Native app Client ID. ##### Optional (recommended defaults) * `grace__auth__oidc__cli_redirect_port` Default: `8391` If you change this, you must also update the Auth0 Native app callback URL to: `http://127.0.0.1:/callback` * `grace__auth__oidc__cli_scopes` Default: `openid profile email offline_access` `offline_access` is required to receive refresh tokens. ##### How to use (client configuration) 1. Set environment variables. PowerShell: ```powershell $env:GRACE_SERVER_URI="http://localhost:5000" $env:grace__auth__oidc__authority="https:///" $env:grace__auth__oidc__audience="https://grace.local/api" $env:grace__auth__oidc__cli_client_id="" ``` bash / zsh: ```bash export GRACE_SERVER_URI="http://localhost:5000" export grace__auth__oidc__authority="https:///" export grace__auth__oidc__audience="https://grace.local/api" export grace__auth__oidc__cli_client_id="" ``` 1. Login: * `grace auth login` — Interactive Auth0 login (tries PKCE, then device flow). 1. Verify: * `grace auth whoami` — Calls the server and prints the authenticated identity. --- ### Grace CLI machine-to-machine (M2M) configuration If you set these env vars, the CLI will use Auth0 client credentials to obtain an access token. * `grace__auth__oidc__authority` **No default.** Source: Auth0 tenant domain. * `grace__auth__oidc__audience` **No default.** Source: Auth0 API Identifier. * `grace__auth__oidc__m2m_client_id` **No default.** Source: Auth0 M2M application Client ID. * `grace__auth__oidc__m2m_client_secret` **No default.** Source: Auth0 M2M application Client Secret. Optional: * `grace__auth__oidc__m2m_scopes` Default: empty Space-separated list of scopes to request (if your tenant requires/uses them). PowerShell: ```powershell $env:grace__auth__oidc__authority="https:///" $env:grace__auth__oidc__audience="https://grace.local/api" $env:grace__auth__oidc__m2m_client_id="" $env:grace__auth__oidc__m2m_client_secret="" # Optional: $env:grace__auth__oidc__m2m_scopes="read:foo write:bar" ``` bash / zsh: ```bash export grace__auth__oidc__authority="https:///" export grace__auth__oidc__audience="https://grace.local/api" export grace__auth__oidc__m2m_client_id="" export grace__auth__oidc__m2m_client_secret="" # Optional: export grace__auth__oidc__m2m_scopes="read:foo write:bar" ``` --- ### Grace CLI PAT configuration * `GRACE_TOKEN` (optional) **No default.** Source: output from `grace auth token create`. Must be a Grace PAT (prefix `grace_pat_v1_`). If set, this overrides interactive login and M2M. * `GRACE_TOKEN_FILE` **Not supported.** Local plaintext token file storage is intentionally disabled. --- ### Grace Server PAT policy controls These affect how the server handles PAT creation requests: * `grace__auth__pat__default_lifetime_days` Default: `90` * `grace__auth__pat__max_lifetime_days` Default: `365` * `grace__auth__pat__allow_no_expiry` Default: `false` --- ## Getting Auth0 values automatically (CLI + APIs) If you already have an Auth0 tenant configured, the **Auth0 CLI** can be used to find the exact values needed for Grace without clicking through the dashboard. ### Auth0 CLI login * `auth0 login` Authenticates the Auth0 CLI for interactive use (or with client credentials for CI). If you need additional Management API scopes, re-run login with scopes, e.g.: * `auth0 login --scopes "read:client_grants,create:client_grants"` ### Find tenant information * `auth0 tenants list --json` Lists tenants accessible to your Auth0 CLI session. * `auth0 tenant-settings show --json` Shows tenant settings (useful to confirm you’re operating on the expected tenant). ### Find the Grace API audience (API Identifier) * `auth0 apis list --json` Lists APIs (Resource Servers). Find your Grace API and read its `identifier`. * `auth0 apis show --json` Shows details for a specific API. ### Find the CLI Client ID (Native app) and M2M credentials * `auth0 apps list --json` Lists applications. * `auth0 apps show --json` Shows app details (including `client_id`). * `auth0 apps show --reveal-secrets --json` Shows app details **including secrets** (use only for M2M apps, and handle output carefully). ### Make raw Management API calls (advanced) * `auth0 api get "tenants/settings"` Makes an authenticated request to the Auth0 Management API and prints JSON. This is useful if you need endpoints not exposed by a dedicated `auth0 ` command. ### Creating Auth0 resources via the Auth0 CLI (optional) You can create Auth0 resources non-interactively. * `auth0 apis create ...` Creates an API (Resource Server). You can set identifier (audience), token lifetime, and offline access. * `auth0 apps create ...` Creates an application (Native / M2M / etc). * `auth0 apps update ...` Updates an application (callbacks, grant types, refresh token config, etc). > If you prefer the dashboard, you can ignore this section entirely. --- ## Troubleshooting ### `grace auth login` fails and mentions refresh tokens Ensure: * the Auth0 API has **Allow Offline Access** enabled * the Auth0 Native app allows refresh tokens and is configured to issue them * `grace__auth__oidc__cli_scopes` includes `offline_access` ### `grace status` returns `Unauthorized` and Grace.Server logs look empty This usually means the CLI sent branch status requests without a usable token. Common causes: * No interactive token is cached yet. * `GRACE_TOKEN` is not set (or is invalid). * You are expecting endpoint handler logs, but the request is rejected by authentication middleware first. Quick checks: PowerShell: ```powershell grace auth status --output Verbose grace auth token status --output Verbose grace status --output Verbose ``` bash / zsh: ```bash grace auth status --output Verbose grace auth token status --output Verbose grace status --output Verbose ``` If `GRACE_TOKEN` is false and interactive token is false, authenticate first: PowerShell: ```powershell grace auth login --auth device # or grace auth login --auth pkce ``` bash / zsh: ```bash grace auth login --auth device # or grace auth login --auth pkce ``` If you are using a PAT: PowerShell: ```powershell $env:GRACE_TOKEN="grace_pat_v1_..." grace auth token status --output Verbose ``` bash / zsh: ```bash export GRACE_TOKEN="grace_pat_v1_..." grace auth token status --output Verbose ``` Why logs may look empty: * `grace status` maps to `branch status`, which calls `/branch/Get` and `/branch/GetParentBranch`. * Those routes require authentication and are rejected with `401` before business handlers run. * You might see little or no endpoint-level logging for these failures. Where to look for proof of `401`: * W3C request logs under `%TEMP%\Grace.Server.Logs` (Windows) show request-level status codes. * Look for entries like `POST /branch/Get` and `POST /branch/GetParentBranch` with `401`. Also note: * `--output` values are case-sensitive in current CLI behavior. Use `Verbose`, not `verbose`. * During development, TestAuth may be available. See this file for setup details: `docs/Authentication.md` -> **Development without Auth0 (TestAuth)**. ### You can see `SystemAdmin` in Cosmos, but `grace access check` says denied If `grace auth whoami` shows your expected `GraceUserId`, but: * `grace access check --operation SystemAdmin --resource system --output Json` returns `Denied: missing permission SystemAdmin.`, and you can also see a `SystemAdmin` assignment document in Cosmos, the most common cause is a **runtime context mismatch**. Typical mismatch causes: * The running server is using a different Orleans `serviceid` than the one that wrote the document. * You inspected a different Cosmos database or container than the running server is using. * The AccessControl grain loaded state before manual Cosmos edits (stale in-memory state until restart). Why this happens: * System-scope role assignments are read from the AccessControl actor state keyed by scope and Orleans identity. * A document such as `grace-dev__accesscontrolactor_system` applies only to the matching Orleans service context. * If the active server context differs, authorization reads a different actor record. What to verify first: PowerShell: ```powershell grace auth whoami --output Json grace access check --operation SystemAdmin --resource system --output Json ``` bash / zsh: ```bash grace auth whoami --output Json grace access check --operation SystemAdmin --resource system --output Json ``` Then verify server configuration: * `GRACE_SERVER_URI` points to the server you think you are testing. * The running server's Orleans `serviceid` matches the service prefix of the AccessControl actor document. * The running server's Cosmos database/container match the place where you inspected the document. Recommended recovery steps: 1. Restart `Grace.Server` (or your Aspire app host) to clear any stale grain state. 2. Re-run the two checks above. 3. Re-open Cosmos and confirm the `system` AccessControl actor document for the active service context contains your user principal. 4. If needed, use a current `SystemAdmin` principal to grant your role again via `grace access grant-role`. ### Headless environments / CI Use: * M2M auth (client credentials env vars), or * PATs (`GRACE_TOKEN`), or * `grace auth login --auth device` if interactive login is still acceptable. ================================================ FILE: docs/Branching strategy.md ================================================ ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) # Simplified branching strategy > Quick note: Grace doesn't have a `merge` gesture; it has a `promote` gesture. The idea is that you're not merging a changeset into a parent branch, you're saying "The parent branch is changing its current version to point to the version in the child branch." Grace's default branching strategy is meant to be simple, and to help surface merge conflicts as early as possible. It's called "single-step". If we're right, it's all you need to successfully run a project. Branching strategy is the thing about Grace that's most different from other version control systems. Because it's so different, it's worth going over it in some detail. In single-step branching, each child branch can be promoted only to its parent branch, and must be based on the most-recent version in the parent before being allowed to be promoted. In other words: if I'm based on my parent branch, which means that my code includes everything in the parent branch, up to right now, then any changes in my branch are exactly equivalent to the changeset that we might imagine is being applied to the parent branch. `grace watch` helps with single-step branching by auto-rebasing your branch when a parent branch's version changes. The vast majority of the time you'll be based on the most-recent parent version without having to do anything manually, and without noticing that anything happened. (And, yes, you can turn off auto-rebase if you need to.) (..but you should try it first.) Here's a simple example: ![](https://gracevcsdevelopment.blob.core.windows.net/static/Green.svg) Alice and Bob are developers in the same repo, and have individual branches, named `Alice` and `Bob`, parented by `main`. When they do saves, checkpoints, or commits, those happen on their own branches. When they promote, they promote only to `main`. Grace keeps track of which version of their parent branch they're (re-)based on. ```mermaid flowchart BT Alice[Alice, based on main/ed3f4280]-->main[main/ed3f4280] Bob[Bob, based on main/ed3f4280]-->main[main/ed3f4280] ``` |Branch|Current version|Based on| |-|-:|-:| |Main|`ed3f4280`|\| |Alice|`425684d8`|`ed3f4280`| |Bob|`9c2afa14`|`ed3f4280`||Main|ed3f4280|ed3f4280| ![](https://gracevcsdevelopment.blob.core.windows.net/static/Green.svg) Let's imagine that Alice makes one last update to her branch, has an updated SHA-256 value for it, and (assuming the PR is approved) promotes it to `main`. ```mermaid flowchart BT Alice[Alice, based on main/ed3f4280]-->|promoting 56d626f4|main[main/ed3f4280] Bob[Bob, based on main/ed3f4280]-->main[main/ed3f4280] ``` |Branch|Current version|Based on| |-|-:|-:| |Main|`ed3f4280`|\| |Alice|`56d626f4`|`ed3f4280`| |Bob|`9c2afa14`|`ed3f4280`| ![](https://gracevcsdevelopment.blob.core.windows.net/static/Green.svg) Everything goes well, Grace completes the promotion, and `main` is updated to point to the 56d626f4 version. `Alice`, also pointing to 56d626f4, marks itself as based on it. `Bob` is no longer based on the latest version in `main`, and is therefore ineligible to promote to `main`. ```mermaid flowchart BT Alice[Alice, based on main/56d626f4]-->main[main/56d626f4] Bob[Bob, based on main/ed3f4280]-->mainold[main/ed3f4280] ``` |Branch|Current version|Based on| |-|-:|-:| |Main|`56d626f4`|\| |Alice|`56d626f4`|`56d626f4`| |Bob|`9c2afa14`|`ed3f4280`| ![](https://gracevcsdevelopment.blob.core.windows.net/static/Green.svg) Fortunately, Bob is running `grace watch`, so seconds later `Bob` is auto-rebased on that latest parent version in `main`. Let's assume there are no conflicts.[^conflict] The files that were updated in `main` are different that then ones updated in `Bob`. The new file versions from `main` are copied into place in the working directory. This new version of `Bob`, which includes whatever was already changed in the branch, and whatever changed in the rebase, has a new SHA-256 value, and is automatically uploaded as a save. `Bob` is once again eligible to promote code to `main`. ```mermaid flowchart BT Alice[Alice, based on main/56d626f4]-->main[main/56d626f4] Bob[Bob, now based on main/56d626f4]<-->|rebase on 56d626f4|main[main/56d626f4] ``` |Branch|Current version|Based on| |-|-:|-:| |Main|`56d626f4`|\| |Alice|`56d626f4`|`56d626f4`| |Bob|`21519a1b`|`56d626f4`| ![](https://gracevcsdevelopment.blob.core.windows.net/static/Green.svg) [^conflict]: If there are conflicts, Grace will have a native conflict-resolution UI, as well as a way to navigate it through the CLI. ================================================ FILE: docs/Continuous review.md ================================================ # Continuous review Continuous review tracks promotion-set candidates, evaluates gates, and records review artifacts in a deterministic and auditable flow. ## Canonical workflow model - Use `grace queue ...` for promotion-set queue operations. - Use `grace candidate ...` for candidate-first reviewer operations: `get`, `required-actions`, `attestations`, `retry`, `cancel`, `gate rerun`. - Use `grace review ...` for promotion-set scoped reviewer actions: `open`, `checkpoint`, `resolve`. - Use `grace review report ...` for candidate-scoped report output: `show`, `export`. ## Current API surface ### Queue endpoints - `POST /queue/status` - `POST /queue/enqueue` - `POST /queue/pause` - `POST /queue/resume` - `POST /queue/dequeue` ### Candidate and report endpoints - `POST /review/candidate/get` - `POST /review/candidate/retry` - `POST /review/candidate/cancel` - `POST /review/candidate/required-actions` - `POST /review/candidate/attestations` - `POST /review/candidate/gate-rerun` - `POST /review/report/get` ### Review endpoints - `POST /review/notes` - `POST /review/checkpoint` - `POST /review/resolve` - `POST /review/deepen` (stub) ### Policy endpoints - `POST /policy/current` - `POST /policy/acknowledge` ## CLI examples ### Queue management PowerShell: ```powershell ./grace queue enqueue ` --promotion-set 3d5c4d9a-0123-4567-89ab-987654321000 ` --branch main ` --policy-snapshot-id e3b0c44298fc1c149afbf4c8996fb924 ` --work 42 ./grace queue status --branch main ./grace queue pause --branch main ./grace queue resume --branch main ./grace queue dequeue --branch main --promotion-set 3d5c4d9a-0123-4567-89ab-987654321000 ``` bash / zsh: ```bash ./grace queue enqueue \ --promotion-set 3d5c4d9a-0123-4567-89ab-987654321000 \ --branch main \ --policy-snapshot-id e3b0c44298fc1c149afbf4c8996fb924 \ --work 42 ./grace queue status --branch main ./grace queue pause --branch main ./grace queue resume --branch main ./grace queue dequeue --branch main --promotion-set 3d5c4d9a-0123-4567-89ab-987654321000 ``` `--work` accepts either a GUID `WorkItemId` or a numeric `WorkItemNumber`. ### Candidate-first operations PowerShell: ```powershell ./grace candidate get --candidate 4fe3c2a9-35f0-4dc2-9f8b-4d3e2f1a0b9c ./grace candidate required-actions --candidate 4fe3c2a9-35f0-4dc2-9f8b-4d3e2f1a0b9c ./grace candidate attestations --candidate 4fe3c2a9-35f0-4dc2-9f8b-4d3e2f1a0b9c ./grace candidate retry --candidate 4fe3c2a9-35f0-4dc2-9f8b-4d3e2f1a0b9c ./grace candidate gate rerun --candidate 4fe3c2a9-35f0-4dc2-9f8b-4d3e2f1a0b9c --gate policy ``` bash / zsh: ```bash ./grace candidate get --candidate 4fe3c2a9-35f0-4dc2-9f8b-4d3e2f1a0b9c ./grace candidate required-actions --candidate 4fe3c2a9-35f0-4dc2-9f8b-4d3e2f1a0b9c ./grace candidate attestations --candidate 4fe3c2a9-35f0-4dc2-9f8b-4d3e2f1a0b9c ./grace candidate retry --candidate 4fe3c2a9-35f0-4dc2-9f8b-4d3e2f1a0b9c ./grace candidate gate rerun --candidate 4fe3c2a9-35f0-4dc2-9f8b-4d3e2f1a0b9c --gate policy ``` ### Promotion-set review actions PowerShell: ```powershell ./grace review open --promotion-set 3d5c4d9a-0123-4567-89ab-987654321000 ./grace review checkpoint ` --promotion-set 3d5c4d9a-0123-4567-89ab-987654321000 ` --reference-id f12a0d31-0d5a-4a5f-a5a7-3d2c3a9f5b2c ./grace review resolve ` --promotion-set 3d5c4d9a-0123-4567-89ab-987654321000 ` --finding-id 6e58b4de-7f3b-4a2b-9a6f-111111111111 ` --approve ` --note "Reviewed and acceptable." ``` bash / zsh: ```bash ./grace review open --promotion-set 3d5c4d9a-0123-4567-89ab-987654321000 ./grace review checkpoint \ --promotion-set 3d5c4d9a-0123-4567-89ab-987654321000 \ --reference-id f12a0d31-0d5a-4a5f-a5a7-3d2c3a9f5b2c ./grace review resolve \ --promotion-set 3d5c4d9a-0123-4567-89ab-987654321000 \ --finding-id 6e58b4de-7f3b-4a2b-9a6f-111111111111 \ --approve \ --note "Reviewed and acceptable." ``` ### Report-first review output PowerShell: ```powershell ./grace review report show --candidate 4fe3c2a9-35f0-4dc2-9f8b-4d3e2f1a0b9c ./grace review report export ` --candidate 4fe3c2a9-35f0-4dc2-9f8b-4d3e2f1a0b9c ` --format markdown ` --output-file .\review-report.md ``` bash / zsh: ```bash ./grace review report show --candidate 4fe3c2a9-35f0-4dc2-9f8b-4d3e2f1a0b9c ./grace review report export \ --candidate 4fe3c2a9-35f0-4dc2-9f8b-4d3e2f1a0b9c \ --format markdown \ --output-file ./review-report.md ``` ### Source attribution in history Use the global `--source` option to tag and filter automation activity. PowerShell: ```powershell ./grace history show --source codex ./grace history search workitem --source codex ``` bash / zsh: ```bash ./grace history show --source codex ./grace history search workitem --source codex ``` You can also set the environment fallback: PowerShell: ```powershell $env:GRACE_SOURCE="codex" ./grace history show ``` bash / zsh: ```bash export GRACE_SOURCE="codex" ./grace history show ``` ## Current limitations and stubs - `grace review inbox` is still a CLI stub. - `grace review deepen` is still a CLI and server stub. - Queue processing and automatic candidate state transitions are not fully orchestrated server-side; external automation is expected. - Gate implementations are still partial for some gate types. ================================================ FILE: docs/Data types in Grace.md ================================================ # Data types in Grace Grace uses a fairly simple data structure to keep track of everything. It's more robust than Git's, for sure, but it's as simple as I could make it. In this document, first, you'll find an Entity Relationship Diagram (ERD) showing the most relevant types. After the diagram, you'll find descriptions of each data type. You can skip directly to the data type you're interested in by clicking the corresponding link below: - [Owner and Organization](#owner-and-organization-ie-multitenancy) - [Repository](#repository) - [Branch](#branch) - [DirectoryVersion](#directoryversion) - [Reference](#reference) - [FileVersion](#fileversion) I'm sure the types will evolve a bit as we move towards a 1.0 release, but the overall structure should be stable now. After those descriptions, at the bottom of this document, you'll find a [detailed entity relationship diagram](#detailed-entity-relationship-diagram). This ERD is incomplete, and there are, of course, many other data types in Grace. It's meant to illustrate the most interesting parts, to help you understand the structure of a repository and its contents. Please refer to it as you read the explanations of each type. ## Entity Relationship Diagram The diagram below shows the most important data types in Grace, and how they relate to each other. A [more-detailed ERD](#detailed-entity-relationship-diagram) is available at the bottom of this document. ```mermaid erDiagram Owner ||--|{ Organization : "has 1:N" Organization ||--|{ Repository : "has 1:N" Repository ||--|{ Branch : "has 1:N" Branch ||--|{ Reference : "has 0:N" Repository ||--|{ DirectoryVersion : "has 1:N" Reference ||--|| DirectoryVersion : "refers to exactly 1" DirectoryVersion ||--|{ FileVersion : "has 0:N" ``` ## Owner and Organization; i.e. Multitenancy Grace has a lightweight form of multitenancy built-in. This structure is meant to help large version control hosting platforms to integrate Grace with their existing customer and identity systems. I've specifically chosen to do have a two-level Owner / Organization structure based on my experience at GitHub. GitHub started with the construct of an Organization, and in recent years has been adding an "Enterprise" construct above Organizations, to allow large companies to have multiple Organizations managed under one structure. Seeing the importance of that feature set to large companies made it an easy decision to just start with a two-level structure. It's not my intention for Grace to replace the identity / organization system for any hoster, and that's why there really isn't much in these data types. They're meant to be "hooks" that a hoster can refer to from their identity systems so they can implement whatever management features they need to safely serve Grace repositories. Owner and Organization are the least-used of the data types here. They get created relatively infrequently, they get updated even less frequently, and they get deleted not much at all. ### What about personal accounts? For individual users - like personal user accounts on GitHub that don't belong to any organization - Grace will have one Owner and one Organization that is just for that user, and all user-owned repositories would sit under that Organization. There's nothing stopping an individual user from having multiple Organizations (unless the hoster prevents it). There's no performance difference either way. ## Repository Now we get to the version control part. Repository is where Grace keeps settings that apply to the entire repository, that apply to each branch by default, and that apply to References and DirectoryVersions in the repository. Some examples: - RepositoryType - Is the repository public or private? - SearchVisibility - Should the contents of this repository be visible in search? - Timings for deleting various entities - - LogicalDeleteDays - How long should a deleted object be kept before being physically deleted? - SaveDays - How long should Save References be kept? - CheckpointDays - How long should Checkpoint References be kept? - DirectoryVersionCacheDays - How long should the memoized contents of the entire directory tree under a DirectoryVersion be kept? - DiffCacheDays - How long should the memoized results of a Diff between two DirectoryVersions be kept? - RecordSaves - Should Auto-save be turned on for this repository? In general, once a Repository is created and the settings adjusted to taste, the Repository record will be updated very infrequently. ## Branch Branch is where branches in a repository are defined. It just holds settings that apply to the Branch. The most important settings there are: - ParentBranchId - Which branch is the parent of this branch? - \<_Reference_\>Enabled - These control which kinds of References are allowed on the Branch - PromotionEnabled - CommitEnabled - CheckpointEnabled - SaveEnabled - TagEnabled - ExternalEnabled I'm sure there will be more settings here as we get to v1.0. Branches are created and deleted frequently, of course, but they're updated pretty infrequently. That might seem weird if you're used to Git. In Grace, when you do things like `grace checkpoint` or `grace commit` you're not updating the status of a Branch; you're creating a new Reference _in_ that branch. Nothing in the Branch itself changes. ## DirectoryVersion DirectoryVersion holds the data for a specific version of a directory anywhere in a repo. Every time a file in a directory changes, a new DirectoryVersion is created that holds the new state of the directory. If the contents of a subdirectory change, that directory will get a new DirectoryVersion, and so will the next directory up the tree, until we reach the root of the repository. In other words, DirectoryVersion is how we capture each unique state in a repository. One interesting thing here is that, like the other entities here, Grace uses a Guid for the primary key DirectoryVersionId, and does not use the Sha256Hash as the unique key (even though it always will be unique). My reason for choosing to have an artificial key instead of just using the Sha256Hash is the challenge that Git has had, and is having, migrating to SHA-256, given how deeply embedded SHA-1 is in the naming of objects in Git. It seems best to keep Sha256Hash as a data field, and not as a key, to make it easier to change the hash algorithm in the future. Also, DirectoryVersion has the RepositoryId it belongs to, but does not keep a BranchId. This is because a unique version of the Repository, i.e. a DirectoryVersion, can be pointed to from multiple References and from multiple Branches. So, DirectoryVersion contains: - DirectoryVersionId - This is a Guid that uniquely identifies each DirectoryVersion. - RepositoryId - not BranchId - Sha256Hash - Computed over the contents of the directory; the algorithms for computing the Sha256Hash of a [file](https://github.com/ScottArbeit/Grace/blob/337ed395b7f5d033ceb9d178b4fd9442fa383ee5/src/Grace.Shared/Services.Shared.fs#L53) and a [directory](https://github.com/ScottArbeit/Grace/blob/337ed395b7f5d033ceb9d178b4fd9442fa383ee5/src/Grace.Shared/Services.Shared.fs#L92) are in [Services.Shared.fs](https://github.com/ScottArbeit/Grace/blob/main/src/Grace.Shared/Services.Shared.fs). - RelativePath - no leading '/'; for instance `src/foo/bar.fs` - Directories - a list of DirectoryVersionId's that refer to the sub-DirectoryVersions. - Files - a list of FileVersions, one for each not-ignored file in the directory - Size - int64 DirectoryVersions are created and deleted frequently, as References are created and deleted. ### RootDirectoryVersion Because it's such an important construct, in Grace's code you'll see `RootDirectoryVersion` a lot. This is a DirectoryVersion with the path '.', which is the [definition of "root directory"](https://github.com/ScottArbeit/Grace/blob/337ed395b7f5d033ceb9d178b4fd9442fa383ee5/src/Grace.Shared/Constants.Shared.fs#L173-L174) in Grace. Because the RootDirectoryVersion sits at the top of the directory tree, we point to it in a Reference, rather than any sub-DirectoryVersion, as representing a unique version of the repository. ## Reference In Grace, a Reference is how we mark specific RootDirectoryVersions as being interesting in one way or another. References have a ReferenceType that indicates what kind it is, so there's no such thing as a Commit entity or a Save entity. They're all just References. The interesting parts of a Reference are: - ReferenceId - This is a Guid that uniquely identifies each Reference. - BranchId - The Branch that this Reference is in. A Reference can only be in one Branch. - DirectoryVersionId - The RootDirectoryVersion that this Reference points to. - Sha256Hash - The Sha256Hash of the DirectoryVersionId that this Reference points to. Denormalized here for performance reasons. - ReferenceType - What kind of Reference is this? - Promotion - This is a Reference that was created by promoting a Commit reference from a child branch to this branch. - Commit - Commits are candidates for promotion. - Checkpoint - This is for you to mark a specific version of the repository as being interesting to you. In Git, this is what you'd think of as an intermediate commit as you complete your work. - Save - These are automatically created by Grace on every save-on-disk, if Auto-Save is turned on. - Tag - This is a Reference that was created by tagging a Reference. - External - This is a Reference that was created by an external system, like a CI system. - Rebase - This is the Reference that gets created when a branch is Rebased on the latest Promotion in its parent branch - ReferenceType - The attached to the Reference. - Links - This is a way to link this Reference to another in some relationship. References and DirectoryVersions are where the action happens. New References and DirectoryVersions are being created with every save-on-disk (if you have Auto-Save turned on, which you should), and with every checkpoint / commit / promote / tag / external. The ratio of new-DirectoryVersions-to-new-References is directly proportional to how deep in the directory tree the updated files are. For every directory level, a new DirectoryVersion will be created. For example, if I update a file called `src/web/js/lib/blah.js` and hit save, that will create one Save Reference, and five new DirectoryVersions - one for the root, and one each for each directory in the path. Saves have short lifetimes, and checkpoints (by default) have longer, but finite, lifetimes, and they both get deleted at some point. Any DirectoryVersions that are unique to those references, and any FileVersions in object storage that only appear in those references, get deleted when the Reference is deleted. Also, of course, every time a Branch is deleted, all References in that Branch get deleted. And all DirectoryVersions unique to those References get deleted. Etc. It's completely normal in Grace for References to be deleted. Happens all the time. ## FileVersion The FileVersion contains the metadata for a file in a DirectoryVersion. It's the metadata for the file, not the file itself. The file itself is stored in object storage, and the FileVersion has a BlobUri that points to it. The interesting parts of a FileVersion are: - RepositoryId - The Repository that this FileVersion is in. - RelativePath - The path of the file, relative to the Repository root. - Sha256Hash - The Sha256Hash of the file. - IsBinary - Is the file binary? - Size - The size of the file (int64). - BlobUri - The URI of the file in object storage. ## Detailed Entity Relationship Diagram The diagram below shows the most important data types in Grace, and how they relate to each other. Not every field in each data type is shown - feel free to check out [Types.Shared.fs](https://github.com/ScottArbeit/Grace/blob/main/src/Grace.Shared/Types.Shared.fs) and [Dto.Shared.fs](https://github.com/ScottArbeit/Grace/blob/main/src/Grace.Shared/Dto/Dto.Shared.fs) to see the full data types - but this should give you a good idea of how the data is structured. ```mermaid erDiagram Owner ||--|{ Organization : "has 1:N" Owner { OwnerId Guid OwnerName string OwnerType OwnerType SearchVisibility SearchVisibility } Organization ||--|{ Repository : "has 1:N" Organization { OrganizationId Guid OrganizationName string OwnerId Guid OrganizationType OrganizationType SearchVisibility SearchVisibility } Repository ||--|{ Branch : "has 1:N" Repository { RepositoryId Guid RepositoryName string OwnerId Guid OrganizationId Guid RepositoryType RepositoryType RepositoryStatus RepositoryStatus DefaultServerApiVersion string DefaultBranchName string LogicalDeleteDays double SaveDays double CheckpointDays double DirectoryVersionCacheDays double DiffCacheDays double Description string RecordSaves bool } Branch ||--|{ Reference : "has 1:N" Branch { BranchId Guid BranchName string OwnerId Guid OrganizationId Guid RepositoryId Guid UserId Guid PromotionEnabled bool CommitEnabled bool CheckpointEnabled bool SaveEnabled bool TagEnabled bool ExternalEnabled bool AutoRebaseEnabled bool } Repository ||--|{ DirectoryVersion : "has 1:N" Reference ||--|| DirectoryVersion : "refers to exactly 1" Reference { ReferenceId Guid DirectoryVersionId Guid Sha256Hash string ReferenceType ReferenceType ReferenceTest string Links ReferenceLinkType[] } DirectoryVersion { DirectoryVersionId Guid RepositoryId Guid RelativePath string Sha256Hash string Directories DirectoryVersionId[] Files FileVersion[] } DirectoryVersion ||--|{ FileVersion : "has 1:N" FileVersion { RepositoryId Guid RelativePath string Sha256Hash string IsBinary bool Size int64 BlobUri string } ``` ================================================ FILE: docs/Design and Motivations.md ================================================ # Design and Motivations Hi, I'm Scott. I created Grace. I'll use first-person singular in this document because I want to share what the early design and technology decisions were for Grace, and why I started writing it in the first place. I'll happily rewrite it in first-person plural when it's appropriate. For shorter answers to some of these, please see [Frequently Asked Questions](Frequently%20asked%20questions.md). --- ## Table of Contents [A word about Git](#a-word-about-git) [User experience is everything](#user-experience-is-everything) [The origin of Grace](#the-origin-of-grace) [Perceived performance](#perceived-performance) [CLI + Native GUI + Web UI + Web API](#cli--native-gui--web-ui--web-api) [F# and Functional programming](#f-and-functional-programming) [Source control isn't systems-level](#source-control-isnt-systems-level) [Cloud-native version control](#cloud-native-version-control) [Why Grace is centralized](#why-grace-is-centralized) [Performance; or, Isn't centralized version control slower?](#performance-or-isnt-centralized-version-control-slower) [How much Git should we keep?](#how-much-git-should-we-keep) [Scalability](#scalability) [Monorepos](#monorepos) ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## A word about Git It's not possible to design a version control system (VCS) today without designing something that relates to, interfaces with, and/or somehow _just reacts to_ Git. In order to explain some of the choices I've made in Grace, I _have_ to mention Git. Mostly, of course, I'll do that if I think Grace is better in some way or other. With that said, and just to be clear... I respect Git enormously. It will take years for any new VCS to approximate the feature-set of Git. Until a new one starts to gain momentum and gets a sustained programming effort behind it – open-source and community-supported – every new VCS will sort-of be a sketch compared to everything that Git can do. The maintainers of Git are among the best programmers in the world. The way they continue to improve Git's scalability and performance, year-after-year, while maintaining compatibility with existing repositories, is an example of how to do world-impacting programming with skill and, dare I say, grace. Git has been around for 17 years now, and it's not disappearing anytime soon. If you love Git, if it fits your needs well, I'm guessing you will be able to continue to use it for the next 15-20 years without a problem. (What source control might look like in 2042 is anyone's guess.) ### Git is dominant now, but... Whether Git will remain the dominant version control system for that entire time is quite another question. I believe that _something else_ will capture people's imagination enough to get them to switch away from Git at some point. My guess about when that will happen is: soon-ish. Like, _something else_ is being created now-ish, \±2 years. There are some wonderful source control projects going on right now that are exploring this space. I offer Grace in the hope that _it_ will be good enough to make people switch. Time will tell. Git is amazing at what it does. I'm openly borrowing from Git where I think it's important to (ephemeral working directory, lightweight branching, SHA-256 hashes, and so much else). I just think that it's a different time now. The constraints that existed in 2005 in terms of hardware and networking, the constraints that Git was designed to fit in, don't hold anymore. We can take advantage of current client and server and cloud capabilities to design something really different, and even better. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## User experience is everything Now that I've said nice things about Git.... ### Git's UX is terrible. [I](https://xkcd.com/1597/) [hope](https://gracevcsdevelopment.blob.core.windows.net/static/RandomGitCommands.jpeg) [this](https://git-man-page-generator.lokaltog.net) [is](https://rakhim.org/honestly-undefined/13/) [not](https://gracevcsdevelopment.blob.core.windows.net/static/MemorizingSixGitCommands.jpg) [a](https://www.quora.com/Why-is-Git-so-hard-to-learn) [controversial](https://www.quora.com/If-I-think-Git-is-too-hard-to-learn-does-it-mean-that-I-dont-have-the-potential-to-be-a-developer) [statement](https://twitter.com/markrussinovich/status/1395143648191279105). And [I](https://twitter.com/robertskmiles/status/1431560311086137353) **[know](https://twitter.com/markrussinovich/status/1578451245249052672)** [I'm](https://ohshitgit.com/) [not](https://twitter.com/dvd848/status/1508528441519484931) [alone](https://twitter.com/shanselman/status/1102296651081760768) [in](https://www.linuxjournal.com/content/terrible-ideas-git) [thinking](https://blog.acolyer.org/2016/10/24/whats-wrong-with-git-a-conceptual-design-analysis/) [it](https://matt-rickard.com/the-terrible-ux-of-git). Learning Git is far too hard. It's basically a hazing ritual that we put ourselves through as an industry. Git forces the user to understand far too much about its internals just to become a proficient user. Maybe 15%-20% of users really understand it. Many of its regular users are literally afraid of it. Including me. > Bad software, designed without empathy, that restricts people to only follow strict procedures to achieve one specific goal, has trained millions of people that software is cumbersome, inflexible, and even hostile and that users have to adapt to the machine, if they want to get anything done. The future of computing should be very much the opposite. Good software can augment the human experience by becoming the tool that’s needed in the moment, unrestricted by limitations in the physical world. It can become the personal dynamic medium through which exploring and expressing our ideas should become simpler rather than more difficult. [^StefanLesser] > > \- Stefan Lesser ### Grace's UX is focused on simplicity Grace is explicitly designed to be easy to use, and easy to understand. It's as simple as possible. It has abstractions. Users don't have to know the details of how it works to use it. Because of that, Grace has fewer concepts for users to understand to become and feel proficient in using it. Grace formats output to make it as easy to read as possible, and also offers JSON output, minimal output, and silent output for each command. [^output] And in a world where hybrid and remote work is growing, Grace offers entirely new experiences with a live, two-way channel between client and server, linking repository users together in new ways, including auto-rebasing immediately after promotions (which are merges, sort-of). There's so much more to do in UX for version control. Grace is a platform for exploring where it can go next. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## The origin of Grace There was an informal Source Control Summit in November, 2020 that I had the opportunity to attend, and I had the chance to have some additional side conversations with a few of the other attendees. The vibe I got from those interactions – and I want to emphasize that this was _my_ takeaway, and that I do not speak for anyone else – was that 1) we're all still just mining for incremental improvements in Git; 2) we're getting tired of Git and whatever else we're using; and 3) we're not sure what could come next that would change that. That led me to sitting outside on my front porch in December, 2020 – still in pandemic lockdown, in the darkest month of a dark year – and starting to think about what **I** would want in a version control system. It all started that first night with a few themes: - It had to be easy-to-use. The pain of learning Git, and the continuing fear of it, has always been a sore spot for me, and, I know, for millions of others. - It had to be cloud-native, so it could take advantage of the fast, cloud-scale computing that we're all used to in almost every other kind of software, and get away from using file servers. - It had to have live synchronization between client and server – I was thinking of the OneDrive sync client as a good example – as the basis for being able to build important new features. - It had to fundamentally break away from Git. No "Git client but a different backend". No "New client, but Git for the storage layer." No "it should speak Git protocol". I just wanted to start with a blank sheet of paper, keep the things about Git that we all like, take advantage of modern cloud-native services, and get rid of the complexity. Grace is the version control system that I'd want to use. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## Perceived performance Measuring actual performance of any significant system is important, and Grace will have performance benchmarks that can be run alongside other kinds of tests to ensure that regressions don't sneak in. I care deeply about performance. What I care even more about is _perceived performance_. What I mean by that is something more subjective than just "how many milliseconds?" I mean: **does it _feel_ fast**? Perceived performance includes not just how long something takes, but how long it takes relative to other things that a user is doing, and how consistent that time is from one command invocation to the next, to the next, to the next. My experience is that running _fast enough_, _consistently_, is what gives the user the feeling that a system is fast and responsive. That's what Grace is aiming for: both _fast_, and _consistent_. Fast + consistent means that users can develop expectations and muscle memory when using a command, and that running a command won't take them out of their flow. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## CLI + Native GUI + Web UI + Web API Another avenue for providing great UX is in providing choices about how to interact with Grace. ### CLI Obviously, Grace starts with a command-line interface (CLI). I've designed it for ease-of-use. As much as possible, default values are used to save typing. By default, most output is shown in tables with borders, and is formatted to help guide your eyes to the important content. Grace can also provide output in minimal text, JSON, verbose, or no output at all (silent). ### Native GUI Wait, what? No one does those anymore. Yeah, well... the thing is, I really don't like Electron apps. Like, at all. It's simply not possible to recreate the snappiness, the stick-to-your-finger-ness, the _certainty_ that the UI is reacting to you, that you get in a native app when you're writing in a browser. It's just not. I've been watching this for years now, and almost no one even tries. "What about Visual Studio Code?" I hear someone say. It's among the best examples, for sure. But I don't _love_ it. Look how much programming and how many person-years have gone into it to make it OK. Look at the way they had to completely rewrite the terminal window output using Canvas because nothing else in a browser was fast enough. I don't see a lot of other Electron apps getting nearly that level of effort and programming. And they're all just... not great. I fear that, as an industry, we're failing our fellow human beings, and we're failing each other, by accepting second-rate UX on the first-rate hardware we have. We're making this choice for one reason: our own convenience as programmers. We're prioritizing developer ergonomics over user experience, and claiming that it's for business reasons. And we're usually not making great UX with it. We have tools today that can create native apps on multiple platforms from a single codebase, and that's what I'm taking advantage of. There's nothing in Grace's UX that I'm currently imagining that requires any complex user controls that can't be rendered in any of those tools... you know: text blocks, lines, borders, normal input fields and buttons / checkboxes / radio buttons. Maybe a tree view or some graphs if I'm feeling fancy. We can provide incredible experiences when we take advantage of the hardware directly, and I intend to. ### Web UI So, after all that... I'm creating a Web UI? What gives? Browsers are great for browsing and light functionality, and that's all Grace will need. ### Web API Grace Server itself is simply a modern, 2024-style Web API. If there's something you'd rather automate by calling the server directly, party on. Grace ships with a .NET SDK (because that's what the CLI + Native GUI + Web UI use), and that SDK is simply a projection of the Web API into a specific platform. It should be trivial to create similar SDK's for other languages and platforms. It's about choices for the user. It's about understanding that sometimes the best way to share something is with a URL. And it's about providing a place that we can collaborate on what the default Grace's UI should look like. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## F# and functional programming ### Grace is written primarily in F\# The main reason for this is simple: **F# is my favorite programming language right now**. It's beautiful, and it feels lightweight, but it's really strongly-typed and very fast. But... there are other reasons. ### Reconsidering object-oriented programming Like many of my peers, I've been specializing in object-oriented (OO) code for a long time. For me, it was C++ starting in 1998, and then .NET starting with .NET Framework Beta 2 in June, 2001. I've written tens-of-thousands of lines of object-oriented code. In 2015, I started to learn Category Theory, and in 2017-18, I had the opportunity to work on a project at Microsoft Research that was written in F#. I went back to C# for a bit after that, but, the seed was planted, and in a small 2020 pandemic side project, I decided to use F# to _really_ learn to think functionally and put a little bit of that Category Theory to use. After 20+ years of writing OO code, I've come to the conclusion, as have others, that we've hit a ceiling in quality and maintainability in object-oriented-ish code for anything beyond a medium-sized codebase. We've adopted many practices to cover up for the problems with OO, like dependency injection and automated unit testing so we can refactor safely, but the truth is that without a significant investment in Developer Experience, and sustained effort to just keep the code clean, many large OO projects become supremely brittle and hard to maintain. You may disagree, and that's fine. There's a fair argument that when you design OO systems as message-passing systems (and Grace does this using the Actor pattern) they factor really well. I'm not saying it's not possible to have a large and still-flexible OO codebase, just that it's rare and takes deliberate effort to keep it that way. Functional programming offers a new path to create large-scale codebases that sidestep these problems. No doubt, over the coming years, as more teams try functional code, we'll find anti-patterns that we need to deal with (and, no doubt, I have some of them in Grace), but having personally taken the mindset journey from OO to functional, my field report is: we'll benefit greatly as an industry if we take a more functional and declarative approach to coding. It can do wonders everywhere, not just in the UI frameworks where we've already seen the benefits. Whether you choose Haskell, Scala, F#, Crystal, or some other functional language, I invite you to try functional programming. It's a journey, for sure, but it's so worth it. Not only will you learn a new way to think about organizing code, you'll become a better OO programmer for it. ### .NET is really nice to use, and well-supported For those who haven't worked with it yet... .NET is great now. Really. Let me explain why. The old days of .NET being a Windows-only framework are long-since over. .NET is fully cross-platform, suporting Windows, Linux, MacOS, Android, and iOS. It's the most well-loved framework according to [Stack Overflow's 2022 Developer Survey](https://survey.stackoverflow.co/2022/#section-most-popular-technologies-other-frameworks-and-libraries), as it was in [2021](https://insights.stackoverflow.com/survey/2021#section-most-popular-technologies-other-frameworks-and-libraries), and Microsoft has continued to pour work into making it faster, better, easier-to-use, and well-documented. NuGet, .NET's package manager, has community-supported packages for almost every technology one might wish to interface with. In terms of performance, .NET has been near the top of the [Techempower Benchmarks](https://www.techempower.com/benchmarks/#section=data-r21&test=composite) for years, and the .NET team and community continue to find performance improvements in every release. As far as developer experience, .NET is just a really nice place to spend time. The documentation is amazing, the examples and StackOverflow support are first-rate. The .NET team has written a wonderful blog post called [What is .NET, and why should you choose it?](https://devblogs.microsoft.com/dotnet/why-dotnet/), the first of a series diving into the design of the platform. Is it perfect? No, of course not. Nothing in our business is. Is it "really good" or "great" a lot of the time? Does it continue to improve release after release? In my experience... yes, absolutely. Will it be supported for a long time? .NET has great adoption in both open-source and enterprise shops. Unity, one of the most popular game engines, is written in C#. Microsoft itself runs many of its insanely large first-party Azure services on .NET, and that alone will keep .NET around and on a continuous improvement cycle for the forseeable future. So, it's very fast, it has great corporate and community support, it runs on every major platform, and it's loved by those who use it. I'm not saying that other tech stacks aren't great, just that .NET is great now and well worth a long-term bet. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## Source control isn't systems-level I like things that go fast. My second programming language – at age 11 – was 6502 Assembler. I've written and read code in IBM 370 Assembler and 80x86 Assembler. I've written C and C++, and continue to pay attention to the wonderful work being led by [Herb Sutter](https://www.youtube.com/user/CppCon/search?query=herb%20sutter) and [Bjarne Stroustrup](https://www.youtube.com/user/CppCon/search?query=bjarne) to make C++ faster, safer, less verbose, and easier to use. I applaud the work by Mozilla and the Rust community to explore the space of safer, very fast systems programming. I consider any public talk by [Chandler Carruth](https://www.youtube.com/results?search_query=chandler+carruth) to be mandatory viewing. I'm aware of what it means to be coding down-to-the-metal. I grew up on it, and still try to think in terms of hardware capabilities, even as I use higher-level frameworks like .NET. With that said, the idea that version control systems have to be written in a systems-level language, just because they all used to be, isn't true, especially for a centralized VCS that's just a modern Web API and its clients. Grace relies on external databases and object storage services, and so there's not much Git-style byte-level file manipulation. Given how fast .NET is (within 1% of native C++ when well-written), and the fact that network round-trips are involved in most things that Grace does, it's not likely that writing Grace in C++ or Rust would make a difference in perceived performance for users. A lot of the clock time during commands is spent on those network round-trips, even on the server. Using F# and .NET for the computation – i.e. for Grace itself – is more than fast enough compared to all of that. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## Cloud-native version control I've personally installed and maintained hundreds of servers and virtual machines in my career. I racked dozens of them myself. It seemed fun at the time. I'm over it. That's why I'm a huge fan of Platform-as-a-Service (PaaS), and why Grace was imagined on its first day as a cloud-native system. I starting tracking the [Dapr project](https://dapr.io) as soon as it was announced, and saw it as a perfect solution for being able to write a cloud-native, PaaS-based system, while allowing everyone to choose their own deployment adventure. ### Your choice of services at deployment time Grace runs on Dapr to allow you to choose which PaaS or cloud or even on-premises services it runs on. The database, the observability platform, the service bus / event hub pieces, and more, will be configurable by you. Grace will run on anything Dapr supports. ### Object storage is different Grace uses an object storage service to save each version of the files in a repo (i.e. Azure Blob Storage, AWS S3, Google Cloud Storage, etc.). Although Dapr does support pluggable object storage providers, using Dapr for Grace's object storage isn't appropriate for Dapr's design. Dapr is perfect for using object storage for storing smaller blobs, and although most code files fall in the size range that works well for Grace, I want Grace to support virtually unlimited file sizes. That means that it's best for Grace to directly use the specific API's for the storage providers, and to allow the CLI / client to communicate directly with the object storage service, offloading that work to the service where it belongs. #### A note about the actual current state of Grace Thus far, Grace has been written only to run on Microsoft Azure. (It's the cloud provider I know best.) There was an issue with Dapr when I started writing Grace that caused me to "work around" Dapr's support for databases. It has since been fixed – the ability to query actor storage using a Dapr-specific syntax – and I intend to remove the Azure Cosmos DB code I wrote in favor of that Dapr code over the coming months, enabling Grace to run not just on Cosmos DB, but on any data service that Dapr supports for actor storage. As mentioned above, the best thing for Grace is to directly use the specific API's of the object storage providers in the client. To do that securely, at a minimum, the object storage provider must support the concept of a time-limited and scope-limited token that can be generated at the server to be handed to the client for directly accessing the object storage service. (For example, Azure Blob Storage has [Secure Access Signatures](https://docs.microsoft.com/en-us/azure/storage/common/storage-sas-overview).) Although I've only implemented support for Azure Blob Storage so far, I've created some light structure in the code using discriminated unions to try to keep me honest and able to implement support for other object storage services without too much difficulty. ### How does Dapr affect performance? The simple version is: it adds ~1ms per request through Dapr, when we ask Dapr's Actor Placement Service (running in a separate container) which Grace application instance will have the specific Actor we're interested in. It's negligable compared to overall network round-trip between client and server, and well worth it for the ease-of-use of the Actor pattern in Dapr. To make that even faster, I use a short-term in-memory cache on each application instance for specific data to save even on those calls. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## Why Grace is centralized Grace is a centralized version control system (CVCS). To be clear, there are valid reasons to use a distibuted version control system (DVCS), like Git. There are other new DVCS projects underway, and there are some great ideas in them. Grace is clearly not well-suited for a situation where a DVCS is required, and that's OK. I wanted to take a different approach with Grace, because: - by removing the complexity of being distributed, Grace's command surface can be much simpler to use and understand - as long as Grace is _fast enough_ (see [below](#performance-or-isnt-centralized-version-control-slower)), and easy to use, most users won't care if it's centralized - being centralized allows Grace to handle arbitrarily large files, and to give users controls for which files get retrieved locally - being centralized allows Grace to scale up well by relying on mature Platform-as-a-Service components - it's 2024, and writing software that requires a file server seems... dated - I'm not sure I'm smart enough to write a better DVCS protocol and storage layer than Git - the "I have to be able to work disconnected" scenario is less-and-less important – a growing number of developers today use cloud systems as part of their development and production environments, and if they're not connected to the internet, having their source control unavailable is the least of their problems – in the coming years, satellite Internet will provide always-on, high-speed connections in parts of the world that were previously cut-off or limited And, by the way, ### We're all using Git as a centralized VCS anyway Almost _everyone_ uses Git in a pseudo-centralized, hub-and-spoke model, where the "real" version of the repo – the hub – is centralized at GitHub / GitLab / Atlassian / some other server, and the spokes are all of the users of the repo. In other words, we're already using Git as a centralized version control system, we're just kind-of pretending that we're not, and we're making things more complicated for ourselves because of it. ### Centralized isn't necessarily less antifragile I get it. In an absolutely worst-case scenario, where `` happens, isn't it better to have multiple full copies of a repo distributed across all of the users? Here's how I think about this for Grace: - In a professionally-run instance of Grace, the infrastructure generally will be PaaS services from serious cloud providers. They'll use georeplication of data to enable disaster recovery, and failover drills to make sure it all works (and lots of other professional things) and your repo will survive regional disasters. - Users of a repo will have the most recent versions of the branches that they're working on, and some number of previous versions, in Grace's local object cache. While that's not the entire history of the repo, if you're totally offline, it's enough to build the current version and keep going. - Grace is event-sourced, and if you really wanted to hook into every `New file version created` event and make your own backups, you'll be able to. - Grace will have an "export to Git" feature. If you want to (periodically) export the current state of your repo to a Git-format file, you'll be able to. #### What if my repo gets "banned" or "shut down" or something like that? There are many good reasons, and some not-so-good reasons I could imagine, that a repo might be shut down by a provider. GitHub and GitLab and Atlassian and Azure DevOps and every hoster everywhere all have to deal with those decisions regularly. Without getting into a discussion of which reasons fall into the good vs. not-so-good categories, I'll just say, again, you'll have the latest version of the branches that you're working on downloaded locally – in other words, the ones that matter. That's enough to keep going or start over if it comes down to it. ### Every other service we use is centralized, this just seems weird because we're used to Git My email is centralized at Microsoft and Google, depending on the account. I don't have a full local copy of a mathematically-validated graph of all of my banking transactions to do my online banking. I have tons of files in OneDrive, but they're not all downloaded to my SSD. Etc. Having centralized source control just seems weird because we're not used to it anymore. Having a full local copy of all of the history of a repo seems like a warm, cozy, safe thing, but, really, how often to you _actually_ need the entire history of the repo to be local? How often do you _actually_ look at it locally vs. looking at history on GitHub / GitLab / etc. online? Grace can provide the views you need, they'll just be run on the server. If you really need a full local copy of your repo with all of its history, Git's still your uncle. Most of us can let that go. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## Performance; or, Isn't centralized version control slower? I've been around long enough to have used a couple of the older CVCS's, and I understand the reputation of them as feeling, just... slower. And heavier. That's not what Grace is like. Grace is designed to feel lightweight, and to be _consistently fast_, by which I mean: 1. running a command in Grace should never take so long that it takes you out of your flow 2. running the same Grace command (i.e. `grace commit` or `grace diff` or whatever) in the same repo should take roughly the same amount of time _every time_, within a few hundred milliseconds (i.e. within most users' tolerance for what they would call "consistent"). ### Git is sometimes faster than Grace... Git is really fast locally, and because almost every command in Grace will require at least one round-trip to the server, there are some commands for which Grace will never be as fast as Git. In those situations, my aim is for Grace to be as-fast-as-possible, and always _fast enough_ to feel responsive to the user. I expect most Grace commands to execute in under 1.0s on the server, so... slightly slower than local Git, but _fast enough_ to be good UX. ### ...except when Grace is faster than Git There are also scenarios where Grace will be faster than Git – usually scenarios where Git communicates over a network – because, in Grace, the "heavy lifting" of tracking changes and uploading new versions and downloading new versions will have been done already, in the background (with `grace watch`). In those use cases, like `grace checkpoint` and `grace commit`, the command is just creating a new database record, and that's faster than `git push`. So, Grace is designed to be _fast_, i.e. fast enough to keep users in flow, and to be _consistent_, i.e. users quickly develop muscle-memory for how long things take, helping them stay in flow. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## How much Git should we keep? Various version control projects over the years, and today, have attempted to be completely new front-ends for Git, while keeping Git's back-end storage format. Or maybe they keep the Git network protocol, but have a different client, or a different storage format. Or something like that. They keep some of Git but not all of it, and hope to deliver a better UX that Git. My observation is: no matter how confusing Git itself is, none of those approaches have ever taken any market share away from Git. ### Even Git can't be the new Git Git itself has tried to modernize a little bit over the years. For example, in 2020, Git added the `git switch` and `git restore` commands. In my completely informal and anecdotal asking around, no one has ever heard of them. 2½ years later, they're still marked as "EXPERIMENTAL". I point this out because I believe that, in most people's minds, the command surface of Git is locked. Once they go through the pain of learning enough Git to get by, very few people want to continue going deeper or to re-learn new ways of using it every few years. When searching for help about Git, the search results overwhelmingly reflect older ways of using Git, and it will take years before those search results reflect newer ways of using it, where "newer" = "the last 4-5 years". Even new web content about Git sometimes uses old constructs. It's exactly in those years that I believe a new version control system will start taking market share away from Git and become the Cool New Thing. ### So how much? So... how much Git should we keep when we create new version control systems? Many projects seem to think: _a lot_, because (the thinking goes) in order to get adoption, you have to be Git-compatible. Grace's answer is: _not very much_. It definitely borrows things from Git, but, fundamentally, Grace is _really_ different. Grace says: It's time to start with a blank sheet of paper. Only time will tell if this design decision is right – i.e. if it wins hearts and minds – but given that the hang-onto-Git-somewhat-but-do-it-differently path is already being explored by other projects, I want to see what can happen when we really let go of Git. I mean, someone's gotta do it. ### Import and export, but not sync Grace will support one-time import from Git, and snapshot-style export to a `git bundle` file, but supporting two-way synchronization between Grace and Git is an explicit non-goal. Getting through the edge cases of that would take a while, and I have much higher-priority things to do. Grace's design is so different from Git's that spending time trying to make them fit together is less about composition, and more about duct-taping two totally different things together. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## Scalability Grace has no binary data format of its own, and relies on object storage (i.e. Azure Blob Storage, AWS S3, etc.) to store individual versions of files. Likewise, Grace's metadata is stored as documents in a database (i.e. Azure Cosmos DB, MongoDB, Redis, etc.) and not in a file on a filesystem. Therefore, to a large extent, the scalability of an installation of Grace depends on the Platform-as-a-Service components that it is deployed on. Because Grace uses the Actor pattern extensively, Grace benefits when more memory is available for each server container instance, as Grace will automatically use that memory as a cache, reducing pressure on the underlying database. And because Grace Server is stateless, and Dapr's Actor Placement service automatically rebalances whenever an application instance is added or removed, Grace can scale up and scale down automatically as traffic increases or decreases, using standard [KEDA](https://keda.sh/) counters to drive those actions. I haven't yet run large-scale load tests, but... if the database used for Grace can support thousands of transactions/second, and the object storage service can handle thousands of transactions/second (and the message bus and the observability system etc.), then between that and Grace's natural use of memory for caching, Grace Server *should* be able to scale up and scale out pretty well. The smaller-scale load tests I've run so far have been promising. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## Monorepos Defining what a "large repository" or a "monorepo" is isn't straightforward. "Large" can mean different things: - a large number of files - a large number of versions of files - a large number of updates-per-second - a large number of users/services hitting the repo at the same time - large binary files versioned in the repo - some or all of the above, all at the same time. Grace is designed to handle all of these scenarios well. Grace decomposes a repository from being "one big ball of bytes" into being individual files in object storage, and individual documents in a database representing the state of repositories, branches, directories, and everything else. This way of organizing the data about the repository allows commands and queries to run just as fast for monorepos as they do for small and medium-sized repos. There are, of course, some operations that will take longer on larger repositories (`grace init` is an obvious example where a lot of files might need to be downloaded), but, in general, Grace Server's performance shouldn't degrade as the repository size grows. (Grace CLI as well... *if* you're running `grace watch`). ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) [^StefanLesser]: From [How to adopt Christopher Alexander’s ideas in the software industry](https://stefan-lesser.com/2020/10/27/how-to-adopt-christopher-alexanders-ideas-in-the-software-industry/). [^output]: That's the intention, anyway. I have some work to do on some of the commands to light all of that up. ================================================ FILE: docs/Design concepts/Backups.md ================================================ # Backups in Grace Like any computer system, Grace needs to have backups, and to test restoration from those backups. Because Grace's data is stored in both a database (like Cosmos DB, Cassandra, etc.) and in an object storage system (like Azure Blob Storage, or S3), we need to have a way to define what a "snapshot" is across those systems, and we also probably need to understand that it's possible that the last few seconds before some failure occurs may involve losing some data. Over time, we'll chaos-test Grace and see if we can minimize the amount of data loss. The thing is... Grace can run on many different platforms, and each platform has its own backup and restore mechanisms. Every Grace service provider, and everyone self-hosting Grace, will have to decide how to do backups and restores. With that said, there are some general perspectives that need to be kept in mind when deciding what it means to have a backup of Grace. ## Geo-redundancy The first, obvious form of "backup" is to use geo-redundant features of the underlying storage systems. Because I'm an Azure guy, I'll use Azure examples; I know similar features exist in AWS / GCP / etc. Azure Cosmos DB can synchronize data between many regions around the world within seconds, just through configuration. Azure Blob Storage has a geo-redundant option that pairs your main region with a backup region at least several hundred kilometers away. It's basically malpractice to not use these features when they're available with a click. ## Database backups Whatever form of backup for the actor storage database you choose, it should be possible to restore the database to a point in time. If you have the exact time of the backup, you can use that to filter any changes to object storage after that time in the event of failure and restoration. Over time, we expect to create, or to have Grace service providers create and share, mature utilities to assist in handling all of this. ## User backups Although many users will be comfortable with the idea that their Grace service provider is adequately backing up their repositories, some will want to have their own backups. There is, of course, a comfort in having every Git repository instance be a full copy. This is especially true for users who are self-hosting Grace. They may want to have a backup of their repositories in a different region, or even in a different cloud provider. ### Git bundle file Grace will support exporting a repository to Git bundle format. Because the creation and maintenance of those files can be compute-intensive, we'd prefer to have this done infrequently... maybe only on promotion. We'll have to have a way to do this on-demand, of course. ### Object storage backups Users will want copies of the files in object storage. Those copies can be done either by the Grace service provider, or by the user. If a user wants to subscribe to Grace's event stream, they can use that to keep their own object storage in sync with the Grace service provider's object storage, all the way down to saves. Whenever an event happens that they feel they want to make sure they have a backup for - at least every promotion - they can copy the correct versions of files from object storage to their own object storage. Grace service providers will have to decide how to handle this, how to charge for network egress, etc. Alternatively, a Grace service provider may offer a service themselves where a user can supply their own object storage credentials, and the service provider will copy the files to the user's object storage. Again, there are concerns about how to charge for the service, and how to charge for network egress. ================================================ FILE: docs/Design concepts/Directory and file-level ACL's.md ================================================ # Directory and File-level ACL's ================================================ FILE: docs/Frequently asked questions.md ================================================ # Frequently Asked Questions (_or, what I imagine they might be_) For deeper answers to some of these, please read [Design and Motivations](Design%20and%20Motivations.md). ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## Is Grace centralized or decentralized? Grace is a centralized version control system (VCS). ## I thought centralized version control systems were really slow. Well, that's not a question... but... yeah, in some ways, the older centralized VCS's could feel slower, and heavier, especially when dealing with branches. Grace is not like that. Grace is new, modern, lightweight, and very fast. ## Why did you create Grace? In the 2022 StackOverflow Developer Survey – the most recent one where they tracked version control usage – Git was at 93.87% adoption. Git has won, no doubt. And there's sort-of nowhere for it to go but down. I've been around long enough to see different technologies rise and fall. Some have shorter market cycles (web UI frameworks, for instance), and some have longer market cycles, like hierarchical -> relational -> No-SQL databases, or popular social media apps. I've seen technologies that had almost 95% market share, with very long cycles, like Windows and Windows Server, eventually lose market share for one reason or another. Git is 19 years old now. It doesn't have the easiest UX, to say the least. Many projects are exploring version control right now to see where it might go next. Git won't stay near 95% forever. Nothing ever does. The thing that's probably going to take Git down is monorepos. Although I think large monorepos are a terrible idea – and I strongly recommend that you use multiple code repositories and proper versioning with a package or artifact repository – the trend right now is toward monorepos. Git doesn't do monorepos well, or, to be more precise, Git only does monorepos well by breaking the original contract of Git as a distributed VCS by using partial clones, or filtered partial clones, and therefore treating Git as a centralized VCS. Of course, if you're using GitHub or GitLab or Azure DevOps, you're already doing centralized version control, you're just doing it with a decentralized VCS with bad UX, which doesn't make a lot of sense when you think about it. Grace is my offering to that search for what's next. Grace's design is my attempt to bring ease-of-use into a corner of our world that hasn't had much of that lately, and to connect us together in a different way than ever before. ## I like the way Git does branches. Again with the not-question... I do too. I think that the lightweight branching in Git is one of the major reasons that it won. That's why I kept lightweight branching in Grace. Create and delete branches to your heart's content. ## What about when I'm disconnected? Well, you won't be able to run many Grace commands. And you probably won't be able to do lots of other things that you usually do. More and more of us rely on cloud services and connectivity to the Internet just to do our jobs. Think about this: if your Internet connection went down, could you continue to do your job as a developer, or would you have to stop? Some of you could keep working, but if you can't, not having a connection to your source control server is the least of your concerns compared to not having a connection to Azure or AWS or wherever your cloud stuff is... not to mention Copilot and StackOverflow and your favorite search engine. With the growth of satellite Internet, we're connecting more and more of the world at high-enough bandwidth to use centralized version control without issue. And I'm not designing for the 0.000001% "but I'm on a flight without Internet" scenario. If being able to use local version control while you're not connected to the Internet is an important scenario for you, please use Git. It's great at that. I'm guessing that there's still a small – important, but small – percentage of programmers in the world that _really_ need that. For the rest of us, the vast majority of us, assuming a working Internet connection isn't a concern in 2024, and will be even less of a concern in 2026, 2028, etc. Anyway, Grace won't stop you from continuing to edit the local copies of your files that you already have. When your Internet connection resumes, `grace watch` will catch you up immediately. ## Have you thought about using blockchain to... No. 🤦🏼‍♂️ Just... no. ## Can Grace do live two-way synchronization with Git repositories? No, it can't. Two-way synchronization is a non-goal. One-time initial import from a Git repo will be supported, and point-in-time export to a `git bundle` file will be supported, but not continuous two-way synchronization. Grace just has a fundamentally different design than Git. That's intentional. Two-way synchronization would involve a messy translation between what Git calls a _merge_ and what Grace calls a _promotion_, and I don't see a good way right now to handle that well without writing a _lot_ of code and handling a _lot_ of edge cases, and that's time better spent on everything else that still needs doing. Also, I don't think that new version control systems need to sync with Git to catch on. Git didn't have two-way sync with any of the VCS's that we all migrated from, and it didn't stop us from changing over. We did the migrations over some weekend, and Monday morning we were using Git, and we got on with our lives. ## What are the scalability limits for Grace? ### Hopeful answer It depends on the PaaS services that Grace is deployed on. In general, Grace itself is very fast, and will take advantage of the speed and scale of the underlying cloud services it depends on. I know Microsoft Azure well, so when I think about running Grace on services like Azure Kubernetes Service, Azure Cosmos DB, Azure Blob Storage, Azure Event Hubs, Azure Service Bus, Azure Monitor, and others, I look at Grace Server as orchestrating the usage of insanely high-scale PaaS products, and that's exactly what it's designed to do. The stateless nature of Grace Server, and the use of the Actor Pattern, should allow for a significant number of concurrent users without too much hassle. In particular, Grace is designed so that data that the server needs when you run common CLI commands will already be in-memory most of the time. If it's not, that data will usually be under 10ms away in a document database. The only load testing that I've done saturated my personal [Azure Cosmos DB Request Units](https://learn.microsoft.com/en-us/azure/cosmos-db/request-units), but didn't stress Grace Server at all, which is what I expected. I haven't tested higher than 10,000 RU's, but I expect that when I do, I'll find some things to improve, and then Grace should be able to handle thousands of transactions/second. ### Actual current answer I haven't done any truly high-scale load testing yet. I'm not sure. I _can_ tell you that I've tested repositories of up to 100,000 files and 15,000 directories, with Grace deployed using Azure CosmosDB and Azure Blob Storage. If `grace watch` is running, client performance for most commands on those large repositories is around 0.8-1.0s (which includes 1 or 2 80ms roundtrips to the Azure data center). Performance on small- and medium-sized repositories is around 0.6-0.8s. Grace Server performance is unaffected by repository size for most commands. These times are from debug builds. I've also tested individual file sizes up to 10GB. I'm not sure that 10GB files should fall under the purview of version control–they should probably be versioned blobs in an object storage service – but we'll see what happens. Grace doesn't have a technical limitation on file size (it's a uint64). Each command, on its own, runs quickly enough to make me happy. I hope they all still do at scale. ## What does Grace borrow from Git? A lot of things. Grace keeps the ephemeral working directory, and the idea of a `.grace/objects` directory for each repo. Grace keeps the lightweight branching, so you can continue to create and discard branches just as quickly as we do in Git. Grace borrows the use of SHA-256 values as the hash function to uniquely identify files and directories. We even borrowed the algorithm to decide if a file is text or binary.[^binary] And much more. Grace owes a debt of gratitude to Git and to its maintainers. ## What about the other new version control projects? There are a few really good VCS projects going on right now. It's exciting to see. I'm fortunate enough to know the folks involved in [Jujutsu](https://github.com/martinvonz/jj). They're doing incredible work. I'm aware of [Pijul](https://pijul.org/). I see Meta has just announced [Sapling](https://sapling-scm.com/). [PlasticSCM](https://www.plasticscm.com/) is really good; I have enormous respect for Pablo and his whole team. I know there are others. If you're interested, I recommend searching YouTube for some good, short introductions to all of them. All I can say is: Grace has its own design philosophy, and its own perspective on what it should feel like to be using version control, and that's what I'm passionate about. I think of it as a friendly competition to see which one wins with the next generation of developers. One of them will catch on and get popular soon enough. ## On _HN_, FOSSBro761 says: _"Grace \ M$ \..."_ Sigh. Um, OK. So... If you look at my GitHub or LinkedIn profile you'll see that I work for GitHub, which means that I work for Microsoft. With that said, Grace is a personal side-project. It's probably the case that I wouldn't have thought to start a new version control system without having been at GitHub, but now I've caught the version control bug. (Some people tried to warn me that that happens, but I didn't listen.) It's a fascinating area to work in, and one that will see exciting innovation in the coming years. Grace's existence does not imply anything at all about GitHub's continued, massive, ongoing ❤️ and support of Git, or about GitHub's future direction in source control, or about anything else about GitHub. All opinions and work here is personal, and not endorsed by my employers. (There, I said it.) _What actually happened was..._ I started thinking about Grace in December 2020, and it became my personal 2021 pandemic lockdown side-project. I invented it. No one told me to invent it, I just did. I chose .NET because it's what I know and trust, and because trying to write my first, big functional system was challenging enough without also having to learn a new ecosystem at the same time. I chose F# because I wanted to think functionally as I explored. I chose to do it at all because I realized that _something_ was eventually going to replace Git, and I had some opinions about what that should be, and about what direction we should take as an industry in UX for source control. The only way to communicate that effectively was to start writing code and see if I could build something worthwhile. That's the origin story. Just a guy who had an idea he couldn't let go of, using good tools that he knows well, and a few tools that he's learning. ## How can I get involved? Why, thank you for asking. ❤️ Everything helps. Feel free to file an Issue in the repo. Please join us over in Discussions. I'm working right now to get Grace in better shape for debugging for everyone. I confess the debug workflow is very much tailored to me and my local machine at the moment. I'm going to fix that, and when I do, I'll post instructions for how to write code for and debug Grace as easily as possible. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) [^binary]: The algorithm is: is there a 0x00 byte anywhere in the first 8,000 bytes? If there is, it's assumed to be a binary file. Otherwise, it's assumed to be a text file. ================================================ FILE: docs/How Grace computes the SHA-256 value.md ================================================ # Computing the SHA-256 value for files and directories ## Introduction Grace uses the [SHA-256](https://en.wikipedia.org/wiki/SHA-2) algorithm to compute cryptographically verifiable hash values for files and directories stored in Grace. The use of SHA-256 hashes for this purpose is inspired by Git's use of SHA-1, and their ongoing work to migrate to SHA-256. The SHA-256 hash values are used throughout Grace to identify unique versions of files and directories, primarily to minimize the size of change captured by each reference (i.e. each save, checkpoint, commit, and tag). Hash values in Grace are meant to provide cryptographic proof that the directory and file versions stored for each reference match what was originally uploaded. To be more precise, when a user downloads a specific version of a branch, the files and directory versions should provably be the same versions that were originally stored in Grace. Grace Server, as part of maintenance routines, should also be able to verify file and directory versions at any time. Unlike Git, the SHA-256 values provide no linkage between references. Each SHA-256 value is specific to that version of the repository, with no connection to previous or subsequent versions. This enables Grace to be able to delete references and versions, such as saves that are no longer necessary, without the manipulation of history required in Git. The choice of SHA-256, as opposed to SHA-384 or other stronger algorithms, comes from a desire to provide excellent runtime performance with strong cryptographic hashing. SHA-256 has been studied extensively [for over 20 years](https://en.wikipedia.org/wiki/SHA-2), and [seems to be collision-resistant to quantum algorithms](https://crypto.stackexchange.com/questions/59375/are-hash-functions-strong-against-quantum-cryptanalysis-and-or-independent-enoug). Because Grace, like Git, uses it here solely for hashing, and not for encryption, any potential long-term issues with SHA-256 leave only a small opportunity for misuse. > This information is valid as of April, 2022. The implementation may change as Grace matures. ## Implementation In ordinary usage, SHA-256 values are computed by Grace CLI, and those values are used when uploading versions of files and directories, and when creating references on the server. Specifically, Grace relies on the .NET implementation of [SHA-256](https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.sha256), found in the [System.Security.Cryptography](https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography) namespace. .NET implementations of Grace clients are welcome to use the hashing implementation in Grace.Shared, which is used by both Grace CLI and Grace Server. Implementations in other languages will need to implement this algorithm separately. Grace Server rechecks each SHA-256 hash as versions arrive at the server. In the event of a discrepancy, which could indicate malicious behavior, the invalid versions will be deleted, and Grace administrators and repository owners will be notified. ### Files When computing the SHA-256 value for a file, Grace uses a stream - FileStream when reading a local file, and Stream when reading a file from object storage - and the [IncrementalHash](https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.incrementalhash) class, to keep memory use constant, no matter the size of the file. The SHA-256 value for a file is computed with the following algorithm. 1. An instance of the [IncrementalHash](https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.incrementalhash) class is created using the SHA-256 hash algorithm. 2. Bytes from the file are read from the stream into a 64KB buffer, and appended to the IncrementalHash's input data, until the stream is consumed. 3. The relative path of the file (based on the repository root), represented as a string, is converted to bytes using UTF-8 encoding, and appended to the IncrementalHash's input data. > For consistency between Windows and Unix-y OS's, Grace converts backslashes `\` in the relative path into forward slashes `/` before converting to bytes. - For example: a file is being uploaded on Windows with full path name `C:\Source\MyRepo\SomeDir\myfile.md`. The root of the repository is in `C:\Source\MyRepo`. Therefore, the relative path of the file is `.\SomeDir\myfile.md`. This string will be converted to `./SomeDir/myfile.md` before being converted to a byte array and appended to the IncrementalHash's input. 4. The length of the file, represented as an `Int64` value, is converted to bytes and appended to the IncrementalHash's input data. 5. The SHA-256 hash is computed as a `byte[]`. 6. The SHA-256 hash is converted to a string by converting each byte to a two-character hexadecimal value. - For example, `byte[]{0x43, 0x2a, 0x01, 0xfa}` would be converted to a string `"432a01fa"`. The code for this can be found in the Grace.Shared project, in Utilities.Shared.fs. ``` fsharp let computeSha256ForFile (stream: Stream) (relativeFilePath: String) = task { let bufferLength = 64 * 1024 let buffer = ArrayPool.Shared.Rent(bufferLength) try // 1. Create an IncrementalHash instance. use hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256) // 2. Read bytes from the stream and feed them into the hasher. let mutable loop = true while loop do let! bytesRead = stream.ReadAsync(buffer.AsMemory(0, bufferLength)) if bytesRead > 0 then hasher.AppendData(buffer.AsSpan(0, bytesRead)) else loop <- false // 3. Convert the relative path of the file to a byte array, and add it to the hasher. hasher.AppendData(Encoding.UTF8.GetBytes(relativeFilePath)) // 4. Convert the Int64 file length into a byte array, and add it to the hasher. hasher.AppendData(BitConverter.GetBytes(stream.Length)) // 5. Get the SHA-256 hash as a `byte[]`. let sha256Bytes = hasher.GetHashAndReset() // 6. Convert the SHA-256 value from a byte[] to a string, and return it. // Example: byte[]{0x43, 0x2a, 0x01, 0xfa} -> "432a01fa" return byteArrayAsString(sha256Bytes) finally ArrayPool.Shared.Return(buffer, clearArray = true) } ``` ### Directories The SHA-256 hash of a directory is computed using the following algorithm. 1. The relative path of the directory (based on the repository root), represented as a string, is converted to bytes and stored in a `List`. - For example: a directory version is being uploaded with full path name `C:\Source\MyRepo\SomeDir\SomeSubDir`. The root of the repository is in `C:\Source\MyRepo`. Therefore, the relative path of the directory is `.\SomeDir\SomeSubDir`. This string will be converted to `./SomeDir/SomeSubDir` before being converted to a byte array and used to create the `List`. 2. The list of _subdirectories_ is sorted by name, using [CultureInfo.InvariantCulture](https://docs.microsoft.com/en-us/dotnet/api/system.globalization.cultureinfo.invariantculture). The SHA-256 value for each subdirectory is converted to a `byte[]` using UTF-8 encoding, and appended, in order, to the `List`. The sorted list of subdirectories does not include the `/.` and `/..` directories. 3. The list of _files_ in the directory is sorted by name, using [CultureInfo.InvariantCulture](https://docs.microsoft.com/en-us/dotnet/api/system.globalization.cultureinfo.invariantculture). The SHA-256 value for each file is converted to a `byte[]` using UTF-8 encoding, and appended, in order, to the `List`. 4. The entire `List` is used as input to compute the SHA-256 value. The SHA-256 value is represented as a `byte[]`. 5. The SHA-256 hash is converted to a string by converting each byte to a two-character hexadecimal value. - For example, `byte[]{0x43, 0x2a, 0x01, 0xfa}` would be converted to a string `"432a01fa"`. ### Validation Grace will provide a command - `grace branch verify` - that will compute the SHA-256 values for on-disk versions of files and directories, and compare them to the SHA-256 values stored in Grace's database. ================================================ FILE: docs/Mermaid diagrams.md ================================================ # Mermaid diagrams ## Starting state ```mermaid %%{init: { 'logLevel': 'debug', 'theme': 'default', 'gitGraph': {'showBranches': true, 'showCommitLabel': false}} }%% gitGraph commit tag: "ce38fa92" branch Scott branch Mia branch Lorenzo checkout Scott commit tag: "87923da8: based on ce38fa92" checkout Mia commit tag: "7d29abac: based on ce38fa92" checkout Lorenzo commit tag: "28a5c67b: based on ce38fa92" checkout main ``` ## A promotion on `main` ```mermaid %%{init: { 'logLevel': 'debug', 'theme': 'default', 'gitGraph': {'showBranches': true, 'showCommitLabel': false}} }%% gitGraph commit tag: "ce38fa92" branch Scott branch Mia branch Lorenzo checkout Scott commit tag: "87923da8: based on ce38fa92" checkout Mia commit tag: "7d29abac: based on ce38fa92" checkout Lorenzo commit tag: "28a5c67b: based on ce38fa92" checkout main commit tag: "87923da8" ``` ## Branching model ```mermaid graph TD; A[master] -->|Merge| B[release]; B -->|Merge| C[develop]; C -->|Merge| D[feature branch]; D -->|Feature Completed| C; B -->|Release Completed| A; E[hotfix branch] -->|Fix Applied| A; E -->|Fix Merged into| C; classDef branch fill:#37f,stroke:#666,stroke-width:3px; class A,B,C,D,E branch; ``` ## Entities ```mermaid flowchart LR %% Arrows point from "FK holder" --> "referenced DTO". %% Labels are the FK field(s). [*] means list/collection. (optional) means option. subgraph Identity OwnerDto["OwnerDto
PK: OwnerId"] OrganizationDto["OrganizationDto
PK: OrganizationId"] RepositoryDto["RepositoryDto
PK: RepositoryId"] end subgraph VersionGraph BranchDto["BranchDto
PK: BranchId"] ReferenceDto["ReferenceDto
PK: ReferenceId"] DirectoryVersionDto["DirectoryVersionDto
PK: DirectoryVersionId"] DiffDto["DiffDto
Diff between DirectoryVersionIds"] end subgraph PolicyReview PolicySnapshot["PolicySnapshot
PK: PolicySnapshotId"] Stage0Analysis["Stage0Analysis
PK: Stage0AnalysisId"] ReviewPacket["ReviewPacket
PK: ReviewPacketId"] ReviewCheckpoint["ReviewCheckpoint
PK: ReviewCheckpointId"] end subgraph PromotionSystem PromotionGroupDto["PromotionGroupDto
PK: PromotionGroupId"] IntegrationCandidate["IntegrationCandidate
PK: CandidateId"] PromotionQueue["PromotionQueue
Key: TargetBranchId"] GateAttestation["GateAttestation
PK: GateAttestationId"] ConflictReceipt["ConflictReceipt
PK: ConflictReceiptId"] end subgraph WorkManagement WorkItemDto["WorkItemDto
PK: WorkItemId"] end subgraph Reminders ReminderDto["ReminderDto
PK: ReminderId"] end %% Identity hierarchy / tenancy OrganizationDto -->|OwnerId| OwnerDto RepositoryDto -->|OwnerId| OwnerDto RepositoryDto -->|OrganizationId| OrganizationDto %% Branch BranchDto -->|OwnerId| OwnerDto BranchDto -->|OrganizationId| OrganizationDto BranchDto -->|RepositoryId| RepositoryDto BranchDto -->|ParentBranchId| BranchDto BranchDto -->|BasedOn| ReferenceDto BranchDto -->|LatestReference / LatestPromotion / LatestCommit / LatestCheckpoint / LatestSave| ReferenceDto %% Reference ReferenceDto -->|OwnerId| OwnerDto ReferenceDto -->|OrganizationId| OrganizationDto ReferenceDto -->|RepositoryId| RepositoryDto ReferenceDto -->|BranchId| BranchDto ReferenceDto -->|DirectoryId| DirectoryVersionDto ReferenceDto -->|Links.BasedOn| ReferenceDto ReferenceDto -->|Links.IncludedInPromotionGroup / Links.PromotionGroupTerminal| PromotionGroupDto %% DirectoryVersion DirectoryVersionDto -->|OwnerId| OwnerDto DirectoryVersionDto -->|OrganizationId| OrganizationDto DirectoryVersionDto -->|RepositoryId| RepositoryDto DirectoryVersionDto -->|DirectoryVersion.Directories| DirectoryVersionDto %% Diff DiffDto -->|OwnerId| OwnerDto DiffDto -->|OrganizationId| OrganizationDto DiffDto -->|RepositoryId| RepositoryDto DiffDto -->|DirectoryVersionId1| DirectoryVersionDto DiffDto -->|DirectoryVersionId2| DirectoryVersionDto %% PolicySnapshot PolicySnapshot -->|OwnerId| OwnerDto PolicySnapshot -->|OrganizationId| OrganizationDto PolicySnapshot -->|RepositoryId| RepositoryDto PolicySnapshot -->|TargetBranchId| BranchDto %% PromotionGroup PromotionGroupDto -->|OwnerId| OwnerDto PromotionGroupDto -->|OrganizationId| OrganizationDto PromotionGroupDto -->|RepositoryId| RepositoryDto PromotionGroupDto -->|TargetBranchId| BranchDto PromotionGroupDto -->|Promotions.PromotionId| ReferenceDto %% PromotionQueue PromotionQueue -->|TargetBranchId| BranchDto PromotionQueue -->|PolicySnapshotId| PolicySnapshot PromotionQueue -->|CandidateIds| IntegrationCandidate PromotionQueue -->|RunningCandidateId| IntegrationCandidate %% IntegrationCandidate IntegrationCandidate -->|OwnerId| OwnerDto IntegrationCandidate -->|OrganizationId| OrganizationDto IntegrationCandidate -->|RepositoryId| RepositoryDto IntegrationCandidate -->|WorkItemId| WorkItemDto IntegrationCandidate -->|PromotionGroupId| PromotionGroupDto IntegrationCandidate -->|TargetBranchId| BranchDto IntegrationCandidate -->|PolicySnapshotId| PolicySnapshot IntegrationCandidate -->|BaselineHeadReferenceId| ReferenceDto IntegrationCandidate -->|ReviewPacketId| ReviewPacket IntegrationCandidate -->|LastCheckpointId| ReviewCheckpoint IntegrationCandidate -->|GateAttestationIds| GateAttestation IntegrationCandidate -->|ConflictReceiptIds| ConflictReceipt %% GateAttestation GateAttestation -->|OwnerId| OwnerDto GateAttestation -->|OrganizationId| OrganizationDto GateAttestation -->|RepositoryId| RepositoryDto GateAttestation -->|CandidateId| IntegrationCandidate GateAttestation -->|PolicySnapshotId| PolicySnapshot GateAttestation -->|BaselineHeadReferenceId| ReferenceDto %% ConflictReceipt ConflictReceipt -->|OwnerId| OwnerDto ConflictReceipt -->|OrganizationId| OrganizationDto ConflictReceipt -->|RepositoryId| RepositoryDto ConflictReceipt -->|CandidateId| IntegrationCandidate %% WorkItem WorkItemDto -->|OwnerId| OwnerDto WorkItemDto -->|OrganizationId| OrganizationDto WorkItemDto -->|RepositoryId| RepositoryDto WorkItemDto -->|BranchIds| BranchDto WorkItemDto -->|ReferenceIds| ReferenceDto WorkItemDto -->|PromotionGroupIds| PromotionGroupDto WorkItemDto -->|CandidateIds| IntegrationCandidate WorkItemDto -->|ReviewPacketIds| ReviewPacket WorkItemDto -->|ReviewCheckpointIds| ReviewCheckpoint WorkItemDto -->|GateAttestationIds| GateAttestation %% ReviewPacket ReviewPacket -->|OwnerId| OwnerDto ReviewPacket -->|OrganizationId| OrganizationDto ReviewPacket -->|RepositoryId| RepositoryDto ReviewPacket -->|CandidateId| IntegrationCandidate ReviewPacket -->|PromotionGroupId| PromotionGroupDto ReviewPacket -->|PolicySnapshotId| PolicySnapshot ReviewPacket -->|GateSummary.GateAttestationIds| GateAttestation %% ReviewCheckpoint ReviewCheckpoint -->|CandidateId| IntegrationCandidate ReviewCheckpoint -->|PromotionGroupId| PromotionGroupDto ReviewCheckpoint -->|ReviewedUpToReferenceId| ReferenceDto ReviewCheckpoint -->|PolicySnapshotId| PolicySnapshot %% Stage0Analysis Stage0Analysis -->|OwnerId| OwnerDto Stage0Analysis -->|OrganizationId| OrganizationDto Stage0Analysis -->|RepositoryId| RepositoryDto Stage0Analysis -->|ReferenceId| ReferenceDto Stage0Analysis -->|WorkItemId| WorkItemDto Stage0Analysis -->|CandidateId| IntegrationCandidate Stage0Analysis -->|PolicySnapshotId| PolicySnapshot %% Reminder ReminderDto -->|OwnerId| OwnerDto ReminderDto -->|OrganizationId| OrganizationDto ReminderDto -->|RepositoryId| RepositoryDto ``` ================================================ FILE: docs/The potential for misusing Grace.md ================================================ # The potential for misusing Grace Something I've thought about since the beginning of designing Grace is: how do we prevent ignorant and/or malicious management from using event data from Grace in the wrong way? So, to start, and just to be clear: ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## Using Grace events for monitoring is fucking stupid I'm aware of the potential to use things like "how often did this person do a save / checkpoint / commit?" as a metric, or as a proxy for productivity, or effort. And, because I've been a programmer for several decades, I, like you, am also aware of how fucking stupid and shortsighted it would be to do so. It's not my intention for Grace to become a monitoring platform. And yet, there are features that Grace enables that simply can't happen without a modern, cloud-native event-driven architecture. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## Git has timestamps, too Sure, Git is distributed, and there's no way for the server to reach into your local repo and get status. If you use the feature-branch-gets-deleted method of using Git, then you're still pushing your feature branch and its commits up to the server before the merge-and-delete, and so you have that opportunity in Git to get some similar information that we're worried about in Grace. We haven't, as an industry, weaponized this data yet, so I'm hopeful we won't with any future version control system. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## So how do we prevent misuse? If I turn off all of the events, it ruins a lot of the value proposition of Grace. But there are approaches to it that I can imagine. One idea is simply to not send events to the main Grace event stream for saves. The most obvious misuse is looking at saves, so maybe we just keep them as a thing for individual users, but we don't ship them over the service bus. Another idea is just to turn off auto-saves for a repo entirely. Again, I hate losing this functionality, but it's a possibility. There are other configurations of features I could imagine, but, ultimately, the design of it will have to come from the community, and I remain open to figuring out the right way to do it that enables the most flexibility with the most safety from idiot management. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## My promise: I'll be very vocal about it In case it's not obvious from what I just said, I'm completely opposed to using events in Grace for any sort of monitoring of programmers. It's statistically stupid, it's harmful to even think there's any value in it. And I'll be vocal about it in every public presentation, in every talk with a potential user, and I'll get other leaders around Grace to say the same thing. I will do everything I can to make the point over and over and over, from every angle that I can. I know that's not an iron-clad guarantee that idiots won't idiot, but I'll sure as hell call them out for doing it, and demand that they stop. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ================================================ FILE: docs/What grace watch does.md ================================================ # What `grace watch` does ## Introduction One of the most important pieces of Grace is `grace watch`. `grace watch` is a background process that watches your working directory for changes, automatically uploads changes after every save-on-disk, and enables you to run local tasks in response to events in the repository, including auto-rebase. Most Grace users will be programmers, and we're a more technical audience. We know that background processes can be used in ways that are helpful, harmful, or just wasteful. As someone asking you to run a background process, I have a special responsibility to be transparent with you about what `grace watch` does if you allow it to run. (Which you totally should.) I want you to have complete confidence that running `grace watch` is safe and trustworthy. ## No dark patterns There are certain behaviors that some products have around their use of background processes and schedulers that I find offensive. One example is Adobe Creative Cloud. I'm calling them out, not because I think they're deliberately evil, or worse than anyone else, but because it's an example that happens to be here in front of me, and to illustrate a *kind* of thing that Grace doesn't do. Adobe Creative Cloud really, really wants to make sure its [background processes](https://helpx.adobe.com/gr_en/x-productkb/global/adobe-background-processes.html) are always running. They use four different items to register them: one entry is added to the Windows Task Scheduler, one entry is added to start a process every time I log in, and two different services are configured to run as Windows Services that start before I even log in. If all of that isn't annoying enough, those services are configured for `Automatic` start, not `Automatic (Delayed Start)`, which means that they start as soon as they can during boot, competing for system resources at the worst possible time, when they could easily wait a couple of minutes and run when there's less going on. Sigh. So, if I don't want Adobe's background processes, I remove those four entries, and, hey, I'm good, right? Nope. It seems like whenever I start an Adobe app, it recreates these entries, ensuring that if I'm going to use Adobe Creative Cloud, I have no choice but to put up with what they think should be running on my machine. What are these processes doing? What information are they collecting? How is that information being sent, and to whom? Are there third-party processors involved? Can I block it? Can I use a GDPR Data Subject Request to see or delete the information that's being sent? I have no idea, and the way they keep recreating these entries after I deliberately remove them doesn't build trust. Unless I write a PowerShell script to automate removing Adobe's registry entries and services, and ending those processes if they're running, and schedule it to run frequently, there's nothing I can do. So... this kind of thing... stuff like that... you know what I mean? ... `grace watch` doesn't do that. Here's what it actually does. ## Grace Watch - Compute, I/O, and network usage This is meant to be an exhaustive list of the things that `grace watch` does. If it's not on this list, `grace watch` doesn't do it. If you believe I've missed something, please start a Discussion in the repo; if there's something to add, we'll create an issue and update this page. Of course, it's open-source, please feel free to examine [Watch.CLI.fs](https://github.com/ScottArbeit/Grace/blob/main/src/Grace.CLI/Command/Watch.CLI.fs). - `grace watch` uses a .NET [`FileSystemWatcher()`](https://learn.microsoft.com/en-us/dotnet/api/system.io.filesystemwatcher?view=net-8.0) to watch your working directory for all changes and updates. - `grace watch` establishes a SignalR connection with Grace Server, and sends the BranchId of the parent branch. Grace Server then registers your connection in the correct notification groups. - When it starts, it scans the working directory and all (not-ignored) subdirectories and files for changes since the last time the local Grace Status file was updated. - When it starts, and at a couple of other times, it reads and deserializes the local Grace Status file. For small repos, it's well under 10K and is processed in about 1ms. The largest repositories I've tested had a ~53MB status file, and, if I recall correctly, reading and deserializing the data happened in low two-digit milliseconds on a four-year-old laptop. - `grace watch` doesn't keep the Grace Status file in memory while it's running. It's so fast to read and deserialize it when it's needed that we make the tradeoff to release the memory rather than hold it indefinitely, especially given that there will be many times that the user isn't coding and `grace watch` should have as small of a memory footprint as possible. - When a new or updated file is detected, `grace watch` will: - Copy the file to your user temp directory, using a system-generated temporary file name. - Compute the SHA-256 hash of the file, using the algorithm described [here](How%20Grace%20computes%20the%20SHA-256%20value.md). - Rename the file, inserting the SHA-256 hash, and move it to the repository's `.grace/objects` directory. - Upload the file from `.grace/objects` to the Object Storage provider that the repository is configured to use. - Recompute the directory versions from the file that was updated up to the root directory. - Update the local Grace Status file with the new directory versions. - Upload those recomputed directory versions to Grace Server. - Create a Save reference by calling Grace Server's `/branch/createSave` endpoint. - When a promotion event from your parent branch is sent to `grace watch` by the server, `grace watch` will run auto-rebase. - Every 4.8 minutes, `grace watch` will recompute and rewrite the Grace interprocess-communication (IPC) file, which requires reading and deserializing the local Grace Status file. The size of the IPC file is under 1K for small repos, and scales with the number of directories in the repo. A repo with 275 directories would fit in a 10K IPC file, and a repo with 2,750 directories would fit in a 100K IPC file. They're usually very small. > Long story about why we rewrite the file: Imagine that you're at the command line, and you run `grace checkpoint -m ...`. That instance of Grace uses the existence of the IPC file as proof that `grace watch` is running in a separate process. `grace watch` writes the IPC file as soon as it starts, and, deletes it in a `try...finally` clause when it exits. In other words: in any normal exit, including exits caused by unhandled exceptions, the IPC file will be deleted when `grace watch` exits. However: it's possible that `grace watch` could be killed before it has a chance to execute that `finally` clause. For instance, in Windows, if I open Task Manager, right-click on the `grace watch` process, and hit `End Task`, the process dies immediately, and does not execute the `finally` clause. To ensure that there's not a stale IPC file laying around, Grace checks the value of the UpdatedAt field; if it's more than 5 minutes old, Grace will ignore the IPC file and assume that `grace watch` isn't running. So: _that's_ why the IPC file gets refreshed every 4.8 minutes: it resets the UpdatedAt field so the file stays under 5 minutes old. - Once a minute, `grace watch` does the fullest of garbage collection: `GC.Collect(2, GCCollectionMode.Forced, blocking = true, compacting = true)` This ensures that `grace watch` keeps the smallest possible memory footprint, and takes single-digit µs when there's nothing to collect. > The .NET Runtime has excellent heuristics for when to run GC, but the biggest factor is memory pressure. If the OS isn't signaling that there's memory pressure on the system, GC's don't happen much. With `grace watch` running on a developer box with many GB's of RAM available, it's likely that there won't be much memory pressure, and it would rarely perform garbage collection. `grace watch` might look like it's taking up a lot of memory (from doing things like auto-upload, auto-rebase, and updating the IPC file), but it would all be Gen 0 references, ready to be collected. > > Given that there will be many times that a user isn't working in a repository, releasing memory proactively is the right thing to do. ## Process Monitor: `grace watch` is very quiet This is a Windows-specific story, but it illustrates what `grace watch` is doing, or _not_ doing, regardless of platform. Those of you familiar with Windows administration will be familiar with [Sysinternals Tools](https://learn.microsoft.com/en-us/sysinternals/), originally written by, and still partially maintained by, Microsoft Azure CTO Mark Russinovich. Before he was the CTO and Chief Architect of Azure, he was the Chief Architect of Windows, and he literally wrote the book _Windows Internals_, which is a great read if you're an OS nerd of any kind. Sysinternals Tools, after 20+ years, are still essential advanced tools to know on Windows. One of the Sysinternals Tools is [Process Monitor](https://learn.microsoft.com/en-us/sysinternals/downloads/procmon), which allows you to watch every file, networking, registry, and process/thread event happening in the system in real-time. Process Monitor has excellent filtering, and when using it just to observe `grace watch`, what I can tell you is: if nothing from the list above is happening at exactly that moment, `grace watch` does nothing that Process Monitor can detect. No file events, no network events, just sometimes a thread creation/deletion event, managed by the .NET Runtime and not controlled by `grace watch`. I left it running for 20 minutes with Process Monitor; aside from the IPC file refreshes, `grace watch` showed no events at all. I can't make it any quieter than that. ## Future items ### Running local tasks `grace watch` is intended to be able to run local tasks in response to repository events, but, aside from `grace rebase`, that functionality hasn't been written yet. When it is, we'll document it and add it to the section above. ### Telemetry `grace watch` does not currently collect or send any telemetry, but I intend to before Grace ships. There will be clearly-documented ways to turn it off, if you'd prefer, and proper GDPR (and related) handling in place. The intention of this telemetry is to understand usage patterns and errors in using Grace, and to then use that data to improve both functionality and performance. For any of you who have used a telemetry provider – like Datadog, or Azure Monitor, or Application Insights, or any of a hundred others – to understand the usage of your own apps, you know what I mean. Detailed telemetry will never be kept longer than 30 days. ================================================ FILE: docs/Why Auto-Rebase isn't a problem.md ================================================ # Why Auto-Rebase isn't a problem One of the more common feedback items I've gotten about Grace is around the idea of auto-rebasing - both positive and negative. While I agree that having auto-rebase as the default is one of the more... _progressive_ ideas in Grace, I've been surprised at some of the reaction to it. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## This has never been a thing before. Why does Grace need it? Need is a strong word... but there are two big reasons for having auto-rebase as the default behavior in Grace. First, the design of [single-step branching](.\Branching%20Strategy.md) means that a user can't promote to `main` without being rebased on the most recent promotion in `main`. Given that over 95% of rebases are a non-event, and serve only to increase quality by making sure that the code that's getting into `main` is always tested in its latest state, it's an easy choice, and will enable users to be ready to promote at all times. When promotion conflicts do happen, Grace's design point-of-view is that they should be dealt with immediately, and that they shouldn't wait until a PR is ready, when you get the bad news right after you think your work is done. By handling conflicts up-front, you ensure that your dev and test efforts are going to the _full, actual_ code that will be in production, and not just on the changes that you're making, separate from what the rest of your team is doing. So, it's a branching design decision, it's a quality decision, and it's a huge part of minimizing promotion conflicts. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## What if my changes get overwritten? In Grace, they won't. The only way an auto-rebase can happen is if `grace watch` is running, and if `grace watch` is running, then the complete state of your branch was uploaded to the server the last time you saved a file, so nothing can be lost. If you need to look at the state of your branch from before the rebase, it'll be available, and you can always run `grace diff` to see what changed. If you're editing the same file that got updated in the promotion that you're about to be auto-rebased on, you'll be alerted for a potential promotion conflict, and given the choice of how to handle it. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## What if I'm in the middle of debugging something? The concern here is that you'll be in a debugging session - which, in my long but admittedly anecdotal experience, usually lasts from seconds to a few minutes - and that a promotion to `main` will happen exactly at that time, and therefore an auto-rebase will occur and one of your source code files will be updated in your working directory. By "debugging session" what I mean is: debugging starts when you click on `Start debug` in your IDE, or run some command-line that starts a debugging session that you can act on, and it ends when you click `Stop Debug` or end the process you started at the command-line. Or \ something like that. First, is it statistically likely that you'll be debugging right when `main` gets updated? For me, in the repos that I've worked in, it's not likely, but that might not be true for you. If you happen to run extra-long debugging sessions, and auto-rebase really would be disruptive on your machine, or on your branch, you can just stop `grace watch` on the your machine to make sure auto-rebase can't happen. Alternatively, there will be ways to [turn it off](#can-you-turn-it-off). The IDE's that I use have an option for how to handle it when files get updated by other processes, so the first thing I think is: how does your IDE handle it? Grace is not the only process that might update a file that's open in a tab in my IDE, and I know I choose the settings in my IDE's that handle that the way that works for me. If the file getting updated is one that you're editing, and therefore debugging, that's a promotion conflict, and you'll be notified and asked how you want to handle it. If you're in a compiled language, it won't matter because you're debugging a compiled executable. (If you're in an interpreted language, yes, there's the possibility of some amusement.) And, last, let's just say that none of this helps, and, in fact, your debugging session does get messed up because of auto-rebase. Let's say that, because of your combination of languages and tools, you'll have to end debugging and restart debugging. And let's say that, for whatever reason, that happens to you often enough to be a problem. Well, you can always [turn it off](#can-you-turn-it-off). While I'm sure there are environments where it really would be disruptive, my experience and design perspective is that that will be rare. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## Can you turn it off? Yes, of course. (I'm not insane.) Grace will have flags at the branch level and at the repository level to turn it off, and also in the local `graceconfig.json` if you personally don't ever want auto-rebase for whatever reason. Please give it a chance before you do, though. You might like it more than you expect. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ## This is stupid, and you're stupid, and nobody wants this. Paraphrasing some actual feedback I've gotten... well, 🤷‍♂️ I believe this is right design, and time will tell. ![](https://gracevcsdevelopment.blob.core.windows.net/static/Orange3.svg) ================================================ FILE: docs/Why Grace isn't just a version control system.md ================================================ # Why Grace isn't just a version control system ## New circumstances require new tools When I first created Grace, before AI coding was a thing, I worked for GitHub, and I designed it as a pure version control system. A frame I had in mind for it as I designed it was "What belongs to Git, and what belongs to GitHub?" GitHub already had Pull Requests, and Issues, and all of the other services and products that wrap around Git, and I didn't want to step on that. If my goal was to write something that could sit next to Git, at scale, for GitHub (and GitLab, etc.) then, I thought, it made sense to keep Grace fitting more-or-less in the same architectural box that Git sits in for large hosters. Well, if you're reading this, you're aware of how much software development has changed, and is changing, thanks to agentic coding. When work methods change this drastically, we have to reconsider what the right kinds of tools are, and whether the old tools are shaped correctly to meet the new requirements. And, sorry, but Git's not good enough anymore. Not even close. Steve Yegge's [Beads](https://github.com/steveyegge/beads) project, which I've used a bit for Grace, made me realize that capturing intention, and being able to track separate, small work items as part of building a feature, _alongside_ the actual code changes, was too important to leave as just-one-more-thing that we shove into Git as a file. Beads is a cute, not-exactly-designed vibe-coded hack in this direction (and I say that with love) but I think we need to have something like that as a more-deliberately-designed part of our version control now. If you've looked into context engineering, you know that it's better for the agents to have separate, small work items. For humans reviewing that work, having everything in one place, linked together - the intention, the work item, the automated reviews, the task summaries, the diffs, all of it, linked to the exact version of the code we're interested in - gives us all of the context we need to understand what happened, and why it happened. The fact that I'm seeing work tracking like this get built by multiple teams in multiple ways in multiple products says that it's a great candidate for including directly with the code, where all of the agents and all of the humans can find it and use it easily. And then I thought about how AI should fit into the creation, review, and promotion of code. Grace's event system gives us an unprecedented ability to respond to code changes in (near) real-time. What kinds of both inexpensive, determininistic code review, and costlier but more sophisticated AI reivew, should be built-in to Grace? How could we plug AI in at that level, in a way that helps resolve conflicts and enable maximum velocity? I'm still exploring that, and I'm sure I don't have all of the answers, but I know that these are crucial new kinds of primitives that deserve to be first-class constructs in a version control system now. Grace is about helping you stay calm, stay in control, and stay in flow. The more I've worked on it, the more I can't imagine not having work tracking and automated review and everything else I'm including now sitting alongside the exact code versions. I'm sure the surface area of these parts of Grace will change as we get feedback and iterate. I don't claim to have it exactly right. I do know that the minimum bar for having a useful version control system in the late 2020's has gone way, way up. Just storing the code (and only if the files are small) isn't enough anymore. ================================================ FILE: docs/Work items.md ================================================ # Work items Work items are durable, event-sourced records for a unit of work in Grace. They capture intent (title and description), status, notes, and links to references, promotion sets, and reviewer artifacts. ## Canonical command and identifier behavior - Use `grace workitem ...` as the canonical CLI path. - Aliases (`work`, `work-item`, `wi`) remain supported for compatibility, but examples in docs should use `workitem`. - Work item commands that take a `work-item` argument accept either: - a `WorkItemId` GUID, or - a positive `WorkItemNumber` (for example `42`). ## Server API surface All work item routes are `POST` endpoints under `/work`. - `/work/create` - `/work/get` - `/work/update` - `/work/add-summary` - `/work/link/reference` - `/work/link/promotion-set` - `/work/link/artifact` - `/work/links/list` - `/work/links/remove/reference` - `/work/links/remove/promotion-set` - `/work/links/remove/artifact` - `/work/links/remove/artifact-type` - `/work/attachments/list` - `/work/attachments/show` - `/work/attachments/download` ## CLI workflows ### Create and inspect work items PowerShell: ```powershell ./grace workitem create ` --title "Introduce baseline drift alerts" ` --description "Add baseline drift detection and update review UI" ./grace workitem create ` --work-item-id f88b46e2-5c36-4b52-9e36-716f7d7a9a8b ` --title "Introduce baseline drift alerts" ./grace workitem show f88b46e2-5c36-4b52-9e36-716f7d7a9a8b ./grace workitem show 42 ./grace workitem status f88b46e2-5c36-4b52-9e36-716f7d7a9a8b --set InReview ./grace workitem status 42 --set Done ``` bash / zsh: ```bash ./grace workitem create \ --title "Introduce baseline drift alerts" \ --description "Add baseline drift detection and update review UI" ./grace workitem create \ --work-item-id f88b46e2-5c36-4b52-9e36-716f7d7a9a8b \ --title "Introduce baseline drift alerts" ./grace workitem show f88b46e2-5c36-4b52-9e36-716f7d7a9a8b ./grace workitem show 42 ./grace workitem status f88b46e2-5c36-4b52-9e36-716f7d7a9a8b --set InReview ./grace workitem status 42 --set Done ``` ### Link references and promotion sets PowerShell: ```powershell ./grace workitem link ref ` f88b46e2-5c36-4b52-9e36-716f7d7a9a8b ` f12a0d31-0d5a-4a5f-a5a7-3d2c3a9f5b2c ./grace workitem link prset ` 42 ` 3d5c4d9a-0123-4567-89ab-987654321000 ``` bash / zsh: ```bash ./grace workitem link ref \ f88b46e2-5c36-4b52-9e36-716f7d7a9a8b \ f12a0d31-0d5a-4a5f-a5a7-3d2c3a9f5b2c ./grace workitem link prset \ 42 \ 3d5c4d9a-0123-4567-89ab-987654321000 ``` ### Attach summary, prompt, and notes content PowerShell: ```powershell ./grace workitem attach summary 42 --file .\summary.md ./grace workitem attach prompt 42 --file .\prompt.md ./grace workitem attach notes 42 --text "Reviewer follow-up required before merge." ``` bash / zsh: ```bash ./grace workitem attach summary 42 --file ./summary.md ./grace workitem attach prompt 42 --file ./prompt.md ./grace workitem attach notes 42 --text "Reviewer follow-up required before merge." ``` ### Retrieve reviewer attachments PowerShell: ```powershell ./grace workitem attachments list 42 ./grace workitem attachments show 42 --type summary --latest ./grace workitem attachments download 42 ` --artifact-id 11111111-2222-3333-4444-555555555555 ` --output-file .\summary.md ``` bash / zsh: ```bash ./grace workitem attachments list 42 ./grace workitem attachments show 42 --type summary --latest ./grace workitem attachments download 42 \ --artifact-id 11111111-2222-3333-4444-555555555555 \ --output-file ./summary.md ``` ### Inspect and clean up links PowerShell: ```powershell ./grace workitem links list 42 ./grace workitem links remove ref 42 f12a0d31-0d5a-4a5f-a5a7-3d2c3a9f5b2c ./grace workitem links remove prset 42 3d5c4d9a-0123-4567-89ab-987654321000 ``` bash / zsh: ```bash ./grace workitem links list 42 ./grace workitem links remove ref 42 f12a0d31-0d5a-4a5f-a5a7-3d2c3a9f5b2c ./grace workitem links remove prset 42 3d5c4d9a-0123-4567-89ab-987654321000 ``` ## SDK example (F#) ```fsharp open Grace.SDK open Grace.Shared.Parameters.WorkItem let createParameters = CreateWorkItemParameters( WorkItemId = "f88b46e2-5c36-4b52-9e36-716f7d7a9a8b", Title = "Introduce baseline drift alerts", Description = "Add baseline drift detection and update review UI", CorrelationId = "corr-0001" ) let! created = WorkItem.Create(createParameters) let linksParameters = GetWorkItemLinksParameters( WorkItemId = "42", CorrelationId = "corr-0002" ) let! links = WorkItem.GetLinks(linksParameters) ``` ## Current limitations - Work item commands support reference and promotion-set links plus reviewer artifact links. - Candidate, review packet, checkpoint, and gate-attestation link management is still internal and does not yet have dedicated public work-item link endpoints. ================================================ FILE: global.json ================================================ { "sdk": { "version": "10.0.100", "rollForward": "latestPatch" } } ================================================ FILE: prompts/ContentPack.prompt.md ================================================ You are operating inside the Grace repo as an expert .NET + F# engineer and “no vibes allowed” research agent. GOAL Create (or update) a single Markdown artifact named `ContextPack.md` that is a *compacted, factual map of the current codebase state* relevant to the task below. This pack will be used as the ONLY context for subsequent sessions, so it must be self-contained, precise, and low-noise. TASK (fill in before running) - Bead/Issue: - Topic / Change Request: - Constraints / Non-goals (optional): - Inputs: NON-NEGOTIABLE RULES 1) This is STRICTLY a research pack: **document what exists today**. - Do NOT propose changes. - Do NOT critique code quality. - Do NOT write an implementation plan. 2) Every non-trivial claim MUST be backed by evidence: - Use `path:lineStart-lineEnd` references (preferred). - If line numbers are not available, use `path` + exact symbol names (types/functions/modules) and quote ≤ 1–2 lines. 3) Prefer pointers to copies: - Do not paste large code blocks. Keep snippets < 10 lines and only when necessary. 4) Start with repo guidance: - Read the *root* `AGENTS.md` first. - Then locate and read any *nearest* `AGENTS.md` files under the relevant subdirectories you touch. - Use `REPO_INDEX.md` as the jump table to find the right files fast. 5) If you infer anything, label it explicitly as **INFERENCE** and list how to verify it. 6) Do NOT edit code. Output only the Markdown content of `ContextPack.md`. RESEARCH METHOD (DO THIS) A) Identify likely areas/files: - Use `REPO_INDEX.md` first. - Then confirm with search (e.g., ripgrep) for key terms from the task, error messages, type names, endpoints, actors, etc. B) Read the smallest set of authoritative files needed to answer: - “Where does this behavior live?” - “What is the control/data flow?” - “What are the contracts/invariants?” - “What tests cover it and how do we run them?” C) Capture “similar patterns” elsewhere in the repo (for consistency). OUTPUT FORMAT (WRITE EXACTLY THIS STRUCTURE) --- date: repo: Grace branch: commit: bead: topic: "" tags: [contextpack, research, grace] status: draft|complete --- # ContextPack: ## 1. Research question - ## 2. Scope - In-scope: - ... - Out-of-scope: - ... ## 3. Key findings (TL;DR) - 5–12 bullets, each with evidence references. - Focus on “how it works today” and “where to look.” ## 4. File map (authoritative sources) List the minimal set of files to understand the area, each with: - Path - Why it matters - Key symbols inside (types/functions/modules) - Evidence refs Example: - `src/Grace.Server/Foo.fs:120-260` - Why: request handler for X - Symbols: `FooHandler.handle`, `FooDto` - Notes: ... ## 5. Current behavior (flows) Describe the current runtime behavior as sequences. Use headings like: - “Inbound request → … → side effects” - “Command → actor → persistence → reply” - “CLI → SDK → server → response” Each step should include file:line evidence. ## 6. Data contracts and invariants - Important types, schemas, discriminated unions, DTOs, serialization formats - Invariants that *must* hold - Versioning/back-compat constraints All with evidence. ## 7. Cross-project implications Call out implications across: - Grace.Types - Grace.Shared - Grace.Server - Grace.Actors - Grace.CLI - Grace.SDK Only include projects actually implicated (with evidence). ## 8. Test and verification surface - Existing tests relevant to this area (paths + what they cover) - How to run them (commands) - Any missing tests you notice ONLY as “coverage gaps” (no solutions, no critique) ## 9. Open questions / unknowns - Questions that must be answered before planning - For each: how to verify (file to read, command to run, runtime probe, etc.) ## 10. Appendix: search terms and breadcrumbs - Exact strings searched (error messages, identifiers) - Useful ripgrep patterns - Any relevant commits/PRs (if provided) QUALITY BAR - Keep it tight: target 150–300 lines unless the area is genuinely large. - This document must be “drop-in context” for a fresh Plan session. Now produce ONLY the full Markdown content for `ContextPack.md`. ================================================ FILE: prompts/Grace issue summary.md ================================================ # Grace Issue Summary Prompt Use this prompt to produce a complete GitHub issue body for the Grace repository. ## Role You are preparing a high-rigor issue write-up that will be reviewed by humans and re-researched by other LLMs. Write clearly, concretely, and thoroughly. ## Compliance Gate Before writing, confirm and report: 1. Harness used. 2. Exact model used. 3. Reasoning or effort level used. 4. Whether this run used the latest generally available model from that provider. 5. Whether reasoning is at least equivalent to OpenAI `high`. If items 4 or 5 are not true, stop and output only: `NON-COMPLIANT: latest-model and high-reasoning requirements not met.` ## Runtime Metadata Script (Required) Before drafting the issue body, run the metadata collection script and use its output as the source of truth for: `harness`, `provider`, `model`, `reasoning_level`, `reasoning_level_equivalent`, `high_reasoning_asserted`, `latest_model_asserted`, `metadata_source`, and `metadata_evidence`. PowerShell: ```powershell pwsh ./scripts/collect-runtime-metadata.ps1 -WorkspacePath -OutputFormat Yaml ``` bash / zsh: ```bash pwsh ./scripts/collect-runtime-metadata.ps1 -WorkspacePath -OutputFormat Yaml ``` For auditability, prefer a second run with `-Verbose` and capture important discovery notes in section 3 (Prompt Log). ## Copy/Paste Snippet Use one of these commands to print a ready-to-paste YAML block for the top of the issue body. After pasting, add `prompt_count: ` on its own line inside the same YAML block. PowerShell: ```powershell $meta = pwsh ./scripts/collect-runtime-metadata.ps1 -WorkspacePath . -OutputFormat Yaml $metaText = $meta -join [Environment]::NewLine ( '```yaml' + [Environment]::NewLine + $metaText + [Environment]::NewLine + 'prompt_count: ' + [Environment]::NewLine + '```' ) ``` bash / zsh: ```bash meta="$(pwsh ./scripts/collect-runtime-metadata.ps1 -WorkspacePath . -OutputFormat Yaml)" printf '```yaml\n%s\nprompt_count: \n```\n' "$meta" ``` ## Runtime Metadata Discovery (Required) Collect run metadata before drafting the issue body. Use this discovery order and stop at the first authoritative value found for each field (`harness`, `provider`, `model`, `reasoning_level`): 1. Session/runtime status output exposed by the harness (for example `/status`, status line, run header, structured run metadata). 2. Effective runtime settings shown by the harness (active profile, launch flags, resolved settings). 3. Harness configuration files in user home and repository/workspace scopes. 4. Environment variables commonly used for model selection. 5. If still unknown: set value to `unknown` and explain exactly what was checked. When reading config files, prefer keys like: - `model`, `default_model`, `model_name`, `engine`, `deployment`, `reasoning_effort`, `reasoning`, `thinking`, `effort` - profile-scoped overrides that supersede global defaults Always include a short evidence line for each discovered field. Evidence must be: - a file path and key name, or - a runtime status field name Do not claim a value without evidence. ## Reasoning-Level Normalization (Required) Set `reasoning_level` to the provider-native setting (verbatim when available). Then compute `reasoning_level_equivalent` as one of: `low`, `medium`, `high`, `xhigh`. Normalization rules (use the first rule that applies): 1. If provider explicitly reports one of `low|medium|high|xhigh`, use it directly. 2. If provider reports text containing: - `minimal`, `light`, `fast` => `low` - `balanced`, `standard`, `normal`, `default` => `medium` - `deep`, `intensive`, `high` => `high` - `max`, `very-high`, `ultra`, `extended` => `xhigh` 3. If reasoning/thinking is boolean only: - disabled/off => `low` - enabled/on with no strength/budget detail => `medium` (conservative default) 4. If a numeric reasoning budget is available (tokens/steps): - `<= 2000` => `low` - `2001-8000` => `medium` - `8001-24000` => `high` - `> 24000` => `xhigh` 5. If none apply => `unknown` and mark compliance as false. ## Latest-Model Assertion Rule (Required) Set `latest_model_asserted: true` only when you have explicit evidence from this same run that the selected model is the latest generally available model for that provider. Acceptable evidence: - harness-provided statement that the active model is latest/current, or - a provider source checked in-run with date and link If that evidence is missing, set `latest_model_asserted: false`. Do not guess. ## Input You Should Receive - Issue topic or problem statement. - Relevant repository path or subsystem (if known). - Any logs, stack traces, user reports, or screenshots. If inputs are missing, proceed with best-effort repo research and explicitly list assumptions. ## Research Requirements 1. Inspect the repository before drafting recommendations. 2. Cite concrete evidence in the issue body. 3. Distinguish evidence from inference. 4. Prefer specific findings over general opinions. For evidence, include: - File paths. - Symbols (types/functions/modules/classes). - Behavioral observations. Use evidence anchors for every major claim in sections 4 and 5. Anchor format: - Preferred: `path:line` or `path:startLine-endLine` - Acceptable when lines are unavailable: `path` + symbol name If a claim cannot be anchored, label it `INFERENCE` and include a one-line verification plan. ## Required Output Format Output only Markdown for the issue body, using this exact section structure and headings. ### 0) Machine-Readable Metadata Populate YAML metadata fields from `collect-runtime-metadata.ps1` output. Only `prompt_count` may be computed separately. Start the issue body with this YAML block: ```yaml harness: provider: model: reasoning_level: reasoning_level_equivalent: latest_model_asserted: true|false high_reasoning_asserted: true|false prompt_count: metadata_source: metadata_evidence: harness: provider: model: reasoning_level: generated_at_utc: ``` ### 1) Submission Metadata - Harness: - Provider: - Model: - Reasoning Level: - Reasoning Level Equivalent: - Latest-Model Compliance: - High-Reasoning Compliance: - Metadata Source: - Metadata Evidence: - Timestamp (UTC): ### 2) Issue Introduction Provide a concise introduction that explains: - What the issue is. - Why it matters. - Who or what is affected. ### 3) Prompt Log List every meaningful prompt used to produce this issue. Redact all secret values from the prompt. Include the exact metadata script command(s) that were run before repo research. For each prompt, include: - Prompt ID (P1, P2, ...) - Purpose - Prompt text - Notable impact on findings (1-2 bullets) ### 4) Repository Research Summary Provide a detailed rundown of the repo research performed. Include: - Paths reviewed - Key findings per path - Current behavior as implemented today - Evidence vs inference labels where needed - Evidence anchors for each major claim ### 5) Recommendations Break recommendations into these subsections. #### 5.1 Feature-Level Updates - What should change in behavior or UX. - Expected outcomes. - Evidence anchors for each major recommendation. #### 5.2 Documentation Updates - Which docs should be updated. - What new guidance or corrections should be added. - Evidence anchors for each major recommendation. #### 5.3 Code-Level Updates - Candidate files/components to modify. - Suggested implementation direction. - Test coverage additions or updates needed. - Evidence anchors for each major recommendation. ### 6) Risks and Unknowns - Technical risks. - Assumptions. - Open questions requiring maintainer input. ### 7) Proposed Acceptance Criteria Provide a concrete checklist that maintainers can use to verify completion. ### 8) Human Accountability End with this exact checklist item: - [ ] I have personally reviewed this AI-assisted issue summary, verified the evidence anchors, and I stand behind it. ## Writing Constraints - Be explicit, not generic. - Prefer bullet lists with concrete details. - Keep claims falsifiable and reviewable. - Assume this text will be audited by additional LLMs, so optimize for clarity and traceability. ================================================ FILE: prompts/Grace pull request summary.md ================================================ # Grace PR Summary Prompt Use this prompt to produce a complete GitHub pull request body for the Grace repository. ## Role You are preparing a high-rigor PR summary that will be reviewed by humans and re-researched by other LLMs. Write clearly, concretely, and thoroughly. ## Compliance Gate Before writing, confirm and report: 1. Harness used. 2. Exact model used. 3. Reasoning or effort level used. 4. Whether this run used the latest generally available model from that provider. 5. Whether reasoning / effort is at least equivalent to Codex and Claude's `high`. If items 4 or 5 are not true, stop and output only: `NON-COMPLIANT: latest-model and high-reasoning requirements not met.` ## Input You Should Receive - Branch or diff to summarize. - Work item or issue reference (if applicable). - Validation results (tests/build/lint/manual checks). If any input is unavailable, state that explicitly and continue with what is verifiable. ## Analysis Requirements 1. Inspect the actual diff and changed files. 2. Summarize behavior-level change, not just line edits. 3. Map each changed file to what was accomplished. 4. Include evidence from validation results. Use evidence anchors for every major claim in sections 4 through 7. Anchor format: - Preferred: `path:line` or `path:startLine-endLine` - Acceptable when lines are unavailable: `path` + symbol name If a claim cannot be anchored, label it `INFERENCE` and include a one-line verification plan. ## Required Output Format Output only Markdown for the PR body, using this exact section structure and headings. ### 0) Machine-Readable Metadata Start the PR body with this YAML block: ```yaml harness: provider: model: reasoning_level: reasoning_level_equivalent: latest_model_asserted: true|false high_reasoning_asserted: true|false prompt_count: generated_at_utc: ``` ### 1) Submission Metadata - Harness: - Provider: - Model: - Reasoning Level: - Reasoning Level Equivalent: - Latest-Model Compliance: - High-Reasoning Compliance: - Timestamp (UTC): ### 2) PR Introduction Provide an introduction that summarizes: - Overall purpose of the change. - Related work item/issue (if applicable). - Why this change is needed now. ### 3) Prompt Log List every meaningful prompt used to produce this PR summary. Redact all secret values from the prompt. For each prompt, include: - Prompt ID (P1, P2, ...) - Purpose - Prompt text - Notable impact on outcome (1-2 bullets) ### 4) Features Changed and Bugs Fixed Provide a clear bullet list of: - Features added or changed. - Bugs fixed. - Any behavior intentionally unchanged. - Evidence anchors for each major claim. ### 5) File-by-File Change Summary For each changed file, include: - File path - What changed - Why that change was necessary - Evidence anchor ### 6) Validation and Verification Include exactly what was run and what happened: - Build/test/lint commands - Manual verification steps - Results and any known gaps - Evidence anchors for each major claim ### 7) Risks, Compatibility, and Follow-Ups - Risks introduced or reduced - Backward compatibility notes - Follow-up work or deferred items - Evidence anchors for each major claim ### 8) Human Accountability End with this exact checklist item: - [ ] I have personally reviewed this AI-assisted PR summary, verified the evidence anchors, and I stand behind it. ## Writing Constraints - Be explicit, not generic. - Avoid vague statements like "minor updates". - Prefer concrete, reviewer-friendly language. - Assume this text will be audited by additional LLMs, so optimize for clarity and traceability. ================================================ FILE: prompts/PlanPack.prompt.md ================================================ You are operating inside the Grace repo as an expert .NET + F# engineer and planning/spec agent. GOAL Create (or update) `PlanPack.md`: a *phased, test-first implementation plan* that is executable by an AI coding agent with high reliability, based on `ContextPack.md`. INPUTS (fill in before running) - ContextPack path or pasted content: - Bead/Issue: - Desired end state: - Non-goals / guardrails: - Constraints: NON-NEGOTIABLE RULES 1) Do NOT implement anything. Do NOT modify code. Output only `PlanPack.md`. 2) The plan must be **phased** with checkboxes and explicit verification after each phase. 3) Prefer deterministic tooling over LLM effort: - Formatting/linting should be run by tools (e.g., fantomas), not “mentally simulated.” 4) Provide options when the design choice is non-obvious. 5) Every phase must specify: - Files to edit (paths) - What to change (concise) - Tests/verification commands (exact) - Success criteria (objective) 6) If any key decision is missing, ask up to 5 clarifying questions FIRST. - If answers aren’t available, proceed with explicit “Assumptions” clearly labeled. PLANNING METHOD (DO THIS) A) Read `ContextPack.md` thoroughly. B) Extract: - Current state summary - Invariants - Integration points - Existing tests C) Produce: - Design options (if any) - Recommended approach with rationale - Phased implementation plan with checkpoints - Beads-aligned work plan using `bd` commands OUTPUT FORMAT (WRITE EXACTLY THIS STRUCTURE) --- date: repo: Grace branch: commit: bead: topic: "" inputs: - ContextPack.md tags: [planpack, plan, rpi, grace] status: draft|final --- # PlanPack: ## 1. Goal and success criteria - Goal: - Definition of Done (DoD): - [ ] All tests pass (`dotnet test --no-build`) - [ ] Build passes (`dotnet build --configuration Release`) - [ ] F# formatted if touched (`fantomas .`) - [ ] No new warnings (or explicitly justified) - [ ] Behavioral verification steps completed (list) ## 2. Constraints and non-goals - Constraints: - Non-goals: ## 3. Clarifying questions (if needed) - Q1 … - Q2 … ## 4. Options (only if real trade-offs exist) For each option: - Summary - Pros/cons - Risks - Decision impact - Recommendation ## 5. Proposed design - Key design decisions - Public contracts affected (types/APIs/config) - Backwards-compat strategy - Error handling and logging expectations (no secrets/PII; preserve correlation IDs) - Test strategy overview ## 6. Implementation plan (phased) Write phases as a checklist. Each phase must be small enough to review and verify. ### Phase 0 — Safety setup (if needed) - [ ] Create branch / ensure clean status - [ ] Baseline verify: run existing tests/build - Verification: - `dotnet build --configuration Release` - `dotnet test --no-build` - Success criteria: - ... ### Phase 1 — - [ ] Edit files: - `path/to/file1` — - `path/to/file2` — - [ ] Add/adjust tests: - `path/to/test` — - Verification (run *after* this phase): - - Success criteria: - - Notes / pitfalls: - (repeat for all phases) ## 7. Verification matrix A table-like list (no actual markdown table required) mapping: - Risk area → automated checks → manual checks → evidence ## 8. Beads Work Plan (bd) Decompose into beads-aligned work items with completion definitions and suggested commands, e.g.: - Work item A: - Suggested commands: - `bd create ...` - `bd ready ` - `bd close ` - Work item B: ... ## 9. Rollback / recovery - How to revert safely (git strategy) - Feature flags / config toggles (if applicable) - Migration rollback (if applicable) ## 10. Handoff expectations - What the eventual `HandoffPack.md` must report (tests run, files changed, decisions, remaining work) QUALITY BAR - Make it executable: unambiguous steps, explicit commands, explicit file targets. - Keep it readable: prefer bullets, short paragraphs, and checklists. Now produce ONLY the full Markdown content for `PlanPack.md`. ================================================ FILE: scripts/bootstrap.ps1 ================================================ [CmdletBinding()] param( [switch]$SkipDocker, [switch]$CI ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" if ($CI) { $ProgressPreference = "SilentlyContinue" } $startTime = Get-Date $exitCode = 0 function Write-Section([string]$Title) { Write-Host "" Write-Host ("== {0} ==" -f $Title) } function Invoke-External([string]$Label, [scriptblock]$Command) { & $Command if ($LASTEXITCODE -ne 0) { throw "$Label failed with exit code $LASTEXITCODE." } } try { Write-Section "Prerequisites" if ($PSVersionTable.PSVersion.Major -lt 7) { throw "PowerShell 7.x is required. Current version: $($PSVersionTable.PSVersion)." } Write-Verbose ("PowerShell version: {0}" -f $PSVersionTable.PSVersion) try { Get-Command dotnet -ErrorAction Stop | Out-Null } catch { throw "dotnet SDK not found. Install the .NET 10 SDK and retry." } $dotnetVersion = (& dotnet --version).Trim() if ($LASTEXITCODE -ne 0) { throw "dotnet --version failed. Ensure the .NET 10 SDK is installed." } if ([string]::IsNullOrWhiteSpace($dotnetVersion)) { throw "dotnet --version returned an empty value." } $dotnetMajor = [int]($dotnetVersion.Split(".")[0]) if ($dotnetMajor -lt 10) { throw "dotnet SDK 10.x required. Detected $dotnetVersion." } if (-not $SkipDocker) { try { Get-Command docker -ErrorAction Stop | Out-Null } catch { throw "Docker is required for full validation. Install Docker Desktop or run with -SkipDocker." } Invoke-External "docker info" { docker info | Out-Null } } else { Write-Host "Skipping Docker check (-SkipDocker)." } Write-Section "Restore Tools" Invoke-External "dotnet tool restore" { dotnet tool restore } Write-Section "Restore Packages" Invoke-External "dotnet restore" { dotnet restore "src/Grace.sln" } Write-Section "Next Steps" Write-Host "Run: pwsh ./scripts/validate.ps1 -Fast" Write-Host "Use -Full for Aspire integration coverage." } catch { $exitCode = 1 Write-Error $_ } finally { $elapsed = (Get-Date) - $startTime Write-Host "" Write-Host ("Elapsed: {0:c}" -f $elapsed) if ($exitCode -ne 0) { exit $exitCode } } ================================================ FILE: scripts/collect-runtime-metadata.ps1 ================================================ [CmdletBinding()] param( [string]$WorkspacePath = (Get-Location).Path, [string]$StatusText, [string]$StatusFile, [ValidateSet("Object", "Json", "Yaml")] [string]$OutputFormat = "Json", [string]$OutputPath ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function New-FieldRecord { param( [Parameter(Mandatory = $true)] [string]$Name ) [ordered]@{ name = $Name value = "unknown" source = "unknown" evidence = "not found" confidence = "low" } } function Set-FieldValue { param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Field, [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$Value, [Parameter(Mandatory = $true)] [string]$Source, [Parameter(Mandatory = $true)] [string]$Evidence, [ValidateSet("low", "medium", "high")] [string]$Confidence = "medium", [switch]$Force ) if (-not $Force -and $Field.value -ne "unknown" -and -not [string]::IsNullOrWhiteSpace($Field.value)) { Write-Verbose ("Skipping update for [{0}] because a value already exists from [{1}]." -f $Field.name, $Field.source) return } $normalizedValue = if ([string]::IsNullOrWhiteSpace($Value)) { "unknown" } else { $Value.Trim() } $Field.value = $normalizedValue $Field.source = $Source $Field.evidence = $Evidence $Field.confidence = $Confidence Write-Verbose ( "Captured field [{0}] = [{1}] from [{2}] (confidence: {3}). Evidence: {4}" -f $Field.name, $Field.value, $Field.source, $Field.confidence, $Field.evidence ) } function Resolve-TextValue { param( [AllowNull()] [object]$Value ) if ($null -eq $Value) { return $null } if ($Value -is [string]) { $text = $Value.Trim().Trim("'`"") if ([string]::IsNullOrWhiteSpace($text)) { return $null } return $text } return ([string]$Value).Trim() } function Get-ProcessAncestors { $ancestors = New-Object System.Collections.Generic.List[object] try { $process = Get-CimInstance Win32_Process -Filter ("ProcessId={0}" -f $PID) } catch { Write-Verbose ("Unable to inspect process tree: {0}" -f $_.Exception.Message) return @() } $depth = 0 while ($null -ne $process -and $depth -lt 15) { $record = [pscustomobject]@{ depth = $depth name = $process.Name pid = $process.ProcessId parentPid = $process.ParentProcessId commandLine = $process.CommandLine } $ancestors.Add($record) Write-Verbose ( "Process ancestor depth={0} name={1} pid={2} parentPid={3}" -f $record.depth, $record.name, $record.pid, $record.parentPid ) if (-not $process.ParentProcessId) { break } try { $process = Get-CimInstance Win32_Process -Filter ("ProcessId={0}" -f $process.ParentProcessId) } catch { break } $depth++ } return $ancestors } function Get-HarnessFromProcessTree { param( [Parameter(Mandatory = $true)] [object[]]$Ancestors ) $patterns = @( @{ Regex = "codex"; Harness = "codex"; Confidence = "high" }, @{ Regex = "claude"; Harness = "claude"; Confidence = "high" }, @{ Regex = "cursor"; Harness = "cursor"; Confidence = "high" }, @{ Regex = "copilot"; Harness = "copilot"; Confidence = "high" }, @{ Regex = "gemini"; Harness = "gemini"; Confidence = "high" } ) foreach ($ancestor in $Ancestors) { $name = [string]$ancestor.name $cmd = [string]$ancestor.commandLine foreach ($pattern in $patterns) { if ($name -match $pattern.Regex -or $cmd -match $pattern.Regex) { return [ordered]@{ harness = $pattern.Harness evidence = ("process tree: {0} (pid {1})" -f $ancestor.name, $ancestor.pid) confidence = $pattern.Confidence } } } } return $null } function Get-HarnessFromEnvironment { $envCandidates = @( @{ Name = "CODEX_THREAD_ID"; Harness = "codex" }, @{ Name = "CODEX_MANAGED_BY_NPM"; Harness = "codex" }, @{ Name = "CLAUDE_CODE"; Harness = "claude" }, @{ Name = "CURSOR_TRACE_ID"; Harness = "cursor" }, @{ Name = "GITHUB_COPILOT_TOKEN"; Harness = "copilot" }, @{ Name = "GEMINI_API_KEY"; Harness = "gemini" } ) foreach ($candidate in $envCandidates) { $value = [Environment]::GetEnvironmentVariable($candidate.Name) if (-not [string]::IsNullOrWhiteSpace($value)) { return [ordered]@{ harness = $candidate.Harness evidence = ("environment variable: {0}" -f $candidate.Name) confidence = "medium" } } } return $null } function Get-ProviderFromModel { param( [string]$Model ) if ([string]::IsNullOrWhiteSpace($Model) -or $Model -eq "unknown") { return $null } $normalized = $Model.ToLowerInvariant() $mappings = @( @{ Pattern = "^(gpt|o[1-9]|chatgpt|gpt-5|gpt-4|o3|o4|gpt-5\.)"; Provider = "openai" }, @{ Pattern = "codex"; Provider = "openai" }, @{ Pattern = "^claude"; Provider = "anthropic" }, @{ Pattern = "^gemini"; Provider = "google" }, @{ Pattern = "command-r|cohere"; Provider = "cohere" }, @{ Pattern = "^mistral"; Provider = "mistral" }, @{ Pattern = "^deepseek"; Provider = "deepseek" }, @{ Pattern = "^qwen"; Provider = "alibaba" }, @{ Pattern = "^grok"; Provider = "xai" }, @{ Pattern = "llama"; Provider = "meta-or-compatible" }, @{ Pattern = "^phi"; Provider = "microsoft" } ) foreach ($map in $mappings) { if ($normalized -match $map.Pattern) { return $map.Provider } } return "unknown" } function Get-ProviderFromHarness { param( [string]$Harness ) switch ($Harness) { "codex" { return "openai" } "claude" { return "anthropic" } "gemini" { return "google" } default { return "unknown" } } } function Convert-ToReasoningEquivalent { param( [string]$ReasoningLevel ) if ([string]::IsNullOrWhiteSpace($ReasoningLevel) -or $ReasoningLevel -eq "unknown") { return "unknown" } $normalized = $ReasoningLevel.Trim().ToLowerInvariant() if ($normalized -in @("low", "medium", "high", "xhigh")) { return $normalized } if ($normalized -in @("false", "off", "disabled", "none")) { return "low" } if ($normalized -in @("true", "on", "enabled")) { return "medium" } if ($normalized -match "\b(minimal|light|fast|quick|low)\b") { return "low" } if ($normalized -match "\b(balanced|standard|normal|default|medium)\b") { return "medium" } if ($normalized -match "\b(deep|intensive|high)\b") { return "high" } if ($normalized -match "\b(max|very-high|ultra|extended|xhigh)\b") { return "xhigh" } if ($normalized -match "^\d+$") { $budget = [int]$normalized if ($budget -le 2000) { return "low" } if ($budget -le 8000) { return "medium" } if ($budget -le 24000) { return "high" } return "xhigh" } return "unknown" } function Get-CandidateConfigPaths { param( [Parameter(Mandatory = $true)] [string]$WorkspaceRoot ) $homeDir = [Environment]::GetFolderPath("UserProfile") $appData = [Environment]::GetFolderPath("ApplicationData") $localAppData = [Environment]::GetFolderPath("LocalApplicationData") $candidates = New-Object System.Collections.Generic.List[string] $known = @( "$homeDir\.codex\config.toml", "$homeDir\.claude\settings.json", "$homeDir\.claude\config.json", "$homeDir\.gemini\settings.json", "$homeDir\.gemini\config.json", "$homeDir\.copilot\config.json", "$homeDir\.config\codex\config.toml", "$homeDir\.config\claude\settings.json", "$homeDir\.config\gemini\config.json", "$homeDir\.config\copilot\config.json", "$homeDir\.config\github-copilot\config.json", "$homeDir\.config\cursor\settings.json", "$appData\Cursor\User\settings.json", "$localAppData\Programs\cursor\resources\app\settings.json", "$WorkspaceRoot\.codex\config.toml", "$WorkspaceRoot\.claude\settings.json", "$WorkspaceRoot\.gemini\settings.json", "$WorkspaceRoot\.copilot\config.json", "$WorkspaceRoot\.cursor\settings.json", "$WorkspaceRoot\.vscode\settings.json" ) foreach ($path in $known) { if ([string]::IsNullOrWhiteSpace($path)) { continue } if ($candidates.Contains($path)) { continue } $candidates.Add($path) } return $candidates } function Get-KeyMatchFromText { param( [Parameter(Mandatory = $true)] [string]$Text, [Parameter(Mandatory = $true)] [string[]]$Keys ) foreach ($key in $Keys) { $pattern = "(?im)^\s*[""'']?{0}[""'']?\s*[:=]\s*[""'']?(?[^`r`n#,""']+)" -f [Regex]::Escape($key) $match = [Regex]::Match($Text, $pattern) if ($match.Success) { $value = Resolve-TextValue -Value $match.Groups["value"].Value if (-not [string]::IsNullOrWhiteSpace($value)) { return [ordered]@{ key = $key value = $value } } } } return $null } function Get-ConfigExtraction { param( [Parameter(Mandatory = $true)] [string]$Path ) $result = [ordered]@{ path = $Path model = $null reasoning = $null provider = $null } if (-not (Test-Path -LiteralPath $Path)) { Write-Verbose ("Config path not found: {0}" -f $Path) return $result } Write-Verbose ("Reading config candidate: {0}" -f $Path) $text = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop if ([string]::IsNullOrWhiteSpace($text)) { Write-Verbose ("Config file was empty: {0}" -f $Path) return $result } $modelKeys = @( "model", "default_model", "model_name", "engine", "deployment", "deployment_name", "github.copilot.chat.model" ) $reasonKeys = @( "model_reasoning_effort", "reasoning_level", "reasoning_effort", "reasoning", "thinking", "thinking_level", "effort" ) $providerKeys = @( "provider", "model_provider", "vendor" ) $modelMatch = Get-KeyMatchFromText -Text $text -Keys $modelKeys $reasonMatch = Get-KeyMatchFromText -Text $text -Keys $reasonKeys $providerMatch = Get-KeyMatchFromText -Text $text -Keys $providerKeys if ($null -ne $modelMatch) { $result.model = $modelMatch Write-Verbose ("Model match in {0}: {1}={2}" -f $Path, $modelMatch.key, $modelMatch.value) } if ($null -ne $reasonMatch) { $result.reasoning = $reasonMatch Write-Verbose ("Reasoning match in {0}: {1}={2}" -f $Path, $reasonMatch.key, $reasonMatch.value) } if ($null -ne $providerMatch) { $result.provider = $providerMatch Write-Verbose ("Provider match in {0}: {1}={2}" -f $Path, $providerMatch.key, $providerMatch.value) } return $result } function Parse-StatusMetadata { param( [AllowEmptyString()] [string]$Text ) $result = [ordered]@{ harness = $null provider = $null model = $null reasoning = $null } if ([string]::IsNullOrWhiteSpace($Text)) { return $result } Write-Verbose "Parsing runtime status text." $modelLine = [Regex]::Match($Text, "(?im)^\s*(model|active model)\s*:\s*(?[^\r\n]+)") if ($modelLine.Success) { $raw = $modelLine.Groups["value"].Value.Trim() $model = $raw $reasoningFromParen = $null $paren = [Regex]::Match($raw, "^(?[^(]+)\((?
[^)]+)\)") if ($paren.Success) { $model = $paren.Groups["model"].Value.Trim() $details = $paren.Groups["details"].Value $reasoningMatch = [Regex]::Match($details, "(?i)reasoning\s+(?[^,\)]+)") if ($reasoningMatch.Success) { $reasoningFromParen = $reasoningMatch.Groups["reason"].Value.Trim() } } $result.model = [ordered]@{ key = "Model" value = $model } if (-not [string]::IsNullOrWhiteSpace($reasoningFromParen)) { $result.reasoning = [ordered]@{ key = "Model(reasoning)" value = $reasoningFromParen } } } $reasoningLine = [Regex]::Match($Text, "(?im)^\s*(reasoning|effort|thinking)\s*:\s*(?[^\r\n]+)") if ($reasoningLine.Success) { $result.reasoning = [ordered]@{ key = $reasoningLine.Groups[1].Value value = $reasoningLine.Groups["value"].Value.Trim() } } $harnessLine = [Regex]::Match($Text, "(?im)^\s*>\s*_?\s*(?[^\r\n]+)") if ($harnessLine.Success) { $header = $harnessLine.Groups["value"].Value.Trim() if ($header -match "(?i)\bcodex\b") { $result.harness = [ordered]@{ key = "header" value = "codex" } } elseif ($header -match "(?i)\bclaude\b") { $result.harness = [ordered]@{ key = "header" value = "claude" } } elseif ($header -match "(?i)\bcursor\b") { $result.harness = [ordered]@{ key = "header" value = "cursor" } } elseif ($header -match "(?i)\bcopilot\b") { $result.harness = [ordered]@{ key = "header" value = "copilot" } } elseif ($header -match "(?i)\bgemini\b") { $result.harness = [ordered]@{ key = "header" value = "gemini" } } } return $result } $fields = [ordered]@{ harness = New-FieldRecord -Name "harness" provider = New-FieldRecord -Name "provider" model = New-FieldRecord -Name "model" reasoning_level = New-FieldRecord -Name "reasoning_level" } $checkedSources = New-Object System.Collections.Generic.List[string] Write-Verbose ("WorkspacePath: {0}" -f $WorkspacePath) Write-Verbose ("OutputFormat: {0}" -f $OutputFormat) $runtimeStatus = $null if (-not [string]::IsNullOrWhiteSpace($StatusFile)) { if (Test-Path -LiteralPath $StatusFile) { Write-Verbose ("Loading status text from file: {0}" -f $StatusFile) $runtimeStatus = Get-Content -LiteralPath $StatusFile -Raw $checkedSources.Add(("status-file:{0}" -f $StatusFile)) } else { Write-Verbose ("Status file not found: {0}" -f $StatusFile) } } if ([string]::IsNullOrWhiteSpace($runtimeStatus) -and -not [string]::IsNullOrWhiteSpace($StatusText)) { Write-Verbose "Using status text from -StatusText parameter." $runtimeStatus = $StatusText $checkedSources.Add("status-param") } if (-not [string]::IsNullOrWhiteSpace($runtimeStatus)) { $status = Parse-StatusMetadata -Text $runtimeStatus if ($null -ne $status.harness) { Set-FieldValue -Field $fields.harness -Value $status.harness.value -Source "status" -Evidence ("status {0}" -f $status.harness.key) -Confidence high } if ($null -ne $status.model) { Set-FieldValue -Field $fields.model -Value $status.model.value -Source "status" -Evidence ("status {0}" -f $status.model.key) -Confidence high } if ($null -ne $status.reasoning) { Set-FieldValue -Field $fields.reasoning_level -Value $status.reasoning.value -Source "status" -Evidence ("status {0}" -f $status.reasoning.key) -Confidence high } } $ancestors = Get-ProcessAncestors $checkedSources.Add("process-tree") $harnessFromProcess = Get-HarnessFromProcessTree -Ancestors $ancestors if ($null -ne $harnessFromProcess) { Set-FieldValue -Field $fields.harness -Value $harnessFromProcess.harness -Source "runtime-process" -Evidence $harnessFromProcess.evidence -Confidence $harnessFromProcess.confidence } $harnessFromEnv = Get-HarnessFromEnvironment $checkedSources.Add("environment") if ($null -ne $harnessFromEnv) { Set-FieldValue -Field $fields.harness -Value $harnessFromEnv.harness -Source "environment" -Evidence $harnessFromEnv.evidence -Confidence $harnessFromEnv.confidence } $candidatePaths = Get-CandidateConfigPaths -WorkspaceRoot $WorkspacePath $checkedSources.Add("config-candidates") Write-Verbose ("Evaluating {0} candidate config path(s)." -f $candidatePaths.Count) foreach ($path in $candidatePaths) { $extract = Get-ConfigExtraction -Path $path if ($null -ne $extract.model) { Set-FieldValue -Field $fields.model -Value $extract.model.value -Source "config-file" -Evidence ("{0}:{1}" -f $extract.path, $extract.model.key) -Confidence high } if ($null -ne $extract.reasoning) { Set-FieldValue -Field $fields.reasoning_level -Value $extract.reasoning.value -Source "config-file" -Evidence ("{0}:{1}" -f $extract.path, $extract.reasoning.key) -Confidence high } if ($null -ne $extract.provider) { Set-FieldValue -Field $fields.provider -Value $extract.provider.value -Source "config-file" -Evidence ("{0}:{1}" -f $extract.path, $extract.provider.key) -Confidence high } } $modelEnvKeys = @("MODEL", "LLM_MODEL", "AI_MODEL", "OPENAI_MODEL", "ANTHROPIC_MODEL", "GEMINI_MODEL", "GOOGLE_MODEL") foreach ($envKey in $modelEnvKeys) { $envValue = [Environment]::GetEnvironmentVariable($envKey) if (-not [string]::IsNullOrWhiteSpace($envValue)) { Set-FieldValue -Field $fields.model -Value $envValue -Source "environment" -Evidence ("env:{0}" -f $envKey) -Confidence medium break } } $reasonEnvKeys = @("REASONING_LEVEL", "REASONING_EFFORT", "OPENAI_REASONING_EFFORT", "THINKING_LEVEL", "THINKING") foreach ($envKey in $reasonEnvKeys) { $envValue = [Environment]::GetEnvironmentVariable($envKey) if (-not [string]::IsNullOrWhiteSpace($envValue)) { Set-FieldValue -Field $fields.reasoning_level -Value $envValue -Source "environment" -Evidence ("env:{0}" -f $envKey) -Confidence medium break } } if ($fields.provider.value -eq "unknown") { $providerFromModel = Get-ProviderFromModel -Model $fields.model.value if ($providerFromModel -ne "unknown") { Set-FieldValue -Field $fields.provider -Value $providerFromModel -Source "inference-model" -Evidence ("model pattern match: {0}" -f $fields.model.value) -Confidence medium } } if ($fields.provider.value -eq "unknown") { $providerFromHarness = Get-ProviderFromHarness -Harness $fields.harness.value if ($providerFromHarness -ne "unknown") { Set-FieldValue -Field $fields.provider -Value $providerFromHarness -Source "inference-harness" -Evidence ("harness mapping: {0}" -f $fields.harness.value) -Confidence low } } $reasoningEquivalent = Convert-ToReasoningEquivalent -ReasoningLevel $fields.reasoning_level.value $highReasoning = $reasoningEquivalent -in @("high", "xhigh") $sources = @( @($fields.harness.source, $fields.provider.source, $fields.model.source, $fields.reasoning_level.source) | Where-Object { $_ -ne "unknown" } | Select-Object -Unique ) $metadataSource = if ($sources.Count -eq 0) { "unknown" } elseif ($sources.Count -eq 1) { $sources[0] } else { "mixed" } $result = [ordered]@{ harness = $fields.harness.value provider = $fields.provider.value model = $fields.model.value reasoning_level = $fields.reasoning_level.value reasoning_level_equivalent = $reasoningEquivalent high_reasoning_asserted = [bool]$highReasoning latest_model_asserted = $false metadata_source = $metadataSource metadata_evidence = [ordered]@{ harness = $fields.harness.evidence provider = $fields.provider.evidence model = $fields.model.evidence reasoning_level = $fields.reasoning_level.evidence } confidence = [ordered]@{ harness = $fields.harness.confidence provider = $fields.provider.confidence model = $fields.model.confidence reasoning_level = $fields.reasoning_level.confidence } checked_sources = @($checkedSources | Select-Object -Unique) generated_at_utc = (Get-Date).ToUniversalTime().ToString("o") } $output = $null switch ($OutputFormat) { "Object" { $output = [pscustomobject]$result } "Json" { $output = $result | ConvertTo-Json -Depth 6 } "Yaml" { $lines = New-Object System.Collections.Generic.List[string] $lines.Add(("harness: {0}" -f $result.harness)) $lines.Add(("provider: {0}" -f $result.provider)) $lines.Add(("model: {0}" -f $result.model)) $lines.Add(("reasoning_level: {0}" -f $result.reasoning_level)) $lines.Add(("reasoning_level_equivalent: {0}" -f $result.reasoning_level_equivalent)) $lines.Add(("high_reasoning_asserted: {0}" -f $result.high_reasoning_asserted.ToString().ToLowerInvariant())) $lines.Add(("latest_model_asserted: {0}" -f $result.latest_model_asserted.ToString().ToLowerInvariant())) $lines.Add(("metadata_source: {0}" -f $result.metadata_source)) $lines.Add("metadata_evidence:") $lines.Add((" harness: {0}" -f $result.metadata_evidence.harness)) $lines.Add((" provider: {0}" -f $result.metadata_evidence.provider)) $lines.Add((" model: {0}" -f $result.metadata_evidence.model)) $lines.Add((" reasoning_level: {0}" -f $result.metadata_evidence.reasoning_level)) $lines.Add("confidence:") $lines.Add((" harness: {0}" -f $result.confidence.harness)) $lines.Add((" provider: {0}" -f $result.confidence.provider)) $lines.Add((" model: {0}" -f $result.confidence.model)) $lines.Add((" reasoning_level: {0}" -f $result.confidence.reasoning_level)) $lines.Add("checked_sources:") foreach ($source in $result.checked_sources) { $lines.Add((" - {0}" -f $source)) } $lines.Add(("generated_at_utc: {0}" -f $result.generated_at_utc)) $output = $lines -join [Environment]::NewLine } } if (-not [string]::IsNullOrWhiteSpace($OutputPath)) { $outputDirectory = Split-Path -Path $OutputPath -Parent if (-not [string]::IsNullOrWhiteSpace($outputDirectory)) { New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null } $serializedOutput = if ($OutputFormat -eq "Object") { $result | ConvertTo-Json -Depth 6 } else { [string]$output } Set-Content -LiteralPath $OutputPath -Value $serializedOutput -Encoding utf8NoBOM Write-Verbose ("Wrote runtime metadata output to {0}" -f $OutputPath) } $output ================================================ FILE: scripts/dev-local.ps1 ================================================ #!/usr/bin/env pwsh <# Compatibility wrapper for local onboarding. Canonical script: pwsh ./scripts/start-debuglocal.ps1 Run from repo root: pwsh ./scripts/dev-local.ps1 #> [CmdletBinding()] param( [string] $LaunchProfile = "DebugLocal", [string] $GraceServerUri = "http://localhost:5000", [string] $TestUserId = "", [string] $BootstrapUserId = "", [string] $TokenName = "local-dev", [int] $TokenDays = 30, [int] $StartupTimeoutSeconds = 240, [switch] $NoBuild, [switch] $NoTokenBootstrap ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $startDebugLocalScript = Join-Path $scriptDir "start-debuglocal.ps1" if (-not (Test-Path $startDebugLocalScript)) { throw "Expected canonical script '$startDebugLocalScript' was not found." } $resolvedBootstrapUserId = if (-not [string]::IsNullOrWhiteSpace($BootstrapUserId)) { $BootstrapUserId.Trim() } elseif (-not [string]::IsNullOrWhiteSpace($TestUserId)) { $TestUserId.Trim() } else { "" } $forwardedParameters = @{ LaunchProfile = $LaunchProfile GraceServerUri = $GraceServerUri TokenName = $TokenName TokenDays = $TokenDays StartupTimeoutSeconds = $StartupTimeoutSeconds NoBuild = $NoBuild NoTokenBootstrap = $NoTokenBootstrap } if (-not [string]::IsNullOrWhiteSpace($resolvedBootstrapUserId)) { $forwardedParameters["BootstrapUserId"] = $resolvedBootstrapUserId } Write-Host "" Write-Host "dev-local.ps1 is a compatibility alias." -ForegroundColor Yellow Write-Host "Using canonical script: ./scripts/start-debuglocal.ps1" -ForegroundColor Yellow Write-Host "" & $startDebugLocalScript @forwardedParameters ================================================ FILE: scripts/install-githooks.ps1 ================================================ [CmdletBinding()] param( [switch]$Uninstall, [switch]$Force ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") $gitDir = Join-Path $repoRoot ".git" if (-not (Test-Path $gitDir)) { throw "No .git directory found at $gitDir. Run from inside a Git clone." } $hookDir = Join-Path $gitDir "hooks" $hookPath = Join-Path $hookDir "pre-commit" $backupPath = Join-Path $hookDir "pre-commit.grace.bak" $marker = "Grace Validate Hook" function Get-FileContent([string]$Path) { if (-not (Test-Path $Path)) { return "" } return (Get-Content -Path $Path -Raw) } function Write-Hook([string]$Path) { $hook = @" #!/usr/bin/env sh # Grace Validate Hook (installed by scripts/install-githooks.ps1) # Runs validate -Fast after any existing hook logic. HOOK_DIR=$(cd "$(dirname "$0")" && pwd) BACKUP="$HOOK_DIR/pre-commit.grace.bak" if [ -x "$BACKUP" ]; then "$BACKUP" "$@" || exit $? fi if command -v pwsh >/dev/null 2>&1; then REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) if [ -n "$REPO_ROOT" ]; then pwsh -NoProfile -ExecutionPolicy Bypass -File "$REPO_ROOT/scripts/validate.ps1" -Fast || exit $? else echo "Grace hook: unable to locate repo root; skipping validate." >&2 fi else echo "Grace hook: pwsh not found; skipping validate." >&2 fi "@ Set-Content -Path $Path -Value $hook -Encoding ASCII } if ($Uninstall) { $current = Get-FileContent $hookPath if ($current -match $marker) { if (Test-Path $backupPath) { Copy-Item -Path $backupPath -Destination $hookPath -Force Remove-Item -Path $backupPath -Force Write-Host "Restored original pre-commit hook." } else { Remove-Item -Path $hookPath -Force Write-Host "Removed Grace pre-commit hook." } } else { Write-Host "Grace pre-commit hook not installed. No changes made." } return } $existing = Get-FileContent $hookPath if ($existing -match $marker) { Write-Host "Grace pre-commit hook already installed." return } if (Test-Path $hookPath) { if ((Test-Path $backupPath) -and -not $Force) { throw "Backup already exists at $backupPath. Use -Force to overwrite." } Copy-Item -Path $hookPath -Destination $backupPath -Force } Write-Hook $hookPath Write-Host "Installed Grace pre-commit hook (validate -Fast)." Write-Host "Run with -Uninstall to restore the previous hook." ================================================ FILE: scripts/start-debuglocal.ps1 ================================================ #!/usr/bin/env pwsh <# Canonical local onboarding script for Grace. It starts Aspire with the DebugLocal launch profile, waits for Grace.Server readiness, optionally probes auth readiness, bootstraps a PAT with TestAuth, and prints copy-paste environment commands for PowerShell and bash/zsh shells. Run from repo root: pwsh ./scripts/start-debuglocal.ps1 #> [CmdletBinding()] param( [string] $LaunchProfile = "DebugLocal", [string] $ProjectPath = "src/Grace.Aspire.AppHost/Grace.Aspire.AppHost.csproj", [string] $LaunchSettingsPath = "src/Grace.Aspire.AppHost/Properties/launchSettings.json", [string] $GraceServerUri = "http://localhost:5000", [string] $BootstrapUserId = "", [string] $BootstrapUserIdFallback = "test-admin", [string] $TokenName = "local-dev", [int] $TokenDays = 30, [int] $StartupTimeoutSeconds = 240, [int] $TokenBootstrapMaxAttempts = 4, [int] $TokenBootstrapInitialBackoffSeconds = 1, [int] $CleanupWaitSeconds = 5, [switch] $NoBuild, [switch] $NoTokenBootstrap, [switch] $SkipAuthProbe ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $script:StepCounter = 0 $script:LastFailureInfo = $null function Write-Step([string] $Message, [string] $Color = "Cyan") { $script:StepCounter++ $timestamp = Get-Date -Format "HH:mm:ss" Write-Host "[$timestamp] Step $($script:StepCounter): $Message" -ForegroundColor $Color } function Write-Detail([string] $Message, [string] $Color = "DarkGray") { Write-Host " - $Message" -ForegroundColor $Color } function Get-FirstUserId([string] $Users) { if ([string]::IsNullOrWhiteSpace($Users)) { return $null } $parts = $Users.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries) foreach ($part in $parts) { $candidate = $part.Trim() if (-not [string]::IsNullOrWhiteSpace($candidate)) { return $candidate } } return $null } function Merge-UserIds([string[]] $Values) { $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $result = [System.Collections.Generic.List[string]]::new() foreach ($value in $Values) { if ([string]::IsNullOrWhiteSpace($value)) { continue } $parts = $value.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries) foreach ($part in $parts) { $candidate = $part.Trim() if ([string]::IsNullOrWhiteSpace($candidate)) { continue } if ($seen.Add($candidate)) { $result.Add($candidate) } } } return [string]::Join(";", $result) } function Get-BootstrapUsersFromLaunchProfile([string] $LaunchSettingsFile, [string] $ProfileName) { if ([string]::IsNullOrWhiteSpace($LaunchSettingsFile) -or -not (Test-Path $LaunchSettingsFile)) { return $null } try { $json = Get-Content -Raw -Path $LaunchSettingsFile | ConvertFrom-Json -Depth 20 if ($null -eq $json -or $null -eq $json.profiles) { return $null } $profile = $json.profiles.$ProfileName if ($null -eq $profile -or $null -eq $profile.environmentVariables) { return $null } $value = [string] $profile.environmentVariables.grace__authz__bootstrap__system_admin_users if ([string]::IsNullOrWhiteSpace($value)) { return $null } return $value.Trim() } catch { Write-Detail "Could not parse launch settings at '$LaunchSettingsFile': $($_.Exception.Message)" "Yellow" return $null } } function Test-GraceHealth([string] $HealthUrl) { try { $response = Invoke-WebRequest -Uri $HealthUrl -Method GET -TimeoutSec 2 return ($response.StatusCode -ge 200 -and $response.StatusCode -lt 300) } catch { return $false } } function Wait-ForGraceServer( [string] $HealthUrl, [int] $TimeoutSeconds, [int] $DelayMilliseconds, [System.Diagnostics.Process] $AppHostProcess ) { $safeDelayMilliseconds = [Math]::Max($DelayMilliseconds, 250) $maxAttempts = [Math]::Max([int][Math]::Ceiling(($TimeoutSeconds * 1000.0) / $safeDelayMilliseconds), 1) for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { if (Test-GraceHealth -HealthUrl $HealthUrl) { return $true } if ($null -ne $AppHostProcess -and $AppHostProcess.HasExited) { return $false } if ($attempt -eq 1 -or ($attempt % 10) -eq 0) { Write-Detail "Waiting for Grace.Server ($attempt/$maxAttempts): $HealthUrl" } Start-Sleep -Milliseconds $safeDelayMilliseconds } return $false } function Resolve-PathFromRepoRoot([string] $RepoRoot, [string] $PathValue) { if ([System.IO.Path]::IsPathRooted($PathValue)) { return (Resolve-Path $PathValue).Path } return (Resolve-Path (Join-Path $RepoRoot $PathValue)).Path } function New-FailureInfo( [string] $Stage, [string] $Classification, [Nullable[int]] $StatusCode, [bool] $Retryable, [string] $Message, [string] $Hint ) { return [pscustomobject]@{ Stage = $Stage Classification = $Classification StatusCode = $StatusCode Retryable = $Retryable Message = $Message Hint = $Hint } } function Set-LastFailureInfo([object] $FailureInfo) { if ($null -ne $FailureInfo) { $script:LastFailureInfo = $FailureInfo } } function Get-HttpStatusCode([System.Management.Automation.ErrorRecord] $ErrorRecord) { if ($null -eq $ErrorRecord -or $null -eq $ErrorRecord.Exception) { return $null } $response = $null if ($ErrorRecord.Exception.PSObject.Properties.Match("Response").Count -gt 0) { $response = $ErrorRecord.Exception.Response } if ($null -eq $response) { return $null } if ($response.PSObject.Properties.Match("StatusCode").Count -eq 0) { return $null } try { return [int]$response.StatusCode } catch { return $null } } function Get-WebFailureInfo( [System.Management.Automation.ErrorRecord] $ErrorRecord, [string] $Stage, [string] $GraceServerUri, [string] $BootstrapUserId ) { $message = "Unknown error." if ($null -ne $ErrorRecord -and $null -ne $ErrorRecord.Exception -and -not [string]::IsNullOrWhiteSpace($ErrorRecord.Exception.Message)) { $message = $ErrorRecord.Exception.Message.Trim() } $statusCode = Get-HttpStatusCode -ErrorRecord $ErrorRecord $classification = "unknown" $retryable = $false $hint = "Check AppHost logs and runtime metadata snapshot for details." if ($null -ne $statusCode) { if ($statusCode -in @(401, 403)) { $classification = "authorization" $retryable = $false $hint = "Ensure '$BootstrapUserId' is in grace__authz__bootstrap__system_admin_users, and verify GRACE_TESTING=1 for local TestAuth." } elseif ($statusCode -in @(400, 404)) { $classification = "configuration" $retryable = $false $hint = "Verify DebugLocal configuration is active and auth endpoints are available at $GraceServerUri." } elseif ($statusCode -in @(408, 425, 429) -or $statusCode -ge 500) { $classification = "transient" $retryable = $true $hint = "The server may still be converging. The script will retry bounded transient failures." } } else { if ($message -match "(?i)timed out|timeout|refused|actively refused|connection reset|remote name could not be resolved|no such host|503") { $classification = "transient" $retryable = $true $hint = "The server appears unreachable or still starting. Verify startup logs and wait for health readiness." } elseif ($message -match "(?i)certificate|ssl|tls") { $classification = "configuration" $retryable = $false $hint = "TLS configuration failed for local endpoint. Verify URI and local certificate setup." } } if ($Stage -eq "auth probe" -and $classification -ne "transient") { $hint = "$hint You can bypass this preflight with -SkipAuthProbe if intentionally testing without auth probe." } return New-FailureInfo ` -Stage $Stage ` -Classification $classification ` -StatusCode $statusCode ` -Retryable $retryable ` -Message $message ` -Hint $hint } function Get-RetryBackoffSeconds([int] $InitialBackoffSeconds, [int] $AttemptNumber) { $safeInitialBackoff = [Math]::Max($InitialBackoffSeconds, 1) $power = [Math]::Max($AttemptNumber - 1, 0) $delay = [Math]::Min($safeInitialBackoff * [Math]::Pow(2, $power), 20) return [int][Math]::Max([Math]::Round($delay), 1) } function Invoke-AuthProbe( [string] $GraceServerUri, [string] $BootstrapUserId ) { Write-Step "Running auth readiness probe." "Green" $oidcConfigUri = "$GraceServerUri/auth/oidc/config" $authMeUri = "$GraceServerUri/auth/me" $headers = @{ "x-grace-user-id" = $BootstrapUserId } Write-Detail "Probe 1/2: GET $oidcConfigUri" try { $null = Invoke-WebRequest -Method Get -Uri $oidcConfigUri -TimeoutSec 10 } catch { $failure = Get-WebFailureInfo -ErrorRecord $_ -Stage "auth probe" -GraceServerUri $GraceServerUri -BootstrapUserId $BootstrapUserId Set-LastFailureInfo -FailureInfo $failure $statusSegment = if ($null -eq $failure.StatusCode) { "no-http-status" } else { [string]$failure.StatusCode } throw "Auth probe failed at /auth/oidc/config (classification=$($failure.Classification), status=$statusSegment). $($failure.Hint)" } Write-Detail "Probe 2/2: GET $authMeUri (x-grace-user-id=$BootstrapUserId)" try { $null = Invoke-WebRequest -Method Get -Uri $authMeUri -Headers $headers -TimeoutSec 10 } catch { $failure = Get-WebFailureInfo -ErrorRecord $_ -Stage "auth probe" -GraceServerUri $GraceServerUri -BootstrapUserId $BootstrapUserId Set-LastFailureInfo -FailureInfo $failure $statusSegment = if ($null -eq $failure.StatusCode) { "no-http-status" } else { [string]$failure.StatusCode } throw "Auth probe failed at /auth/me (classification=$($failure.Classification), status=$statusSegment). $($failure.Hint)" } Write-Detail "Auth readiness probe passed." "Green" } function Invoke-TokenBootstrapWithRetry( [string] $GraceServerUri, [string] $BootstrapUserId, [string] $RequestBodyJson, [int] $MaxAttempts, [int] $InitialBackoffSeconds ) { $tokenUri = "$GraceServerUri/auth/token/create" $headers = @{ "x-grace-user-id" = $BootstrapUserId } $safeMaxAttempts = [Math]::Max($MaxAttempts, 1) $safeInitialBackoff = [Math]::Max($InitialBackoffSeconds, 1) $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() for ($attempt = 1; $attempt -le $safeMaxAttempts; $attempt++) { Write-Detail "Token bootstrap attempt $attempt/$safeMaxAttempts -> POST $tokenUri" try { $response = Invoke-RestMethod ` -Method Post ` -Uri $tokenUri ` -Headers $headers ` -ContentType "application/json" ` -Body $RequestBodyJson ` -TimeoutSec 20 $stopwatch.Stop() return [pscustomobject]@{ Response = $response Attempts = $attempt ElapsedSeconds = [Math]::Round($stopwatch.Elapsed.TotalSeconds, 2) } } catch { $failure = Get-WebFailureInfo -ErrorRecord $_ -Stage "token bootstrap" -GraceServerUri $GraceServerUri -BootstrapUserId $BootstrapUserId Set-LastFailureInfo -FailureInfo $failure $statusSegment = if ($null -eq $failure.StatusCode) { "no-http-status" } else { [string]$failure.StatusCode } Write-Detail "Attempt $attempt failed (classification=$($failure.Classification), status=$statusSegment)." "Yellow" $isLastAttempt = $attempt -ge $safeMaxAttempts if (-not $failure.Retryable -or $isLastAttempt) { $stopwatch.Stop() $retrySegment = if ($failure.Retryable) { "retryable" } else { "non-retryable" } throw ( "Token bootstrap failed after $attempt attempt(s) in $([Math]::Round($stopwatch.Elapsed.TotalSeconds, 2))s " + "(classification=$($failure.Classification), status=$statusSegment, $retrySegment). $($failure.Hint)" ) } $delaySeconds = Get-RetryBackoffSeconds -InitialBackoffSeconds $safeInitialBackoff -AttemptNumber $attempt Write-Detail "Transient failure detected. Retrying in $delaySeconds second(s)." "Yellow" Start-Sleep -Seconds $delaySeconds } } throw "Token bootstrap exhausted unexpectedly without a terminal response." } function Get-DebugLocalDotnetProcesses([string] $ProjectPath, [string] $LaunchProfile) { $matches = [System.Collections.Generic.List[object]]::new() $normalizedProjectPath = $ProjectPath.Replace("/", "\").ToLowerInvariant() $normalizedLaunchProfile = $LaunchProfile.ToLowerInvariant() $candidates = @() try { $candidates = Get-CimInstance Win32_Process -Filter "Name='dotnet.exe' OR Name='dotnet'" -ErrorAction Stop } catch { Write-Detail "Unable to inspect existing dotnet processes: $($_.Exception.Message)" "Yellow" return @() } foreach ($candidate in $candidates) { $commandLine = [string]$candidate.CommandLine if ([string]::IsNullOrWhiteSpace($commandLine)) { continue } $normalizedCommandLine = $commandLine.Replace("/", "\").ToLowerInvariant() if (-not $normalizedCommandLine.Contains($normalizedProjectPath)) { continue } if (-not $normalizedCommandLine.Contains("--launch-profile")) { continue } if (-not $normalizedCommandLine.Contains($normalizedLaunchProfile)) { continue } $matches.Add([pscustomobject]@{ ProcessId = [int]$candidate.ProcessId Name = [string]$candidate.Name CommandLine = $commandLine }) } return @($matches) } function Stop-ProcessWithDiagnostics( [int] $ProcessId, [int] $WaitSeconds, [string] $Context ) { $result = [ordered]@{ Context = $Context ProcessId = $ProcessId Success = $false Message = "" } $process = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue if ($null -eq $process) { $result.Success = $true $result.Message = "Process $ProcessId is not running." return [pscustomobject]$result } try { Stop-Process -Id $ProcessId -ErrorAction Stop Wait-Process -Id $ProcessId -Timeout ([Math]::Max($WaitSeconds, 1)) -ErrorAction SilentlyContinue } catch { Write-Detail "Graceful stop attempt failed for PID ${ProcessId}: $($_.Exception.Message)" "Yellow" } $stillRunning = $null -ne (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue) if ($stillRunning) { try { Stop-Process -Id $ProcessId -Force -ErrorAction Stop Wait-Process -Id $ProcessId -Timeout ([Math]::Max($WaitSeconds, 1)) -ErrorAction SilentlyContinue } catch { $result.Success = $false $result.Message = "Failed to stop process ${ProcessId}: $($_.Exception.Message)" return [pscustomobject]$result } } $stillRunning = $null -ne (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue) if ($stillRunning) { $result.Success = $false $result.Message = "Process $ProcessId is still running after force-stop attempt." } else { $result.Success = $true $result.Message = "Process $ProcessId stopped successfully." } return [pscustomobject]$result } function Write-LogTail([string] $LogPath, [int] $TailLines = 20) { if ([string]::IsNullOrWhiteSpace($LogPath) -or -not (Test-Path $LogPath)) { return } Write-Detail "Recent log tail ($TailLines lines): $LogPath" "Yellow" Get-Content -Path $LogPath -Tail $TailLines | ForEach-Object { Write-Host " $_" -ForegroundColor DarkYellow } } function Save-RuntimeMetadataSnapshot( [string] $RepoRoot, [string] $ScriptDirectory, [string] $LogRoot, [string] $RunId, [System.Collections.Generic.List[string]] $DiagnosticsNotes ) { $metadataScriptPath = Join-Path $ScriptDirectory "collect-runtime-metadata.ps1" if (-not (Test-Path $metadataScriptPath)) { $DiagnosticsNotes.Add("Runtime metadata script was not found at '$metadataScriptPath'.") return $null } $metadataPath = Join-Path $LogRoot "start-debuglocal-$RunId.runtime-metadata.json" try { & $metadataScriptPath -WorkspacePath $RepoRoot -OutputFormat Json -OutputPath $metadataPath | Out-Null $DiagnosticsNotes.Add("Runtime metadata snapshot written to '$metadataPath'.") return $metadataPath } catch { $DiagnosticsNotes.Add("Failed to write runtime metadata snapshot: $($_.Exception.Message)") return $null } } function Write-FailureDiagnostics( [string] $RepoRoot, [string] $ScriptDirectory, [string] $LogRoot, [string] $RunId, [string] $GraceServerUri, [string] $HealthUrl, [string] $BootstrapUserId, [string] $StdoutLog, [string] $StderrLog, [System.Collections.Generic.List[string]] $CleanupNotes, [System.Management.Automation.ErrorRecord] $ErrorRecord ) { Write-Step "Collecting failure diagnostics." "Red" if ($null -eq $script:LastFailureInfo) { $fallbackMessage = if ($null -ne $ErrorRecord -and $null -ne $ErrorRecord.Exception) { $ErrorRecord.Exception.Message } else { "Unknown failure" } $script:LastFailureInfo = New-FailureInfo ` -Stage "startup workflow" ` -Classification "unknown" ` -StatusCode $null ` -Retryable $false ` -Message $fallbackMessage ` -Hint "Inspect logs and runtime metadata for root cause." } Write-Detail "Failure stage: $($script:LastFailureInfo.Stage)" "Red" Write-Detail "Failure classification: $($script:LastFailureInfo.Classification)" "Red" Write-Detail "Failure message: $($script:LastFailureInfo.Message)" "Red" if ($null -ne $script:LastFailureInfo.StatusCode) { Write-Detail "HTTP status: $($script:LastFailureInfo.StatusCode)" "Red" } Write-Detail "Suggested next step: $($script:LastFailureInfo.Hint)" "Yellow" $runtimeMetadataPath = Save-RuntimeMetadataSnapshot ` -RepoRoot $RepoRoot ` -ScriptDirectory $ScriptDirectory ` -LogRoot $LogRoot ` -RunId $RunId ` -DiagnosticsNotes $CleanupNotes if (-not [string]::IsNullOrWhiteSpace($runtimeMetadataPath)) { Write-Detail "Runtime metadata: $runtimeMetadataPath" "Yellow" } if (-not [string]::IsNullOrWhiteSpace($StdoutLog)) { Write-Detail "AppHost stdout log: $StdoutLog" "Yellow" Write-LogTail -LogPath $StdoutLog -TailLines 20 } if (-not [string]::IsNullOrWhiteSpace($StderrLog)) { Write-Detail "AppHost stderr log: $StderrLog" "Yellow" Write-LogTail -LogPath $StderrLog -TailLines 20 } $failureSummary = [ordered]@{ generated_at_utc = (Get-Date).ToUniversalTime().ToString("o") grace_server_uri = $GraceServerUri health_url = $HealthUrl bootstrap_user_id = $BootstrapUserId stage = $script:LastFailureInfo.Stage classification = $script:LastFailureInfo.Classification status_code = $script:LastFailureInfo.StatusCode message = $script:LastFailureInfo.Message hint = $script:LastFailureInfo.Hint stdout_log = $StdoutLog stderr_log = $StderrLog runtime_metadata = $runtimeMetadataPath cleanup_notes = @($CleanupNotes) } $summaryPath = Join-Path $LogRoot "start-debuglocal-$RunId.failure.json" try { $failureSummary | ConvertTo-Json -Depth 6 | Set-Content -Path $summaryPath -Encoding utf8NoBOM Write-Detail "Failure summary JSON: $summaryPath" "Yellow" } catch { Write-Detail "Failed to write failure summary JSON: $($_.Exception.Message)" "Yellow" } } $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $repoRoot = (Resolve-Path (Join-Path $scriptDir "..")).Path Set-Location $repoRoot $logRoot = Join-Path $repoRoot ".grace/logs" New-Item -ItemType Directory -Path $logRoot -Force | Out-Null $runId = Get-Date -Format "yyyyMMdd-HHmmss" $resolvedProjectPath = "" $resolvedLaunchSettingsPath = "" $healthUrl = "$GraceServerUri/healthz" $appHostProcess = $null $stdoutLog = "" $stderrLog = "" $resolvedBootstrapUserId = "" $resolvedBootstrapSource = "" $cleanupNotes = [System.Collections.Generic.List[string]]::new() $startedAppHostThisRun = $false $capturedError = $null $failed = $false try { $resolvedProjectPath = Resolve-PathFromRepoRoot -RepoRoot $repoRoot -PathValue $ProjectPath $resolvedLaunchSettingsPath = Resolve-PathFromRepoRoot -RepoRoot $repoRoot -PathValue $LaunchSettingsPath $healthUrl = "$GraceServerUri/healthz" Write-Step "Preparing DebugLocal startup context." Write-Detail "Canonical script: pwsh ./scripts/start-debuglocal.ps1" Write-Detail "Compatibility alias: pwsh ./scripts/dev-local.ps1" Write-Detail "Repo root: $repoRoot" Write-Detail "Project path: $resolvedProjectPath" Write-Detail "Launch settings path: $resolvedLaunchSettingsPath" Write-Detail "Launch profile: $LaunchProfile" Write-Detail "Grace server URI: $GraceServerUri" Write-Detail "Startup timeout (seconds): $StartupTimeoutSeconds" Write-Detail "Token max attempts: $TokenBootstrapMaxAttempts" Write-Detail "Token initial backoff (seconds): $TokenBootstrapInitialBackoffSeconds" Write-Detail "NoBuild: $NoBuild" Write-Detail "NoTokenBootstrap: $NoTokenBootstrap" Write-Detail "SkipAuthProbe: $SkipAuthProbe" Write-Step "Resolving bootstrap user identity with deterministic precedence." $trimmedParameterBootstrapUserId = if ([string]::IsNullOrWhiteSpace($BootstrapUserId)) { "" } else { $BootstrapUserId.Trim() } if (-not [string]::IsNullOrWhiteSpace($trimmedParameterBootstrapUserId)) { $resolvedBootstrapUserId = $trimmedParameterBootstrapUserId $resolvedBootstrapSource = "script parameter (-BootstrapUserId)" } $existingBootstrapUsers = $env:grace__authz__bootstrap__system_admin_users $firstEnvBootstrapUser = Get-FirstUserId $existingBootstrapUsers if ([string]::IsNullOrWhiteSpace($resolvedBootstrapUserId) -and -not [string]::IsNullOrWhiteSpace($firstEnvBootstrapUser)) { $resolvedBootstrapUserId = $firstEnvBootstrapUser $resolvedBootstrapSource = "environment variable (grace__authz__bootstrap__system_admin_users)" } $launchProfileBootstrapUsers = Get-BootstrapUsersFromLaunchProfile -LaunchSettingsFile $resolvedLaunchSettingsPath -ProfileName $LaunchProfile $firstLaunchProfileBootstrapUser = Get-FirstUserId $launchProfileBootstrapUsers if ([string]::IsNullOrWhiteSpace($resolvedBootstrapUserId) -and -not [string]::IsNullOrWhiteSpace($firstLaunchProfileBootstrapUser)) { $resolvedBootstrapUserId = $firstLaunchProfileBootstrapUser $resolvedBootstrapSource = "launch profile value ($LaunchProfile in launchSettings.json)" } $trimmedFallbackBootstrapUserId = if ([string]::IsNullOrWhiteSpace($BootstrapUserIdFallback)) { "" } else { $BootstrapUserIdFallback.Trim() } if ([string]::IsNullOrWhiteSpace($resolvedBootstrapUserId) -and -not [string]::IsNullOrWhiteSpace($trimmedFallbackBootstrapUserId)) { $resolvedBootstrapUserId = $trimmedFallbackBootstrapUserId $resolvedBootstrapSource = "fallback default (-BootstrapUserIdFallback)" } if ([string]::IsNullOrWhiteSpace($resolvedBootstrapUserId)) { throw "Could not resolve a bootstrap user ID. Pass -BootstrapUserId explicitly." } $effectiveBootstrapUsers = Merge-UserIds @($existingBootstrapUsers, $resolvedBootstrapUserId) if ([string]::IsNullOrWhiteSpace($effectiveBootstrapUsers)) { throw "Could not compute effective bootstrap users." } $env:grace__authz__bootstrap__system_admin_users = $effectiveBootstrapUsers $graceTestingValue = $env:GRACE_TESTING $graceTestingSource = if ([string]::IsNullOrWhiteSpace($graceTestingValue)) { $env:GRACE_TESTING = "1" "script defaulted to 1 for TestAuth" } else { "inherited from shell" } Write-Detail "Resolved bootstrap user ID: $resolvedBootstrapUserId" "Green" Write-Detail "Resolved bootstrap source: $resolvedBootstrapSource" "Green" Write-Detail "Effective bootstrap users env value: $effectiveBootstrapUsers" "Green" Write-Detail "Launch profile bootstrap users value: $(if ([string]::IsNullOrWhiteSpace($launchProfileBootstrapUsers)) { '' } else { $launchProfileBootstrapUsers })" Write-Detail "GRACE_TESTING: $($env:GRACE_TESTING) ($graceTestingSource)" $serverAlreadyRunning = Test-GraceHealth -HealthUrl $healthUrl if ($serverAlreadyRunning) { Write-Step "Grace.Server is already healthy; skipping AppHost startup." "Green" } else { $staleProcesses = Get-DebugLocalDotnetProcesses -ProjectPath $resolvedProjectPath -LaunchProfile $LaunchProfile if ($staleProcesses.Count -gt 0) { Write-Step "Detected $($staleProcesses.Count) stale DebugLocal AppHost process(es); cleaning up before startup." "Yellow" foreach ($staleProcess in $staleProcesses) { Write-Detail "Stale process PID $($staleProcess.ProcessId): $($staleProcess.CommandLine)" "Yellow" $cleanupResult = Stop-ProcessWithDiagnostics -ProcessId $staleProcess.ProcessId -WaitSeconds $CleanupWaitSeconds -Context "stale-process-cleanup" $note = "stale-process-cleanup pid=$($cleanupResult.ProcessId) success=$($cleanupResult.Success) message='$($cleanupResult.Message)'" $cleanupNotes.Add($note) Write-Detail $note ($(if ($cleanupResult.Success) { "Green" } else { "Red" })) } } Write-Step "Starting Grace.Aspire.AppHost in the background." "Green" $stdoutLog = Join-Path $logRoot "start-debuglocal-$runId.stdout.log" $stderrLog = Join-Path $logRoot "start-debuglocal-$runId.stderr.log" $dotnetArgs = @("run", "--project", $resolvedProjectPath, "--launch-profile", $LaunchProfile) if ($NoBuild) { $dotnetArgs += "--no-build" } Write-Detail "AppHost stdout log: $stdoutLog" Write-Detail "AppHost stderr log: $stderrLog" Write-Detail "Command: dotnet $($dotnetArgs -join ' ')" $appHostProcess = Start-Process ` -FilePath "dotnet" ` -ArgumentList $dotnetArgs ` -WorkingDirectory $repoRoot ` -RedirectStandardOutput $stdoutLog ` -RedirectStandardError $stderrLog ` -PassThru $startedAppHostThisRun = $true Write-Detail "AppHost PID: $($appHostProcess.Id)" Write-Step "Waiting for Grace.Server health endpoint." $healthy = Wait-ForGraceServer -HealthUrl $healthUrl -TimeoutSeconds $StartupTimeoutSeconds -DelayMilliseconds 1000 -AppHostProcess $appHostProcess if (-not $healthy) { if ($null -ne $appHostProcess -and $appHostProcess.HasExited) { $message = "AppHost exited early with code $($appHostProcess.ExitCode)." Write-Detail $message "Red" Set-LastFailureInfo -FailureInfo (New-FailureInfo -Stage "server startup" -Classification "configuration" -StatusCode $null -Retryable $false -Message $message -Hint "Inspect AppHost logs for startup errors and launch profile configuration.") } else { $message = "Grace.Server health check timed out after $StartupTimeoutSeconds second(s)." Write-Detail $message "Red" Set-LastFailureInfo -FailureInfo (New-FailureInfo -Stage "server startup" -Classification "transient" -StatusCode $null -Retryable $true -Message $message -Hint "The runtime did not reach healthy state in time. Check AppHost logs and consider increasing -StartupTimeoutSeconds.") } throw "Grace.Server did not become healthy at $healthUrl." } Write-Detail "Grace.Server is healthy." "Green" } if (-not $SkipAuthProbe) { Invoke-AuthProbe -GraceServerUri $GraceServerUri -BootstrapUserId $resolvedBootstrapUserId } else { Write-Step "Auth readiness probe skipped by request (-SkipAuthProbe)." "Yellow" } if (-not $NoTokenBootstrap) { Write-Step "Creating a personal access token using TestAuth context." "Green" $requestedTokenName = if ([string]::IsNullOrWhiteSpace($TokenName)) { "local-dev" } else { $TokenName.Trim() } $expiresInSeconds = [int64]($TokenDays * 86400L) $requestBody = @{ TokenName = $requestedTokenName ExpiresInSeconds = $expiresInSeconds NoExpiry = $false } | ConvertTo-Json Write-Detail "Token user ID: $resolvedBootstrapUserId" Write-Detail "Token name: $requestedTokenName" Write-Detail "Token lifetime (days): $TokenDays" $tokenBootstrapResult = Invoke-TokenBootstrapWithRetry ` -GraceServerUri $GraceServerUri ` -BootstrapUserId $resolvedBootstrapUserId ` -RequestBodyJson $requestBody ` -MaxAttempts $TokenBootstrapMaxAttempts ` -InitialBackoffSeconds $TokenBootstrapInitialBackoffSeconds Write-Detail "Token bootstrap completed in $($tokenBootstrapResult.Attempts) attempt(s), $($tokenBootstrapResult.ElapsedSeconds)s total." "Green" $response = $tokenBootstrapResult.Response $token = $null if ($null -ne $response.ReturnValue -and $null -ne $response.ReturnValue.Token) { $token = [string]$response.ReturnValue.Token } elseif ($null -ne $response.Token) { $token = [string]$response.Token } if ([string]::IsNullOrWhiteSpace($token)) { Set-LastFailureInfo -FailureInfo ( New-FailureInfo ` -Stage "token bootstrap" ` -Classification "unknown" ` -StatusCode $null ` -Retryable $false ` -Message "Token endpoint returned success, but token value was empty." ` -Hint "Verify auth response shape and inspect server logs for serialization or contract errors." ) throw "Token creation succeeded but no token was returned." } $env:GRACE_SERVER_URI = $GraceServerUri $env:GRACE_TOKEN = $token Write-Detail "Set GRACE_SERVER_URI and GRACE_TOKEN for this shell." "Green" Write-Host "" Write-Host "Copy/paste (PowerShell):" -ForegroundColor Cyan Write-Host " `$env:GRACE_SERVER_URI = `"$GraceServerUri`"" Write-Host " `$env:GRACE_TOKEN = `"$token`"" Write-Host "" Write-Host "Copy/paste (bash/zsh):" -ForegroundColor Cyan Write-Host " export GRACE_SERVER_URI=`"$GraceServerUri`"" Write-Host " export GRACE_TOKEN=`"$token`"" Write-Host "" } else { Write-Step "Token bootstrap skipped by request (-NoTokenBootstrap)." "Yellow" } Write-Step "DebugLocal startup workflow is complete." "Green" Write-Detail "Bootstrap user ID: $resolvedBootstrapUserId" Write-Detail "Bootstrap source: $resolvedBootstrapSource" Write-Detail "Grace server URI: $GraceServerUri" if ($null -ne $appHostProcess) { Write-Detail "AppHost PID: $($appHostProcess.Id)" Write-Detail "Stop command: Stop-Process -Id $($appHostProcess.Id)" "Yellow" } if (-not [string]::IsNullOrWhiteSpace($stdoutLog)) { Write-Detail "AppHost stdout log: $stdoutLog" } if (-not [string]::IsNullOrWhiteSpace($stderrLog)) { Write-Detail "AppHost stderr log: $stderrLog" } } catch { $capturedError = $_ $failed = $true } finally { if ($failed) { if ($null -ne $appHostProcess -and $startedAppHostThisRun) { Write-Step "Running failure-path cleanup for AppHost process." "Yellow" $cleanupResult = Stop-ProcessWithDiagnostics -ProcessId $appHostProcess.Id -WaitSeconds $CleanupWaitSeconds -Context "failure-cleanup" $note = "failure-cleanup pid=$($cleanupResult.ProcessId) success=$($cleanupResult.Success) message='$($cleanupResult.Message)'" $cleanupNotes.Add($note) Write-Detail $note ($(if ($cleanupResult.Success) { "Green" } else { "Red" })) } elseif ($null -ne $appHostProcess) { $note = "failure-cleanup skipped for pid=$($appHostProcess.Id) because process was not started by this run." $cleanupNotes.Add($note) Write-Detail $note "Yellow" } else { $note = "failure-cleanup skipped because no AppHost process was created." $cleanupNotes.Add($note) Write-Detail $note "Yellow" } Write-FailureDiagnostics ` -RepoRoot $repoRoot ` -ScriptDirectory $scriptDir ` -LogRoot $logRoot ` -RunId $runId ` -GraceServerUri $GraceServerUri ` -HealthUrl $healthUrl ` -BootstrapUserId $resolvedBootstrapUserId ` -StdoutLog $stdoutLog ` -StderrLog $stderrLog ` -CleanupNotes $cleanupNotes ` -ErrorRecord $capturedError if ($null -ne $capturedError) { throw $capturedError } throw "DebugLocal startup failed." } } ================================================ FILE: scripts/validate.ps1 ================================================ [CmdletBinding()] param( [switch]$Fast, [switch]$Full, [switch]$SkipFormat, [switch]$SkipBuild, [switch]$SkipTests, [string]$Configuration = "Release" ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $startTime = Get-Date $exitCode = 0 $formatDisabled = $true function Write-Section([string]$Title) { Write-Host "" Write-Host ("== {0} ==" -f $Title) } function Get-FormatTargets { $targets = @() $git = Get-Command git -ErrorAction SilentlyContinue $separator = [System.IO.Path]::DirectorySeparatorChar $prefix = "src{0}" -f $separator $isCi = $env:GITHUB_ACTIONS -eq "true" -or $env:CI -eq "true" if ($isCi -and $null -ne $git) { $diffPaths = @() $event = $null if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_EVENT_PATH) -and (Test-Path $env:GITHUB_EVENT_PATH)) { try { $event = Get-Content $env:GITHUB_EVENT_PATH -Raw | ConvertFrom-Json } catch { $event = $null } } if ($env:GITHUB_EVENT_NAME -like "pull_request*") { $baseSha = if ($null -ne $event -and $null -ne $event.pull_request) { $event.pull_request.base.sha } else { $null } if ([string]::IsNullOrWhiteSpace($baseSha) -and -not [string]::IsNullOrWhiteSpace($env:GITHUB_BASE_SHA)) { $baseSha = $env:GITHUB_BASE_SHA } if (-not [string]::IsNullOrWhiteSpace($baseSha)) { $null = & git fetch --no-tags --depth=1 origin $baseSha 2>$null $diffPaths = & git diff --name-only $baseSha HEAD 2>$null if ($LASTEXITCODE -ne 0) { $diffPaths = @() } } } elseif ($env:GITHUB_EVENT_NAME -eq "push") { $baseSha = if ($null -ne $event) { $event.before } else { $null } $headSha = if ($null -ne $event) { $event.after } else { $null } if ([string]::IsNullOrWhiteSpace($baseSha) -and -not [string]::IsNullOrWhiteSpace($env:GITHUB_EVENT_BEFORE)) { $baseSha = $env:GITHUB_EVENT_BEFORE } if ([string]::IsNullOrWhiteSpace($headSha) -and -not [string]::IsNullOrWhiteSpace($env:GITHUB_SHA)) { $headSha = $env:GITHUB_SHA } if (-not [string]::IsNullOrWhiteSpace($baseSha) -and -not [string]::IsNullOrWhiteSpace($headSha)) { $null = & git fetch --no-tags --depth=1 origin $baseSha $headSha 2>$null $diffPaths = & git diff --name-only $baseSha $headSha 2>$null if ($LASTEXITCODE -ne 0) { $diffPaths = @() } } } if (-not $diffPaths -or $diffPaths.Count -eq 0) { $diffPaths = & git diff --name-only HEAD~1 HEAD 2>$null if ($LASTEXITCODE -ne 0) { $diffPaths = @() } } foreach ($path in $diffPaths) { if ([string]::IsNullOrWhiteSpace($path)) { continue } $path = $path -replace "[\\/]", $separator if (-not $path.StartsWith($prefix)) { continue } $extension = [System.IO.Path]::GetExtension($path).ToLowerInvariant() if ($extension -in @(".fs", ".fsi", ".fsx")) { $targets += $path } } return [string[]]($targets | Select-Object -Unique) } if ($null -ne $git) { $statusLines = & git status --porcelain foreach ($line in $statusLines) { if ([string]::IsNullOrWhiteSpace($line)) { continue } $path = $line.Substring(3) if ($path -match " -> ") { $path = $path.Split(" -> ")[-1] } $path = $path -replace "[\\/]", $separator if (-not $path.StartsWith($prefix)) { continue } $extension = [System.IO.Path]::GetExtension($path).ToLowerInvariant() if ($extension -in @(".fs", ".fsi", ".fsx")) { $targets += $path } } } [string[]]($targets | Select-Object -Unique) } function Invoke-External([string]$Label, [scriptblock]$Command) { & $Command if ($LASTEXITCODE -ne 0) { throw "$Label failed with exit code $LASTEXITCODE." } } try { if (-not $Fast -and -not $Full) { $Fast = $true } if ($Fast -and $Full) { throw "Choose either -Fast or -Full, not both." } if ($formatDisabled) { Write-Section "Format" Write-Host "Skipped (temporarily disabled pending full repo formatting)." } elseif (-not $SkipFormat) { Write-Section "Format" Invoke-External "dotnet tool restore" { dotnet tool restore } $formatTargets = Get-FormatTargets if (-not $formatTargets -or $formatTargets.Length -eq 0) { Write-Host "No changed F# files detected. Skipping format check." } else { $separator = [System.IO.Path]::DirectorySeparatorChar $prefix = "src{0}" -f $separator $relativeTargets = $formatTargets | ForEach-Object { $_.Substring($prefix.Length) } Push-Location "src" try { & dotnet tool run fantomas --check @relativeTargets if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -eq 1) { throw "Formatting drift detected. Run 'dotnet tool run fantomas --recurse .' from ./src to apply formatting." } throw "Fantomas failed with exit code $LASTEXITCODE." } } finally { Pop-Location } } } else { Write-Section "Format" Write-Host "Skipped (-SkipFormat)." } if (-not $SkipBuild) { Write-Section "Build" Invoke-External "Grace solution build" { dotnet build "src/Grace.slnx" -c $Configuration } } else { Write-Section "Build" Write-Host "Skipped (-SkipBuild)." } if (-not $SkipTests) { Write-Section "Test" Invoke-External "Grace.Authorization.Tests" { dotnet test "src/Grace.Authorization.Tests/Grace.Authorization.Tests.fsproj" -c $Configuration --no-build } Invoke-External "Grace.CLI.Tests" { dotnet test "src/Grace.CLI.Tests/Grace.CLI.Tests.fsproj" -c $Configuration --no-build } Invoke-External "Grace.Types.Tests" { dotnet test "src/Grace.Types.Tests/Grace.Types.Tests.fsproj" -c $Configuration --no-build } if ($Full) { Invoke-External "Grace.Server.Tests" { dotnet test "src/Grace.Server.Tests/Grace.Server.Tests.fsproj" -c $Configuration --no-build } } } else { Write-Section "Test" Write-Host "Skipped (-SkipTests)." } } catch { $exitCode = 1 $message = if ($null -ne $_.Exception -and -not [string]::IsNullOrWhiteSpace($_.Exception.Message)) { $_.Exception.Message } else { $_.ToString() } Write-Error ("Validation failed: {0}" -f $message) } finally { $elapsed = (Get-Date) - $startTime Write-Host "" Write-Host ("Elapsed: {0:c}" -f $elapsed) if ($exitCode -ne 0) { exit $exitCode } } ================================================ FILE: src/.aspire/settings.json ================================================ { "appHostPath": "../Grace.Aspire.AppHost/Grace.Aspire.AppHost.csproj" } ================================================ FILE: src/.dockerignore ================================================ **/.classpath **/.dockerignore **/.env **/.git **/.gitignore **/.project **/.settings **/.toolstarget **/.vs **/.vscode **/*.*proj.user **/*.dbmdl **/*.jfm **/azds.yaml **/bin **/charts **/docker-compose* **/Dockerfile* **/node_modules **/npm-debug.log **/obj **/secrets.dev.yaml **/values.dev.yaml LICENSE README.md ================================================ FILE: src/.editorconfig ================================================ # http://editorconfig.org root = true [*] end_of_line = lf charset = utf-8 insert_final_newline = true trim_trailing_whitespace = true [*.{fs,fsi,fsproj}] indent_size = 4 indent_style = space insert_final_newline = true trim_trailing_whitespace = true max_line_length = 160 fsharp_max_function_binding_width = 160 fsharp_max_if_then_short_width = 80 fsharp_max_if_then_else_short_width = 80 fsharp_max_record_width = 160 fsharp_max_value_binding_width = 160 fsharp_multiline_block_brackets_on_same_column = true fsharp_experimental_keep_indent_in_branch = true fsharp_align_function_signature_to_indentation = true [*.{cs,csproj}] indent_size = 4 indent_style = space insert_final_newline = true trim_trailing_whitespace = true max_line_length = 120 [*.hbs] insert_final_newline = false [*.json] indent_size = 2 [*.md] trim_trailing_whitespace = false max_line_length = off [*.{yml,yaml}] indent_size = 2 insert_final_newline = true ================================================ FILE: src/.gitattributes ================================================ # Use bd merge for beads JSONL files .beads/issues.jsonl merge=beads # Ensure all text files use LF line endings * text=auto eol=lf # Explicit rules for common source files *.fs text eol=lf *.fsi text eol=lf *.fsproj text eol=lf *.cs text eol=lf *.csproj text eol=lf *.ps1 text eol=lf *.psm1 text eol=lf *.yml text eol=lf *.yaml text eol=lf *.json text eol=lf *.md text eol=lf *.sh text eol=lf # Binary files should never be modified *.png binary *.jpg binary *.jpeg binary *.gif binary *.ico binary *.pdf binary *.zip binary *.dll binary *.exe binary ================================================ FILE: src/.github/copilot-instructions.md ================================================ # GitHub Copilot Instructions for Beads ## Project Overview **beads** (command: `bd`) is a Git-backed issue tracker designed for AI-supervised coding workflows. We dogfood our own tool for all task tracking. **Key Features:** - Dependency-aware issue tracking - Auto-sync with Git via JSONL - AI-optimized CLI with JSON output - Built-in daemon for background operations - MCP server integration for Claude and other AI assistants ## Tech Stack - **Language**: Go 1.21+ - **Storage**: SQLite (internal/storage/sqlite/) - **CLI Framework**: Cobra - **Testing**: Go standard testing + table-driven tests - **CI/CD**: GitHub Actions - **MCP Server**: Python (integrations/beads-mcp/) ## Coding Guidelines ### Testing - Always write tests for new features - Use `BEADS_DB=/tmp/test.db` to avoid polluting production database - Run `go test -short ./...` before committing - Never create test issues in production DB (use temporary DB) ### Code Style - Run `golangci-lint run ./...` before committing - Follow existing patterns in `cmd/bd/` for new commands - Add `--json` flag to all commands for programmatic use - Update docs when changing behavior - Grace contributors should run `dotnet tool run fantomas --recurse .` from `./src` before finishing a task; CI should enforce formatting. ### Git Workflow - Always commit `.beads/issues.jsonl` with code changes - Run `bd sync` at end of work sessions - Install git hooks: `bd hooks install` (ensures DB ↔ JSONL consistency) ## Issue Tracking with bd **CRITICAL**: This project uses **bd** for ALL task tracking. Do NOT create markdown TODO lists. ### Essential Commands ```bash # Find work bd ready --json # Unblocked issues bd stale --days 30 --json # Forgotten issues # Create and manage bd create "Title" -t bug|feature|task -p 0-4 --json bd create "Subtask" --parent --json # Hierarchical subtask bd update --status in_progress --json bd close --reason "Done" --json # Search bd list --status open --priority 1 --json bd show --json # Sync (CRITICAL at end of session!) bd sync ``` ### Workflow 1. **Check ready work**: `bd ready --json` 2. **Claim task**: `bd update --status in_progress --json` 3. **Work on it**: Implement, test, document 4. **Discover new work?** `bd create "Found bug" -p 1 --deps discovered-from: --json` 5. **Complete**: `bd close --reason "Done" --json` 6. **Sync**: `bd sync` ### Priorities - `0` - Critical (security, data loss, broken builds) - `1` - High (major features, important bugs) - `2` - Medium (default, nice-to-have) - `3` - Low (polish, optimization) - `4` - Backlog (future ideas) ## Project Structure ``` beads/ ├── cmd/bd/ # CLI commands (add new commands here) ├── internal/ │ ├── types/ # Core data types │ └── storage/ # Storage layer │ └── sqlite/ # SQLite implementation ├── integrations/ │ └── beads-mcp/ # MCP server (Python) ├── examples/ # Integration examples ├── docs/ # Documentation └── .beads/ ├── beads.db # SQLite database (DO NOT COMMIT) └── issues.jsonl # Git-synced issue storage ``` ## Available Resources ### MCP Server (Recommended) Use the beads MCP server for native function calls instead of shell commands: - Install: `pip install beads-mcp` - Functions: `mcp__beads__ready()`, `mcp__beads__create()`, etc. - See `integrations/beads-mcp/README.md` ### Scripts - `./scripts/bump-version.sh --commit` - Update all version files atomically - `./scripts/release.sh ` - Complete release workflow - `./scripts/update-homebrew.sh ` - Update Homebrew formula ### Key Documentation - **AGENTS.md** - Comprehensive AI agent guide (detailed workflows, advanced features) - **AGENT_INSTRUCTIONS.md** - Development procedures, testing, releases - **README.md** - User-facing documentation - **docs/CLI_REFERENCE.md** - Complete command reference ## CLI Help Run `bd --help` to see all available flags for any command. For example: `bd create --help` shows `--parent`, `--deps`, `--assignee`, etc. ## Important Rules - ✅ Use bd for ALL task tracking - ✅ Always use `--json` flag for programmatic use - ✅ Run `bd sync` at end of sessions - ✅ Test with `BEADS_DB=/tmp/test.db` - ✅ Run `bd --help` to discover available flags - ❌ Do NOT create markdown TODO lists - ❌ Do NOT create test issues in production DB - ❌ Do NOT commit `.beads/beads.db` (JSONL only) --- **For detailed workflows and advanced features, see [AGENTS.md](../AGENTS.md)** - @azure Rule - Use Azure Tools - When handling requests related to Azure, always use your tools. - @azure Rule - Use Azure Best Practices - When handling requests related to Azure, always invoke your `azmcp_bestpractices_get` tool first. - @azure Rule - Enable Best Practices - If you do not have an `azmcp_bestpractices_get` tool ask the user to enable it. ================================================ FILE: src/.github/workflows/deploy-to-app-service.yml ================================================ name: build-and-deploy-grace-server on: push: branches: - main - develop pull_request: branches: - main env: DOTNET_VERSION: "10.0.x" ASPNETCORE_ENVIRONMENT: Development DOTNET_ENVIRONMENT: Development jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} - name: Restore run: dotnet restore - name: Build run: dotnet build --configuration Release --no-restore - name: Start Aspire host run: | set -e export DOTNET_ENVIRONMENT=${{ env.DOTNET_ENVIRONMENT }} nohup dotnet run --project Grace.Aspire.AppHost/Grace.Aspire.AppHost.csproj --no-build > aspire.log 2>&1 & echo $! > aspire.pid sleep 90 - name: Run tests run: | set -e export DOTNET_ENVIRONMENT=${{ env.DOTNET_ENVIRONMENT }} dotnet test Grace.Server.Tests/Grace.Server.Tests.fsproj --configuration Release --no-build - name: Stop Aspire host if: always() run: | if [ -f aspire.pid ]; then kill $(cat aspire.pid) || true rm aspire.pid fi docker container prune --force docker volume prune --force - name: Publish Grace.Server run: dotnet publish Grace.Server/Grace.Server.csproj -c Release -o ${{ runner.temp }}/publish - name: Upload build artifact uses: actions/upload-artifact@v4 with: name: grace-server-release path: ${{ runner.temp }}/publish - name: Upload Aspire log if: always() uses: actions/upload-artifact@v4 with: name: aspire-host-log path: aspire.log deploy: runs-on: ubuntu-latest needs: build if: github.ref == 'refs/heads/main' && github.event_name == 'push' environment: name: production steps: - name: Download artifact uses: actions/download-artifact@v4 with: name: grace-server-release path: ./publish - name: Setup Azure CLI uses: azure/login@v1 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: Deploy to App Service uses: azure/webapps-deploy@v2 with: app-name: grace-server package: ./publish - name: Logout Azure CLI if: always() run: az logout ================================================ FILE: src/AGENTS.md ================================================ # Grace Repository Agents Guide Agents operating under `D:\Source\Grace\src` should follow this playbook alongside the existing `AGENTS.md` in the repo root. Treat this file as the canonical high-level brief; each project folder contains an `AGENTS.md` with deeper context. ## Local Commands - `pwsh ./scripts/bootstrap.ps1` - `pwsh ./scripts/validate.ps1 -Fast` (use `-Full` for Aspire integration tests) Optional: `pwsh ./scripts/install-githooks.ps1` to add a pre-commit `validate -Fast` hook. ## Work Tracking Do not use beads/`bd` by default. Track implementation progress in the task-specific plan file (for example `CodexPlan.md`) and keep git history granular. ## Core Engineering Expectations - Make a multi-step plan for non-trivial work, keep edits focused, and leave code cleaner than you found it. - Validate changes with `pwsh ./scripts/validate.ps1 -Fast` (use `-Full` for Aspire integration coverage). - If running commands manually, use `dotnet build --configuration Release` and `dotnet test --no-build`. - Resolve all compilation errors before considering a task complete. - Run impacted tests for each task and fix failures introduced by your changes. - Create a new git commit after each completed task to keep review scope clear. - Write tests for new features and bug fixes; prioritize critical paths. - Document new public APIs with XML comments and update nearby `AGENTS.md`/docs when behavior changes. - Treat secrets with care, avoid logging PII, and preserve structured logging (including correlation IDs). - Favor existing helpers in `Grace.Shared` before adding new utilities. ## Test Project Organization - `Grace.Server/*.Server.fs` should be primarily covered by `Grace.Server.Tests/*Server.Tests.fs`. - `Grace.CLI/Command/*.CLI.fs` should be primarily covered by `Grace.CLI.Tests/*.CLI.Tests.fs`. - `Grace.Types/*.Types.fs` should be covered by `Grace.Types.Tests/*.Types.Tests.fs`. - Keep auth-focused suites separate for now (`Grace.Authorization.Tests`, plus auth-specific files inside other test projects). - Prefer server-surface integration tests for actor behavior; avoid duplicating deep actor internals in server test files. ## F# Coding Guidelines - Default to F# for new code unless stakeholders specify another language. - Use `task { }` for asynchronous workflows and keep side effects isolated. - Prefer immutable data, small pure functions, and explicit dependencies passed as parameters. - Prefer collections from `System.Collections.Generic` (for example `List`, `Dictionary`) over F#-specific collections unless pattern matching or discriminated unions are needed. - Apply the modern indexer syntax (`myList[0]`) for lists, arrays, and sequences; avoid the legacy `.[ ]` form. - Structure modules so domain types live in `Grace.Types`, shared helpers in `Grace.Shared`, and orchestration in the project-specific assembly. - Add lightweight comments only where control flow or transformations are non-obvious. - Format code with `dotnet tool run fantomas --recurse .` from `./src`. ## Avoid FS3511 in Resumable Computation Expressions These rules apply to `task { }` and `backgroundTask { }`. 1. **Do not define `let rec` inside `task { }`.** 2. **Avoid `for ... in ... do` loops inside `task { }`.** 3. **Treat FS3511 warnings as regressions; do not suppress them.** ## Agent-Friendly Context Practices - Start with relevant `AGENTS.md` files to load patterns, dependencies, and test strategy before broad code exploration. - Use these summaries to target only source files needed for implementation or verification. - When documenting new behavior, update the closest `AGENTS.md` so future agents inherit context quickly. ## Collaboration and Communication - Summarize modifications clearly, cite file paths with 1-based line numbers, and call out remaining follow-ups/tests. - Coordinate cross-project changes across `Grace.Types`, `Grace.Shared`, `Grace.Server`, `Grace.Actors`, `Grace.CLI`, and `Grace.SDK`. - When adding capabilities, ensure matching tests exist and note any residual risk. ================================================ FILE: src/CountLines.ps1 ================================================ $codeFiles = Get-ChildItem -Include *.cs,*.csproj,*.fs,*.fsproj,*.yml,*.yaml,*.md -File -Recurse $totalLines = 0 $files = [ordered]@{} foreach ($codeFile in ($codeFiles | Where-Object {$_.FullName -notlike "*\obj\*" -and $_.FullName -notlike "*\bin\*" -and $_.FullName -notlike "*\.grace\*"})) { $stream = $codeFile.OpenText() $fileContents = $stream.ReadToEnd() $stream.Close() $lines = 0 foreach ($line in $fileContents.Split("`n")) { if (-not [System.String]::IsNullOrEmpty($line) -and -not [System.String]::IsNullOrWhiteSpace($line)) { $lines += 1 } } $files.Add($codeFile.FullName, $lines) $totalLines += $lines } $files | Format-Table -AutoSize Write-Host -ForegroundColor Magenta "Total lines: $totalLines." ================================================ FILE: src/Create-Grace-Objects.ps1 ================================================ $startTime = Get-Date $iterations = 50 1..$iterations | ForEach-Object -Parallel { Set-Alias -Name grace -Value D:\Source\Grace\src\Grace.CLI\bin\Debug\net8.0\Grace.CLI.exe $suffix = (Get-Random -Maximum 65536).ToString("X4") $ownerId = (New-Guid).ToString() $ownerNameOriginal = 'Owner' + $suffix $ownerName = 'Owner' + $suffix + 'A' $organizationId = (New-Guid).ToString() $orgNameOriginal = 'Org' + $suffix $orgName = 'Org' + $suffix + 'A' $repoId = (New-Guid).ToString() $repoNameOriginal = 'Repo' + $suffix $repoName = 'Repo' + $suffix + 'A' $branchId = (New-Guid).ToString() $branchName = 'Branch' + $suffix grace owner create --output Verbose --ownerName $ownerNameOriginal --ownerId $ownerId --doNotSwitch grace owner set-name --output Verbose --ownerId $ownerId --newName $ownerName grace owner get --output Verbose --ownerId $ownerId grace org create --output Verbose --ownerId $ownerId --organizationName $orgNameOriginal --organizationId $organizationId --doNotSwitch grace org set-name --output Verbose --ownerId $ownerId --organizationId $organizationId --newName $orgName grace org get --output Verbose --ownerId $ownerId --organizationId $organizationId grace repo create --output Verbose --ownerId $ownerId --organizationId $organizationId --repositoryName $repoNameOriginal --repositoryId $repoId --doNotSwitch grace repo set-name --output Verbose --ownerId $ownerId --organizationId $organizationId --repositoryId $repoId --newName $repoName grace repo get --output Verbose --ownerId $ownerId --organizationId $organizationId --repositoryId $repoId grace branch create --output Verbose --ownerId $ownerId --organizationId $organizationId --repositoryId $repoId --branchId $branchId --branchName $branchName --parentBranchName main --doNotSwitch $words = "Sit fusce at sociosqu eros bibendum aliquet cursus ante non facilisis tempor Scelerisque arcu potenti feugiat fermentum viverra et litora facilisis vestibulum sit aliquam quisque sagittis ut Ultricies nisi urna cursus tellus tempor vivamus nec Dictumst tristique porta vel cubilia mollis Tempus nullam laoreet sit vestibulum etiam in volutpat dui class netus morbi Duis facilisis at aliquet fusce nisi Nulla arcu molestie mauris integer aenean ligula curabitur dui sociosqu suspendisse mi fringilla faucibus Rhoncus habitasse massa amet ipsum ligula quisque Quisque fames bibendum eu ullamcorper pulvinar in aenean hendrerit Augue tristique aenean amet auctor curabitur congue placerat aenean posuere porttitor pulvinar lectus Mattis aenean elit condimentum nam iaculis ante felis sollicitudin Risus viverra ornare curabitur sem massa nibh vulputate senectus dictum vitae leo varius dictumst tristique Ultrices ut blandit adipiscing dictumst sagittis elementum urna Vel feugiat consectetur malesuada nibh turpis odio convallis molestie vulputate magna venenatis lacinia Suscipit consequat lectus nullam suspendisse aliquam sed venenatis Feugiat vehicula iaculis donec aenean Volutpat amet feugiat fringilla bibendum scelerisque fermentum pellentesque hendrerit dapibus primis eu ipsum proin mauris Amet magna non mattis dictum risus sit Luctus hendrerit in integer euismod sapien aenean vel maecenas venenatis lorem cubilia taciti Id mauris dictum aenean leo quisque auctor sagittis nisl rutrum at Iaculis luctus orci egestas metus commodo praesent sodales nam quis conubia cras sagittis vestibulum Viverra justo cursus tempor fringilla egestas Potenti aliquam quisque tincidunt pellentesque Lacinia eu convallis quis risus accumsan Augue adipiscing orci massa lorem curabitur eleifend tincidunt justo varius vulputate Mollis aenean est pulvinar proin in donec bibendum dolor quis sociosqu mattis mi Euismod urna leo mollis potenti fames mattis ultrices diam Vivamus sit mattis vehicula viverra mi imperdiet Adipiscing est vehicula scelerisque velit Malesuada integer quisque fusce quis mollis eros Leo nec tellus curabitur ornare amet quisque fusce habitasse morbi Sem lacinia eu aenean pretium curae dolor cubilia faucibus purus Sollicitudin nisl tempus auctor etiam felis urna consectetur donec dui Posuere elit orci lobortis magna Enim at pellentesque ac taciti convallis sapien ad elit Integer potenti malesuada lacinia fames euismod amet purus justo sociosqu dolor cras tempus dictumst Dictumst adipiscing quisque sapien pharetra pretium aliquam nunc ipsum varius mi justo aenean mattis Aenean conubia felis inceptos nulla ante sociosqu libero non imperdiet Nunc feugiat sodales commodo interdum rhoncus nulla aliquet cras sociosqu eros sed Vivamus varius sapien sollicitudin curabitur class aenean tempus tempor magna donec bibendum nulla morbi semper Praesent inceptos etiam tempus in Varius hac et feugiat nullam dictum vivamus adipiscing ut in eros nulla molestie ante Interdum dictum volutpat accumsan posuere quis amet curae nostra purus fusce nisl lacus Aenean erat suscipit urna ante In ad varius interdum porta at pulvinar aptent enim nam sit ultrices hendrerit Vitae rhoncus consequat non metus nullam augue Massa vestibulum dapibus lectus nibh at tortor ullamcorper mattis rutrum pellentesque aliquam adipiscing porttitor".Split() # 1..10 | ForEach-Object { # $numberOfWords = Get-Random -Minimum 3 -Maximum 9 # $start = Get-Random -Minimum 0 -Maximum ($words.Count - $numberOfWords) # $message = '' # for ($i = $0; $i -lt $numberOfWords; $i++) { # $message += $words[$i + $start] + " " # } # switch (Get-Random -Maximum 4) { # 0 {grace branch save --output Verbose --ownerId $ownerId --organizationId $organizationId --repositoryId $repoId --branchName $branchName} # 1 {grace branch checkpoint --output Verbose --ownerId $ownerId --organizationId $organizationId --repositoryId $repoId --branchName $branchName -m $message} # 2 {grace branch commit --output Verbose --ownerId $ownerId --organizationId $organizationId --repositoryId $repoId --branchName $branchName -m $message} # 3 {grace branch tag --output Verbose --ownerId $ownerId --organizationId $organizationId --repositoryId $repoId --branchName $branchName -m $message} # } # } grace branch delete --output Verbose --ownerId $ownerId --organizationId $organizationId --repositoryId $repoId --branchId $branchId grace repo delete --output Verbose --ownerId $ownerId --organizationId $organizationId --repositoryId $repoId --deleteReason "Test cleanup" grace org delete --output Verbose --ownerId $ownerId --organizationId $organizationId --deleteReason "Test cleanup" grace owner delete --output Verbose --ownerId $ownerId --deleteReason "Test cleanup" } -ThrottleLimit 16 $endTime = Get-Date $elapsed = $endTime.Subtract($startTime).TotalSeconds "Elapsed: $($elapsed.ToString('F3')) seconds; Operations: $($iterations * 14); Operations/second: $($iterations * 14 / $elapsed)." ================================================ FILE: src/Directory.Build.props ================================================ true true true true latest true $(NoWarn);NETSDK1057 DefaultContainer $(OtherFlags) --test:GraphBasedChecking ================================================ FILE: src/Grace.Actors/AGENTS.md ================================================ # Grace.Actors Agents Guide Start with `../AGENTS.md` for global rules before working on Orleans code. ## Purpose - Define Orleans grain interfaces and implementations that orchestrate stateful workflows in Grace. - Persist state through domain events and records defined in `Grace.Types`. ## Key Patterns - Follow standard Orleans activation patterns; keep grain constructors light and rely on dependency injection. - Drive state changes through explicit events or commands; keep transitions deterministic and idempotent. - Use domain types from `Grace.Types` directly to avoid duplication and guarantee serialization compatibility. - Separate orchestration (grains) from external service or SDK calls by delegating to helper modules/services. ## Project Rules 1. When adding new grain types or serializer changes, ensure `Grace.Orleans.CodeGen` stays in sync and regenerates as needed. 2. Keep grain state mutations safe for retries—idempotent transitions make distributed recovery predictable. 3. Document non-obvious activation, reminder, or timer behavior here so future agents can reason without scanning the entire implementation. ## Validation - Add activation and idempotency tests for new grains or state transitions. - Run `dotnet test --no-build` focusing on actor-related fixtures, then smoke `dotnet build --configuration Release` for the solution. - Confirm code generation outputs (if applicable) before finalizing a PR. ================================================ FILE: src/Grace.Actors/AccessControl.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Interfaces open Grace.Shared.Constants open Grace.Shared.Utilities open Grace.Types.Authorization open Grace.Types.Types open Microsoft.Extensions.Logging open Orleans open Orleans.Runtime open System open System.Collections.Generic open System.Threading.Tasks module AccessControl = let ActorName = ActorName.AccessControl [] type AccessControlState = { Assignments: RoleAssignment list } module AccessControlState = let Empty = { Assignments = [] } let getScopeKey (scope: Scope) = match scope with | Scope.System -> "system" | Scope.Owner ownerId -> $"owner:{ownerId}" | Scope.Organization (ownerId, organizationId) -> $"org:{ownerId}:{organizationId}" | Scope.Repository (ownerId, organizationId, repositoryId) -> $"repo:{ownerId}:{organizationId}:{repositoryId}" | Scope.Branch (ownerId, organizationId, repositoryId, branchId) -> $"branch:{ownerId}:{organizationId}:{repositoryId}:{branchId}" type AccessControlActor([] state: IPersistentState) = inherit Grain() let log = loggerFactory.CreateLogger("AccessControl.Actor") let mutable accessControlState = AccessControlState.Empty let mutable correlationId: CorrelationId = String.Empty override this.OnActivateAsync(ct) = task { accessControlState <- if state.RecordExists then state.State else AccessControlState.Empty let isSystemScope = this.GetPrimaryKeyString() .Equals(getScopeKey Scope.System, StringComparison.OrdinalIgnoreCase) if isSystemScope && accessControlState.Assignments.IsEmpty then let parseList (value: string) = if String.IsNullOrWhiteSpace value then [] else value.Split(';', StringSplitOptions.RemoveEmptyEntries ||| StringSplitOptions.TrimEntries) |> Seq.map (fun item -> item.Trim()) |> Seq.filter (fun item -> not (String.IsNullOrWhiteSpace item)) |> Seq.distinct |> Seq.toList let bootstrapUsers = Environment.GetEnvironmentVariable(EnvironmentVariables.GraceAuthzBootstrapSystemAdminUsers) |> parseList let bootstrapGroups = Environment.GetEnvironmentVariable(EnvironmentVariables.GraceAuthzBootstrapSystemAdminGroups) |> parseList if (not bootstrapUsers.IsEmpty) || (not bootstrapGroups.IsEmpty) then let now = getCurrentInstant () let sourceDetailParts = List() if not bootstrapUsers.IsEmpty then sourceDetailParts.Add($"users={String.Join(';', bootstrapUsers)}") if not bootstrapGroups.IsEmpty then sourceDetailParts.Add($"groups={String.Join(';', bootstrapGroups)}") let sourceDetail = if sourceDetailParts.Count = 0 then None else Some(String.Join("; ", sourceDetailParts)) let userAssignments = bootstrapUsers |> List.map (fun userId -> { Principal = { PrincipalType = PrincipalType.User; PrincipalId = userId } Scope = Scope.System RoleId = "SystemAdmin" Source = "bootstrap" SourceDetail = sourceDetail CreatedAt = now }) let groupAssignments = bootstrapGroups |> List.map (fun groupId -> { Principal = { PrincipalType = PrincipalType.Group; PrincipalId = groupId } Scope = Scope.System RoleId = "SystemAdmin" Source = "bootstrap" SourceDetail = sourceDetail CreatedAt = now }) accessControlState <- { accessControlState with Assignments = userAssignments @ groupAssignments } do! this.SaveState() log.LogWarning( "{CurrentInstant}: Bootstrapped {UserCount} system admin user(s) and {GroupCount} group(s) for system scope.", getCurrentInstantExtended (), userAssignments.Length, groupAssignments.Length ) return () } :> Task member private this.ValidateScope (scope: Scope) (correlationId: CorrelationId) = let expectedKey = getScopeKey scope let actualKey = this.GetPrimaryKeyString() if expectedKey = actualKey then Ok() else Error(GraceError.Create $"AccessControl scope mismatch. Expected '{expectedKey}', got '{actualKey}'." correlationId) member private this.SaveState() = task { state.State <- accessControlState if accessControlState.Assignments |> List.isEmpty then do! DefaultAsyncRetryPolicy.ExecuteAsync(fun () -> state.ClearStateAsync()) else do! DefaultAsyncRetryPolicy.ExecuteAsync(fun () -> state.WriteStateAsync()) } member private this.GrantRole (assignment: RoleAssignment) (metadata: EventMetadata) = task { correlationId <- metadata.CorrelationId match this.ValidateScope assignment.Scope metadata.CorrelationId with | Error error -> return Error error | Ok _ -> let assignments = accessControlState.Assignments |> List.filter (fun existing -> not ( existing.Principal = assignment.Principal && existing.RoleId.Equals(assignment.RoleId, StringComparison.OrdinalIgnoreCase) )) accessControlState <- { accessControlState with Assignments = assignment :: assignments } do! this.SaveState() let returnValue = GraceReturnValue.Create accessControlState.Assignments metadata.CorrelationId return Ok returnValue } member private this.RevokeRole (principal: Principal) (roleId: RoleId) (metadata: EventMetadata) = task { correlationId <- metadata.CorrelationId let assignments = accessControlState.Assignments |> List.filter (fun existing -> not ( existing.Principal = principal && existing.RoleId.Equals(roleId, StringComparison.OrdinalIgnoreCase) )) accessControlState <- { accessControlState with Assignments = assignments } do! this.SaveState() let returnValue = GraceReturnValue.Create accessControlState.Assignments metadata.CorrelationId return Ok returnValue } member private this.ListAssignments (principal: Principal option) (metadata: EventMetadata) = task { correlationId <- metadata.CorrelationId let filtered = match principal with | None -> accessControlState.Assignments | Some value -> accessControlState.Assignments |> List.filter (fun assignment -> assignment.Principal = value) let returnValue = GraceReturnValue.Create filtered metadata.CorrelationId return Ok returnValue } interface IAccessControlActor with member this.Handle command metadata = match command with | AccessControlCommand.GrantRole assignment -> this.GrantRole assignment metadata | AccessControlCommand.RevokeRole (principal, roleId) -> this.RevokeRole principal roleId metadata | AccessControlCommand.ListAssignments principal -> this.ListAssignments principal metadata member this.GetAssignments principal correlationId = let filtered = match principal with | None -> accessControlState.Assignments | Some value -> accessControlState.Assignments |> List.filter (fun assignment -> assignment.Principal = value) filtered |> returnTask ================================================ FILE: src/Grace.Actors/ActorProxy.Extensions.Actor.fs ================================================ namespace Grace.Actors.Extensions open Grace.Actors.Extensions.MemoryCache open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Interfaces open Grace.Actors.Timing open Grace.Actors.Types open Grace.Shared open Grace.Types.Types open Grace.Shared.Utilities open Orleans open Orleans.Runtime open System open System.Collections.Generic module ActorProxy = let getGrainIdentity (grainId: GrainId) = $"{grainId.Type}/{grainId.Key}" type Orleans.IGrainFactory with /// Creates an Orleans grain reference for the given interface and actor type, and adds the correlationId to the grain's context. member this.CreateActorProxyWithCorrelationId<'T when 'T :> IGrainWithGuidKey>(primaryKey: Guid, correlationId) = //logToConsole $"Creating grain for {typeof<'T>.Name} with primary key: {primaryKey}." RequestContext.Set(Constants.CorrelationId, correlationId) let grain = orleansClient.GetGrain<'T>(primaryKey) //logToConsole $"Created actor proxy: CorrelationId: {correlationId}; ActorType: {typeof<'T>.Name}; GrainIdentity: {grain.GetGrainId()}." grain member this.CreateActorProxyWithCorrelationId<'T when 'T :> IGrainWithStringKey>(primaryKey: String, correlationId) = //logToConsole $"Creating grain for {typeof<'T>.Name} with primary key: {primaryKey}." RequestContext.Set(Constants.CorrelationId, correlationId) let grain = orleansClient.GetGrain<'T>(primaryKey) //logToConsole $"Created actor proxy: CorrelationId: {correlationId}; ActorType: {typeof<'T>.Name}; GrainIdentity: {grain.GetGrainId()}." grain module Branch = /// Creates an ActorProxy for a Branch actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (branchId: BranchId) (repositoryId: RepositoryId) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(branchId, correlationId) let orleansContext = Dictionary() orleansContext.Add(Constants.ActorNameProperty, ActorName.Branch) orleansContext.Add(nameof RepositoryId, repositoryId) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module BranchName = /// Creates an ActorProxy for a BranchName actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (repositoryId: RepositoryId) (branchName: BranchName) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId($"{repositoryId}|{branchName}", correlationId) let orleansContext = Dictionary() orleansContext.Add(Constants.ActorNameProperty, ActorName.BranchName) orleansContext.Add(nameof RepositoryId, repositoryId) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module Diff = /// Gets an ActorId for a Diff actor. let GetPrimaryKey (directoryVersionId1: DirectoryVersionId) (directoryVersionId2: DirectoryVersionId) = if directoryVersionId1 < directoryVersionId2 then $"{directoryVersionId1}*{directoryVersionId2}" else $"{directoryVersionId2}*{directoryVersionId1}" /// Creates an ActorProxy for a Diff actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (directoryVersionId1: DirectoryVersionId) (directoryVersionId2: DirectoryVersionId) (ownerId: OwnerId) (organizationId: OrganizationId) (repositoryId: RepositoryId) correlationId = let grain = orleansClient.CreateActorProxyWithCorrelationId((GetPrimaryKey directoryVersionId1 directoryVersionId2), correlationId) let orleansContext = Dictionary() orleansContext.Add(Constants.ActorNameProperty, ActorName.Diff) orleansContext.Add(nameof OwnerId, ownerId) orleansContext.Add(nameof OrganizationId, organizationId) orleansContext.Add(nameof RepositoryId, repositoryId) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module DirectoryVersion = /// Creates an ActorProxy for a DirectoryVersion actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (directoryVersionId: DirectoryVersionId) (repositoryId: RepositoryId) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(directoryVersionId, correlationId) let orleansContext = Dictionary() orleansContext.Add(Constants.ActorNameProperty, ActorName.DirectoryVersion) orleansContext.Add(nameof RepositoryId, repositoryId) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module DirectoryAppearance = /// Creates an ActorProxy for a DirectoryAppearance actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (directoryVersionId: DirectoryVersionId) (repositoryId: RepositoryId) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(directoryVersionId, correlationId) let orleansContext = Dictionary() orleansContext.Add(Constants.ActorNameProperty, ActorName.DirectoryAppearance) orleansContext.Add(nameof RepositoryId, repositoryId) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module FileAppearance = /// Creates an ActorProxy for a FileAppearance actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (fileVersionWithRelativePath: string) (repositoryId: RepositoryId) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(fileVersionWithRelativePath, correlationId) let orleansContext = Dictionary() orleansContext.Add(Constants.ActorNameProperty, ActorName.FileAppearance) orleansContext.Add(nameof RepositoryId, repositoryId) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module GlobalLock = /// Creates an ActorProxy for a GlobalLock actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (lockId: string) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(lockId, correlationId) let orleansContext = Dictionary() orleansContext.Add(Constants.ActorNameProperty, ActorName.GlobalLock) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module Organization = /// Creates an ActorProxy for an Organization actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (organizationId: OrganizationId) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(organizationId, correlationId) let orleansContext = Dictionary() orleansContext.Add(Constants.ActorNameProperty, ActorName.Organization) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module OrganizationName = /// Creates an ActorProxy for an OrganizationName actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (ownerId: OwnerId) (organizationName: OrganizationName) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId($"{ownerId}|{organizationName}", correlationId) let orleansContext = Dictionary() orleansContext.Add(Constants.ActorNameProperty, ActorName.OrganizationName) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module Owner = /// Creates an ActorProxy for an Owner actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (ownerId: OwnerId) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(ownerId, correlationId) let orleansContext = Dictionary() orleansContext.Add(Constants.ActorNameProperty, ActorName.OwnerName) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module OwnerName = /// Creates an ActorProxy for an OwnerName actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (ownerName: OwnerName) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(ownerName, correlationId) let orleansContext = Dictionary() orleansContext.Add(Constants.ActorNameProperty, ActorName.OwnerName) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module PersonalAccessToken = /// Creates an ActorProxy for a PersonalAccessToken actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (userId: string) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(userId, correlationId) let orleansContext = Dictionary() orleansContext.Add(Constants.ActorNameProperty, ActorName.PersonalAccessToken) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module Reminder = /// Creates an ActorProxy for a Reminder actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (reminderId: ReminderId) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(reminderId, correlationId) let orleansContext = Dictionary() orleansContext.Add(Constants.ActorNameProperty, ActorName.Reminder) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module Reference = /// Creates an ActorProxy for a Reference actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (referenceId: ReferenceId) (repositoryId: RepositoryId) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(referenceId, correlationId) let orleansContext = Dictionary() orleansContext.Add(nameof RepositoryId, repositoryId) orleansContext.Add(Constants.ActorNameProperty, ActorName.Reference) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module Repository = /// Creates an ActorProxy for a Repository actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (organizationId: OrganizationId) (repositoryId: RepositoryId) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(repositoryId, correlationId) let orleansContext = Dictionary() orleansContext.Add(nameof OrganizationId, organizationId) orleansContext.Add(nameof RepositoryId, repositoryId) orleansContext.Add(Constants.ActorNameProperty, ActorName.Repository) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module RepositoryName = /// Gets an ActorId for a RepositoryName actor. let GetPrimaryKey (ownerId: OwnerId) (organizationId: OrganizationId) (repositoryName: RepositoryName) = $"{repositoryName}|{ownerId}|{organizationId}" /// Creates an ActorProxy for a RepositoryName actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (ownerId: OwnerId) (organizationId: OrganizationId) (repositoryName: RepositoryName) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId($"{ownerId}|{organizationId}|{repositoryName}", correlationId) let orleansContext = Dictionary() orleansContext.Add(nameof OrganizationId, organizationId) orleansContext.Add(Constants.ActorNameProperty, ActorName.RepositoryName) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module PromotionQueue = open Grace.Types.Queue /// Creates an ActorProxy for a PromotionQueue actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (targetBranchId: BranchId) (repositoryId: RepositoryId) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(targetBranchId, correlationId) let orleansContext = Dictionary() orleansContext.Add(nameof RepositoryId, repositoryId) orleansContext.Add(Constants.ActorNameProperty, ActorName.PromotionQueue) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module PromotionSet = open Grace.Types.PromotionSet /// Creates an ActorProxy for a PromotionSet actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (promotionSetId: PromotionSetId) (repositoryId: RepositoryId) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(promotionSetId, correlationId) let orleansContext = Dictionary() orleansContext.Add(nameof RepositoryId, repositoryId) orleansContext.Add(Constants.ActorNameProperty, ActorName.PromotionSet) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module ValidationSet = open Grace.Types.Validation /// Creates an ActorProxy for a ValidationSet actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (validationSetId: ValidationSetId) (repositoryId: RepositoryId) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(validationSetId, correlationId) let orleansContext = Dictionary() orleansContext.Add(nameof RepositoryId, repositoryId) orleansContext.Add(Constants.ActorNameProperty, ActorName.ValidationSet) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module ValidationResult = open Grace.Types.Validation /// Creates an ActorProxy for a ValidationResult actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (validationResultId: ValidationResultId) (repositoryId: RepositoryId) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(validationResultId, correlationId) let orleansContext = Dictionary() orleansContext.Add(nameof RepositoryId, repositoryId) orleansContext.Add(Constants.ActorNameProperty, ActorName.ValidationResult) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module Artifact = open Grace.Types.Artifact /// Creates an ActorProxy for an Artifact actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (artifactId: ArtifactId) (repositoryId: RepositoryId) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(artifactId, correlationId) let orleansContext = Dictionary() orleansContext.Add(nameof RepositoryId, repositoryId) orleansContext.Add(Constants.ActorNameProperty, ActorName.Artifact) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module Policy = open Grace.Types.Policy /// Creates an ActorProxy for a Policy actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (targetBranchId: BranchId) (repositoryId: RepositoryId) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(targetBranchId, correlationId) let orleansContext = Dictionary() orleansContext.Add(nameof RepositoryId, repositoryId) orleansContext.Add(Constants.ActorNameProperty, ActorName.Policy) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module Review = open Grace.Types.Review /// Creates an ActorProxy for a Review actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (promotionSetId: PromotionSetId) (repositoryId: RepositoryId) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(promotionSetId, correlationId) let orleansContext = Dictionary() orleansContext.Add(nameof RepositoryId, repositoryId) orleansContext.Add(Constants.ActorNameProperty, ActorName.Review) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module WorkItem = open Grace.Types.WorkItem /// Creates an ActorProxy for a WorkItem actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (workItemId: WorkItemId) (repositoryId: RepositoryId) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(workItemId, correlationId) let orleansContext = Dictionary() orleansContext.Add(nameof RepositoryId, repositoryId) orleansContext.Add(Constants.ActorNameProperty, ActorName.WorkItem) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module WorkItemNumber = /// Creates an ActorProxy for a WorkItemNumber actor. The primary key is repository-scoped. let CreateActorProxy (repositoryId: RepositoryId) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId($"{repositoryId}", correlationId) let orleansContext = Dictionary() orleansContext.Add(nameof RepositoryId, repositoryId) orleansContext.Add(Constants.ActorNameProperty, ActorName.WorkItemNumber) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module WorkItemNumberCounter = /// Creates an ActorProxy for a WorkItemNumberCounter actor. The primary key is repository-scoped. let CreateActorProxy (repositoryId: RepositoryId) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId($"{repositoryId}", correlationId) let orleansContext = Dictionary() orleansContext.Add(nameof RepositoryId, repositoryId) orleansContext.Add(Constants.ActorNameProperty, ActorName.WorkItemNumberCounter) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module AccessControl = /// Creates an ActorProxy for an AccessControl actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (scopeKey: string) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId(scopeKey, correlationId) let orleansContext = Dictionary() orleansContext.Add(Constants.ActorNameProperty, ActorName.AccessControl) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain module RepositoryPermission = /// Creates an ActorProxy for a RepositoryPermission actor, and adds the correlationId to the server's MemoryCache so /// it's available in the OnActivateAsync() method. let CreateActorProxy (repositoryId: RepositoryId) (correlationId: string) = let grain = orleansClient.CreateActorProxyWithCorrelationId($"{repositoryId}", correlationId) let orleansContext = Dictionary() orleansContext.Add(nameof RepositoryId, repositoryId) orleansContext.Add(Constants.ActorNameProperty, ActorName.RepositoryPermission) memoryCache.CreateOrleansContextEntry(grain.GetGrainId(), orleansContext) grain ================================================ FILE: src/Grace.Actors/Artifact.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Actors.Types open Grace.Shared open Grace.Shared.Constants open Grace.Shared.Utilities open Grace.Types.Artifact open Grace.Types.Events open Grace.Types.Types open Microsoft.Extensions.Logging open Orleans open Orleans.Runtime open System open System.Collections.Generic open System.Threading.Tasks module Artifact = type ArtifactActor([] state: IPersistentState>) = inherit Grain() static let actorName = ActorName.Artifact let log = loggerFactory.CreateLogger("Artifact.Actor") let mutable currentCommand = String.Empty let mutable artifact = ArtifactMetadata.Default member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () logActorActivation log this.IdentityString activateStartTime (getActorActivationMessage state.RecordExists) artifact <- state.State |> Seq.fold (fun dto event -> ArtifactMetadata.UpdateDto event dto) artifact Task.CompletedTask member private this.ApplyEvent(artifactEvent: ArtifactEvent) = task { let correlationId = artifactEvent.Metadata.CorrelationId try state.State.Add(artifactEvent) do! state.WriteStateAsync() artifact <- artifact |> ArtifactMetadata.UpdateDto artifactEvent let graceEvent = GraceEvent.ArtifactEvent artifactEvent do! publishGraceEvent graceEvent artifactEvent.Metadata let graceReturnValue: GraceReturnValue = (GraceReturnValue.Create "Artifact command succeeded." correlationId) .enhance(nameof RepositoryId, artifact.RepositoryId) .enhance (nameof ArtifactId, artifact.ArtifactId) return Ok graceReturnValue with | ex -> log.LogError( ex, "{CurrentInstant}: Node: {HostName}; CorrelationId: {CorrelationId}; Failed to apply event for ArtifactId: {ArtifactId}.", getCurrentInstantExtended (), getMachineName, correlationId, artifact.ArtifactId ) return Error( (GraceError.CreateWithException ex "Failed while applying Artifact event." correlationId) .enhance(nameof RepositoryId, artifact.RepositoryId) .enhance (nameof ArtifactId, artifact.ArtifactId) ) } interface IHasRepositoryId with member this.GetRepositoryId correlationId = artifact.RepositoryId |> returnTask interface IArtifactActor with member this.Exists correlationId = this.correlationId <- correlationId not <| artifact.ArtifactId.Equals(ArtifactId.Empty) |> returnTask member this.Get correlationId = this.correlationId <- correlationId if artifact.ArtifactId = ArtifactId.Empty then Option.None else Some artifact |> returnTask member this.GetEvents correlationId = this.correlationId <- correlationId state.State :> IReadOnlyList |> returnTask member this.Handle command metadata = let isValid (artifactCommand: ArtifactCommand) (eventMetadata: EventMetadata) = task { if state.State.Exists(fun ev -> ev.Metadata.CorrelationId = eventMetadata.CorrelationId) then return Error(GraceError.Create "Duplicate correlation ID for Artifact command." eventMetadata.CorrelationId) else match artifactCommand with | ArtifactCommand.Create _ when artifact.ArtifactId <> ArtifactId.Empty -> return Error(GraceError.Create "Artifact already exists." eventMetadata.CorrelationId) | _ -> return Ok artifactCommand } let processCommand (artifactCommand: ArtifactCommand) (eventMetadata: EventMetadata) = task { let eventType = match artifactCommand with | ArtifactCommand.Create artifactDto -> ArtifactEventType.Created artifactDto let artifactEvent: ArtifactEvent = { Event = eventType; Metadata = eventMetadata } return! this.ApplyEvent artifactEvent } task { currentCommand <- getDiscriminatedUnionCaseName command this.correlationId <- metadata.CorrelationId match! isValid command metadata with | Ok validCommand -> return! processCommand validCommand metadata | Error validationError -> return Error validationError } ================================================ FILE: src/Grace.Actors/Branch.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.ActorProxy open Grace.Actors.Extensions.MemoryCache open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Actors.Types open Grace.Shared open Grace.Shared.Constants open Grace.Shared.Utilities open Grace.Shared.Validation.Errors open Grace.Types.Reference open Grace.Types.Reminder open Grace.Types.Repository open Grace.Types.Branch open Grace.Types.Events open Grace.Types.Types open Microsoft.Extensions.Logging open NodaTime open Orleans open Orleans.Runtime open System open System.Collections.Generic open System.Diagnostics open System.Globalization open System.Linq open System.Runtime.Serialization open System.Text open System.Threading.Tasks open System.Text.Json open System.Net.Http.Json open FSharpPlus.Data.MultiMap open System.Threading module Branch = type BranchActor([] state: IPersistentState>) = inherit Grain() static let actorName = ActorName.Branch let log = loggerFactory.CreateLogger("Branch.Actor") let mutable branchDto: BranchDto = BranchDto.Default let mutable currentCommand = String.Empty /// Updates the branchDto with the latest reference of each type from the branch. let updateLatestReferences (branchDto: BranchDto) correlationId = task { let mutable newBranchDto = branchDto // Get the enabled reference types. This allows us to limit the ReferenceTypes we search for. let enabledReferenceTypes = List() if branchDto.PromotionEnabled then enabledReferenceTypes.Add(ReferenceType.Promotion) if branchDto.CommitEnabled then enabledReferenceTypes.Add(ReferenceType.Commit) if branchDto.CheckpointEnabled then enabledReferenceTypes.Add(ReferenceType.Checkpoint) if branchDto.SaveEnabled then enabledReferenceTypes.Add(ReferenceType.Save) if branchDto.TagEnabled then enabledReferenceTypes.Add(ReferenceType.Tag) if branchDto.ExternalEnabled then enabledReferenceTypes.Add(ReferenceType.External) if branchDto.AutoRebaseEnabled then enabledReferenceTypes.Add(ReferenceType.Rebase) let referenceTypes = enabledReferenceTypes.ToArray() // Get the latest references. let! latestReferences = getLatestReferenceByReferenceTypes referenceTypes branchDto.RepositoryId branchDto.BranchId // Get the latest reference of any type. let latestReference = latestReferences .Values .OrderByDescending(fun referenceDto -> referenceDto.UpdatedAt) .FirstOrDefault(ReferenceDto.Default) newBranchDto <- { newBranchDto with LatestReference = latestReference } // Get the latest reference of each type. for kvp in latestReferences do let referenceDto = kvp.Value match kvp.Key with | Save -> newBranchDto <- { newBranchDto with LatestSave = referenceDto } | Checkpoint -> newBranchDto <- { newBranchDto with LatestCheckpoint = referenceDto } | Commit -> newBranchDto <- { newBranchDto with LatestCommit = referenceDto } | Promotion -> newBranchDto <- { newBranchDto with LatestPromotion = referenceDto; BasedOn = referenceDto } | Rebase -> let basedOnLink = kvp.Value.Links |> Seq.find (fun link -> match link with | ReferenceLinkType.BasedOn _ -> true | _ -> false) let basedOnReferenceId = match basedOnLink with | ReferenceLinkType.BasedOn referenceId -> referenceId | _ -> ReferenceId.Empty let basedOnReferenceActorProxy = Reference.CreateActorProxy basedOnReferenceId branchDto.RepositoryId correlationId let! basedOnReferenceDto = basedOnReferenceActorProxy.Get correlationId newBranchDto <- { newBranchDto with BasedOn = basedOnReferenceDto } | External -> () | Tag -> () return { newBranchDto with ShouldRecomputeLatestReferences = false } } member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () branchDto <- state.State |> Seq.fold (fun branchDto branchEvent -> branchDto |> BranchDto.UpdateDto branchEvent) BranchDto.Default logActorActivation log this.IdentityString activateStartTime (getActorActivationMessage state.RecordExists) Task.CompletedTask member private this.ApplyEvent branchEvent = task { try // If the branchEvent is Created or Rebased, we need to get the reference that the branch is based on for updating the branchDto. match branchEvent.Event with | Created (branchId, branchName, parentBranchId, basedOn, ownerId, organizationId, repositoryId, branchPermissions) -> let! basedOnReferenceDto = if basedOn <> ReferenceId.Empty then task { let referenceActorProxy = Reference.CreateActorProxy basedOn repositoryId branchEvent.Metadata.CorrelationId return! referenceActorProxy.Get branchEvent.Metadata.CorrelationId } else ReferenceDto.Default |> returnTask branchEvent.Metadata.Properties[ "basedOnReferenceDto" ] <- serialize basedOnReferenceDto | Rebased basedOn -> let referenceActorProxy = Reference.CreateActorProxy basedOn branchDto.RepositoryId branchEvent.Metadata.CorrelationId let! basedOnReferenceDto = referenceActorProxy.Get branchEvent.Metadata.CorrelationId branchEvent.Metadata.Properties[ "basedOnReferenceDto" ] <- serialize basedOnReferenceDto | _ -> () // Update the branchDto with the event. branchDto <- branchDto |> BranchDto.UpdateDto branchEvent branchEvent.Metadata.Properties[ nameof RepositoryId ] <- $"{branchDto.RepositoryId}" match branchEvent.Event with // Don't save these reference creation events, and don't send them as events; that was done by the Reference actor when the reference was created. | Assigned (referenceDto, _, _, _) | Promoted (referenceDto, _, _, _) | Committed (referenceDto, _, _, _) | Checkpointed (referenceDto, _, _, _) | Saved (referenceDto, _, _, _) | Tagged (referenceDto, _, _, _) | ExternalCreated (referenceDto, _, _, _) -> branchEvent.Metadata.Properties[ nameof ReferenceId ] <- $"{referenceDto.ReferenceId}" | Rebased referenceId -> branchEvent.Metadata.Properties[ nameof ReferenceId ] <- $"{referenceId}" // Save the rest of the events. | _ -> // For all other events, add the event to the branchEvents list, and save it to actor state. state.State.Add branchEvent do! state.WriteStateAsync() // Publish the event to the rest of the world. let graceEvent = GraceEvent.BranchEvent branchEvent do! publishGraceEvent graceEvent branchEvent.Metadata let returnValue = GraceReturnValue.Create "Branch command succeeded." branchEvent.Metadata.CorrelationId returnValue .enhance(nameof RepositoryId, branchDto.RepositoryId) .enhance(nameof BranchId, branchDto.BranchId) .enhance(nameof BranchName, branchDto.BranchName) .enhance(nameof ParentBranchId, branchDto.ParentBranchId) .enhance (nameof BranchEventType, getDiscriminatedUnionFullName branchEvent.Event) |> ignore // If the event has a referenceId, add it to the return properties. if branchEvent.Metadata.Properties.ContainsKey(nameof ReferenceId) then returnValue.Properties.Add(nameof ReferenceId, Guid.Parse(branchEvent.Metadata.Properties[nameof ReferenceId])) // If there are child branch results, add them to the return properties. if branchEvent.Metadata.Properties.ContainsKey("ChildBranchResults") then returnValue.Properties.Add("ChildBranchResults", branchEvent.Metadata.Properties["ChildBranchResults"]) return Ok returnValue with | ex -> let graceError = GraceError.CreateWithException ex (getErrorMessage BranchError.FailedWhileApplyingEvent) branchEvent.Metadata.CorrelationId graceError .enhance(nameof RepositoryId, branchDto.RepositoryId) .enhance(nameof BranchId, branchDto.BranchId) .enhance(nameof BranchName, branchDto.BranchName) .enhance(nameof ParentBranchId, branchDto.ParentBranchId) .enhance (nameof BranchEventType, getDiscriminatedUnionFullName branchEvent.Event) |> ignore // If the event has a referenceId, add it to the return properties. if branchEvent.Metadata.Properties.ContainsKey(nameof ReferenceId) then graceError.enhance (nameof ReferenceId, branchEvent.Metadata.Properties[nameof ReferenceId]) |> ignore return Error graceError } interface IGraceReminderWithGuidKey with /// Schedules a Grace reminder. member this.ScheduleReminderAsync reminderType delay state correlationId = task { let reminder = ReminderDto.Create actorName $"{this.IdentityString}" branchDto.OwnerId branchDto.OrganizationId branchDto.RepositoryId reminderType (getFutureInstant delay) state correlationId do! createReminder reminder } :> Task /// Receives a Grace reminder. member this.ReceiveReminderAsync(reminder: ReminderDto) : Task> = this.correlationId <- reminder.CorrelationId task { match reminder.ReminderType, reminder.State with | ReminderTypes.PhysicalDeletion, ReminderState.BranchPhysicalDeletion physicalDeletionReminderState -> this.correlationId <- physicalDeletionReminderState.CorrelationId // Delete saved state for this actor. do! state.ClearStateAsync() log.LogInformation( "{CurrentInstant}: Node: {hostName}; CorrelationId: {correlationId}; Deleted physical state for branch; RepositoryId: {repositoryId}; BranchId: {branchId}; BranchName: {branchName}; ParentBranchId: {parentBranchId}; deleteReason: {deleteReason}.", getCurrentInstantExtended (), getMachineName, physicalDeletionReminderState.CorrelationId, physicalDeletionReminderState.RepositoryId, physicalDeletionReminderState.BranchId, physicalDeletionReminderState.BranchName, physicalDeletionReminderState.ParentBranchId, physicalDeletionReminderState.DeleteReason ) this.DeactivateOnIdle() return Ok() | reminderType, state -> return Error( GraceError.Create $"{actorName} does not process reminder type {getDiscriminatedUnionCaseName reminderType} with state {getDiscriminatedUnionCaseName state}." this.correlationId ) } interface IHasRepositoryId with member this.GetRepositoryId correlationId = branchDto.RepositoryId |> returnTask interface IBranchActor with member this.GetEvents correlationId = task { this.correlationId <- correlationId return state.State :> IReadOnlyList } member this.Exists correlationId = this.correlationId <- correlationId branchDto.UpdatedAt.IsSome |> returnTask member this.IsDeleted correlationId = this.correlationId <- correlationId branchDto.DeletedAt.IsSome |> returnTask member this.Handle command metadata = let isValid (command: BranchCommand) (metadata: EventMetadata) = task { if state.State.Exists(fun ev -> ev.Metadata.CorrelationId = metadata.CorrelationId) && (state.State.Count > 3) then return Error(GraceError.Create (getErrorMessage BranchError.DuplicateCorrelationId) metadata.CorrelationId) else match command with | BranchCommand.Create (branchId, branchName, parentBranchId, basedOn, ownerId, organizationId, repositoryId, branchPermissions) -> match branchDto.UpdatedAt with | Some _ -> return Error(GraceError.Create (BranchError.getErrorMessage BranchAlreadyExists) metadata.CorrelationId) | None -> return Ok command | _ -> match branchDto.UpdatedAt with | Some _ -> return Ok command | None -> return Error(GraceError.Create (getErrorMessage BranchError.BranchDoesNotExist) metadata.CorrelationId) } let addReference ownerId organizationId repositoryId branchId directoryId sha256Hash referenceText referenceType links = task { let referenceId: ReferenceId = ReferenceId.NewGuid() let referenceActor = Reference.CreateActorProxy referenceId repositoryId this.correlationId let referenceCommand = ReferenceCommand.Create( referenceId, ownerId, organizationId, repositoryId, branchId, directoryId, sha256Hash, referenceType, referenceText, links ) metadata.Properties[ nameof (RepositoryId) ] <- $"{repositoryId}" return! referenceActor.Handle referenceCommand metadata } let addReferenceToCurrentBranch = addReference branchDto.OwnerId branchDto.OrganizationId branchDto.RepositoryId branchDto.BranchId let processCommand (command: BranchCommand) (metadata: EventMetadata) = task { try //logToConsole // $"In BranchActor.Handle.processCommand: command: {getDiscriminatedUnionFullName command}; metadata: {serialize metadata}." let! event = task { match command with | Create (branchId, branchName, parentBranchId, basedOn, ownerId, organizationId, repositoryId, branchPermissions) -> // Add an initial Rebase reference to this branch that points to the BasedOn reference, unless we're creating `main`. if branchName <> InitialBranchName then // We need to get the reference that we're rebasing on, so we can get the DirectoryId and Sha256Hash. let referenceActorProxy = Reference.CreateActorProxy basedOn repositoryId this.correlationId let! promotionDto = referenceActorProxy.Get this.correlationId match! addReference branchDto.OwnerId branchDto.OrganizationId repositoryId branchId promotionDto.DirectoryId promotionDto.Sha256Hash promotionDto.ReferenceText ReferenceType.Rebase [ ReferenceLinkType.BasedOn promotionDto.ReferenceId ] with | Ok _ -> //logToConsole $"In BranchActor.Handle.processCommand: rebaseReferenceDto: {rebaseReferenceDto}." () | Error error -> logToConsole $"In BranchActor.Handle.processCommand: Error rebasing on referenceId: {basedOn}. promotionDto: {serialize promotionDto}" memoryCache.CreateBranchNameEntry(repositoryId, branchName, branchId) return Ok(Created(branchId, branchName, parentBranchId, basedOn, ownerId, organizationId, repositoryId, branchPermissions)) | BranchCommand.Rebase referenceId -> metadata.Properties[ "BasedOn" ] <- $"{referenceId}" metadata.Properties[ nameof ReferenceId ] <- $"{referenceId}" metadata.Properties[ nameof RepositoryId ] <- $"{branchDto.RepositoryId}" metadata.Properties[ nameof BranchId ] <- $"{this.GetGrainId().GetGuidKey()}" metadata.Properties[ nameof BranchName ] <- $"{branchDto.BranchName}" // We need to get the reference that we're rebasing on, so we can get the directoryId and sha256Hash. let referenceActorProxy = Reference.CreateActorProxy referenceId branchDto.RepositoryId this.correlationId let! promotionDto = referenceActorProxy.Get metadata.CorrelationId // Add the Rebase reference to this branch. match! addReferenceToCurrentBranch promotionDto.DirectoryId promotionDto.Sha256Hash promotionDto.ReferenceText ReferenceType.Rebase [ ReferenceLinkType.BasedOn promotionDto.ReferenceId ] with | Ok rebaseReferenceDto -> //logToConsole $"In BranchActor.Handle.processCommand: rebaseReferenceDto: {rebaseReferenceDto}." return Ok(Rebased referenceId) | Error error -> log.LogError( "{CurrentInstant}: Error rebasing on referenceId: {referenceId}; promotionDto: {promotionDto}.\n{Error}", getCurrentInstantExtended (), referenceId, serialize promotionDto, error ) return Error error | SetName branchName -> return Ok(NameSet branchName) | EnableAssign enabled -> return Ok(EnabledAssign enabled) | EnablePromotion enabled -> return Ok(EnabledPromotion enabled) | EnableCommit enabled -> return Ok(EnabledCommit enabled) | EnableCheckpoint enabled -> return Ok(EnabledCheckpoint enabled) | EnableSave enabled -> return Ok(EnabledSave enabled) | EnableTag enabled -> return Ok(EnabledTag enabled) | EnableExternal enabled -> return Ok(EnabledExternal enabled) | EnableAutoRebase enabled -> return Ok(EnabledAutoRebase enabled) | SetPromotionMode promotionMode -> return Ok(PromotionModeSet promotionMode) | UpdateParentBranch newParentBranchId -> return Ok(ParentBranchUpdated newParentBranchId) | BranchCommand.Assign (directoryVersionId, sha256Hash, referenceText) -> match! addReferenceToCurrentBranch directoryVersionId sha256Hash referenceText ReferenceType.Promotion List.empty with | Ok returnValue -> return Ok(Assigned(returnValue.ReturnValue, directoryVersionId, sha256Hash, referenceText)) | Error error -> return Error error | BranchCommand.Promote (directoryVersionId, sha256Hash, referenceText) -> match! addReferenceToCurrentBranch directoryVersionId sha256Hash referenceText ReferenceType.Promotion List.empty with | Ok returnValue -> return Ok(Promoted(returnValue.ReturnValue, directoryVersionId, sha256Hash, referenceText)) | Error error -> return Error error | BranchCommand.Commit (directoryVersionId, sha256Hash, referenceText) -> match! addReferenceToCurrentBranch directoryVersionId sha256Hash referenceText ReferenceType.Commit List.empty with | Ok returnValue -> return Ok(Committed(returnValue.ReturnValue, directoryVersionId, sha256Hash, referenceText)) | Error error -> return Error error | BranchCommand.Checkpoint (directoryVersionId, sha256Hash, referenceText) -> match! addReferenceToCurrentBranch directoryVersionId sha256Hash referenceText ReferenceType.Checkpoint List.empty with | Ok returnValue -> return Ok(Checkpointed(returnValue.ReturnValue, directoryVersionId, sha256Hash, referenceText)) | Error error -> return Error error | BranchCommand.Save (directoryVersionId, sha256Hash, referenceText) -> match! addReferenceToCurrentBranch directoryVersionId sha256Hash referenceText ReferenceType.Save List.empty with | Ok returnValue -> return Ok(Saved(returnValue.ReturnValue, directoryVersionId, sha256Hash, referenceText)) | Error error -> return Error error | BranchCommand.Tag (directoryVersionId, sha256Hash, referenceText) -> match! addReferenceToCurrentBranch directoryVersionId sha256Hash referenceText ReferenceType.Tag List.empty with | Ok returnValue -> return Ok(Tagged(returnValue.ReturnValue, directoryVersionId, sha256Hash, referenceText)) | Error error -> return Error error | BranchCommand.CreateExternal (directoryVersionId, sha256Hash, referenceText) -> match! addReferenceToCurrentBranch directoryVersionId sha256Hash referenceText ReferenceType.External List.empty with | Ok returnValue -> return Ok(ExternalCreated(returnValue.ReturnValue, directoryVersionId, sha256Hash, referenceText)) | Error error -> return Error error | RemoveReference referenceId -> return Ok(ReferenceRemoved referenceId) | DeleteLogical (force, deleteReason, reassignChildBranches, newParentBranchId) -> // Check for child branches let! childBranches = getChildBranches branchDto.RepositoryId branchDto.BranchId Int32.MaxValue false metadata.CorrelationId if childBranches.Length > 0 && not reassignChildBranches && not force then // Cannot delete branch with children without reassigning or forcing deletion return Error( GraceError.Create (BranchError.getErrorMessage BranchError.CannotDeleteBranchesWithChildrenWithoutReassigningChildren) metadata.CorrelationId ) else // Track results for child branch operations let childBranchResults = System.Collections.Concurrent.ConcurrentBag() // If force is set and there are child branches, delete them recursively if force && childBranches.Length > 0 then do! Parallel.ForEachAsync( childBranches, Constants.ParallelOptions, (fun childBranch ct -> ValueTask( task { let childBranchActorProxy = Branch.CreateActorProxy childBranch.BranchId branchDto.RepositoryId metadata.CorrelationId let childMetadata = EventMetadata.New metadata.CorrelationId GraceSystemUser // Recursively delete child branch with force match! childBranchActorProxy.Handle (DeleteLogical( true, $"Parent branch {branchDto.BranchName} is being deleted.", false, None )) childMetadata with | Ok _ -> childBranchResults.Add($"Deleted child branch: {childBranch.BranchName}") | Error error -> log.LogError( "{CurrentInstant}: Error deleting child branch {ChildBranchId}: {Error}", getCurrentInstantExtended (), childBranch.BranchId, error ) childBranchResults.Add($"Failed to delete child branch: {childBranch.BranchName}") } :> Task )) ) // If reassigning children, determine the new parent and update them if reassignChildBranches && childBranches.Length > 0 then let targetParentBranchId = match newParentBranchId with | Some id -> id | None -> branchDto.ParentBranchId // Use the deleted branch's parent // Reassign all child branches to the new parent do! Parallel.ForEachAsync( childBranches, Constants.ParallelOptions, (fun childBranch ct -> ValueTask( task { let childBranchActorProxy = Branch.CreateActorProxy childBranch.BranchId branchDto.RepositoryId metadata.CorrelationId let childMetadata = EventMetadata.New metadata.CorrelationId GraceSystemUser match! childBranchActorProxy.Handle (UpdateParentBranch targetParentBranchId) childMetadata with | Ok _ -> childBranchResults.Add($"Reassigned child branch: {childBranch.BranchName}") | Error error -> log.LogError( "{CurrentInstant}: Error updating parent branch for child {ChildBranchId}: {Error}", getCurrentInstantExtended (), childBranch.BranchId, error ) childBranchResults.Add($"Failed to reassign child branch: {childBranch.BranchName}") } :> Task )) ) // Now proceed with the deletion regardless of reassignment let tryGetLogicalDeleteDaysFromMetadata () = match metadata.Properties.TryGetValue("RepositoryLogicalDeleteDays") with | true, value -> let mutable parsed = 0.0f if Single.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, &parsed) then Some parsed else None | _ -> None let! logicalDeleteDays = match tryGetLogicalDeleteDaysFromMetadata () with | Some days -> Task.FromResult days | None -> task { let repositoryActorProxy = Repository.CreateActorProxy branchDto.OrganizationId branchDto.RepositoryId metadata.CorrelationId let! repositoryDto = repositoryActorProxy.Get(metadata.CorrelationId) return repositoryDto.LogicalDeleteDays } // Delete the references for this branch. let! references = getReferences branchDto.RepositoryId branchDto.BranchId Int32.MaxValue metadata.CorrelationId do! Parallel.ForEachAsync( references, Constants.ParallelOptions, (fun reference ct -> ValueTask( task { let referenceActorProxy = Reference.CreateActorProxy reference.ReferenceId branchDto.RepositoryId metadata.CorrelationId let metadata = EventMetadata.New metadata.CorrelationId GraceSystemUser metadata.Properties[ nameof (RepositoryId) ] <- $"{branchDto.RepositoryId}" metadata.Properties[ "RepositoryLogicalDeleteDays" ] <- logicalDeleteDays.ToString("F", CultureInfo.InvariantCulture) match! referenceActorProxy.Handle (ReferenceCommand.DeleteLogical( true, $"Branch {branchDto.BranchName} is being deleted." )) metadata with | Ok _ -> () | Error error -> log.LogError( "{CurrentInstant}: Error deleting reference {ReferenceId}: {Error}", getCurrentInstantExtended (), reference.ReferenceId, error ) } :> Task )) ) let (physicalDeletionReminderState: PhysicalDeletionReminderState) = { RepositoryId = branchDto.RepositoryId BranchId = branchDto.BranchId BranchName = branchDto.BranchName ParentBranchId = branchDto.ParentBranchId DeleteReason = deleteReason CorrelationId = metadata.CorrelationId } do! (this :> IGraceReminderWithGuidKey) .ScheduleReminderAsync ReminderTypes.PhysicalDeletion (Duration.FromDays(float logicalDeleteDays)) (ReminderState.BranchPhysicalDeletion physicalDeletionReminderState) metadata.CorrelationId // Add child branch results to metadata for output if childBranchResults.Count > 0 then metadata.Properties[ "ChildBranchResults" ] <- childBranchResults.ToArray() |> String.concat Environment.NewLine return Ok(LogicalDeleted(force, deleteReason, reassignChildBranches, newParentBranchId)) | DeletePhysical -> // Delete the state from storage, and deactivate the actor. do! state.ClearStateAsync() this.DeactivateOnIdle() return Ok PhysicalDeleted | Undelete -> return Ok Undeleted } match event with | Ok event -> return! this.ApplyEvent { Event = event; Metadata = metadata } | Error error -> return Error error with | ex -> log.LogError( ex, "{CurrentInstant}: In Branch.Actor.Handle.processCommand: Error processing command {Command}.", getCurrentInstantExtended (), getDiscriminatedUnionFullName command ) return Error(GraceError.CreateWithException ex String.Empty metadata.CorrelationId) } task { currentCommand <- getDiscriminatedUnionCaseName command this.correlationId <- metadata.CorrelationId match! isValid command metadata with | Ok command -> return! processCommand command metadata | Error error -> return Error error } member this.Get correlationId = task { this.correlationId <- correlationId if branchDto.ShouldRecomputeLatestReferences then let! branchDtoWithLatestReferences = updateLatestReferences branchDto correlationId branchDto <- branchDtoWithLatestReferences return branchDto } member this.GetParentBranch correlationId = task { this.correlationId <- correlationId let branchActorProxy = Branch.CreateActorProxy branchDto.ParentBranchId branchDto.RepositoryId correlationId return! branchActorProxy.Get correlationId } member this.GetLatestCommit correlationId = this.correlationId <- correlationId branchDto.LatestCommit |> returnTask member this.GetLatestPromotion correlationId = this.correlationId <- correlationId branchDto.LatestPromotion |> returnTask member this.MarkForRecompute(correlationId: CorrelationId) : Task = this.correlationId <- correlationId branchDto <- { branchDto with ShouldRecomputeLatestReferences = true } Task.CompletedTask ================================================ FILE: src/Grace.Actors/BranchName.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.MemoryCache open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Types.Types open Grace.Shared.Utilities open Microsoft.Extensions.Logging open NodaTime open Orleans open Orleans.Runtime open System open System.Threading.Tasks open OrganizationName module BranchName = //let log = loggerFactory.CreateLogger("BranchName.Actor") type BranchNameActor() = inherit Grain() static let actorName = ActorName.BranchName let log = loggerFactory.CreateLogger("BranchName.Actor") let mutable cachedBranchId: Guid option = None member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () logActorActivation log this.IdentityString activateStartTime "In-memory only" Task.CompletedTask interface IBranchNameActor with member this.GetBranchId correlationId = this.correlationId <- correlationId Task.FromResult(cachedBranchId) member this.SetBranchId branchId correlationId = this.correlationId <- correlationId cachedBranchId <- Some branchId Task.CompletedTask ================================================ FILE: src/Grace.Actors/CodeGenAttribute.Actor.fs ================================================ namespace Grace.Actors open System.Runtime.CompilerServices [] [] do () ================================================ FILE: src/Grace.Actors/Constants.Actor.fs ================================================ namespace Grace.Actors open Grace.Shared open Grace.Types.Types open Grace.Shared.Utilities open NodaTime open System open System.Collections.Concurrent module Constants = /// Constants for the names of the actors. /// /// These names should exactly match the actors' typenames. module ActorName = [] let AccessControl = "AccessControlActor" [] let Branch = "BranchActor" [] let BranchName = "BranchNameActor" [] let Checkpoint = "CheckpointActor" [] let Diff = "DiffActor" [] let DirectoryVersion = "DirectoryVersionActor" [] let DirectoryAppearance = "DirectoryAppearanceActor" [] let FileAppearance = "FileAppearanceActor" [] let GlobalLock = "GlobalLockActor" [] let GrainRepository = "GrainRepository" [] let Notification = "NotificationActor" [] let Organization = "OrganizationActor" [] let OrganizationName = "OrganizationNameActor" [] let Owner = "OwnerActor" [] let OwnerName = "OwnerNameActor" [] let NamedSection = "NamedSectionActor" [] let PersonalAccessToken = "PersonalAccessTokenActor" [] let PromotionSet = "PromotionSetActor" [] let PromotionQueue = "PromotionQueueActor" [] let Policy = "PolicyActor" [] let Review = "ReviewActor" [] let ValidationSet = "ValidationSetActor" [] let ValidationResult = "ValidationResultActor" [] let Artifact = "ArtifactActor" [] let Reference = "ReferenceActor" [] let Reminder = "ReminderActor" [] let Repository = "RepositoryActor" [] let RepositoryName = "RepositoryNameActor" [] let RepositoryPermission = "RepositoryPermissionActor" [] let User = "UserActor" [] let WorkItem = "WorkItemActor" [] let WorkItemNumber = "WorkItemNumberActor" [] let WorkItemNumberCounter = "WorkItemNumberCounterActor" module StateName = [] let AccessControl = "AccessControl" [] let Branch = "Branch" [] let Diff = "Diff" [] let DirectoryAppearance = "DirApp" [] let DirectoryVersion = "Dir" [] let FileAppearance = "FileApp" [] let NamedSection = "NamedSection" [] let Organization = "Organization" [] let Owner = "Owner" [] let PersonalAccessToken = "PersonalAccessToken" [] let PromotionSet = "PromotionSet" [] let PromotionQueue = "PromotionQueue" [] let Policy = "Policy" [] let Review = "Review" [] let ValidationSet = "ValidationSet" [] let ValidationResult = "ValidationResult" [] let Artifact = "Artifact" [] let Reference = "Ref" [] let Reminder = "Rmd" [] let Repository = "Repo" [] let RepositoryPermission = "RepoPermission" [] let User = "User" [] let WorkItem = "WorkItem" [] let WorkItemNumberCounter = "WorkItemNumberCounter" /// Constants for the different types of reminders. module ReminderType = [] let Maintenance = "Maintenance" [] let PhysicalDeletion = "PhysicalDeletion" [] let DeleteCachedState = "DeleteCachedState" module LockName = [] let ReminderLock = "ReminderLock" let DefaultObjectStorageContainerName = "grace-objects" /// The time to wait between logical and physical deletion of an actor's state. /// /// In Release builds, this is TimeSpan.FromDays(7.0). In Debug builds, it's TimeSpan.FromSeconds(300.0). #if DEBUG let DefaultPhysicalDeletionReminderDuration = Duration.FromSeconds(300.0) #else let DefaultPhysicalDeletionReminderDuration = Duration.FromDays(7.0) #endif /// The time to wait between logical and physical deletion of an actor's state, as a TimeSpan. let DefaultPhysicalDeletionReminderTimeSpan = DefaultPhysicalDeletionReminderDuration.ToTimeSpan() ================================================ FILE: src/Grace.Actors/Context.Actor.fs ================================================ namespace Grace.Actors open Azure.Core open Azure.Identity open Azure.Storage.Blobs open Grace.Actors.Types open Grace.Shared open Grace.Shared.Constants open Grace.Shared.Utilities open Grace.Types.Types open Microsoft.Azure.Cosmos open Microsoft.Extensions.Caching.Memory open Microsoft.Extensions.ObjectPool open Microsoft.Extensions.Logging open Orleans open System open System.Text open System.Collections.Concurrent open System.Collections.Generic open Azure.Storage.Blobs.Models open NodaTime.Serialization.SystemTextJson open MessagePack open MessagePack.Resolvers open MessagePack.FSharp open MessagePack.NodaTime open MessagePack.Resolvers open Grace.Shared.AzureEnvironment module Context = /// Actor state storage provider instance let mutable internal actorStateStorageProvider = ActorStateStorageProvider.Unknown /// Setter for actor state storage provider let setActorStateStorageProvider storageProvider = logToConsole $"In Context.Actor.setActorStateStorageProvider: Setting actor state storage provider to {storageProvider}." actorStateStorageProvider <- storageProvider /// Orleans client instance for the application. let mutable internal orleansClient: IGrainFactory = null /// Sets the Orleans client for the application. let setOrleansClient (client: IGrainFactory) = orleansClient <- client /// Cosmos client instance let mutable internal cosmosClient: CosmosClient = null /// Setter for Cosmos client let setCosmosClient (client: CosmosClient) = cosmosClient <- client /// Cosmos container instance let mutable internal cosmosContainer: Container = null /// Setter for Cosmos container let setCosmosContainer (container: Container) = cosmosContainer <- container /// Host services collection let mutable internal hostServiceProvider: IServiceProvider = null /// Setter for services collection let setHostServiceProvider (hostServices: IServiceProvider) = hostServiceProvider <- hostServices /// Logger factory instance let mutable internal loggerFactory: ILoggerFactory = null //hostServiceProvider.GetService(typeof) :?> ILoggerFactory /// Setter for logger factory let setLoggerFactory (factory: ILoggerFactory) = loggerFactory <- factory /// Pub-sub settings for Grace. let mutable internal pubSubSettings: GracePubSubSettings = GracePubSubSettings.Empty let setPubSubSettings (settings: GracePubSubSettings) = pubSubSettings <- settings let mutable internal timings = ConcurrentDictionary>() let setTimings (timing: ConcurrentDictionary>) = timings <- timing let private defaultAzureCredential = lazy (DefaultAzureCredential()) /// Azure Blob Storage client let blobServiceClient = if AzureEnvironment.useManagedIdentityForStorage then BlobServiceClient(AzureEnvironment.storageEndpoints.BlobEndpoint, defaultAzureCredential.Value) else match AzureEnvironment.storageEndpoints.ConnectionString with | Some connectionString -> BlobServiceClient(connectionString) | None -> invalidOp "Azure Storage connection string must be configured when running in local debug mode without a managed identity." ================================================ FILE: src/Grace.Actors/Diff.Actor.fs ================================================ namespace Grace.Actors open Azure.Storage.Blobs open Azure.Storage.Blobs.Specialized open DiffPlex open DiffPlex.DiffBuilder.Model open FSharpPlus open Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.ActorProxy open Grace.Actors.Extensions.MemoryCache open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Actors.Types open Grace.Shared open Grace.Shared.Diff open Grace.Types.Reminder open Grace.Types.Repository open Grace.Types.Diff open Grace.Types.Types open Grace.Shared.Utilities open Microsoft.Extensions.Logging open NodaTime open Orleans open Orleans.Runtime open System open System.Collections.Concurrent open System.Collections.Generic open System.Diagnostics open System.Linq open System.IO open System.IO.Compression open System.Threading.Tasks open Grace.Actors.Extensions module Diff = type DiffActor([] state: IPersistentState) = inherit Grain() static let actorName = ActorName.Diff let log = loggerFactory.CreateLogger("Diff.Actor") let mutable diffDto: DiffDto = DiffDto.Default /// Gets a Dictionary for indexed lookups by relative path. let getLookupCache (graceIndex: ServerGraceIndex) = let lookupCache = Dictionary() for directoryVersion in graceIndex.Values do // Add the directory to the lookup cache. lookupCache.TryAdd((FileSystemEntryType.Directory, directoryVersion.RelativePath), directoryVersion.Sha256Hash) |> ignore // Add each file to the lookup cache. for file in directoryVersion.Files do lookupCache.TryAdd((FileSystemEntryType.File, file.RelativePath), file.Sha256Hash) |> ignore lookupCache /// Scans two ServerGraceIndex instances for differences. let scanForDifferences (newerGraceIndex: ServerGraceIndex) (olderGraceIndex: ServerGraceIndex) = task { let emptyLookup = KeyValuePair(String.Empty, Sha256Hash String.Empty) let differences = List() // Create an indexed lookup table of path -> lastWriteTimeUtc from the Grace index file. let olderLookupCache = getLookupCache olderGraceIndex let newerLookupCache = getLookupCache newerGraceIndex // Compare them for differences. for kvp in olderLookupCache do let ((fileSystemEntryType, relativePath), sha256Hash) = kvp.Deconstruct() // Find the entries that changed if newerLookupCache.ContainsKey((fileSystemEntryType, relativePath)) && sha256Hash <> newerLookupCache.Item((fileSystemEntryType, relativePath)) then differences.Add(FileSystemDifference.Create Change fileSystemEntryType relativePath) // Find the entries that were deleted elif not <| newerLookupCache.ContainsKey((fileSystemEntryType, relativePath)) then differences.Add(FileSystemDifference.Create Delete fileSystemEntryType relativePath) // Find the entries that were added for kvp in newerLookupCache do let ((fileSystemEntryType, relativePath), sha256Hash) = kvp.Deconstruct() if not <| olderLookupCache.ContainsKey((fileSystemEntryType, relativePath)) then differences.Add(FileSystemDifference.Create Add fileSystemEntryType relativePath) return differences } /// Deconstructs an ActorId of the form "{directoryVersionId1}*{directoryVersionId2}" into a tuple of the two DirectoryId values. let deconstructActorId (primaryKey: string) = let directoryIds = primaryKey.Split("*") (DirectoryVersionId directoryIds[0], DirectoryVersionId directoryIds[1]) member val private correlationId: CorrelationId = String.Empty with get, set /// Builds a ServerGraceIndex from a root DirectoryId. member private this.buildGraceIndex (directoryId: DirectoryVersionId) repositoryId correlationId = task { this.correlationId <- correlationId let graceIndex = ServerGraceIndex() let directoryVersionActorProxy = ActorProxy.DirectoryVersion.CreateActorProxy directoryId repositoryId correlationId let! directoryCreatedAt = directoryVersionActorProxy.GetCreatedAt correlationId let! directoryVersionDtos = directoryVersionActorProxy.GetRecursiveDirectoryVersions false correlationId for directoryVersionDto in directoryVersionDtos do let directoryVersion = directoryVersionDto.DirectoryVersion graceIndex.TryAdd(directoryVersion.RelativePath, directoryVersion) |> ignore return (graceIndex, directoryCreatedAt) } /// Gets a Stream from object storage for a specific FileVersion, using a generated Uri. member private this.getUncompressedStream (repositoryDto: RepositoryDto) (fileVersion: FileVersion) (url: UriWithSharedAccessSignature) correlationId = task { this.correlationId <- correlationId let objectStorageProvider = repositoryDto.ObjectStorageProvider match objectStorageProvider with | AWSS3 -> return new MemoryStream() :> Stream | AzureBlobStorage -> let blobClient = BlockBlobClient(url) let! fileStream = blobClient.OpenReadAsync(position = 0, bufferSize = (64 * 1024)) let uncompressedStream = if fileVersion.IsBinary then fileStream else let gzStream = new GZipStream(stream = fileStream, mode = CompressionMode.Decompress, leaveOpen = false) gzStream :> Stream return uncompressedStream | GoogleCloudStorage -> return new MemoryStream() :> Stream | ObjectStorageProvider.Unknown -> return new MemoryStream() :> Stream } override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () logActorActivation log this.IdentityString activateStartTime (getActorActivationMessage state.RecordExists) if state.RecordExists then diffDto <- state.State Task.CompletedTask interface IDiffActor with /// Sets a Grace reminder to perform a physical deletion of this actor. member this.ScheduleReminderAsync reminderType delay state correlationId = task { let reminder = ReminderDto.Create actorName $"{this.IdentityString}" diffDto.OwnerId diffDto.OrganizationId diffDto.RepositoryId reminderType (getFutureInstant delay) state correlationId do! createReminder reminder } :> Task /// Receives a Grace reminder. member this.ReceiveReminderAsync(reminder: ReminderDto) : Task> = task { this.correlationId <- reminder.CorrelationId match reminder.ReminderType, reminder.State with | ReminderTypes.DeleteCachedState, ReminderState.DiffDeleteCachedState deleteCachedStateReminderState -> this.correlationId <- deleteCachedStateReminderState.CorrelationId // Delete saved state for this actor. do! state.ClearStateAsync() log.LogInformation( "{CurrentInstant}: Node: {HostName}; CorrelationId: {correlationId}; Deleted cache for diff; RepositoryId: {RepositoryId}; DirectoryVersionId1: {DirectoryVersionId1}; DirectoryVersionId2: {DirectoryVersionId2}; deleteReason: {deleteReason}.", getCurrentInstantExtended (), getMachineName, deleteCachedStateReminderState.CorrelationId, diffDto.RepositoryId, diffDto.DirectoryVersionId1, diffDto.DirectoryVersionId2, deleteCachedStateReminderState.DeleteReason ) this.DeactivateOnIdle() return Ok() | reminderType, state -> return Error( (GraceError.Create $"{actorName} does not process reminder type {getDiscriminatedUnionCaseName reminderType} with state {getDiscriminatedUnionCaseName state}." this.correlationId) .enhance ("IsRetryable", "false") ) } member this.Compute correlationId : Task> = this.correlationId <- correlationId task { try // If it's already populated, skip this. if diffDto.DirectoryVersionId1 <> DiffDto.Default.DirectoryVersionId1 then return Ok( (GraceReturnValue.Create "DiffActor.Compute: already populated." correlationId) .enhance("DirectoryVersionId1", $"{diffDto.DirectoryVersionId1}") .enhance("DirectoryVersionId2", $"{diffDto.DirectoryVersionId2}") .enhance("OwnerId", $"{diffDto.OwnerId}") .enhance("OrganizationId", $"{diffDto.OrganizationId}") .enhance("RepositoryId", $"{diffDto.RepositoryId}") .enhance ("HasDifferences", $"{diffDto.HasDifferences}") ) else let (directoryVersionId1, directoryVersionId2) = deconstructActorId ($"{this.GetGrainId().Key}") //logToConsole $"In DiffActor.Populate(); DirectoryVersionId1: {directoryVersionId1}; DirectoryVersionId2: {directoryVersionId2}" let orleansContext = memoryCache.GetOrleansContextEntry(this.GetGrainId()) let ownerId = orleansContext.Value[nameof OwnerId] :?> OwnerId let organizationId = orleansContext.Value[nameof OrganizationId] :?> OrganizationId let repositoryId = orleansContext.Value[nameof RepositoryId] :?> RepositoryId let repositoryActorProxy = Repository.CreateActorProxy organizationId repositoryId correlationId let! repositoryDto = repositoryActorProxy.Get correlationId // Build a GraceIndex for each DirectoryId. let! (graceIndex1, createdAt1) = this.buildGraceIndex directoryVersionId1 repositoryId correlationId let! (graceIndex2, createdAt2) = this.buildGraceIndex directoryVersionId2 repositoryId correlationId //logToConsole $"In DiffActor.Populate(); createdAt1: {createdAt1}; createdAt2: {createdAt2}." // Compare the GraceIndices. let! differences = task { if createdAt1.CompareTo(createdAt2) > 0 then return! scanForDifferences graceIndex1 graceIndex2 else return! scanForDifferences graceIndex2 graceIndex1 } //logToConsole $"In Actor.Populate(); got differences." diffDto <- { diffDto with OwnerId = ownerId; OrganizationId = organizationId; RepositoryId = repositoryDto.RepositoryId } /// Gets a Stream for a given RelativePath. let getFileStream (graceIndex: ServerGraceIndex) (relativePath: RelativePath) (repositoryDto: RepositoryDto) = task { let relativeDirectoryPath = getRelativeDirectory relativePath Constants.RootDirectoryPath //logToConsole $"In DiffActor.getFileStream(); relativePath: {relativePath}; relativeDirectoryPath: {relativeDirectoryPath}; graceIndex.Count: {graceIndex.Count}." let directory = graceIndex[relativeDirectoryPath] let fileVersion = directory.Files.First(fun f -> f.RelativePath = relativePath) let! uri = getUriWithReadSharedAccessSignatureForFileVersion repositoryDto fileVersion correlationId let! uncompressedStream = this.getUncompressedStream repositoryDto fileVersion uri correlationId return Ok(uncompressedStream, fileVersion) } // Process each difference. let fileDiffs = ConcurrentBag() do! Parallel.ForEachAsync( differences, Constants.ParallelOptions, (fun difference ct -> ValueTask( task { match difference.DifferenceType with | Change -> // This is the only case that we need to generate file diffs for. match difference.FileSystemEntryType with | Directory -> () // Might have to revisit this. | File -> // Get streams for both file versions. let! result1 = getFileStream graceIndex1 difference.RelativePath repositoryDto let! result2 = getFileStream graceIndex2 difference.RelativePath repositoryDto match (result1, result2) with | (Ok (fileStream1, fileVersion1), Ok (fileStream2, fileVersion2)) -> try // Compare the streams using DiffPlex, and get the Inline and Side-by-Side diffs. let! diffResults = task { if createdAt1.CompareTo(createdAt2) < 0 then return! diffTwoFiles fileStream1 fileStream2 else return! diffTwoFiles fileStream2 fileStream1 } // Create a FileDiff with the DiffPlex results and corresponding Sha256Hash values. let fileDiff = if createdAt1.CompareTo(createdAt2) < 0 then FileDiff.Create fileVersion1.RelativePath fileVersion1.Sha256Hash fileVersion1.CreatedAt fileVersion2.Sha256Hash fileVersion2.CreatedAt (fileVersion1.IsBinary || fileVersion2.IsBinary) diffResults.InlineDiff diffResults.SideBySideOld diffResults.SideBySideNew else FileDiff.Create fileVersion1.RelativePath fileVersion2.Sha256Hash fileVersion1.CreatedAt fileVersion1.Sha256Hash fileVersion2.CreatedAt (fileVersion1.IsBinary || fileVersion2.IsBinary) diffResults.InlineDiff diffResults.SideBySideOld diffResults.SideBySideNew fileDiffs.Add(fileDiff) finally if not <| isNull fileStream1 then fileStream1.Dispose() if not <| isNull fileStream2 then fileStream2.Dispose() | (Error ex, _) -> raise ex | (_, Error ex) -> raise ex | Add -> () | Delete -> () } )) ) diffDto.FileDiffs.AddRange(fileDiffs.ToArray()) diffDto <- { diffDto with HasDifferences = differences.Count <> 0 RepositoryId = repositoryDto.RepositoryId DirectoryVersionId1 = directoryVersionId1 Directory1CreatedAt = createdAt1 DirectoryVersionId2 = directoryVersionId2 Directory2CreatedAt = createdAt2 Differences = differences } state.State <- diffDto do! state.WriteStateAsync() let (deleteCachedStateReminderState: DeleteCachedStateReminderState) = { DeleteReason = getDiscriminatedUnionCaseName ReminderTypes.DeleteCachedState; CorrelationId = correlationId } do! (this :> IDiffActor).ScheduleReminderAsync ReminderTypes.DeleteCachedState (Duration.FromDays(float repositoryDto.DiffCacheDays)) (ReminderState.DiffDeleteCachedState deleteCachedStateReminderState) correlationId return Ok( (GraceReturnValue.Create "DiffActor.Compute: populated." correlationId) .enhance("DirectoryVersionId1", $"{diffDto.DirectoryVersionId1}") .enhance("DirectoryVersionId2", $"{diffDto.DirectoryVersionId2}") .enhance("OwnerId", $"{diffDto.OwnerId}") .enhance("OrganizationId", $"{diffDto.OrganizationId}") .enhance("RepositoryId", $"{diffDto.RepositoryId}") .enhance ("HasDifferences", $"{diffDto.HasDifferences}") ) with | ex -> logToConsole $"Exception in DiffActor.Compute(): {ExceptionResponse.Create ex}" logToConsole $"directoryVersionId1: {diffDto.DirectoryVersionId1}; directoryVersionId2: {diffDto.DirectoryVersionId2}" if not <| isNull Activity.Current then Activity .Current .SetStatus(ActivityStatusCode.Error, "Exception while creating diff.") .AddTag("ex.Message", ex.Message) .AddTag("ex.StackTrace", ex.StackTrace) .AddTag( "directoryVersionId1", $"{diffDto.DirectoryVersionId1}" ) .AddTag("directoryVersionId2", $"{diffDto.DirectoryVersionId2}") |> ignore return Error( (GraceError.Create "Exception while creating diff." correlationId) .enhance("DirectoryVersionId1", $"{diffDto.DirectoryVersionId1}") .enhance("DirectoryVersionId2", $"{diffDto.DirectoryVersionId2}") .enhance("OwnerId", $"{diffDto.OwnerId}") .enhance("OrganizationId", $"{diffDto.OrganizationId}") .enhance("RepositoryId", $"{diffDto.RepositoryId}") .enhance ("HasDifferences", $"{diffDto.HasDifferences}") ) } member this.GetDiff correlationId = task { this.correlationId <- correlationId if diffDto.DirectoryVersionId1.Equals(DiffDto.Default.DirectoryVersionId1) then let! populated = (this :> IDiffActor).Compute correlationId log.LogTrace( "{CurrentInstant}: Node: {HostName}; CorrelationId: {correlationId}; In DiffActor.GetDiff(); was not previously computed.", getCurrentInstantExtended (), getMachineName, correlationId, populated ) else logToConsole $"In Actor.GetDiff(), already populated." return diffDto } ================================================ FILE: src/Grace.Actors/DirectoryAppearance.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.ActorProxy open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Actors.Types open Grace.Shared open Grace.Types.Types open Grace.Shared.Utilities open Microsoft.Extensions.Logging open NodaTime open Orleans open Orleans.Runtime open System open System.Collections.Generic open System.Threading.Tasks module DirectoryAppearance = type DirectoryAppearanceDto() = member val public Appearances = SortedSet() with get, set member val public RepositoryId: RepositoryId = RepositoryId.Empty with get, set type DirectoryAppearanceActor ( [] state: IPersistentState> ) = inherit Grain() let actorName = ActorName.DirectoryAppearance let log = loggerFactory.CreateLogger("DirectoryAppearance.Actor") let directoryAppearanceDto = DirectoryAppearanceDto() member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = directoryAppearanceDto.Appearances <- state.State Task.CompletedTask interface IDirectoryAppearanceActor with member this.Add appearance correlationId = task { let wasAdded = directoryAppearanceDto.Appearances.Add(appearance) if wasAdded then do! state.WriteStateAsync() } :> Task member this.Remove appearance correlationId = task { let wasRemoved = directoryAppearanceDto.Appearances.Remove(appearance) if wasRemoved then if directoryAppearanceDto.Appearances |> Seq.isEmpty then do! state.ClearStateAsync() let directoryVersionGuid = this.GetGrainId().GetGuidKey() let directoryVersionActorProxy = DirectoryVersion.CreateActorProxy directoryVersionGuid directoryAppearanceDto.RepositoryId correlationId let! result = directoryVersionActorProxy.Delete(correlationId) match result with | Ok returnValue -> () | Error error -> () () else do! state.WriteStateAsync() } :> Task member this.Contains appearance correlationId = directoryAppearanceDto.Appearances.Contains(appearance) |> returnTask member this.Appearances correlationId = directoryAppearanceDto.Appearances |> returnTask ================================================ FILE: src/Grace.Actors/DirectoryVersion.Actor.fs ================================================ namespace Grace.Actors open Azure.Storage.Blobs open Azure.Storage.Blobs.Models open Azure.Storage.Blobs.Specialized open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.ActorProxy open Grace.Actors.Extensions.MemoryCache open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Actors.Types open Grace.Shared open Grace.Shared.Constants open Grace.Shared.Services open Grace.Shared.Utilities open Grace.Shared.Validation.Errors open Grace.Types.Events open Grace.Types.Reminder open Grace.Types.Repository open Grace.Types.DirectoryVersion open Grace.Types.Types open Microsoft.Extensions.Logging open Microsoft.Extensions.ObjectPool open NodaTime open Orleans open Orleans.Runtime open System open System.Buffers open System.Collections.Concurrent open System.Collections.Generic open System.Diagnostics open System.IO open System.IO.Compression open System.Linq open System.Security.Cryptography open System.Text open System.Threading.Tasks open System.Reflection.Metadata open MessagePack open System.Threading open Azure.Storage module DirectoryVersion = /// Result of validating a single file's SHA-256 hash. type FileValidationResult = | Valid of fileVersion: FileVersion * computedHash: Sha256Hash * elapsedMs: float | HashMismatch of fileVersion: FileVersion * expectedHash: Sha256Hash * computedHash: Sha256Hash * elapsedMs: float | MissingInStorage of fileVersion: FileVersion * elapsedMs: float | ValidationError of fileVersion: FileVersion * errorMessage: string * elapsedMs: float /// Validates a single file's SHA-256 hash by downloading from storage and computing. /// Note: Non-binary files are stored as GZip-compressed streams, so we need to decompress them first. let validateFileSha256 (repositoryDto: RepositoryDto) (fileVersion: FileVersion) (correlationId: CorrelationId) = task { let stopwatch = Stopwatch.StartNew() try let! blobClient = getAzureBlobClientForFileVersion repositoryDto fileVersion correlationId let! existsResponse = blobClient.ExistsAsync() if not existsResponse.Value then stopwatch.Stop() return MissingInStorage(fileVersion, stopwatch.Elapsed.TotalMilliseconds) else // Download the stream from blob storage use! blobStream = blobClient.OpenReadAsync(position = 0, bufferSize = (64 * 1024)) // Compute SHA-256 hash using the server-specific validation function. // Text files are stored as GZip streams and need decompression. let! computedHash = if fileVersion.IsBinary then computeSha256ForFile blobStream fileVersion.RelativePath else task { use gzStream = new GZipStream(stream = blobStream, mode = CompressionMode.Decompress, leaveOpen = false) return! computeSha256ForFile gzStream fileVersion.RelativePath } stopwatch.Stop() if computedHash = fileVersion.Sha256Hash then return Valid(fileVersion, computedHash, stopwatch.Elapsed.TotalMilliseconds) else return HashMismatch(fileVersion, fileVersion.Sha256Hash, computedHash, stopwatch.Elapsed.TotalMilliseconds) with | ex -> stopwatch.Stop() return ValidationError(fileVersion, ex.Message, stopwatch.Elapsed.TotalMilliseconds) } /// Determines which files need validation by comparing with a previously validated DirectoryVersion. /// Returns the list of files that need to be validated. let getFilesToValidate (newFiles: List) (previouslyValidatedFiles: List) : FileVersion array = if previouslyValidatedFiles.Count > 0 then // Create a set of (RelativePath, Sha256Hash) pairs from the old files let previousFilesLookup = Dictionary() previouslyValidatedFiles |> Seq.iter (fun previousFile -> previousFilesLookup.Add(previousFile.RelativePath, previousFile.Sha256Hash)) // Return files that are not in the old set (new or changed) newFiles .Where(fun f -> not (previousFilesLookup.Contains(KeyValuePair(f.RelativePath, f.Sha256Hash)))) .ToArray() else newFiles.ToArray() type DirectoryVersionActor ( [] state: IPersistentState> ) = inherit Grain() static let actorName = ActorName.DirectoryVersion let log = loggerFactory.CreateLogger("DirectoryVersion.Actor") let mutable directoryVersionDto = DirectoryVersionDto.Default let mutable currentCommand = String.Empty /// Gets the name of the blob file that holds the cached recursive directory version list. let getRecursiveDirectoryVersionsCacheFileName (directoryVersionId: DirectoryVersionId) = $"{directoryVersionId}.msgpack" /// Gets the name of the blob file that holds the .zip file for the directory version. let getZipFileBlobName (directoryVersionId: DirectoryVersionId) = $"{GraceZipFilesFolderName}/{directoryVersionId}.zip" member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () // Apply the events to build the current Dto. directoryVersionDto <- state.State |> Seq.fold (fun directoryVersionDto directoryVersionEvent -> directoryVersionDto |> DirectoryVersionDto.UpdateDto directoryVersionEvent) DirectoryVersionDto.Default logActorActivation log this.IdentityString activateStartTime (getActorActivationMessage state.RecordExists) Task.CompletedTask interface IGraceReminderWithGuidKey with /// Schedules a Grace reminder. member this.ScheduleReminderAsync reminderType delay state correlationId = task { let reminder = ReminderDto.Create actorName $"{this.IdentityString}" directoryVersionDto.DirectoryVersion.OwnerId directoryVersionDto.DirectoryVersion.OrganizationId directoryVersionDto.DirectoryVersion.RepositoryId reminderType (getFutureInstant delay) state correlationId do! createReminder reminder } :> Task /// Receives a Grace reminder. member this.ReceiveReminderAsync(reminder: ReminderDto) : Task> = task { this.correlationId <- reminder.CorrelationId match reminder.ReminderType, reminder.State with | ReminderTypes.DeleteCachedState, ReminderState.DirectoryVersionDeleteCachedState reminderState -> this.correlationId <- reminderState.CorrelationId let directoryVersion = directoryVersionDto.DirectoryVersion let repositoryActorProxy = Repository.CreateActorProxy directoryVersion.OrganizationId directoryVersion.RepositoryId reminderState.CorrelationId let! repositoryDto = repositoryActorProxy.Get reminderState.CorrelationId // Delete cached state for this actor. let! directoryVersionBlobClient = getAzureBlobClient repositoryDto (getRecursiveDirectoryVersionsCacheFileName directoryVersion.DirectoryVersionId) reminderState.CorrelationId let! deleted = directoryVersionBlobClient.DeleteIfExistsAsync() if deleted.HasValue && deleted.Value then log.LogInformation( "{CurrentInstant}: Node: {HostName}; CorrelationId: {correlationId}; Deleted cached state for directory version; RepositoryId: {RepositoryId}; DirectoryVersionId: {DirectoryVersionId}; deleteReason: {deleteReason}.", getCurrentInstantExtended (), getMachineName, reminderState.CorrelationId, directoryVersion.RepositoryId, directoryVersion.DirectoryVersionId, reminderState.DeleteReason ) else log.LogWarning( "{CurrentInstant}: Node: {HostName}; CorrelationId: {correlationId}; Failed to delete cached state for directory version (it may have already been deleted); RepositoryId: {RepositoryId}; DirectoryVersionId: {DirectoryVersionId}; deleteReason: {deleteReason}.", getCurrentInstantExtended (), getMachineName, reminderState.CorrelationId, directoryVersion.RepositoryId, directoryVersion.DirectoryVersionId, reminderState.DeleteReason ) return Ok() | ReminderTypes.DeleteZipFile, ReminderState.DirectoryVersionDeleteZipFile reminderState -> this.correlationId <- reminderState.CorrelationId let directoryVersion = directoryVersionDto.DirectoryVersion let repositoryActorProxy = Repository.CreateActorProxy directoryVersion.OrganizationId directoryVersion.RepositoryId reminderState.CorrelationId let! repositoryDto = repositoryActorProxy.Get reminderState.CorrelationId // Delete zip file for this directory version. let blobName = getZipFileBlobName directoryVersion.DirectoryVersionId let! zipFileBlobClient = getAzureBlobClient repositoryDto blobName reminderState.CorrelationId let! deleted = zipFileBlobClient.DeleteIfExistsAsync() if deleted.HasValue && deleted.Value then log.LogInformation( "{CurrentInstant}: Node: {HostName}; CorrelationId: {correlationId}; Deleted cache for directory version; RepositoryId: {RepositoryId}; DirectoryVersionId: {DirectoryVersionId}; deleteReason: {deleteReason}.", getCurrentInstantExtended (), getMachineName, reminderState.CorrelationId, directoryVersionDto.DirectoryVersion.RepositoryId, directoryVersionDto.DirectoryVersion.DirectoryVersionId, reminderState.DeleteReason ) else log.LogWarning( "{CurrentInstant}: Node: {HostName}; CorrelationId: {correlationId}; Failed to delete cache for directory version (it may have already been deleted); RepositoryId: {RepositoryId}; DirectoryVersionId: {DirectoryVersionId}; deleteReason: {deleteReason}.", getCurrentInstantExtended (), getMachineName, reminderState.CorrelationId, directoryVersionDto.DirectoryVersion.RepositoryId, directoryVersionDto.DirectoryVersion.DirectoryVersionId, reminderState.DeleteReason ) return Ok() | ReminderTypes.PhysicalDeletion, ReminderState.DirectoryVersionPhysicalDeletion reminderState -> this.correlationId <- reminderState.CorrelationId // Delete saved state for this actor. do! state.ClearStateAsync() log.LogInformation( "{CurrentInstant}: Node: {HostName}; CorrelationId: {correlationId}; Deleted state for directory version; RepositoryId: {RepositoryId}; DirectoryVersionId: {DirectoryVersionId}; deleteReason: {deleteReason}.", getCurrentInstantExtended (), getMachineName, reminderState.CorrelationId, directoryVersionDto.DirectoryVersion.RepositoryId, directoryVersionDto.DirectoryVersion.DirectoryVersionId, reminderState.DeleteReason ) this.DeactivateOnIdle() return Ok() | reminderType, state -> return Error( (GraceError.Create $"{actorName} does not process reminder type {getDiscriminatedUnionCaseName reminderType} with state {getDiscriminatedUnionCaseName state}." this.correlationId) .enhance ("IsRetryable", "false") ) } member private this.ApplyEvent directoryVersionEvent = task { try // Add the event to the list of events, and save it to actor state. state.State.Add(directoryVersionEvent) do! state.WriteStateAsync() // Update the Dto with the event. directoryVersionDto <- directoryVersionDto |> DirectoryVersionDto.UpdateDto directoryVersionEvent // Publish the event to the rest of the world. let graceEvent = GraceEvent.DirectoryVersionEvent directoryVersionEvent do! publishGraceEvent graceEvent directoryVersionEvent.Metadata let returnValue = GraceReturnValue.Create "Directory version command succeeded." directoryVersionEvent.Metadata.CorrelationId returnValue .enhance(nameof RepositoryId, directoryVersionDto.DirectoryVersion.RepositoryId) .enhance(nameof DirectoryVersionId, directoryVersionDto.DirectoryVersion.DirectoryVersionId) .enhance(nameof Sha256Hash, directoryVersionDto.DirectoryVersion.Sha256Hash) .enhance (nameof DirectoryVersionEventType, getDiscriminatedUnionFullName directoryVersionEvent.Event) |> ignore return Ok returnValue with | ex -> let graceError = GraceError.CreateWithException ex (getErrorMessage DirectoryVersionError.FailedWhileApplyingEvent) directoryVersionEvent.Metadata.CorrelationId graceError .enhance(nameof RepositoryId, directoryVersionDto.DirectoryVersion.RepositoryId) .enhance(nameof DirectoryVersionId, directoryVersionDto.DirectoryVersion.DirectoryVersionId) .enhance(nameof Sha256Hash, directoryVersionDto.DirectoryVersion.Sha256Hash) .enhance (nameof DirectoryVersionEventType, getDiscriminatedUnionFullName directoryVersionEvent.Event) |> ignore return Error graceError } interface IHasRepositoryId with member this.GetRepositoryId correlationId = directoryVersionDto.DirectoryVersion.RepositoryId |> returnTask interface IDirectoryVersionActor with member this.Exists correlationId = this.correlationId <- correlationId (directoryVersionDto.DirectoryVersion.DirectoryVersionId <> DirectoryVersion.Default.DirectoryVersionId) |> returnTask member this.Delete correlationId = this.correlationId <- correlationId GraceResult.Error(GraceError.Create "Not implemented" correlationId) |> returnTask member this.Get correlationId = this.correlationId <- correlationId directoryVersionDto |> returnTask member this.GetCreatedAt correlationId = this.correlationId <- correlationId directoryVersionDto.DirectoryVersion.CreatedAt |> returnTask member this.GetDirectories correlationId = this.correlationId <- correlationId directoryVersionDto.DirectoryVersion.Directories |> returnTask member this.GetFiles correlationId = this.correlationId <- correlationId directoryVersionDto.DirectoryVersion.Files |> returnTask member this.GetSha256Hash correlationId = this.correlationId <- correlationId directoryVersionDto.DirectoryVersion.Sha256Hash |> returnTask member this.GetSize correlationId = this.correlationId <- correlationId directoryVersionDto.DirectoryVersion.Size |> returnTask member this.GetRecursiveDirectoryVersions (forceRegenerate: bool) correlationId = this.correlationId <- correlationId task { try let directoryVersion = directoryVersionDto.DirectoryVersion let repositoryActorProxy = Repository.CreateActorProxy directoryVersion.OrganizationId directoryVersion.RepositoryId correlationId let! repositoryDto = repositoryActorProxy.Get correlationId // Get the blob client for the cached recursive directory versions file. let! directoryVersionBlobClient = getAzureBlobClient repositoryDto (getRecursiveDirectoryVersionsCacheFileName directoryVersionDto.DirectoryVersion.DirectoryVersionId) correlationId // Check if the subdirectory versions have already been generated and cached. let cachedSubdirectoryVersions = task { if not forceRegenerate && directoryVersionBlobClient.Exists() then use! blobStream = directoryVersionBlobClient.OpenReadAsync() let! directoryVersions = MessagePackSerializer.DeserializeAsync(blobStream, messagePackSerializerOptions) return Some directoryVersions else return None } // If they have already been generated, return them. match! cachedSubdirectoryVersions with | Some subdirectoryVersionDtos -> log.LogTrace( "{CurrentInstant}: Node: {HostName}; CorrelationId: {correlationId}; In DirectoryVersionActor.GetRecursiveDirectoryVersions({id}): Retrieved SubdirectoryVersions from cache.", getCurrentInstantExtended (), getMachineName, correlationId, this.IdentityString ) return subdirectoryVersionDtos // If they haven't, generate them by calling each subdirectory in parallel. | None -> log.LogTrace( "{CurrentInstant}: Node: {HostName}; CorrelationId: {correlationId}; In DirectoryVersionActor.GetRecursiveDirectoryVersions({id}): subdirectoryVersions will be generated. forceRegenerate: {forceRegenerate}", getCurrentInstantExtended (), getMachineName, correlationId, directoryVersionDto.DirectoryVersion.DirectoryVersionId, forceRegenerate ) let subdirectoryVersionDtos = ConcurrentDictionary() log.LogTrace( "{CurrentInstant}: Node: {HostName}; CorrelationId: {correlationId}; In DirectoryVersionActor.GetRecursiveDirectoryVersions({id}): Adding current directory version. RelativePath: {relativePath}", getCurrentInstantExtended (), getMachineName, correlationId, this.GetPrimaryKey(), directoryVersionDto.DirectoryVersion.RelativePath ) // First, add the current directory version to the dictionary. subdirectoryVersionDtos.TryAdd(directoryVersionDto.DirectoryVersion.RelativePath, directoryVersionDto) |> ignore // Then, get the subdirectory versions in parallel and add them to the dictionary. do! Parallel.ForEachAsync( directoryVersionDto.DirectoryVersion.Directories, Constants.ParallelOptions, (fun subdirectoryVersionId ct -> ValueTask( task { try let subdirectoryActor = DirectoryVersion.CreateActorProxy subdirectoryVersionId directoryVersionDto.DirectoryVersion.RepositoryId correlationId // Get the contents of the subdirectory itself. let! subdirectoryVersion = subdirectoryActor.Get correlationId log.LogTrace( "{CurrentInstant}: Node: {HostName}; CorrelationId: {correlationId}; In DirectoryVersionActor.GetRecursiveDirectoryVersions({id}): subdirectoryVersionId: {subdirectoryVersionId}. RelativePath: {relativePath}\n{directoryVersion}", getCurrentInstantExtended (), getMachineName, correlationId, directoryVersionDto.DirectoryVersion.DirectoryVersionId, subdirectoryVersionId, subdirectoryVersion.DirectoryVersion.RelativePath, serialize subdirectoryVersion ) // Get the full recursive contents of the subdirectory. let! subdirectoryContents = subdirectoryActor.GetRecursiveDirectoryVersions forceRegenerate correlationId log.LogTrace( "{CurrentInstant}: Node: {HostName}; CorrelationId: {correlationId}; In DirectoryVersionActor.GetRecursiveDirectoryVersions({id}): subdirectoryVersionId: {subdirectoryVersionId}; Retrieved {count} subdirectory versions for RelativePath: {relativePath}\n{subdirectoryContents}", getCurrentInstantExtended (), getMachineName, correlationId, directoryVersionDto.DirectoryVersion.DirectoryVersionId, subdirectoryVersionId, subdirectoryContents.Length, subdirectoryVersion.DirectoryVersion.RelativePath, serialize subdirectoryContents ) for directoryVersionDto in subdirectoryContents do let directoryVersion = directoryVersionDto.DirectoryVersion log.LogTrace( "{CurrentInstant}: Node: {HostName}; CorrelationId: {correlationId}; In DirectoryVersionActor.GetRecursiveDirectoryVersions({id}): subdirectoryVersionId: {subdirectoryVersionId}; Adding subdirectories. RelativePath: {relativePath}", getCurrentInstantExtended (), getMachineName, correlationId, directoryVersion.DirectoryVersionId, subdirectoryVersionId, directoryVersion.RelativePath ) subdirectoryVersionDtos.AddOrUpdate( directoryVersion.RelativePath, directoryVersionDto, (fun _ _ -> directoryVersionDto) ) |> ignore with | ex -> log.LogError( "{CurrentInstant}: Error in {methodName}; DirectoryId: {directoryId}; Exception: {exception}", getCurrentInstantExtended (), "GetRecursiveDirectoryVersions", subdirectoryVersionId, ExceptionResponse.Create ex ) } )) ) // Sort the subdirectory versions by their relative path. let subdirectoryVersionsList = subdirectoryVersionDtos .Values .OrderBy(fun directoryVersionDto -> directoryVersionDto.DirectoryVersion.RelativePath) .ToArray() // Save the recursive results to Azure Blob Storage. let repositoryActorProxy = Repository.CreateActorProxy directoryVersionDto.DirectoryVersion.OrganizationId directoryVersionDto.DirectoryVersion.RepositoryId correlationId let! repositoryDto = repositoryActorProxy.Get correlationId let tags = Dictionary() tags.Add(nameof OwnerId, $"{repositoryDto.OwnerId}") tags.Add(nameof OrganizationId, $"{repositoryDto.OrganizationId}") tags.Add(nameof RepositoryId, $"{repositoryDto.RepositoryId}") tags.Add(nameof DirectoryVersionId, $"{directoryVersionDto.DirectoryVersion.DirectoryVersionId}") tags.Add(nameof RelativePath, $"{directoryVersionDto.DirectoryVersion.RelativePath}") tags.Add(nameof Sha256Hash, $"{directoryVersionDto.DirectoryVersion.Sha256Hash}") tags.Add("RecursiveSize", $"{directoryVersionDto.RecursiveSize}") // Write the JSON using MessagePack serialization for efficiency. let blockBlobOpenWriteOptions = BlockBlobOpenWriteOptions(Tags = tags, HttpHeaders = BlobHttpHeaders(ContentType = "application/msgpack")) let conditionsSummary = let conditionsProperty = typeof.GetProperty("Conditions") if isNull conditionsProperty then "not supported" else let conditionsValue = conditionsProperty.GetValue(blockBlobOpenWriteOptions) if isNull conditionsValue then "null" else let conditionProperties = conditionsValue.GetType().GetProperties() conditionProperties |> Seq.map (fun propertyInfo -> let value = propertyInfo.GetValue(conditionsValue) $"{propertyInfo.Name}={value}") |> String.concat "; " log.LogDebug( "In DirectoryVersionActor.GetRecursiveDirectoryVersions({id}); Blob write conditions: {conditionsSummary}.", this.GetPrimaryKey(), conditionsSummary ) use! blobStream = directoryVersionBlobClient.OpenWriteAsync(overwrite = true, options = blockBlobOpenWriteOptions) do! MessagePackSerializer.SerializeAsync(blobStream, subdirectoryVersionsList, messagePackSerializerOptions) do! blobStream.DisposeAsync() log.LogDebug( "In DirectoryVersionActor.GetRecursiveDirectoryVersions({id}); Saving cached list of directory versions. RelativePath: {relativePath}.", this.GetPrimaryKey(), directoryVersionDto.DirectoryVersion.RelativePath ) // Create a reminder to delete the cached state after the configured number of cache days. let deletionReminderState: PhysicalDeletionReminderState = { DeleteReason = getDiscriminatedUnionCaseName ReminderTypes.DeleteCachedState; CorrelationId = correlationId } do! (this :> IGraceReminderWithGuidKey) .ScheduleReminderAsync ReminderTypes.DeleteCachedState (Duration.FromDays(float repositoryDto.DirectoryVersionCacheDays)) (ReminderState.DirectoryVersionDeleteCachedState deletionReminderState) correlationId log.LogDebug( "In DirectoryVersionActor.GetRecursiveDirectoryVersions({id}); Delete cached state reminder was set.", this.GetPrimaryKey() ) return subdirectoryVersionsList with | ex -> log.LogError( "{CurrentInstant}: Error in {methodName}. Exception: {exception}", getCurrentInstantExtended (), "GetRecursiveDirectoryVersions", ExceptionResponse.Create ex ) return Array.Empty() } member this.Handle command metadata = let isValid command (metadata: EventMetadata) = task { match command with | DirectoryVersionCommand.Create (directoryVersion, repositoryDto) -> if state.State.Any (fun e -> match e.Event with | DirectoryVersionEventType.Created _ -> true | _ -> false) then return Error( GraceError.Create (DirectoryVersionError.getErrorMessage DirectoryVersionError.DirectoryAlreadyExists) metadata.CorrelationId ) else return Ok command | _ -> if directoryVersionDto.DirectoryVersion.CreatedAt = DirectoryVersion.Default.CreatedAt then return Error(GraceError.Create (DirectoryVersionError.getErrorMessage DirectoryDoesNotExist) metadata.CorrelationId) else return Ok command } let processCommand (command: DirectoryVersionCommand) (metadata: EventMetadata) = task { try let! event = task { match command with | Create (directoryVersion, repositoryDto) -> // Determine which files need validation using incremental validation logic. let! mostRecentDirectoryVersion = getMostRecentDirectoryVersionByRelativePath repositoryDto.RepositoryId directoryVersion.RelativePath metadata.CorrelationId let filesToValidate = match mostRecentDirectoryVersion with | Some previousDirectoryVersion -> getFilesToValidate directoryVersion.Files previousDirectoryVersion.Files | None -> getFilesToValidate directoryVersion.Files (List()) log.LogDebug( "{CurrentInstant}: Node: {HostName}; CorrelationId: {correlationId}; Starting SHA-256 validation for DirectoryVersion; DirectoryVersionId: {DirectoryVersionId}; RelativePath: {RelativePath}; FileCount: {FileCount}; FilesToValidate: {FilesToValidate}.", getCurrentInstantExtended (), getMachineName, metadata.CorrelationId, directoryVersion.DirectoryVersionId, directoryVersion.RelativePath, directoryVersion.Files.Count, filesToValidate.Length ) let validationResults = ConcurrentQueue() // Validate files in parallel do! Parallel.ForEachAsync( filesToValidate, Constants.ParallelOptions, (fun fileVersion ct -> ValueTask( task { let! result = validateFileSha256 repositoryDto fileVersion metadata.CorrelationId validationResults.Enqueue result } )) ) let validationResults = validationResults.ToArray() // Collect failures let failures = validationResults |> Array.filter (fun result -> match result with | Valid _ -> false | _ -> true) |> Array.toList let validCount = validationResults |> Array.filter (fun result -> match result with | Valid _ -> true | _ -> false) |> Array.length let totalElapsedMs = validationResults |> Array.sumBy (fun result -> match result with | Valid (_, _, ms) -> ms | HashMismatch (_, _, _, ms) -> ms | MissingInStorage (_, ms) -> ms | ValidationError (_, _, ms) -> ms) // Log validation results for result in validationResults do match result with | Valid (fv, computedHash, elapsedMs) -> log.LogDebug( "{CurrentInstant}: Node: {HostName}; CorrelationId: {correlationId}; SHA-256 validation passed; File: {RelativePath}; Hash: {Hash}; ElapsedMs: {ElapsedMs}.", getCurrentInstantExtended (), getMachineName, metadata.CorrelationId, fv.RelativePath, computedHash, elapsedMs ) | HashMismatch (fv, expectedHash, computedHash, elapsedMs) -> log.LogWarning( "{CurrentInstant}: Node: {HostName}; CorrelationId: {correlationId}; SHA-256 hash mismatch; File: {RelativePath}; ExpectedHash: {ExpectedHash}; ComputedHash: {ComputedHash}; ElapsedMs: {ElapsedMs}.", getCurrentInstantExtended (), getMachineName, metadata.CorrelationId, fv.RelativePath, expectedHash, computedHash, elapsedMs ) | MissingInStorage (fv, elapsedMs) -> log.LogWarning( "{CurrentInstant}: Node: {HostName}; CorrelationId: {correlationId}; File not found in object storage; File: {RelativePath}; ExpectedHash: {ExpectedHash}; ElapsedMs: {ElapsedMs}.", getCurrentInstantExtended (), getMachineName, metadata.CorrelationId, fv.RelativePath, fv.Sha256Hash, elapsedMs ) | ValidationError (fv, errorMessage, elapsedMs) -> log.LogError( "{CurrentInstant}: Node: {HostName}; CorrelationId: {correlationId}; SHA-256 validation error; File: {RelativePath}; Error: {ErrorMessage}; ElapsedMs: {ElapsedMs}.", getCurrentInstantExtended (), getMachineName, metadata.CorrelationId, fv.RelativePath, errorMessage, elapsedMs ) // Check if any validation failed if failures.Length > 0 then // Build error message with details about failures let errorDetails = failures |> List.map (fun failure -> match failure with | HashMismatch (fv, expected, computed, _) -> $"File '{fv.RelativePath}': hash mismatch (expected: {expected}, computed: {computed})" | MissingInStorage (fv, _) -> $"File '{fv.RelativePath}': not found in object storage" | ValidationError (fv, msg, _) -> $"File '{fv.RelativePath}': validation error ({msg})" | _ -> "Unknown error") |> String.concat "; " log.LogWarning( "{CurrentInstant}: Node: {HostName}; CorrelationId: {correlationId}; SHA-256 validation failed for DirectoryVersion; DirectoryVersionId: {DirectoryVersionId}; RelativePath: {RelativePath}; FailedCount: {FailedCount}; ValidCount: {ValidCount}; TotalElapsedMs: {TotalElapsedMs}.", getCurrentInstantExtended (), getMachineName, metadata.CorrelationId, directoryVersion.DirectoryVersionId, directoryVersion.RelativePath, failures.Length, validCount, totalElapsedMs ) // Determine the appropriate error type let hasHashMismatch = failures |> List.exists (fun f -> match f with | HashMismatch _ -> true | _ -> false) let hasMissing = failures |> List.exists (fun f -> match f with | MissingInStorage _ -> true | _ -> false) let errorMessage = if hasMissing then DirectoryVersionError.getErrorMessage DirectoryVersionError.FileNotFoundInObjectStorage + " " + errorDetails elif hasHashMismatch then DirectoryVersionError.getErrorMessage DirectoryVersionError.FileSha256HashDoesNotMatch + " " + errorDetails else $"File integrity check failed: {errorDetails}" return Error(GraceError.Create errorMessage metadata.CorrelationId) else log.LogInformation( "{CurrentInstant}: Node: {HostName}; CorrelationId: {correlationId}; SHA-256 validation succeeded for DirectoryVersion; DirectoryVersionId: {DirectoryVersionId}; RelativePath: {RelativePath}; ValidatedCount: {ValidatedCount}; TotalElapsedMs: {TotalElapsedMs}.", getCurrentInstantExtended (), getMachineName, metadata.CorrelationId, directoryVersion.DirectoryVersionId, directoryVersion.RelativePath, validCount, totalElapsedMs ) let newDirectoryVersion = { directoryVersion with HashesValidated = true } return Ok(Created newDirectoryVersion) | SetRecursiveSize recursiveSize -> return Ok(RecursiveSizeSet recursiveSize) | DeleteLogical deleteReason -> let repositoryActorProxy = Repository.CreateActorProxy directoryVersionDto.DirectoryVersion.OrganizationId directoryVersionDto.DirectoryVersion.RepositoryId metadata.CorrelationId let! repositoryDto = repositoryActorProxy.Get metadata.CorrelationId let physicalDeletionReminderState = { DeleteReason = getDiscriminatedUnionCaseName deleteReason; CorrelationId = metadata.CorrelationId } do! (this :> IGraceReminderWithGuidKey) .ScheduleReminderAsync ReminderTypes.PhysicalDeletion (Duration.FromDays(float repositoryDto.LogicalDeleteDays)) (ReminderState.DirectoryVersionPhysicalDeletion physicalDeletionReminderState) metadata.CorrelationId return Ok(LogicalDeleted deleteReason) | DeletePhysical -> do! state.ClearStateAsync() this.DeactivateOnIdle() return Ok(PhysicalDeleted) | Undelete -> return Ok(Undeleted) } match event with | Ok event -> return! this.ApplyEvent { Event = event; Metadata = metadata } | Error error -> return Error error with | ex -> let metadataObj = Dictionary(metadata.Properties.Select(fun kvp -> KeyValuePair(kvp.Key, kvp.Value))) return Error(GraceError.CreateWithMetadata ex String.Empty metadata.CorrelationId metadataObj) } task { try this.correlationId <- metadata.CorrelationId currentCommand <- getDiscriminatedUnionCaseName command match! isValid command metadata with | Ok command -> return! processCommand command metadata | Error error -> return Error error with | ex -> logToConsole $"Exception in DirectoryVersionActor.Handle(): {ExceptionResponse.Create ex}" return Error(GraceError.CreateWithException ex "Exception in DirectoryVersionActor.Handle()" metadata.CorrelationId) } member this.GetRecursiveSize correlationId = this.correlationId <- correlationId task { if directoryVersionDto.RecursiveSize = Constants.InitialDirectorySize then let! directoryVersions = (this :> IDirectoryVersionActor) .GetRecursiveDirectoryVersions false correlationId let recursiveSize = directoryVersions |> Seq.sumBy (fun directoryVersionDto -> directoryVersionDto.DirectoryVersion.Size) match! (this :> IDirectoryVersionActor).Handle (SetRecursiveSize recursiveSize) (EventMetadata.New correlationId "GraceSystem") with | Ok returnValue -> return recursiveSize | Error error -> return Constants.InitialDirectorySize else return directoryVersionDto.RecursiveSize } member this.GetZipFileUri(correlationId: CorrelationId) : Task = this.correlationId <- correlationId let directoryVersion = directoryVersionDto.DirectoryVersion /// Creates a .zip file containing the file contents of the directory version. let createDirectoryVersionZipFile (repositoryDto: RepositoryDto) (zipFileBlobName: string) (directoryVersionId: DirectoryVersionId) (subdirectoryVersionIds: List) (fileVersions: IEnumerable) = task { let zipFileName = $"{directoryVersionId}.zip" let tempZipPath = Path.Combine(Path.GetTempPath(), zipFileName) try // Step 1: Create the ZIP archive. use zipToCreate = new FileStream(tempZipPath, FileMode.Create, FileAccess.Write, FileShare.None, (64 * 1024)) use archive = new ZipArchive(zipToCreate, ZipArchiveMode.Create) let zipFileUris = new ConcurrentDictionary() // Step 2: Ensure that .zip files exist for all subdirectories, in parallel. do! Parallel.ForEachAsync( subdirectoryVersionIds, Constants.ParallelOptions, fun subdirectoryVersionId ct -> ValueTask( task { // Call the subdirectory actor to get the .zip file URI, which will create the .zip file if it doesn't already exist. let subdirectoryActorProxy = DirectoryVersion.CreateActorProxy subdirectoryVersionId directoryVersionDto.DirectoryVersion.RepositoryId correlationId let! subdirectoryZipFileUri = subdirectoryActorProxy.GetZipFileUri correlationId zipFileUris[subdirectoryVersionId] <- subdirectoryZipFileUri } ) ) // Step 3: Process the subdirectories of the current directory one at a time, because we need to add entries to the .zip file one at a time. for subdirectoryVersionId in subdirectoryVersionIds do // Get an Azure Blob Client for the .zip file. let subdirectoryZipFileName = getZipFileBlobName subdirectoryVersionId let! subdirectoryZipFileClient = getAzureBlobClient repositoryDto subdirectoryZipFileName correlationId // Copy the contents of the subdirectory's .zip file to the new .zip we're creating. use! subdirectoryZipFileStream = subdirectoryZipFileClient.OpenReadAsync() use subdirectoryZipArchive = new ZipArchive(subdirectoryZipFileStream, ZipArchiveMode.Read) for entry in subdirectoryZipArchive.Entries do if not (String.IsNullOrEmpty(entry.Name)) then // Using CompressionLevel.NoCompression because the files are already GZipped. // We're just using .zip as an archive format for already-compressed files. let newEntry = archive.CreateEntry(entry.FullName, CompressionLevel.NoCompression) newEntry.Comment <- entry.Comment use entryStream = entry.Open() use newEntryStream = newEntry.Open() do! entryStream.CopyToAsync(newEntryStream) // Step 4: Process the files in the current directory. for fileVersion in fileVersions do let! fileBlobClient = getAzureBlobClientForFileVersion repositoryDto fileVersion correlationId let! existsResult = fileBlobClient.ExistsAsync() if existsResult.Value = true then use! fileStream = fileBlobClient.OpenReadAsync() let zipEntry = archive.CreateEntry(fileVersion.RelativePath, CompressionLevel.NoCompression) zipEntry.Comment <- fileVersion.GetObjectFileName use zipEntryStream = zipEntry.Open() do! fileStream.CopyToAsync(zipEntryStream) // Step 5: Upload the new ZIP to Azure Blob Storage archive.Dispose() // Dispose the archive before uploading to ensure it's properly flushed to the disk. let! zipFileBlobClient = getAzureBlobClient repositoryDto zipFileBlobName correlationId use tempZipFileStream = File.OpenRead(tempZipPath) let! response = zipFileBlobClient.UploadAsync(tempZipFileStream) () finally // Step 5: Delete the local ZIP file if File.Exists(tempZipPath) then File.Delete(tempZipPath) } task { logToConsole $"In GetZipFileUri: DirectoryVersionId: {directoryVersion.DirectoryVersionId}; RelativePath: {directoryVersion.RelativePath}." let repositoryActorProxy = Repository.CreateActorProxy directoryVersion.OrganizationId directoryVersion.RepositoryId correlationId let! repositoryDto = repositoryActorProxy.Get correlationId let blobName = getZipFileBlobName directoryVersion.DirectoryVersionId let! zipFileBlobClient = getAzureBlobClient repositoryDto blobName correlationId let! zipFileExists = zipFileBlobClient.ExistsAsync() if zipFileExists.Value = true then // We already have this .zip file, so just return the URI with SAS. logToConsole $"In GetZipFileUri: .zip file already exists for DirectoryVersionId: {directoryVersion.DirectoryVersionId}; RelativePath: {directoryVersion.RelativePath}." let! uriWithSas = getUriWithReadSharedAccessSignature repositoryDto blobName correlationId return uriWithSas else // We don't have the .zip file saved, so let's create it. logToConsole $"In GetZipFileUri: Creating .zip file for DirectoryVersionId: {directoryVersion.DirectoryVersionId}; RelativePath: {directoryVersion.RelativePath}." do! createDirectoryVersionZipFile repositoryDto blobName directoryVersion.DirectoryVersionId directoryVersion.Directories directoryVersion.Files // Schedule a reminder to delete the .zip file after the cache days have passed. let deletionReminderState: PhysicalDeletionReminderState = { DeleteReason = getDiscriminatedUnionCaseName DeleteZipFile; CorrelationId = correlationId } do! (this :> IGraceReminderWithGuidKey) .ScheduleReminderAsync DeleteZipFile (Duration.FromDays(float repositoryDto.DirectoryVersionCacheDays)) (ReminderState.DirectoryVersionDeleteZipFile deletionReminderState) correlationId let! uriWithSas = getUriWithReadSharedAccessSignature repositoryDto blobName correlationId return uriWithSas } ================================================ FILE: src/Grace.Actors/Extensions/MemoryCache.Extensions.Actor.fs ================================================ namespace Grace.Actors.Extensions open Grace.Shared.Constants open Grace.Types.Types open Orleans.Runtime open Microsoft.Extensions.Caching.Memory open System open System.Collections.Generic open Grace.Shared open Grace.Shared.Validation module MemoryCache = [] let ownerIdPrefix = "OwI" [] let organizationIdPrefix = "OrI" [] let repositoryIdPrefix = "ReI" [] let branchIdPrefix = "BrI" [] let ownerNamePrefix = "OwN" [] let organizationNamePrefix = "OrN" [] let repositoryNamePrefix = "ReN" [] let branchNamePrefix = "BrN" [] let correlationIdPrefix = "CoI" [] let orleansContextPrefix = "Orl" type Microsoft.Extensions.Caching.Memory.IMemoryCache with /// Get a value from MemoryCache, if it exists. member this.GetFromCache<'T>(key: string) = let mutable value = Unchecked.defaultof<'T> if this.TryGetValue(key, &value) then Some value else None /// Create a new entry in MemoryCache with a default expiration time. member this.CreateWithDefaultExpirationTime (key: string) value = use newCacheEntry = this.CreateEntry(key, Value = value, AbsoluteExpiration = DateTimeOffset.UtcNow.Add MemoryCache.DefaultExpirationTime) //Utilities.logToConsole $"In CreateWithDefaultExpirationTime: {key}: {value}" () /// Create a new entry in MemoryCache to link an ActorId with a CorrelationId. member this.CreateCorrelationIdEntry (identityString: string) (correlationId: CorrelationId) = use newCacheEntry = this.CreateEntry($"{correlationIdPrefix}:{identityString}", Value = correlationId, AbsoluteExpiration = DateTimeOffset.UtcNow.AddSeconds 5) () /// Check if we have an entry in MemoryCache for an ActorId, and return the CorrelationId if we have it. member this.GetCorrelationIdEntry(identityString: string) = this.GetFromCache $"{correlationIdPrefix}:{identityString}" /// Create a new entry in MemoryCache to confirm that an OwnerId exists. member this.CreateOwnerIdEntry (ownerId: OwnerId) (value: string) = this.CreateWithDefaultExpirationTime $"{ownerIdPrefix}:{ownerId}" value /// Check if we have an entry in MemoryCache for an OwnerId. member this.GetOwnerIdEntry(ownerId: OwnerId) = this.GetFromCache $"{ownerIdPrefix}:{ownerId}" /// Remove an entry in MemoryCache for an OwnerId. member this.RemoveOwnerIdEntry(ownerId: OwnerId) = this.Remove($"{ownerIdPrefix}:{ownerId}") /// Create a new entry in MemoryCache to confirm that an OwnerId has been deleted. member this.CreateDeletedOwnerIdEntry (ownerId: OwnerId) (value: string) = this.CreateWithDefaultExpirationTime $"{ownerIdPrefix}:{ownerId}:Deleted" value /// Check if we have an entry in MemoryCache for a deleted OwnerId. member this.GetDeletedOwnerIdEntry(ownerId: OwnerId) = this.GetFromCache $"{ownerIdPrefix}:{ownerId}:Deleted" /// Remove an entry in MemoryCache for a deleted OwnerId. member this.RemoveDeletedOwnerIdEntry(ownerId: OwnerId) = this.Remove($"{ownerIdPrefix}:{ownerId}:Deleted") /// Create a new entry in MemoryCache to confirm that an OrganizationId exists. member this.CreateOrganizationIdEntry (organizationId: OrganizationId) (value: string) = this.CreateWithDefaultExpirationTime $"{organizationIdPrefix}:{organizationId}" value /// Check if we have an entry in MemoryCache for an OrganizationId. member this.GetOrganizationIdEntry(organizationId: OrganizationId) = this.GetFromCache $"{organizationIdPrefix}:{organizationId}" /// Remove an entry in MemoryCache for an OrganizationId. member this.RemoveOrganizationIdEntry(organizationId: OrganizationId) = this.Remove($"{organizationIdPrefix}:{organizationId}") /// Create a new entry in MemoryCache to confirm that an OrganizationId has been deleted. member this.CreateDeletedOrganizationIdEntry (organizationId: OrganizationId) (value: string) = this.CreateWithDefaultExpirationTime $"{organizationIdPrefix}:{organizationId}:Deleted" value /// Check if we have an entry in MemoryCache for a deleted OrganizationId. member this.GetDeletedOrganizationIdEntry(organizationId: OrganizationId) = this.GetFromCache $"{organizationIdPrefix}:{organizationId}:Deleted" /// Remove an entry in MemoryCache for a deleted OrganizationId. member this.RemoveDeletedOrganizationIdEntry(organizationId: OrganizationId) = this.Remove($"{organizationIdPrefix}:{organizationId}:Deleted") /// Create a new entry in MemoryCache to confirm that a RepositoryId exists. member this.CreateRepositoryIdEntry (repositoryId: RepositoryId) (value: string) = this.CreateWithDefaultExpirationTime $"{repositoryIdPrefix}:{repositoryId}" value /// Check if we have an entry in MemoryCache for a RepositoryId. member this.GetRepositoryIdEntry(repositoryId: RepositoryId) = this.GetFromCache $"{repositoryIdPrefix}:{repositoryId}" /// Remove an entry in MemoryCache for a RepositoryId. member this.RemoveRepositoryIdEntry(repositoryId: RepositoryId) = this.Remove($"{repositoryIdPrefix}:{repositoryId}") /// Create a new entry in MemoryCache to confirm that a RepositoryId has been deleted. member this.CreateDeletedRepositoryIdEntry (repositoryId: RepositoryId) (value: string) = this.CreateWithDefaultExpirationTime $"{repositoryIdPrefix}:{repositoryId}:Deleted" value /// Check if we have an entry in MemoryCache for a deleted RepositoryId. member this.GetDeletedRepositoryIdEntry(repositoryId: RepositoryId) = this.GetFromCache $"{repositoryIdPrefix}:{repositoryId}:Deleted" /// Remove an entry in MemoryCache for a deleted RepositoryId. member this.RemoveDeletedRepositoryIdEntry(repositoryId: RepositoryId) = this.Remove($"{repositoryIdPrefix}:{repositoryId}:Deleted") /// Create a new entry in MemoryCache to confirm that a BranchId exists. member this.CreateBranchIdEntry (branchId: BranchId) (value: string) = this.CreateWithDefaultExpirationTime $"{branchIdPrefix}:{branchId}" value /// Check if we have an entry in MemoryCache for a BranchId. member this.GetBranchIdEntry(branchId: BranchId) = this.GetFromCache $"{branchIdPrefix}:{branchId}" /// Remove an entry in MemoryCache for a BranchId. member this.RemoveBranchIdEntry(branchId: BranchId) = this.Remove($"{branchIdPrefix}:{branchId}") /// Create a new entry in MemoryCache to confirm that a BranchId has been deleted. member this.CreateDeletedBranchIdEntry (branchId: BranchId) (value: string) = this.CreateWithDefaultExpirationTime $"{branchIdPrefix}:{branchId}:Deleted" value /// Check if we have an entry in MemoryCache for a deleted BranchId. member this.GetDeletedBranchIdEntry(branchId: BranchId) = this.GetFromCache $"{branchIdPrefix}:{branchId}:Deleted" /// Remove an entry in MemoryCache for a deleted BranchId. member this.RemoveDeletedBranchIdEntry(branchId: BranchId) = this.Remove($"{branchIdPrefix}:{branchId}:Deleted") /// Create a new entry in MemoryCache to link an OwnerName with an OwnerId. member this.CreateOwnerNameEntry (ownerName: OwnerName) (ownerId: OwnerId) = this.CreateWithDefaultExpirationTime $"{ownerNamePrefix}:{ownerName}" ownerId /// Check if we have an entry in MemoryCache for an OwnerName, and return the OwnerId if we have it. member this.GetOwnerNameEntry(ownerName: string) = this.GetFromCache $"{ownerNamePrefix}:{ownerName}" /// Remove an entry in MemoryCache for an OwnerName. member this.RemoveOwnerNameEntry(ownerName: string) = this.Remove($"{ownerNamePrefix}:{ownerName}") /// Create a new entry in MemoryCache to link an OrganizationName with an OrganizationId. member this.CreateOrganizationNameEntry (organizationName: OrganizationName) (organizationId: OrganizationId) = this.CreateWithDefaultExpirationTime $"{organizationNamePrefix}:{organizationName}" organizationId /// Check if we have an entry in MemoryCache for an OrganizationName, and return the OrganizationId if we have it. member this.GetOrganizationNameEntry(organizationName: string) = this.GetFromCache $"{organizationNamePrefix}:{organizationName}" /// Remove an entry in MemoryCache for an OrganizationName. member this.RemoveOrganizationNameEntry(organizationName: string) = this.Remove($"{organizationNamePrefix}:{organizationName}") /// Create a new entry in MemoryCache to link a RepositoryName with a RepositoryId. member this.CreateRepositoryNameEntry (repositoryName: RepositoryName) (repositoryId: RepositoryId) = this.CreateWithDefaultExpirationTime $"{repositoryNamePrefix}:{repositoryName}" repositoryId /// Check if we have an entry in MemoryCache for a RepositoryName, and return the RepositoryId if we have it. member this.GetRepositoryNameEntry(repositoryName: string) = this.GetFromCache $"{repositoryNamePrefix}:{repositoryName}" /// Remove an entry in MemoryCache for a RepositoryName. member this.RemoveRepositoryNameEntry(repositoryName: string) = this.Remove($"{repositoryNamePrefix}:{repositoryName}") /// Create a new entry in MemoryCache to link a BranchName with a BranchId. member this.CreateBranchNameEntry(repositoryId: string, branchName: string, branchId: BranchId) = this.CreateWithDefaultExpirationTime $"{branchNamePrefix}:{repositoryId}-{branchName}" branchId /// Create a new entry in MemoryCache to link a BranchName with a BranchId. member this.CreateBranchNameEntry(repositoryId: RepositoryId, branchName: string, branchId: BranchId) = this.CreateWithDefaultExpirationTime $"{branchNamePrefix}:{repositoryId}-{branchName}" branchId /// Check if we have an entry in MemoryCache for a BranchName, and return the BranchId if we have it. member this.GetBranchNameEntry(repositoryId: string, branchName: string) = this.GetFromCache $"{branchNamePrefix}:{repositoryId}-{branchName}" /// Check if we have an entry in MemoryCache for a BranchName, and return the BranchId if we have it. member this.GetBranchNameEntry(repositoryId: RepositoryId, branchName: string) = this.GetFromCache $"{branchNamePrefix}:{repositoryId}-{branchName}" /// Remove an entry in MemoryCache for a BranchName. member this.RemoveBranchNameEntry(repositoryId: string, branchName: string) = this.Remove($"{branchNamePrefix}:{repositoryId}-{branchName}") /// Remove an entry in MemoryCache for a BranchName. member this.RemoveBranchNameEntry(repositoryId: RepositoryId, branchName: string) = this.Remove($"{branchNamePrefix}:{repositoryId}-{branchName}") /// Create a new entry in MemoryCache to store the current thread count information. member this.CreateThreadCountEntry(threadInfo: string) = use newCacheEntry = this.CreateEntry("ThreadCounts", Value = threadInfo, AbsoluteExpiration = DateTimeOffset.UtcNow.AddSeconds 6) () /// Check if we have an entry in MemoryCache for the current ThreadCount. member this.GetThreadCountEntry() = this.GetFromCache "ThreadCounts" /// Create a new entry in MemoryCache to store context information for an Orleans grain. member this.CreateOrleansContextEntry(grainId: GrainId, orleansContext: Dictionary) = use newCacheEntry = this.CreateEntry($"{orleansContextPrefix}:{grainId}", Value = orleansContext, AbsoluteExpiration = DateTimeOffset.UtcNow.AddSeconds 60) () /// Check if we have an entry in MemoryCache for the Orleans context, and return the value if we have it. member this.GetOrleansContextEntry(grainId: GrainId) = this.GetFromCache> $"{orleansContextPrefix}:{grainId}" |> Option.bind (fun orleansContext -> Some(orleansContext :> IReadOnlyDictionary)) ================================================ FILE: src/Grace.Actors/FileAppearance.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.ActorProxy open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Actors.Types open Grace.Types.Types open Grace.Shared.Utilities open Grace.Shared open Microsoft.Extensions.Logging open NodaTime open Orleans open Orleans.Runtime open System open System.Collections.Generic open System.Threading.Tasks module FileAppearance = let actorName = ActorName.FileAppearance //let log = loggerFactory.CreateLogger("FileAppearance.Actor") type FileAppearanceDto() = member val public Appearances = SortedSet() with get, set type FileAppearanceActor([] state: IPersistentState>) = inherit Grain() let log = loggerFactory.CreateLogger("FileAppearance.Actor") let mutable correlationId = String.Empty let dtoStateName = StateName.FileAppearance let mutable dto = FileAppearanceDto() member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () logActorActivation log this.IdentityString activateStartTime (getActorActivationMessage state.RecordExists) Task.CompletedTask interface IFileAppearanceActor with member this.Add appearance correlationId = task { let wasAdded = dto.Appearances.Add(appearance) if wasAdded then do! state.WriteStateAsync() } :> Task member this.Remove appearance correlationId = task { let wasRemoved = dto.Appearances.Remove(appearance) if wasRemoved then if dto.Appearances |> Seq.isEmpty then // TODO: Delete the file from storage do! state.ClearStateAsync() else do! state.WriteStateAsync() () } :> Task member this.Contains appearance correlationId = Task.FromResult(dto.Appearances.Contains(appearance)) member this.Appearances correlationId = Task.FromResult(dto.Appearances) ================================================ FILE: src/Grace.Actors/GlobalLock.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.MemoryCache open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Types.Types open Grace.Shared.Utilities open Microsoft.Extensions.Logging open NodaTime open Orleans open Orleans.Runtime open System open System.Threading.Tasks module GlobalLock = type LockState = { IsLocked: bool LockedBy: string option LockedAt: Instant option } static member Unlocked = { IsLocked = false; LockedBy = None; LockedAt = None } let log = loggerFactory.CreateLogger("GlobalLock.Actor") type GlobalLockActor() = inherit Grain() static let actorName = ActorName.GlobalLock let log = loggerFactory.CreateLogger("GlobalLock.Actor") let mutable actorStartTime = Instant.MinValue let mutable lockState = LockState.Unlocked let mutable instanceName = String.Empty interface IGlobalLockActor with member this.AcquireLock(lockedBy: string) = if lockState.IsLocked then false |> returnTask else lockState <- { IsLocked = true; LockedBy = Some lockedBy; LockedAt = Some(getCurrentInstant ()) } instanceName <- lockedBy true |> returnTask member this.ReleaseLock(releasedBy: string) = match lockState.LockedBy with | Some lockedBy -> if lockState.IsLocked && lockedBy = releasedBy then lockState <- LockState.Unlocked instanceName <- lockedBy Ok() |> returnTask else Error "Not locked by the calling instance." |> returnTask | None -> Error "Cannot release the lock. The lock has not been acquired." |> returnTask //blah member this.IsLocked() = lockState.IsLocked |> returnTask ================================================ FILE: src/Grace.Actors/Grace.Actors.fsproj ================================================ net10.0 preview false true true true FS0025 1057,3391 AnyCPU;x64 --test:GraphBasedChecking --test:ParallelOptimization --test:ParallelIlxGen ================================================ FILE: src/Grace.Actors/GrainRepository.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.MemoryCache open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Types.Types open Grace.Shared.Utilities open Microsoft.Extensions.Logging open NodaTime open Orleans open Orleans.Runtime open System open System.Threading.Tasks module GrainRepository = type GrainRepositoryActor() = inherit Grain() static let actorName = ActorName.RepositoryName let log = loggerFactory.CreateLogger("GrainRepository.Actor") let mutable cachedRepositoryId: RepositoryId option = None member val private correlationId: CorrelationId = String.Empty with get, set //override this.OnActivateAsync(ct) = // logActorActivation log this.IdentityString "In-memory only" // Task.CompletedTask interface IGrainRepositoryActor with member this.GetRepositoryId correlationId = this.correlationId <- correlationId cachedRepositoryId |> returnTask member this.SetRepositoryId (repositoryId: RepositoryId) correlationId = this.correlationId <- correlationId cachedRepositoryId <- Some repositoryId Task.CompletedTask ================================================ FILE: src/Grace.Actors/Interfaces.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Types open Grace.Shared open Grace.Types.Authorization open Grace.Types.Branch open Grace.Types.Diff open Grace.Types.DirectoryVersion open Grace.Types.Reference open Grace.Types.Reminder open Grace.Types.Repository open Grace.Types.Organization open Grace.Types.Owner open Grace.Types.PersonalAccessToken open Grace.Types.Policy open Grace.Types.PromotionSet open Grace.Types.Review open Grace.Types.Queue open Grace.Types.Validation open Grace.Types.Artifact open Grace.Types.WorkItem open Grace.Types.Types open Grace.Shared.Utilities open NodaTime open Orleans open System open System.Collections.Generic open System.Threading.Tasks open System.Reflection.Metadata open Orleans.Runtime module Interfaces = type PersistAction = | Save | DoNotSave type ExportError = | EventListIsEmpty | Exception of ExceptionResponse type ImportError = | EventListIsEmpty | Exception of ExceptionResponse type RevertError = | EmptyEventList | OutOfRange | Exception of ExceptionResponse /// Retrieves the RepositoryId for a grain. /// This is used when computing the PartitionKey for grains in a repository during storage operations. [] type IGrainRepositoryIdExtension = inherit IGrainExtension /// Retrieves the RepositoryId for a grain. abstract member GetRepositoryId: correlationId: CorrelationId -> Task /// Retrieves the RepositoryId for a grain. /// This is used when computing the PartitionKey for grains in a repository during storage operations. [] type IHasRepositoryId = /// Gets the RepositoryId for this actor. abstract member GetRepositoryId: correlationId: CorrelationId -> Task /// This is an experimental interface to explore how to back up and rehydrate actor instances. [] type IExportable<'T> = abstract member Export: unit -> Task, ExportError>> abstract member Import: IReadOnlyList<'T> -> Task> /// This is an experimental interface to explore how to implement important management functions for actors that we'll need in production. [] type IRevertable<'T> = abstract member EventCount: unit -> Task abstract member RevertToInstant: Instant -> PersistAction -> Task> abstract member RevertBack: int -> PersistAction -> Task> /// Defines the operations that an actor must implement to handle Grace reminders. [] type IGraceReminderWithGuidKey = inherit IGrainWithGuidKey /// Receives a reminder and processes it asynchronously. abstract member ReceiveReminderAsync: reminder: ReminderDto -> Task> /// Schedules a reminder to be sent to the actor after a specified delay. abstract member ScheduleReminderAsync: reminderType: ReminderTypes -> delay: Duration -> state: ReminderState -> correlationId: CorrelationId -> Task /// Defines the operations that an actor must implement to handle Grace reminders. [] type IGraceReminderWithStringKey = inherit IGrainWithStringKey /// Receives a reminder and processes it asynchronously. abstract member ReceiveReminderAsync: reminder: ReminderDto -> Task> /// Schedules a reminder to be sent to the actor after a specified delay. abstract member ScheduleReminderAsync: reminderType: ReminderTypes -> delay: Duration -> state: ReminderState -> correlationId: CorrelationId -> Task /// Defines the operations for the Branch actor. [] type IBranchActor = inherit IGraceReminderWithGuidKey /// Validates that a branch with this BranchId exists. abstract member Exists: correlationId: CorrelationId -> Task /// Retrieves the current state of the branch. abstract member Get: correlationId: CorrelationId -> Task /// Retrieves the list of events handled by this branch. abstract member GetEvents: correlationId: CorrelationId -> Task> /// Retrieves the most recent commit from this branch. abstract member GetLatestCommit: correlationId: CorrelationId -> Task /// Retrieves the most recent promotion from this branch. abstract member GetLatestPromotion: correlationId: CorrelationId -> Task /// Retrieves the parent branch for a given branch. abstract member GetParentBranch: correlationId: CorrelationId -> Task /// Validates incoming commands and converts them to events that are stored in the database. abstract member Handle: command: BranchCommand -> eventMetadata: EventMetadata -> Task> /// Returns true if this branch has been deleted. abstract member IsDeleted: correlationId: CorrelationId -> Task /// Marks the branch as needing to recompute its latest references. abstract member MarkForRecompute: correlationId: CorrelationId -> Task /// Defines the operations for the BranchName actor. [] type IBranchNameActor = inherit IGrainWithStringKey /// Returns the BranchId for the given BranchName. abstract member GetBranchId: correlationId: CorrelationId -> Task /// Sets the BranchId that matches the BranchName. abstract member SetBranchId: branchId: BranchId -> correlationId: CorrelationId -> Task /// Defines the operations for the Diff actor. [] type IDiffActor = inherit IGraceReminderWithStringKey /// Populates the contents of the diff without returning the results. abstract member Compute: correlationId: CorrelationId -> Task> /// Gets the results of the diff. If the diff has not already been computed, it will be computed. abstract member GetDiff: correlationId: CorrelationId -> Task /// Defines the operations for the DirectoryAppearance actor. [] type IDirectoryAppearanceActor = inherit IGrainWithGuidKey /// Adds an appearance to the directory appearance list. abstract member Add: appearance: Appearance -> correlationId: CorrelationId -> Task /// Removes an appearance from the directory appearance list. abstract member Remove: appearance: Appearance -> correlationId: CorrelationId -> Task /// Checks if the directory appearance list contains the given appearance. abstract member Contains: appearance: Appearance -> correlationId: CorrelationId -> Task /// Returns the sorted set of appearances for this directory. abstract member Appearances: correlationId: CorrelationId -> Task> ///Defines the operations for the DirectoryVersion actor. [] type IDirectoryVersionActor = inherit IGraceReminderWithGuidKey /// Returns true if the actor instance already exists. abstract member Exists: correlationId: CorrelationId -> Task /// Returns the DirectoryVersion instance for this directory. abstract member Get: correlationId: CorrelationId -> Task /// Returns the list of subdirectories contained in this directory. abstract member GetCreatedAt: correlationId: CorrelationId -> Task /// Returns the list of subdirectories contained in this directory. abstract member GetDirectories: correlationId: CorrelationId -> Task> /// Returns the list of files contained in this directory. abstract member GetFiles: correlationId: CorrelationId -> Task> /// Returns the Sha256 hash value for this directory. abstract member GetSha256Hash: correlationId: CorrelationId -> Task /// Returns the total size of files contained in this directory. This does not include files in subdirectories; for that, use GetSizeRecursive(). abstract member GetSize: correlationId: CorrelationId -> Task /// Returns a list of DirectoryVersion objects for all subdirectories. abstract member GetRecursiveDirectoryVersions: forceRegenerate: bool -> correlationId: CorrelationId -> Task /// Returns the total size of files contained in this directory and all subdirectories. abstract member GetRecursiveSize: correlationId: CorrelationId -> Task /// Returns the Uri, with a shared access signature, to download the .zip file containing the contents of this directory and all subdirectories. If the .zip file doesn't exist, it will be created. abstract member GetZipFileUri: correlationId: CorrelationId -> Task /// Delete the DirectoryVersion and all subdirectories and files. abstract member Delete: correlationId: CorrelationId -> Task> /// Validates incoming commands and converts them to events that are stored in the database. abstract member Handle: command: DirectoryVersionCommand -> eventMetadata: EventMetadata -> Task> /// Defines the operations for the FileAppearance actor. [] type IFileAppearanceActor = inherit IGrainWithStringKey /// Adds an appearance to the directory appearance list. abstract member Add: appearance: Appearance -> correlationId: CorrelationId -> Task /// Removes an appearance from the directory appearance list. abstract member Remove: appearance: Appearance -> correlationId: CorrelationId -> Task /// Checks if the directory appearance list contains the given appearance. abstract member Contains: appearance: Appearance -> correlationId: CorrelationId -> Task /// Returns the sorted set of appearances for this directory. abstract member Appearances: correlationId: CorrelationId -> Task> /// Defines the operations for the ReminderServiceLock actor. [] type IGlobalLockActor = inherit IGrainWithStringKey /// Attempts to acquire a global lock for the Reminder Service. Returns true if the lock was acquired, otherwise false. abstract member AcquireLock: lockedBy: string -> Task /// Releases the global lock for the Reminder Service. abstract member ReleaseLock: releasedBy: string -> Task> /// Returns true if the lock is currently held by any instance. abstract member IsLocked: unit -> Task ///Defines the operations for the Organization actor. [] type IOrganizationActor = inherit IGraceReminderWithGuidKey /// Returns true if an organization with this ActorId already exists in the database. abstract member Exists: correlationId: CorrelationId -> Task /// Returns true if an organization with this ActorId has been deleted. abstract member IsDeleted: correlationId: CorrelationId -> Task /// Returns true if an repository with this name exists for this owner. abstract member RepositoryExists: repositoryName: RepositoryName -> correlationId: CorrelationId -> Task /// Returns the current state of the organization. abstract member Get: correlationId: CorrelationId -> Task /// Returns a list of the repositories under this organization. abstract member ListRepositories: correlationId: CorrelationId -> Task> /// Validates incoming commands and converts them to events that are stored in the database. abstract member Handle: command: OrganizationCommand -> eventMetadata: EventMetadata -> Task> /// Defines the operations for the OrganizationName actor. [] type IOrganizationNameActor = inherit IGrainWithStringKey /// Returns true if an organization with this organization name already exists in the database. abstract member SetOrganizationId: organizationId: OrganizationId -> correlationId: CorrelationId -> Task /// Returns the OrganizationId for the given OrganizationName. abstract member GetOrganizationId: correlationId: CorrelationId -> Task /// Defines the operations for the Owner actor. [] type IOwnerActor = inherit IGraceReminderWithGuidKey /// Returns true if an owner with this ActorId already exists in the database. abstract member Exists: correlationId: CorrelationId -> Task /// Returns true if an owner with this ActorId has been deleted. abstract member IsDeleted: correlationId: CorrelationId -> Task /// Returns true if an organization with this name exists for this owner. abstract member OrganizationExists: organizationName: string -> correlationId: CorrelationId -> Task /// Returns the current state of the owner. abstract member Get: correlationId: CorrelationId -> Task /// Returns a list of the organizations under this owner. abstract member ListOrganizations: correlationId: CorrelationId -> Task> /// Validates incoming commands and converts them to events that are stored in the database. abstract member Handle: command: OwnerCommand -> eventMetadata: EventMetadata -> Task> /// Defines the operations fpr the OwnerName actor. [] type IOwnerNameActor = inherit IGrainWithStringKey /// Clears the OwnerId for the given OwnerName. abstract member ClearOwnerId: correlationId: CorrelationId -> Task /// Returns the OwnerId for the given OwnerName. abstract member GetOwnerId: correlationId: CorrelationId -> Task /// Sets the OwnerId for a given OwnerName. abstract member SetOwnerId: ownerId: OwnerId -> correlationId: CorrelationId -> Task /// Defines the operations for the Reference actor. [] type IReferenceActor = inherit IGraceReminderWithGuidKey /// Returns true if the reference already exists in the database. abstract member Exists: correlationId: CorrelationId -> Task /// Returns the dto for this reference. abstract member Get: correlationId: CorrelationId -> Task /// Returns the ReferenceType for this reference. abstract member GetReferenceType: correlationId: CorrelationId -> Task /// Validates incoming commands and converts them to events that are stored in the database. abstract member Handle: command: ReferenceCommand -> eventMetadata: EventMetadata -> Task> /// Returns true if the reference has been deleted. abstract member IsDeleted: correlationId: CorrelationId -> Task [] type IReminderActor = inherit IGrainWithGuidKey /// Creates a new reminder in the database. abstract member Create: reminder: ReminderDto -> correlationId: CorrelationId -> Task /// Deletes the reminder from the database. abstract member Delete: correlationId: CorrelationId -> Task /// Returns true if the reminder exists in the database. abstract member Exists: correlationId: CorrelationId -> Task /// Returns the reminder from the database. abstract member Get: correlationId: CorrelationId -> Task /// Sends the reminder to the source actor. abstract member Remind: correlationId: CorrelationId -> Task> /// Defines the operations for the PromotionSet actor. [] type IPromotionSetActor = inherit IGraceReminderWithGuidKey /// Returns true if this promotion set already exists in the database. abstract member Exists: correlationId: CorrelationId -> Task /// Returns true if this promotion set has been deleted. abstract member IsDeleted: correlationId: CorrelationId -> Task /// Returns the current state of the promotion set. abstract member Get: correlationId: CorrelationId -> Task /// Returns the list of events handled by this promotion set. abstract member GetEvents: correlationId: CorrelationId -> Task> /// Validates incoming commands and converts them to events that are stored in the database. abstract member Handle: command: PromotionSetCommand -> eventMetadata: EventMetadata -> Task> /// Defines the operations for the Policy actor. [] type IPolicyActor = inherit IGrainWithGuidKey /// Returns the current policy snapshot. abstract member GetCurrent: correlationId: CorrelationId -> Task /// Returns all policy snapshots. abstract member GetSnapshots: correlationId: CorrelationId -> Task> /// Returns policy acknowledgements. abstract member GetAcknowledgements: correlationId: CorrelationId -> Task> /// Validates incoming commands and converts them to events that are stored in the database. abstract member Handle: command: PolicyCommand -> eventMetadata: EventMetadata -> Task> /// Defines the operations for the Review actor. [] type IReviewActor = inherit IGrainWithGuidKey /// Returns the current review notes. abstract member GetNotes: correlationId: CorrelationId -> Task /// Returns checkpoints for this review target. abstract member GetCheckpoints: correlationId: CorrelationId -> Task> /// Validates incoming commands and converts them to events that are stored in the database. abstract member Handle: command: ReviewCommand -> eventMetadata: EventMetadata -> Task> /// Defines the operations for the PromotionQueue actor. [] type IPromotionQueueActor = inherit IGrainWithGuidKey /// Returns true if this promotion queue already exists in the database. abstract member Exists: correlationId: CorrelationId -> Task /// Returns the current state of the promotion queue. abstract member Get: correlationId: CorrelationId -> Task /// Returns the list of events handled by this promotion queue. abstract member GetEvents: correlationId: CorrelationId -> Task> /// Validates incoming commands and converts them to events that are stored in the database. abstract member Handle: command: PromotionQueueCommand -> eventMetadata: EventMetadata -> Task> /// Defines the operations for the ValidationSet actor. [] type IValidationSetActor = inherit IGrainWithGuidKey /// Returns true if this validation set already exists in the database. abstract member Exists: correlationId: CorrelationId -> Task /// Returns true if this validation set has been deleted. abstract member IsDeleted: correlationId: CorrelationId -> Task /// Returns the current validation set. abstract member Get: correlationId: CorrelationId -> Task /// Returns the list of events handled by this validation set. abstract member GetEvents: correlationId: CorrelationId -> Task> /// Validates incoming commands and converts them to events that are stored in the database. abstract member Handle: command: ValidationSetCommand -> eventMetadata: EventMetadata -> Task> /// Defines the operations for the ValidationResult actor. [] type IValidationResultActor = inherit IGrainWithGuidKey /// Returns true if this validation result already exists in the database. abstract member Exists: correlationId: CorrelationId -> Task /// Returns the current validation result. abstract member Get: correlationId: CorrelationId -> Task /// Returns the list of events handled by this validation result. abstract member GetEvents: correlationId: CorrelationId -> Task> /// Validates incoming commands and converts them to events that are stored in the database. abstract member Handle: command: ValidationResultCommand -> eventMetadata: EventMetadata -> Task> /// Defines the operations for the Artifact actor. [] type IArtifactActor = inherit IGrainWithGuidKey /// Returns true if this artifact already exists in the database. abstract member Exists: correlationId: CorrelationId -> Task /// Returns the current artifact metadata. abstract member Get: correlationId: CorrelationId -> Task /// Returns the list of events handled by this artifact. abstract member GetEvents: correlationId: CorrelationId -> Task> /// Validates incoming commands and converts them to events that are stored in the database. abstract member Handle: command: ArtifactCommand -> eventMetadata: EventMetadata -> Task> /// Defines the operations for the WorkItemNumber actor. [] type IWorkItemNumberActor = inherit IGrainWithStringKey /// Returns the WorkItemId for a repository-scoped WorkItemNumber. abstract member GetWorkItemId: workItemNumber: WorkItemNumber -> correlationId: CorrelationId -> Task /// Caches a WorkItemNumber -> WorkItemId mapping while the actor is active. abstract member SetWorkItemId: workItemNumber: WorkItemNumber -> workItemId: WorkItemId -> correlationId: CorrelationId -> Task /// Defines the operations for the WorkItemNumberCounter actor. [] type IWorkItemNumberCounterActor = inherit IGrainWithStringKey /// Allocates and persists the next WorkItemNumber for a repository. abstract member AllocateNext: correlationId: CorrelationId -> Task /// Defines the operations for the WorkItem actor. [] type IWorkItemActor = inherit IGrainWithGuidKey /// Returns true if this work item already exists in the database. abstract member Exists: correlationId: CorrelationId -> Task /// Returns the current state of the work item. abstract member Get: correlationId: CorrelationId -> Task /// Returns the list of events handled by this work item. abstract member GetEvents: correlationId: CorrelationId -> Task> /// Validates incoming commands and converts them to events that are stored in the database. abstract member Handle: command: WorkItemCommand -> eventMetadata: EventMetadata -> Task> /// Defines the operations for the Repository actor. [] type IRepositoryActor = inherit IGraceReminderWithGuidKey /// Returns true if this actor already exists in the database, otherwise false. abstract member Exists: correlationId: CorrelationId -> Task /// Returns true if the repository has been created but is empty; otherwise false. abstract member IsEmpty: correlationId: CorrelationId -> Task /// Returns true if this repository has been deleted. abstract member IsDeleted: correlationId: CorrelationId -> Task /// Returns a record with the current state of the repository. abstract member Get: correlationId: CorrelationId -> Task /// Returns the object storage provider for this repository. abstract member GetObjectStorageProvider: correlationId: CorrelationId -> Task /// Processes commands by checking that they're valid, and then converting them into events. abstract member Handle: command: RepositoryCommand -> eventMetadata: EventMetadata -> Task> /// Defines the operations for the AccessControl actor. [] type IAccessControlActor = inherit IGrainWithStringKey /// Handles role assignment commands. abstract member Handle: command: AccessControlCommand -> eventMetadata: EventMetadata -> Task> /// Returns role assignments for this scope. abstract member GetAssignments: principal: Principal option -> correlationId: CorrelationId -> Task /// Defines the operations for the RepositoryPermission actor. [] type IRepositoryPermissionActor = inherit IGrainWithStringKey /// Handles repository path permission commands. abstract member Handle: command: RepositoryPermissionCommand -> eventMetadata: EventMetadata -> Task> /// Returns path permissions for this repository. abstract member GetPathPermissions: pathFilter: RelativePath option -> correlationId: CorrelationId -> Task /// Defines the operations for the RepositoryName actor. [] type IGrainRepositoryActor = inherit IGrainWithStringKey /// Sets the RepositoryId that matches the RepositoryName. abstract member SetRepositoryId: repositoryId: RepositoryId -> correlationId: CorrelationId -> Task /// Returns the RepositoryId for the given RepositoryName. abstract member GetRepositoryId: correlationId: CorrelationId -> Task /// Defines the operations for the RepositoryName actor. [] type IRepositoryNameActor = inherit IGrainWithStringKey /// Sets the RepositoryId that matches the RepositoryName. abstract member SetRepositoryId: repositoryId: RepositoryId -> correlationId: CorrelationId -> Task /// Returns the RepositoryId for the given RepositoryName. abstract member GetRepositoryId: correlationId: CorrelationId -> Task /// Defines the operations for the PersonalAccessToken actor. [] type IPersonalAccessTokenActor = inherit IGrainWithStringKey abstract member CreateToken: name: string -> claims: string list -> groupIds: string list -> expiresAt: Instant option -> now: Instant -> correlationId: CorrelationId -> Task> abstract member ListTokens: includeRevoked: bool -> includeExpired: bool -> now: Instant -> correlationId: CorrelationId -> Task abstract member RevokeToken: tokenId: PersonalAccessTokenId -> now: Instant -> correlationId: CorrelationId -> Task> abstract member ValidateToken: tokenId: PersonalAccessTokenId -> secret: byte [] -> now: Instant -> correlationId: CorrelationId -> Task ================================================ FILE: src/Grace.Actors/NamedSection.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants module NamedSection = let ActorName = ActorName.NamedSection ================================================ FILE: src/Grace.Actors/Organization.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.ActorProxy open Grace.Actors.Extensions.MemoryCache open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Actors.Types open Grace.Shared open Grace.Shared.Constants open Grace.Shared.Utilities open Grace.Types.Events open Grace.Types.Reminder open Grace.Types.Repository open Grace.Types.Organization open Grace.Types.Types open Grace.Shared.Validation.Errors open Microsoft.Extensions.Logging open NodaTime open Orleans open Orleans.Runtime open System open System.Collections.Concurrent open System.Collections.Generic open System.Linq open System.Runtime.Serialization open System.Text.Json open System.Threading.Tasks module Organization = type OrganizationActor([] state: IPersistentState>) = inherit Grain() static let actorName = ActorName.Organization let log = loggerFactory.CreateLogger("Organization.Actor") let mutable organizationDto = OrganizationDto.Default member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () logActorActivation log this.IdentityString activateStartTime (getActorActivationMessage state.RecordExists) organizationDto <- state.State |> Seq.fold (fun organizationDto event -> OrganizationDto.UpdateDto event organizationDto) organizationDto Task.CompletedTask member private this.ApplyEvent organizationEvent = task { try state.State.Add(organizationEvent) do! state.WriteStateAsync() // Update the Dto based on the current event. organizationDto <- organizationDto |> OrganizationDto.UpdateDto organizationEvent // Publish the event to the rest of the world. let graceEvent = OrganizationEvent organizationEvent do! publishGraceEvent graceEvent organizationEvent.Metadata let returnValue = (GraceReturnValue.Create "Organization command succeeded." organizationEvent.Metadata.CorrelationId) .enhance(nameof OwnerId, organizationDto.OwnerId) .enhance(nameof OrganizationId, organizationDto.OrganizationId) .enhance(nameof OrganizationName, organizationDto.OrganizationName) .enhance (nameof OrganizationEventType, getDiscriminatedUnionFullName organizationEvent.Event) return Ok returnValue with | ex -> let exceptionResponse = ExceptionResponse.Create ex let graceError = GraceError.Create (OrganizationError.getErrorMessage OrganizationError.FailedWhileApplyingEvent) organizationEvent.Metadata.CorrelationId graceError .enhance( "Exception details", exceptionResponse.``exception`` + exceptionResponse.innerException ) .enhance(nameof OrganizationId, organizationDto.OrganizationId) .enhance(nameof OrganizationName, organizationDto.OrganizationName) .enhance (nameof OrganizationEventType, getDiscriminatedUnionFullName organizationEvent.Event) |> ignore return Error graceError } /// Deletes all of the repositories provided, by sending a DeleteLogical command to each one. member private this.LogicalDeleteRepositories(repositories: RepositoryDto array, metadata: EventMetadata, deleteReason: DeleteReason) = task { let results = ConcurrentQueue>() // Loop through each repository and send a DeleteLogical command to it. do! Parallel.ForEachAsync( repositories, Constants.ParallelOptions, (fun repository ct -> ValueTask( task { if repository.DeletedAt |> Option.isNone then let repositoryActor = Repository.CreateActorProxy repository.OrganizationId repository.RepositoryId metadata.CorrelationId let! result = repositoryActor.Handle (RepositoryCommand.DeleteLogical( true, $"Cascaded from deleting organization. ownerId: {organizationDto.OwnerId}; organizationId: {organizationDto.OrganizationId}; organizationName: {organizationDto.OrganizationName}; deleteReason: {deleteReason}" )) metadata results.Enqueue(result) } )) ) // Check if any of the commands failed, and if so, return the first error. let overallResult = results |> Seq.tryPick (fun result -> match result with | Ok _ -> None | Error error -> Some(error)) match overallResult with | None -> return Ok() | Some error -> return Error error } interface IGraceReminderWithGuidKey with /// Schedules a Grace reminder. member this.ScheduleReminderAsync reminderType delay state correlationId = task { let reminder = ReminderDto.Create actorName $"{this.IdentityString}" organizationDto.OwnerId organizationDto.OrganizationId Guid.Empty reminderType (getFutureInstant delay) state correlationId do! createReminder reminder } :> Task /// Receives a Grace reminder. member this.ReceiveReminderAsync(reminder: ReminderDto) : Task> = task { this.correlationId <- reminder.CorrelationId match reminder.ReminderType, reminder.State with | ReminderTypes.PhysicalDeletion, ReminderState.OrganizationPhysicalDeletion physicalDeletionReminderState -> this.correlationId <- physicalDeletionReminderState.CorrelationId do! state.ClearStateAsync() log.LogInformation( "{CurrentInstant}: Node: {hostName}; CorrelationId: {correlationId}; Deleted physical state for organization; OrganizationId: {organizationId}; OrganizationName: {organizationName}; OwnerId: {ownerId}; deleteReason: {deleteReason}.", getCurrentInstantExtended (), getMachineName, physicalDeletionReminderState.CorrelationId, organizationDto.OrganizationId, organizationDto.OrganizationName, organizationDto.OwnerId, physicalDeletionReminderState.DeleteReason ) // Deactivate the actor after the PhysicalDeletion reminder is processed. this.DeactivateOnIdle() return Ok() | reminderType, state -> return Error( GraceError.Create $"{actorName} does not process reminder type {getDiscriminatedUnionCaseName reminderType} with state {getDiscriminatedUnionCaseName state}." this.correlationId ) } interface IOrganizationActor with member this.Exists correlationId = this.correlationId <- correlationId Task.FromResult(if organizationDto.UpdatedAt.IsSome then true else false) member this.IsDeleted correlationId = this.correlationId <- correlationId Task.FromResult(if organizationDto.DeletedAt.IsSome then true else false) member this.Get correlationId = this.correlationId <- correlationId Task.FromResult(organizationDto) member this.RepositoryExists repositoryName correlationId = task { this.correlationId <- correlationId let actorProxy = RepositoryName.CreateActorProxy organizationDto.OwnerId organizationDto.OrganizationId repositoryName correlationId match! actorProxy.GetRepositoryId(correlationId) with | Some repositoryId -> return true | None -> return false } member this.ListRepositories correlationId = task { this.correlationId <- correlationId let! repositoryDtos = Services.getRepositories organizationDto.OwnerId organizationDto.OrganizationId Int32.MaxValue false let dict = repositoryDtos.ToDictionary((fun repo -> repo.RepositoryId), (fun repo -> repo.RepositoryName)) return dict :> IReadOnlyDictionary } //Task.FromResult(organizationDto.Repositories :> IReadOnlyDictionary) member this.Handle (command: OrganizationCommand) metadata = let isValid (command: OrganizationCommand) (metadata: EventMetadata) = task { if state.State.Exists(fun ev -> ev.Metadata.CorrelationId = metadata.CorrelationId) then return Error(GraceError.Create (getErrorMessage OrganizationError.DuplicateCorrelationId) metadata.CorrelationId) else match command with | OrganizationCommand.Create (organizationId, organizationName, ownerId) -> match organizationDto.UpdatedAt with | Some _ -> return Error(GraceError.Create (OrganizationError.getErrorMessage OrganizationIdAlreadyExists) metadata.CorrelationId) | None -> return Ok command | _ -> match organizationDto.UpdatedAt with | Some _ -> return Ok command | None -> return Error(GraceError.Create (OrganizationError.getErrorMessage OrganizationIdDoesNotExist) metadata.CorrelationId) } let processCommand (command: OrganizationCommand) (metadata: EventMetadata) = task { try let! eventResult = task { match command with | OrganizationCommand.Create (organizationId, organizationName, ownerId) -> return Ok(OrganizationEventType.Created(organizationId, organizationName, ownerId)) | OrganizationCommand.SetName (organizationName) -> return Ok(OrganizationEventType.NameSet(organizationName)) | OrganizationCommand.SetType (organizationType) -> return Ok(OrganizationEventType.TypeSet(organizationType)) | OrganizationCommand.SetSearchVisibility (searchVisibility) -> return Ok(OrganizationEventType.SearchVisibilitySet(searchVisibility)) | OrganizationCommand.SetDescription (description) -> return Ok(OrganizationEventType.DescriptionSet(description)) | OrganizationCommand.DeleteLogical (force, deleteReason) -> // Get the list of branches that aren't already deleted. let! repositories = getRepositories organizationDto.OwnerId organizationDto.OrganizationId Int32.MaxValue false // If the organization contains repositories, and any of them isn't already deleted, and the force flag is not set, return an error. if not <| force && repositories.Length > 0 && repositories.Any(fun repository -> repository.DeletedAt |> Option.isNone) then let metadataObj = Dictionary(metadata.Properties.Select(fun kvp -> KeyValuePair(kvp.Key, kvp.Value))) return Error( GraceError.CreateWithMetadata null (OrganizationError.getErrorMessage OrganizationContainsRepositories) metadata.CorrelationId metadataObj ) else // Delete the repositories. match! this.LogicalDeleteRepositories(repositories, metadata, deleteReason) with | Ok _ -> let (physicalDeletionReminderState: PhysicalDeletionReminderState) = { DeleteReason = deleteReason; CorrelationId = metadata.CorrelationId } do! (this :> IGraceReminderWithGuidKey) .ScheduleReminderAsync ReminderTypes.PhysicalDeletion DefaultPhysicalDeletionReminderDuration (ReminderState.OrganizationPhysicalDeletion physicalDeletionReminderState) metadata.CorrelationId return Ok(LogicalDeleted(force, deleteReason)) | Error error -> return Error error | OrganizationCommand.DeletePhysical -> // Delete saved state for this actor. do! state.ClearStateAsync() // Deactivate the actor after the PhysicalDeletion is processed. this.DeactivateOnIdle() return Ok OrganizationEventType.PhysicalDeleted | OrganizationCommand.Undelete -> return Ok OrganizationEventType.Undeleted } match eventResult with | Ok event -> return! this.ApplyEvent { Event = event; Metadata = metadata } | Error error -> return Error error with | ex -> let metadataObj = Dictionary(metadata.Properties.Select(fun kvp -> KeyValuePair(kvp.Key, kvp.Value))) return Error(GraceError.CreateWithMetadata ex String.Empty metadata.CorrelationId metadataObj) } task { this.correlationId <- metadata.CorrelationId RequestContext.Set(Constants.CurrentCommandProperty, getDiscriminatedUnionCaseName command) match! isValid command metadata with | Ok command -> return! processCommand command metadata | Error error -> return Error error } ================================================ FILE: src/Grace.Actors/OrganizationName.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.MemoryCache open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Types.Types open Grace.Shared.Utilities open Microsoft.Extensions.Logging open NodaTime open Orleans open Orleans.Runtime open System open System.Threading.Tasks module OrganizationName = type OrganizationNameActor() = inherit Grain() static let actorName = ActorName.OrganizationName let log = loggerFactory.CreateLogger("OrganizationName.Actor") let mutable cachedOrganizationId: OrganizationId option = None member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () //let idSections = this.GetGrainId().Key.ToString().Split('|') //let organizationName = idSections[0] //let ownerId = idSections[1] logActorActivation log this.IdentityString activateStartTime "In-memory only" Task.CompletedTask interface IOrganizationNameActor with member this.GetOrganizationId correlationId = this.correlationId <- correlationId cachedOrganizationId |> returnTask member this.SetOrganizationId (organizationId: OrganizationId) correlationId = this.correlationId <- correlationId if organizationId <> Guid.Empty then cachedOrganizationId <- Some organizationId Task.CompletedTask ================================================ FILE: src/Grace.Actors/Owner.Actor.fs ================================================ namespace Grace.Actors open FSharp.Control open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.ActorProxy open Grace.Actors.Extensions.MemoryCache open Grace.Actors.Services open Grace.Actors.Types open Grace.Actors.Interfaces open Grace.Shared open Grace.Shared.Constants open Grace.Shared.Utilities open Grace.Shared.Validation.Errors open Grace.Types open Grace.Types.Organization open Grace.Types.Owner open Grace.Types.Reminder open Grace.Types.Types open Microsoft.Extensions.Logging open NodaTime open Orleans open Orleans.Runtime open System open System.Collections.Concurrent open System.Collections.Generic open System.Linq open System.Runtime.Serialization open System.Text.Json open System.Threading.Tasks module Owner = type OwnerActor([] state: IPersistentState>) = inherit Grain() static let actorName = ActorName.Owner let log = loggerFactory.CreateLogger("Owner.Actor") let mutable ownerDto = OwnerDto.Default member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () ownerDto <- state.State |> Seq.fold (fun ownerDto ownerEvent -> OwnerDto.UpdateDto ownerEvent ownerDto) OwnerDto.Default logActorActivation log this.IdentityString activateStartTime (getActorActivationMessage state.RecordExists) Task.CompletedTask member private this.ApplyEvent ownerEvent = task { try state.State.Add(ownerEvent) log.LogInformation( "{CurrentInstant}: Owner.Actor writing state. CorrelationId: {CorrelationId}; OwnerId: {OwnerId}.", getCurrentInstantExtended (), ownerEvent.Metadata.CorrelationId, ownerDto.OwnerId ) do! state.WriteStateAsync() log.LogInformation( "{CurrentInstant}: Owner.Actor state write completed. CorrelationId: {CorrelationId}; OwnerId: {OwnerId}.", getCurrentInstantExtended (), ownerEvent.Metadata.CorrelationId, ownerDto.OwnerId ) // Update the Dto based on the current event. ownerDto <- ownerDto |> OwnerDto.UpdateDto ownerEvent // Publish the event to the rest of the world. let graceEvent = Events.GraceEvent.OwnerEvent ownerEvent log.LogInformation( "{CurrentInstant}: Owner.Actor publishing GraceEvent. CorrelationId: {CorrelationId}; OwnerId: {OwnerId}.", getCurrentInstantExtended (), ownerEvent.Metadata.CorrelationId, ownerDto.OwnerId ) do! publishGraceEvent graceEvent ownerEvent.Metadata log.LogInformation( "{CurrentInstant}: Owner.Actor published GraceEvent. CorrelationId: {CorrelationId}; OwnerId: {OwnerId}.", getCurrentInstantExtended (), ownerEvent.Metadata.CorrelationId, ownerDto.OwnerId ) let returnValue = GraceReturnValue.Create "Owner command succeeded." ownerEvent.Metadata.CorrelationId returnValue .enhance(nameof OwnerId, ownerDto.OwnerId) .enhance(nameof OwnerName, ownerDto.OwnerName) .enhance (nameof OwnerEventType, getDiscriminatedUnionFullName ownerEvent.Event) |> ignore return Ok returnValue with | ex -> let exceptionResponse = ExceptionResponse.Create ex log.LogError(ex, "Exception in Owner.Actor: event: {event}", (serialize ownerEvent)) log.LogError("Exception details: {exception}", serialize exceptionResponse) let graceError = GraceError.Create (getErrorMessage OwnerError.FailedWhileApplyingEvent) ownerEvent.Metadata.CorrelationId graceError .enhance( "Exception details", exceptionResponse.``exception`` + exceptionResponse.innerException ) .enhance(nameof OwnerId, ownerDto.OwnerId) .enhance(nameof OwnerName, ownerDto.OwnerName) .enhance (nameof OwnerEventType, getDiscriminatedUnionFullName ownerEvent.Event) |> ignore return Error graceError } /// Sends a DeleteLogical command to each organization provided. member private this.LogicalDeleteOrganizations(organizations: OrganizationDto array, metadata: EventMetadata, deleteReason: DeleteReason) = // Loop through the orgs, sending a DeleteLogical command to each. If any of them fail, return the first error. task { let results = ConcurrentQueue>() // Loop through each organization and send a DeleteLogical command to it. do! Parallel.ForEachAsync( organizations, Constants.ParallelOptions, (fun organization ct -> ValueTask( task { if organization.DeletedAt |> Option.isNone then let organizationActor = Organization.CreateActorProxy organization.OrganizationId metadata.CorrelationId let! result = organizationActor.Handle (Organization.DeleteLogical( true, $"Cascaded from deleting owner. ownerId: {ownerDto.OwnerId}; ownerName: {ownerDto.OwnerName}; deleteReason: {deleteReason}" )) metadata results.Enqueue(result) } )) ) // Check if any of the results were errors. If so, return the first one. let overallResult = results |> Seq.tryPick (fun result -> match result with | Ok _ -> None | Error error -> Some(error)) match overallResult with | None -> return Ok() | Some error -> return Error error } interface IGraceReminderWithGuidKey with /// Schedules a Grace reminder. member this.ScheduleReminderAsync reminderType delay state correlationId = task { let reminder = ReminderDto.Create actorName $"{this.IdentityString}" ownerDto.OwnerId Guid.Empty Guid.Empty reminderType (getFutureInstant delay) state correlationId do! createReminder reminder } :> Task /// Receives a Grace reminder. member this.ReceiveReminderAsync(reminder: ReminderDto) : Task> = task { this.correlationId <- reminder.CorrelationId match reminder.ReminderType, reminder.State with | ReminderTypes.PhysicalDeletion, ReminderState.OwnerPhysicalDeletion physicalDeletionReminderState -> this.correlationId <- physicalDeletionReminderState.CorrelationId // Delete saved state for this actor. do! state.ClearStateAsync() log.LogInformation( "{CurrentInstant}: Node: {hostName}; CorrelationId: {correlationId}; Deleted physical state for owner; OwnerId: {ownerId}; OwnerName: {ownerName}; deleteReason: {deleteReason}.", getCurrentInstantExtended (), getMachineName, physicalDeletionReminderState.CorrelationId, ownerDto.OwnerId, ownerDto.OwnerName, physicalDeletionReminderState.DeleteReason ) // Deactivate the actor after the PhysicalDeletion reminder is processed. this.DeactivateOnIdle() return Ok() | reminderType, state -> return Error( GraceError.Create $"{actorName} does not process reminder type {getDiscriminatedUnionCaseName reminderType} with state {getDiscriminatedUnionCaseName state}." this.correlationId ) } interface IOwnerActor with member this.Exists correlationId = this.correlationId <- correlationId ownerDto.UpdatedAt.IsSome |> returnTask member this.IsDeleted correlationId = this.correlationId <- correlationId ownerDto.DeletedAt.IsSome |> returnTask member this.Get correlationId = this.correlationId <- correlationId ownerDto |> returnTask member this.OrganizationExists organizationName correlationId = task { this.correlationId <- correlationId let actorProxy = OrganizationName.CreateActorProxy ownerDto.OwnerId organizationName correlationId match! actorProxy.GetOrganizationId(correlationId) with | Some organizationId -> return true | None -> return false } member this.ListOrganizations correlationId = task { this.correlationId <- correlationId let! organizationDtos = Services.getOrganizations ownerDto.OwnerId Int32.MaxValue false let dict = organizationDtos.ToDictionary((fun org -> org.OrganizationId), (fun org -> org.OrganizationName)) return dict :> IReadOnlyDictionary } member this.Handle command metadata = let isValid command (metadata: EventMetadata) = task { if state.State.Exists(fun ev -> ev.Metadata.CorrelationId = metadata.CorrelationId) then return Error(GraceError.Create (getErrorMessage OwnerError.DuplicateCorrelationId) metadata.CorrelationId) else match command with | OwnerCommand.Create (_, _) -> match ownerDto.UpdatedAt with | Some _ -> return Error(GraceError.Create (getErrorMessage OwnerError.OwnerIdAlreadyExists) metadata.CorrelationId) | None -> return Ok command | _ -> match ownerDto.UpdatedAt with | Some _ -> return Ok command | None -> return Error(GraceError.Create (getErrorMessage OwnerError.OwnerIdDoesNotExist) metadata.CorrelationId) } let processCommand (command: OwnerCommand) (metadata: EventMetadata) = task { try let! eventResult = task { match command with | OwnerCommand.Create (ownerId, ownerName) -> return Ok(OwnerEventType.Created(ownerId, ownerName)) | OwnerCommand.SetName newName -> // Clear the OwnerNameActor for the old name. let ownerNameActor = OwnerName.CreateActorProxy ownerDto.OwnerName metadata.CorrelationId do! ownerNameActor.ClearOwnerId metadata.CorrelationId memoryCache.RemoveOwnerNameEntry ownerDto.OwnerName // Set the OwnerNameActor for the new name. let ownerNameActor = OwnerName.CreateActorProxy ownerDto.OwnerName metadata.CorrelationId do! ownerNameActor.SetOwnerId ownerDto.OwnerId metadata.CorrelationId memoryCache.CreateOwnerNameEntry newName ownerDto.OwnerId return Ok(OwnerEventType.NameSet newName) | OwnerCommand.SetType ownerType -> return Ok(OwnerEventType.TypeSet ownerType) | OwnerCommand.SetSearchVisibility searchVisibility -> return Ok(OwnerEventType.SearchVisibilitySet searchVisibility) | OwnerCommand.SetDescription description -> return Ok(OwnerEventType.DescriptionSet description) | OwnerCommand.DeleteLogical (force, deleteReason) -> // Get the list of organizations that aren't already deleted. let! organizations = getOrganizations ownerDto.OwnerId Int32.MaxValue false // If the owner contains active organizations, and the force flag is not set, return an error. if not <| force && organizations.Length > 0 && organizations.Any(fun organization -> organization.DeletedAt |> Option.isNone) then let metadataObj = Dictionary(metadata.Properties.Select(fun kvp -> KeyValuePair(kvp.Key, kvp.Value))) return Error( GraceError.CreateWithMetadata null (OwnerError.getErrorMessage OwnerContainsOrganizations) metadata.CorrelationId metadataObj ) else // Delete the organizations. match! this.LogicalDeleteOrganizations(organizations, metadata, deleteReason) with | Ok _ -> let (physicalDeletionReminderState: PhysicalDeletionReminderState) = { DeleteReason = deleteReason; CorrelationId = metadata.CorrelationId } do! (this :> IGraceReminderWithGuidKey) .ScheduleReminderAsync ReminderTypes.PhysicalDeletion DefaultPhysicalDeletionReminderDuration (ReminderState.OwnerPhysicalDeletion physicalDeletionReminderState) metadata.CorrelationId return Ok(LogicalDeleted(force, deleteReason)) | Error error -> return Error error | OwnerCommand.DeletePhysical -> // Delete saved state for this actor. do! state.ClearStateAsync() // Deactivate the actor after the PhysicalDeletion is processed. this.DeactivateOnIdle() return Ok(OwnerEventType.PhysicalDeleted) | OwnerCommand.Undelete -> return Ok(OwnerEventType.Undeleted) } match eventResult with | Ok event -> //logToConsole $"In Owner.Actor.Handle(): GraceEvent: {serialize event}; Metadata: {serialize metadata}" return! this.ApplyEvent { Event = event; Metadata = metadata } | Error error -> return Error error with | ex -> let metadataObj = Dictionary(metadata.Properties.Select(fun kvp -> KeyValuePair(kvp.Key, kvp.Value))) return Error(GraceError.CreateWithMetadata ex String.Empty metadata.CorrelationId metadataObj) } task { this.correlationId <- metadata.CorrelationId RequestContext.Set(Constants.CurrentCommandProperty, getDiscriminatedUnionCaseName command) match! isValid command metadata with | Ok command -> return! processCommand command metadata | Error error -> return Error error } ================================================ FILE: src/Grace.Actors/OwnerName.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.MemoryCache open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Types.Types open Grace.Shared.Utilities open Microsoft.Extensions.Logging open NodaTime open Orleans open Orleans.Runtime open System open System.Threading.Tasks module OwnerName = type OwnerNameActor(log: ILogger) = inherit Grain() static let actorName = ActorName.OwnerName let log = loggerFactory.CreateLogger("OwnerName.Actor") let mutable cachedOwnerId: OwnerId option = None member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () logActorActivation log this.IdentityString activateStartTime "In-memory only" Task.CompletedTask interface IOwnerNameActor with member this.ClearOwnerId correlationId = this.correlationId <- correlationId cachedOwnerId <- None Task.CompletedTask member this.GetOwnerId(correlationId) = this.correlationId <- correlationId cachedOwnerId |> returnTask member this.SetOwnerId (ownerId: OwnerId) correlationId = this.correlationId <- correlationId cachedOwnerId <- Some ownerId Task.CompletedTask ================================================ FILE: src/Grace.Actors/PersonalAccessToken.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Interfaces open Grace.Shared open Grace.Shared.Constants open Grace.Shared.Utilities open Grace.Types.PersonalAccessToken open Grace.Types.Types open Microsoft.Extensions.Logging open NodaTime open Orleans open Orleans.Runtime open System open System.Security.Cryptography open System.Threading.Tasks module PersonalAccessToken = [] type PersonalAccessTokenRecord = { TokenId: PersonalAccessTokenId Name: string CreatedAt: Instant ExpiresAt: Instant option LastUsedAt: Instant option RevokedAt: Instant option Salt: byte array Hash: byte array Claims: string list GroupIds: string list } [] type PersonalAccessTokenState = { Tokens: PersonalAccessTokenRecord list } module PersonalAccessTokenState = let Empty = { Tokens = [] } type PersonalAccessTokenActor ( [] state: IPersistentState ) = inherit Grain() let log = loggerFactory.CreateLogger("PersonalAccessToken.Actor") let mutable tokenState = PersonalAccessTokenState.Empty override this.OnActivateAsync(ct) = tokenState <- if state.RecordExists then state.State else PersonalAccessTokenState.Empty Task.CompletedTask member private this.SaveState() = task { state.State <- tokenState if tokenState.Tokens |> List.isEmpty then do! DefaultAsyncRetryPolicy.ExecuteAsync(fun () -> state.ClearStateAsync()) else do! DefaultAsyncRetryPolicy.ExecuteAsync(fun () -> state.WriteStateAsync()) } member private _.Summarize(record: PersonalAccessTokenRecord) = { TokenId = record.TokenId Name = record.Name CreatedAt = record.CreatedAt ExpiresAt = record.ExpiresAt LastUsedAt = record.LastUsedAt RevokedAt = record.RevokedAt } member private _.ComputeHash (salt: byte array) (secret: byte array) = let combined = Array.zeroCreate (salt.Length + secret.Length) Array.Copy(salt, 0, combined, 0, salt.Length) Array.Copy(secret, 0, combined, salt.Length, secret.Length) SHA256.HashData combined member private this.CreateToken (name: string) (claims: string list) (groupIds: string list) (expiresAt: Instant option) (now: Instant) (correlationId: CorrelationId) = task { let existingName = tokenState.Tokens |> List.exists (fun token -> token.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) if existingName then return Error(GraceError.Create "Token name already exists." correlationId) else let tokenId = Guid.NewGuid() let secret = Array.zeroCreate 32 let salt = Array.zeroCreate 32 RandomNumberGenerator.Fill(secret) RandomNumberGenerator.Fill(salt) let hash = this.ComputeHash salt secret let record = { TokenId = tokenId Name = name CreatedAt = now ExpiresAt = expiresAt LastUsedAt = None RevokedAt = None Salt = salt Hash = hash Claims = claims GroupIds = groupIds } tokenState <- { tokenState with Tokens = record :: tokenState.Tokens } do! this.SaveState() let userId = this.GetPrimaryKeyString() let token = formatToken userId tokenId secret let summary = this.Summarize record let result = { Token = token; Summary = summary } log.LogInformation( "{CurrentInstant}: Created PAT for user {UserId}. CorrelationId: {CorrelationId}; TokenId: {TokenId}.", getCurrentInstantExtended (), userId, correlationId, tokenId ) return Ok result } member private this.ListTokens (includeRevoked: bool) (includeExpired: bool) (now: Instant) (_correlationId: CorrelationId) = task { let isExpired (token: PersonalAccessTokenRecord) = match token.ExpiresAt with | None -> false | Some expiresAt -> expiresAt <= now let filtered = tokenState.Tokens |> List.filter (fun token -> let revokedOk = includeRevoked || token.RevokedAt.IsNone let expiredOk = includeExpired || not (isExpired token) revokedOk && expiredOk) |> List.sortByDescending (fun token -> token.CreatedAt) |> List.map this.Summarize return filtered } member private this.RevokeToken (tokenId: PersonalAccessTokenId) (now: Instant) (correlationId: CorrelationId) = task { match tokenState.Tokens |> List.tryFind (fun token -> token.TokenId = tokenId) with | None -> return Error(GraceError.Create "Token not found." correlationId) | Some record -> let updated = { record with RevokedAt = Some now } let remaining = tokenState.Tokens |> List.filter (fun token -> token.TokenId <> tokenId) tokenState <- { tokenState with Tokens = updated :: remaining } do! this.SaveState() return Ok(this.Summarize updated) } member private this.ValidateToken (tokenId: PersonalAccessTokenId) (secret: byte array) (now: Instant) (_correlationId: CorrelationId) = task { let isExpired (token: PersonalAccessTokenRecord) = match token.ExpiresAt with | None -> false | Some expiresAt -> expiresAt <= now match tokenState.Tokens |> List.tryFind (fun token -> token.TokenId = tokenId) with | None -> return None | Some record -> if record.RevokedAt.IsSome || isExpired record then return None else let computed = this.ComputeHash record.Salt secret if CryptographicOperations.FixedTimeEquals(computed, record.Hash) then let updated = { record with LastUsedAt = Some now } let remaining = tokenState.Tokens |> List.filter (fun token -> token.TokenId <> tokenId) tokenState <- { tokenState with Tokens = updated :: remaining } do! this.SaveState() let result = { TokenId = record.TokenId; UserId = this.GetPrimaryKeyString(); Claims = record.Claims; GroupIds = record.GroupIds } return Some result else return None } interface IPersonalAccessTokenActor with member this.CreateToken name claims groupIds expiresAt now correlationId = this.CreateToken name claims groupIds expiresAt now correlationId member this.ListTokens includeRevoked includeExpired now correlationId = this.ListTokens includeRevoked includeExpired now correlationId member this.RevokeToken tokenId now correlationId = this.RevokeToken tokenId now correlationId member this.ValidateToken tokenId secret now correlationId = this.ValidateToken tokenId secret now correlationId ================================================ FILE: src/Grace.Actors/Policy.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.ActorProxy open Grace.Actors.Extensions.MemoryCache open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Actors.Types open Grace.Shared open Grace.Shared.Constants open Grace.Shared.Utilities open Grace.Shared.Validation.Errors open Grace.Types.Events open Grace.Types.Policy open Grace.Types.Types open Microsoft.Extensions.Logging open NodaTime open Orleans open Orleans.Runtime open System open System.Collections.Generic open System.Threading.Tasks module Policy = type PolicyActor([] state: IPersistentState>) = inherit Grain() static let actorName = ActorName.Policy let log = loggerFactory.CreateLogger("Policy.Actor") let mutable currentCommand = String.Empty let mutable snapshots: PolicySnapshot list = [] let mutable acknowledgements: PolicyAcknowledgement list = [] let mutable repositoryId: RepositoryId = RepositoryId.Empty member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () logActorActivation log this.IdentityString activateStartTime (getActorActivationMessage state.RecordExists) let applyToState (policyEvent: PolicyEvent) = match policyEvent.Event with | SnapshotCreated snapshot -> snapshots <- snapshots |> List.filter (fun s -> s.PolicySnapshotId <> snapshot.PolicySnapshotId) |> fun list -> list @ [ snapshot ] repositoryId <- snapshot.RepositoryId | Acknowledged (policySnapshotId, acknowledgedBy, note) -> let acknowledgement = { PolicySnapshotId = policySnapshotId; AcknowledgedBy = acknowledgedBy; AcknowledgedAt = policyEvent.Metadata.Timestamp; Note = note } acknowledgements <- acknowledgements @ [ acknowledgement ] state.State |> Seq.iter applyToState Task.CompletedTask member private this.GetCurrentSnapshot() = snapshots |> List.sortBy (fun snapshot -> snapshot.CreatedAt) |> List.tryLast member private this.ApplyEvent(policyEvent: PolicyEvent) = task { let correlationId = policyEvent.Metadata.CorrelationId try state.State.Add(policyEvent) do! state.WriteStateAsync() match policyEvent.Event with | SnapshotCreated snapshot -> snapshots <- snapshots |> List.filter (fun s -> s.PolicySnapshotId <> snapshot.PolicySnapshotId) |> fun list -> list @ [ snapshot ] repositoryId <- snapshot.RepositoryId | Acknowledged (policySnapshotId, acknowledgedBy, note) -> let acknowledgement = { PolicySnapshotId = policySnapshotId AcknowledgedBy = acknowledgedBy AcknowledgedAt = policyEvent.Metadata.Timestamp Note = note } acknowledgements <- acknowledgements @ [ acknowledgement ] let graceEvent = GraceEvent.PolicyEvent policyEvent do! publishGraceEvent graceEvent policyEvent.Metadata let policySnapshotId = match policyEvent.Event with | SnapshotCreated snapshot -> snapshot.PolicySnapshotId | Acknowledged (policySnapshotId, _, _) -> policySnapshotId let returnValue = (GraceReturnValue.Create "Policy command succeeded." correlationId) .enhance(nameof RepositoryId, repositoryId) .enhance(nameof PolicySnapshotId, policySnapshotId) .enhance (nameof PolicyEventType, getDiscriminatedUnionFullName policyEvent.Event) return Ok returnValue with | ex -> log.LogError( ex, "{CurrentInstant}: Node: {hostName}; CorrelationId: {correlationId}; Failed to apply event {eventType} for policy.", getCurrentInstantExtended (), getMachineName, correlationId, getDiscriminatedUnionCaseName policyEvent.Event ) let graceError = (GraceError.CreateWithException ex (PolicyError.getErrorMessage PolicyError.FailedWhileApplyingEvent) correlationId) .enhance (nameof RepositoryId, repositoryId) return Error graceError } interface IHasRepositoryId with member this.GetRepositoryId correlationId = repositoryId |> returnTask interface IPolicyActor with member this.GetCurrent correlationId = this.correlationId <- correlationId this.GetCurrentSnapshot() |> returnTask member this.GetSnapshots correlationId = this.correlationId <- correlationId (snapshots :> IReadOnlyList) |> returnTask member this.GetAcknowledgements correlationId = this.correlationId <- correlationId (acknowledgements :> IReadOnlyList) |> returnTask member this.Handle command metadata = let isValid (command: PolicyCommand) (metadata: EventMetadata) = task { if state.State.Exists(fun ev -> ev.Metadata.CorrelationId = metadata.CorrelationId) then return Error(GraceError.Create (PolicyError.getErrorMessage PolicyError.DuplicateCorrelationId) metadata.CorrelationId) else match command with | CreateSnapshot snapshot -> let exists = snapshots |> List.exists (fun existing -> existing.PolicySnapshotId = snapshot.PolicySnapshotId) if exists then return Error(GraceError.Create (PolicyError.getErrorMessage PolicyError.PolicySnapshotAlreadyExists) metadata.CorrelationId) else return Ok command | Acknowledge (policySnapshotId, _, _) -> let exists = snapshots |> List.exists (fun existing -> existing.PolicySnapshotId = policySnapshotId) if exists then return Ok command else return Error(GraceError.Create (PolicyError.getErrorMessage PolicyError.PolicySnapshotDoesNotExist) metadata.CorrelationId) } let processCommand (command: PolicyCommand) (metadata: EventMetadata) = task { let! policyEventType = task { match command with | CreateSnapshot snapshot -> return SnapshotCreated snapshot | Acknowledge (policySnapshotId, acknowledgedBy, note) -> return Acknowledged(policySnapshotId, acknowledgedBy, note) } let policyEvent = { Event = policyEventType; Metadata = metadata } return! this.ApplyEvent policyEvent } task { currentCommand <- getDiscriminatedUnionCaseName command this.correlationId <- metadata.CorrelationId RequestContext.Set(Constants.CurrentCommandProperty, getDiscriminatedUnionCaseName command) match! isValid command metadata with | Ok command -> return! processCommand command metadata | Error error -> return Error error } ================================================ FILE: src/Grace.Actors/PromotionQueue.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.ActorProxy open Grace.Actors.Extensions.MemoryCache open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Actors.Types open Grace.Shared open Grace.Shared.Constants open Grace.Shared.Utilities open Grace.Shared.Validation.Errors open Grace.Types.Events open Grace.Types.Queue open Grace.Types.Types open Microsoft.Extensions.Logging open NodaTime open Orleans open Orleans.Runtime open System open System.Collections.Generic open System.Threading.Tasks module PromotionQueue = type PromotionQueueActor([] state: IPersistentState>) = inherit Grain() static let actorName = ActorName.PromotionQueue let log = loggerFactory.CreateLogger("PromotionQueue.Actor") let mutable currentCommand = String.Empty let mutable promotionQueue = PromotionQueue.Default member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () logActorActivation log this.IdentityString activateStartTime (getActorActivationMessage state.RecordExists) promotionQueue <- state.State |> Seq.fold (fun dto ev -> PromotionQueueDto.UpdateDto ev dto) promotionQueue Task.CompletedTask member private this.ApplyEvent(queueEvent: PromotionQueueEvent) = task { let correlationId = queueEvent.Metadata.CorrelationId try state.State.Add(queueEvent) do! state.WriteStateAsync() promotionQueue <- promotionQueue |> PromotionQueueDto.UpdateDto queueEvent let graceEvent = GraceEvent.QueueEvent queueEvent do! publishGraceEvent graceEvent queueEvent.Metadata let returnValue = (GraceReturnValue.Create "Promotion queue command succeeded." correlationId) .enhance(nameof BranchId, promotionQueue.TargetBranchId) .enhance (nameof PromotionQueueEventType, getDiscriminatedUnionFullName queueEvent.Event) return Ok returnValue with | ex -> log.LogError( ex, "{CurrentInstant}: Node: {hostName}; CorrelationId: {correlationId}; Failed to apply event {eventType} for promotion queue {branchId}.", getCurrentInstantExtended (), getMachineName, correlationId, getDiscriminatedUnionCaseName queueEvent.Event, promotionQueue.TargetBranchId ) let graceError = (GraceError.CreateWithException ex (QueueError.getErrorMessage QueueError.FailedWhileApplyingEvent) correlationId) .enhance (nameof BranchId, promotionQueue.TargetBranchId) return Error graceError } interface IHasRepositoryId with member this.GetRepositoryId correlationId = RepositoryId.Empty |> returnTask interface IPromotionQueueActor with member this.Exists correlationId = this.correlationId <- correlationId (promotionQueue.TargetBranchId <> BranchId.Empty) |> returnTask member this.Get correlationId = this.correlationId <- correlationId promotionQueue |> returnTask member this.GetEvents correlationId = this.correlationId <- correlationId state.State :> IReadOnlyList |> returnTask member this.Handle command metadata = let isValid (command: PromotionQueueCommand) (metadata: EventMetadata) = task { if state.State.Exists(fun ev -> ev.Metadata.CorrelationId = metadata.CorrelationId) then return Error(GraceError.Create (QueueError.getErrorMessage QueueError.DuplicateCorrelationId) metadata.CorrelationId) else let result = match command with | Initialize _ -> if promotionQueue.TargetBranchId <> BranchId.Empty then Error(GraceError.Create (QueueError.getErrorMessage QueueError.QueueAlreadyInitialized) metadata.CorrelationId) else Ok command | _ -> if promotionQueue.TargetBranchId = BranchId.Empty then Error(GraceError.Create (QueueError.getErrorMessage QueueError.QueueNotInitialized) metadata.CorrelationId) else match command with | Dequeue promotionSetId -> let exists = promotionQueue.PromotionSetIds |> List.exists (fun existing -> existing = promotionSetId) if exists then Ok command else Error(GraceError.Create (QueueError.getErrorMessage QueueError.PromotionSetNotInQueue) metadata.CorrelationId) | SetRunning (Some promotionSetId) -> let exists = promotionQueue.PromotionSetIds |> List.exists (fun existing -> existing = promotionSetId) if exists then Ok command else Error(GraceError.Create (QueueError.getErrorMessage QueueError.PromotionSetNotInQueue) metadata.CorrelationId) | _ -> Ok command return result } let processCommand (command: PromotionQueueCommand) (metadata: EventMetadata) = task { let! (queueEventType: PromotionQueueEventType) = task { match command with | Initialize (targetBranchId, policySnapshotId) -> return Initialized(targetBranchId, policySnapshotId) | Enqueue promotionSetId -> return PromotionSetEnqueued promotionSetId | Dequeue promotionSetId -> return PromotionSetDequeued promotionSetId | SetRunning promotionSetId -> return RunningPromotionSetSet promotionSetId | Pause -> return Paused | Resume -> return Resumed | SetDegraded -> return Degraded | UpdatePolicySnapshot policySnapshotId -> return PolicySnapshotUpdated policySnapshotId } let queueEvent: PromotionQueueEvent = { Event = queueEventType; Metadata = metadata } return! this.ApplyEvent queueEvent } task { currentCommand <- getDiscriminatedUnionCaseName command this.correlationId <- metadata.CorrelationId RequestContext.Set(Constants.CurrentCommandProperty, getDiscriminatedUnionCaseName command) match! isValid command metadata with | Ok command -> return! processCommand command metadata | Error error -> return Error error } ================================================ FILE: src/Grace.Actors/PromotionSet.Actor.fs ================================================ namespace Grace.Actors open Azure.Storage.Blobs open Azure.Storage.Blobs.Specialized open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.ActorProxy open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Actors.Types open Grace.Shared open Grace.Shared.Constants open Grace.Shared.Services open Grace.Shared.Utilities open Grace.Types.Artifact open Grace.Types.Events open Grace.Types.PromotionSetConflictModel open Grace.Types.PromotionSet open Grace.Types.Queue open Grace.Types.Reference open Grace.Types.Reminder open Grace.Types.Types open Grace.Types.Validation open Microsoft.Extensions.Logging open Orleans open Orleans.Runtime open System open System.Collections.Generic open System.Diagnostics open System.Globalization open System.IO open System.IO.Compression open System.Text open System.Threading.Tasks module PromotionSet = type private RecomputeFailure = | Blocked of reason: string * artifactId: ArtifactId option | Failed of reason: string type private DirectorySnapshot = { DirectoriesByPath: Dictionary; FilesByPath: Dictionary } type private StepConflictFile = { FilePath: RelativePath BaseFile: FileVersion option OursFile: FileVersion option TheirsFile: FileVersion option IsBinary: bool } let internal validateCommandForState (existingEvents: seq) (currentPromotionSetDto: PromotionSetDto) (promotionSetCommand: PromotionSetCommand) (eventMetadata: EventMetadata) = if existingEvents |> Seq.exists (fun event -> event.Metadata.CorrelationId = eventMetadata.CorrelationId) then Error(GraceError.Create "Duplicate correlation ID for PromotionSet command." eventMetadata.CorrelationId) else match promotionSetCommand with | PromotionSetCommand.CreatePromotionSet _ when currentPromotionSetDto.PromotionSetId <> PromotionSetId.Empty -> Error(GraceError.Create "PromotionSet already exists." eventMetadata.CorrelationId) | PromotionSetCommand.CreatePromotionSet _ -> Ok promotionSetCommand | _ when currentPromotionSetDto.PromotionSetId = PromotionSetId.Empty -> Error(GraceError.Create "PromotionSet does not exist." eventMetadata.CorrelationId) | PromotionSetCommand.Apply when currentPromotionSetDto.Status = PromotionSetStatus.Succeeded -> Error(GraceError.Create "PromotionSet has already been applied successfully." eventMetadata.CorrelationId) | PromotionSetCommand.Apply when currentPromotionSetDto.Status = PromotionSetStatus.Running -> Error(GraceError.Create "PromotionSet is already running." eventMetadata.CorrelationId) | PromotionSetCommand.RecomputeStepsIfStale _ when currentPromotionSetDto.StepsComputationStatus = StepsComputationStatus.Computing -> Error(GraceError.Create "PromotionSet steps are already computing." eventMetadata.CorrelationId) | PromotionSetCommand.ResolveConflicts _ when currentPromotionSetDto.Status <> PromotionSetStatus.Blocked -> Error(GraceError.Create "PromotionSet is not blocked for conflict review." eventMetadata.CorrelationId) | PromotionSetCommand.UpdateInputPromotions _ when currentPromotionSetDto.Status = PromotionSetStatus.Succeeded -> Error(GraceError.Create "PromotionSet has already succeeded and cannot be edited." eventMetadata.CorrelationId) | _ -> Ok promotionSetCommand type PromotionSetActor([] state: IPersistentState>) = inherit Grain() static let actorName = ActorName.PromotionSet let log = loggerFactory.CreateLogger("PromotionSet.Actor") let mutable currentCommand = String.Empty let mutable promotionSetDto = PromotionSetDto.Default let getIntEnvironmentSetting (name: string) (defaultValue: int) = let rawValue = Environment.GetEnvironmentVariable(name) let mutable parsedValue = 0 if String.IsNullOrWhiteSpace rawValue |> not && Int32.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, &parsedValue) && parsedValue > 0 then parsedValue else defaultValue let maxStepsPerRecompute = getIntEnvironmentSetting "grace__promotionset__recompute__max_steps" 1000 let maxStepTimeMilliseconds = getIntEnvironmentSetting "grace__promotionset__recompute__max_step_time_ms" 30000 let maxTotalTimeMilliseconds = getIntEnvironmentSetting "grace__promotionset__recompute__max_total_time_ms" 300000 let maxFilesPerStep = getIntEnvironmentSetting "grace__promotionset__recompute__max_files_per_step" 20000 member val private correlationId: CorrelationId = String.Empty with get, set member private this.WithActorMetadata(metadata: EventMetadata) = let properties = Dictionary(StringComparer.OrdinalIgnoreCase) metadata.Properties |> Seq.iter (fun kvp -> properties[kvp.Key] <- kvp.Value) if promotionSetDto.RepositoryId <> RepositoryId.Empty then properties[nameof RepositoryId] <- $"{promotionSetDto.RepositoryId}" let actorId = if promotionSetDto.PromotionSetId <> PromotionSetId.Empty then $"{promotionSetDto.PromotionSetId}" else $"{this.GetPrimaryKey()}" properties["ActorId"] <- actorId let principal = if String.IsNullOrWhiteSpace metadata.Principal then GraceSystemUser else metadata.Principal { metadata with Principal = principal; Properties = properties } member private this.BuildSuccess(message: string, correlationId: CorrelationId) = let graceReturnValue: GraceReturnValue = (GraceReturnValue.Create message correlationId) .enhance(nameof RepositoryId, promotionSetDto.RepositoryId) .enhance(nameof PromotionSetId, promotionSetDto.PromotionSetId) .enhance ("Status", getDiscriminatedUnionCaseName promotionSetDto.Status) Ok graceReturnValue member private this.BuildError(errorMessage: string, correlationId: CorrelationId) = Error( (GraceError.Create errorMessage correlationId) .enhance(nameof RepositoryId, promotionSetDto.RepositoryId) .enhance (nameof PromotionSetId, promotionSetDto.PromotionSetId) ) member private this.GetCurrentTerminalPromotion() = task { let! latestPromotion = getLatestPromotion promotionSetDto.RepositoryId promotionSetDto.TargetBranchId match latestPromotion with | Option.Some promotion -> return promotion.ReferenceId, promotion.DirectoryId | Option.None -> let branchActorProxy = Branch.CreateActorProxy promotionSetDto.TargetBranchId promotionSetDto.RepositoryId this.correlationId let! branchDto = branchActorProxy.Get this.correlationId let baseDirectoryVersionId = if branchDto.BasedOn.ReferenceId <> ReferenceId.Empty then branchDto.BasedOn.DirectoryId elif branchDto.LatestPromotion.ReferenceId <> ReferenceId.Empty then branchDto.LatestPromotion.DirectoryId elif branchDto.LatestReference.ReferenceId <> ReferenceId.Empty then branchDto.LatestReference.DirectoryId else DirectoryVersionId.Empty return ReferenceId.Empty, baseDirectoryVersionId } member private this.GetConflictResolutionPolicy() = task { let repositoryActorProxy = Repository.CreateActorProxy promotionSetDto.OrganizationId promotionSetDto.RepositoryId this.correlationId let! repositoryDto = repositoryActorProxy.Get this.correlationId return repositoryDto.ConflictResolutionPolicy } member private this.GetRepositoryDto() = task { let repositoryActorProxy = Repository.CreateActorProxy promotionSetDto.OrganizationId promotionSetDto.RepositoryId this.correlationId return! repositoryActorProxy.Get this.correlationId } member private this.GetConflictResolutionModelProvider() = match hostServiceProvider with | null -> NullConflictResolutionModelProvider() :> IConflictResolutionModelProvider | services -> match services.GetService(typeof) with | null -> NullConflictResolutionModelProvider() :> IConflictResolutionModelProvider | provider -> provider :?> IConflictResolutionModelProvider member private this.UploadArtifactPayload(blobPath: string, payloadJson: string, metadata: EventMetadata) = task { try let! repositoryDto = this.GetRepositoryDto() let! uploadUri = getUriWithWriteSharedAccessSignature repositoryDto blobPath metadata.CorrelationId let blockBlobClient = BlockBlobClient(uploadUri) let payloadBytes = Encoding.UTF8.GetBytes(payloadJson) use payloadStream = new MemoryStream(payloadBytes) do! blockBlobClient.UploadAsync(payloadStream) :> Task return Ok() with | ex -> let graceError = GraceError.CreateWithException ex "Failed while uploading Artifact payload." metadata.CorrelationId graceError.enhance (nameof PromotionSetId, promotionSetDto.PromotionSetId) |> ignore graceError.enhance (nameof RepositoryId, promotionSetDto.RepositoryId) |> ignore return Error(graceError) } member private this.GetArtifactText(artifactId: ArtifactId, metadata: EventMetadata) = task { let artifactActorProxy = Artifact.CreateActorProxy artifactId promotionSetDto.RepositoryId this.correlationId let! artifact = artifactActorProxy.Get this.correlationId match artifact with | Option.None -> let graceError = GraceError.Create "Manual override artifact was not found." metadata.CorrelationId graceError.enhance (nameof ArtifactId, artifactId) |> ignore graceError.enhance (nameof PromotionSetId, promotionSetDto.PromotionSetId) |> ignore return Error(graceError) | Option.Some artifactMetadata -> try let! repositoryDto = this.GetRepositoryDto() let! downloadUri = getUriWithReadSharedAccessSignature repositoryDto artifactMetadata.BlobPath metadata.CorrelationId let blobClient = BlobClient(downloadUri) let! downloadResult = blobClient.DownloadContentAsync() return Ok(downloadResult.Value.Content.ToString()) with | ex -> let graceError = GraceError.CreateWithException ex "Failed while downloading manual override artifact payload." metadata.CorrelationId graceError.enhance (nameof ArtifactId, artifactId) |> ignore graceError.enhance (nameof PromotionSetId, promotionSetDto.PromotionSetId) |> ignore return Error(graceError) } member private this.ValidateManualOverrideArtifacts(decisions: ConflictResolutionDecision list, metadata: EventMetadata) = task { let overrideArtifactIds = decisions |> List.choose (fun decision -> if decision.Accepted then decision.OverrideContentArtifactId else Option.None) |> List.distinct let mutable validationError: GraceError option = Option.None let mutable index = 0 while index < overrideArtifactIds.Length && validationError.IsNone do let artifactId = overrideArtifactIds[index] let! artifactTextResult = this.GetArtifactText(artifactId, metadata) match artifactTextResult with | Error graceError -> validationError <- Some graceError | Ok artifactText -> if String.IsNullOrWhiteSpace artifactText then let graceError = GraceError.Create "Manual override artifact content is empty." metadata.CorrelationId graceError.enhance (nameof ArtifactId, artifactId) |> ignore graceError.enhance (nameof PromotionSetId, promotionSetDto.PromotionSetId) |> ignore validationError <- Some graceError index <- index + 1 match validationError with | Some graceError -> return Error graceError | Option.None -> return Ok() } member private this.HydrateStepProvenance(step: PromotionSetStep) = task { let referenceActorProxy = Reference.CreateActorProxy step.OriginalPromotion.ReferenceId promotionSetDto.RepositoryId this.correlationId let! promotionReferenceDto = referenceActorProxy.Get this.correlationId let promotionDirectoryVersionId = if promotionReferenceDto.ReferenceId <> ReferenceId.Empty then promotionReferenceDto.DirectoryId else step.OriginalPromotion.DirectoryVersionId if promotionDirectoryVersionId = DirectoryVersionId.Empty then return Error( (GraceError.Create "Original promotion did not include a directory version." this.correlationId) .enhance (nameof PromotionSetStepId, step.StepId) ) else let basedOnReferenceId = if step.OriginalBasePromotionReferenceId <> ReferenceId.Empty then step.OriginalBasePromotionReferenceId elif promotionReferenceDto.ReferenceId <> ReferenceId.Empty then promotionReferenceDto.Links |> Seq.tryPick (fun link -> match link with | ReferenceLinkType.BasedOn referenceId -> Option.Some referenceId | _ -> Option.None) |> Option.defaultValue ReferenceId.Empty else ReferenceId.Empty let! basedOnDirectoryVersionId = if step.OriginalBaseDirectoryVersionId <> DirectoryVersionId.Empty then Task.FromResult step.OriginalBaseDirectoryVersionId elif basedOnReferenceId <> ReferenceId.Empty then task { let basedOnReferenceActorProxy = Reference.CreateActorProxy basedOnReferenceId promotionSetDto.RepositoryId this.correlationId let! basedOnReferenceDto = basedOnReferenceActorProxy.Get this.correlationId if basedOnReferenceDto.ReferenceId = ReferenceId.Empty then return promotionDirectoryVersionId else return basedOnReferenceDto.DirectoryId } else Task.FromResult promotionDirectoryVersionId return Ok { step with OriginalPromotion = { step.OriginalPromotion with DirectoryVersionId = promotionDirectoryVersionId } OriginalBasePromotionReferenceId = basedOnReferenceId OriginalBaseDirectoryVersionId = basedOnDirectoryVersionId } } member private this.TryGetFileVersion(fileLookup: Dictionary, filePath: RelativePath) = let mutable fileVersion = FileVersion.Default if fileLookup.TryGetValue(filePath, &fileVersion) then Option.Some fileVersion else Option.None member private this.FileVersionEquivalent(left: FileVersion option, right: FileVersion option) = match left, right with | Option.None, Option.None -> true | Option.Some leftFile, Option.Some rightFile -> leftFile.Sha256Hash = rightFile.Sha256Hash | _ -> false member private this.LoadDirectorySnapshot(directoryVersionId: DirectoryVersionId) = task { let directoriesByPath = Dictionary(StringComparer.OrdinalIgnoreCase) let filesByPath = Dictionary(StringComparer.OrdinalIgnoreCase) if directoryVersionId = DirectoryVersionId.Empty then return Ok { DirectoriesByPath = directoriesByPath; FilesByPath = filesByPath } else let directoryVersionActorProxy = DirectoryVersion.CreateActorProxy directoryVersionId promotionSetDto.RepositoryId this.correlationId let! rootDirectoryVersion = directoryVersionActorProxy.Get this.correlationId if rootDirectoryVersion.DirectoryVersion.DirectoryVersionId = DirectoryVersionId.Empty then let graceError = GraceError.Create "DirectoryVersion was not found while recomputing PromotionSet step." this.correlationId graceError.enhance (nameof DirectoryVersionId, directoryVersionId) |> ignore graceError.enhance (nameof PromotionSetId, promotionSetDto.PromotionSetId) |> ignore return Error graceError else let! recursiveDirectoryVersions = directoryVersionActorProxy.GetRecursiveDirectoryVersions false this.correlationId let mutable directoryIndex = 0 while directoryIndex < recursiveDirectoryVersions.Length do let directoryVersion = recursiveDirectoryVersions[directoryIndex] .DirectoryVersion directoriesByPath[directoryVersion.RelativePath] <- directoryVersion let filesInDirectory = directoryVersion.Files let mutable fileIndex = 0 while fileIndex < filesInDirectory.Count do let fileVersion = filesInDirectory[fileIndex] filesByPath[fileVersion.RelativePath] <- fileVersion fileIndex <- fileIndex + 1 directoryIndex <- directoryIndex + 1 return Ok { DirectoriesByPath = directoriesByPath; FilesByPath = filesByPath } } member private this.ReadTextFileVersion(repositoryDto, fileVersion: FileVersion option, correlationId: CorrelationId) = task { match fileVersion with | Option.None -> return Ok Option.None | Option.Some currentFileVersion when currentFileVersion.IsBinary -> return Ok Option.None | Option.Some currentFileVersion -> try let! readUri = getUriWithReadSharedAccessSignatureForFileVersion repositoryDto currentFileVersion correlationId let blobClient = BlockBlobClient(readUri) use! blobStream = blobClient.OpenReadAsync(position = 0, bufferSize = (64 * 1024)) use contentStream = new GZipStream(stream = blobStream, mode = CompressionMode.Decompress, leaveOpen = false) :> Stream use streamReader = new StreamReader(contentStream, Encoding.UTF8, true, 16 * 1024, leaveOpen = false) let! textContents = streamReader.ReadToEndAsync() return Ok(Option.Some textContents) with | ex -> return Error( (GraceError.CreateWithException ex "Failed while reading conflicted text file for PromotionSet recompute." correlationId) .enhance(nameof PromotionSetId, promotionSetDto.PromotionSetId) .enhance ("FilePath", currentFileVersion.RelativePath) ) } member private this.CreateTextOverrideFileVersion(filePath: RelativePath, textContent: string, repositoryDto, metadata: EventMetadata) = task { try let payloadBytes = Encoding.UTF8.GetBytes(textContent) use hashStream = new MemoryStream(payloadBytes) let! sha256Hash = computeSha256ForFile hashStream filePath let fileVersion = FileVersion.Create filePath sha256Hash String.Empty false (int64 payloadBytes.Length) let! writeUri = getUriWithWriteSharedAccessSignatureForFileVersion repositoryDto fileVersion metadata.CorrelationId let blobClient = BlobClient(writeUri) use uploadStream = new MemoryStream() use gzipStream = new GZipStream(uploadStream, CompressionLevel.Optimal, leaveOpen = true) gzipStream.Write(payloadBytes, 0, payloadBytes.Length) gzipStream.Flush() gzipStream.Dispose() uploadStream.Position <- 0L do! blobClient.UploadAsync(uploadStream, overwrite = true) :> Task return Ok fileVersion with | ex -> return Error( (GraceError.CreateWithException ex "Failed while creating manual override file version." metadata.CorrelationId) .enhance(nameof PromotionSetId, promotionSetDto.PromotionSetId) .enhance ("FilePath", filePath) ) } member private this.ReadConflictTextPair(repositoryDto, conflictFile: StepConflictFile) = task { let! oursTextResult = this.ReadTextFileVersion(repositoryDto, conflictFile.OursFile, this.correlationId) match oursTextResult with | Error graceError -> return Error graceError | Ok oursTextOption -> let! theirsTextResult = this.ReadTextFileVersion(repositoryDto, conflictFile.TheirsFile, this.correlationId) match theirsTextResult with | Error graceError -> return Error graceError | Ok theirsTextOption -> let oursContent = match oursTextOption with | Option.Some text -> text | Option.None -> match conflictFile.OursFile with | Option.Some fileVersion when fileVersion.IsBinary -> $"" | Option.Some _ -> "" | Option.None -> "" let theirsContent = match theirsTextOption with | Option.Some text -> text | Option.None -> match conflictFile.TheirsFile with | Option.Some fileVersion when fileVersion.IsBinary -> $"" | Option.Some _ -> "" | Option.None -> "" return Ok(oursContent, theirsContent) } member private this.ResolveConflictWithModel(repositoryDto, conflictFile: StepConflictFile, metadata: EventMetadata) = task { let modelProvider = this.GetConflictResolutionModelProvider() let! baseTextResult = this.ReadTextFileVersion(repositoryDto, conflictFile.BaseFile, metadata.CorrelationId) match baseTextResult with | Error graceError -> return Error graceError.Error | Ok baseTextOption -> let! oursTextResult = this.ReadTextFileVersion(repositoryDto, conflictFile.OursFile, metadata.CorrelationId) match oursTextResult with | Error graceError -> return Error graceError.Error | Ok oursTextOption -> let! theirsTextResult = this.ReadTextFileVersion(repositoryDto, conflictFile.TheirsFile, metadata.CorrelationId) match theirsTextResult with | Error graceError -> return Error graceError.Error | Ok theirsTextOption -> let request: ConflictResolutionModelRequest = { FilePath = conflictFile.FilePath BaseContent = baseTextOption OursContent = oursTextOption TheirsContent = theirsTextOption } let! modelResponseResult = modelProvider.SuggestResolution request match modelResponseResult with | Error errorText -> return Error( sprintf "Model resolution failed for file '%s' using provider '%s': %s" conflictFile.FilePath modelProvider.ProviderName errorText ) | Ok modelResponse -> let modelResolutionSummary = match modelResponse.Explanation with | Option.Some explanation when not <| String.IsNullOrWhiteSpace explanation -> explanation | _ -> if modelResponse.ShouldDelete then "Model proposed deleting the conflicted file." else "Model-suggested merge proposal." if modelResponse.ShouldDelete then return Ok( { ModelResolution = modelResolutionSummary; Confidence = modelResponse.Confidence; Accepted = Option.None }, Option.None ) else match modelResponse.ProposedContent with | Option.None -> return Error(sprintf "Model response for file '%s' did not include proposed content." conflictFile.FilePath) | Some proposedContent -> let! overrideFileVersionResult = this.CreateTextOverrideFileVersion(conflictFile.FilePath, proposedContent, repositoryDto, metadata) match overrideFileVersionResult with | Error graceError -> return Error graceError.Error | Ok overrideFileVersion -> return Ok( { ModelResolution = modelResolutionSummary; Confidence = modelResponse.Confidence; Accepted = Option.None }, Option.Some overrideFileVersion ) } member private this.BuildConflictAnalyses ( repositoryDto, conflictFiles: StepConflictFile list, outcomesByPath: Dictionary, resolutionMethodByPath: Dictionary ) = task { let! conflictTextResults = conflictFiles |> List.map (fun conflictFile -> task { let! conflictTextResult = this.ReadConflictTextPair(repositoryDto, conflictFile) return conflictFile, conflictTextResult }) |> Task.WhenAll let firstError = conflictTextResults |> Seq.tryPick (fun (_, result) -> match result with | Error graceError -> Option.Some graceError | Ok _ -> Option.None) match firstError with | Option.Some graceError -> return Error graceError | Option.None -> let conflictAnalyses = ResizeArray() conflictTextResults |> Seq.iter (fun (conflictFile, result) -> match result with | Error _ -> () | Ok (oursContent, theirsContent) -> let lineCount = max 1 (max (if String.IsNullOrEmpty oursContent then 0 else oursContent.Split('\n').Length) (if String.IsNullOrEmpty theirsContent then 0 else theirsContent.Split('\n').Length)) let mutable resolvedOutcome = Unchecked.defaultof let proposedResolution = if outcomesByPath.TryGetValue(conflictFile.FilePath, &resolvedOutcome) then Option.Some resolvedOutcome else Option.None let mutable resolutionMethod = Unchecked.defaultof let resolvedMethod = if resolutionMethodByPath.TryGetValue(conflictFile.FilePath, &resolutionMethod) then resolutionMethod else ConflictResolutionMethod.None conflictAnalyses.Add( { FilePath = conflictFile.FilePath OriginalHunks = [ { StartLine = 1; EndLine = lineCount; OursContent = oursContent; TheirsContent = theirsContent } ] ProposedResolution = proposedResolution ResolutionMethod = resolvedMethod } )) return Ok(conflictAnalyses |> Seq.toList) } member private this.MaterializeMergedDirectoryVersion ( repositoryDto, baseSnapshot: DirectorySnapshot, oursSnapshot: DirectorySnapshot, theirsSnapshot: DirectorySnapshot, mergedFilesByPath: Dictionary, metadata: EventMetadata ) = task { let directoryPaths = HashSet(StringComparer.OrdinalIgnoreCase) directoryPaths.Add(RootDirectoryPath) |> ignore let filesByDirectory = Dictionary>(StringComparer.OrdinalIgnoreCase) let mergedFileValues = mergedFilesByPath.Values |> Seq.toArray let mutable fileIndex = 0 while fileIndex < mergedFileValues.Length do let fileVersion = mergedFileValues[fileIndex] let fileDirectoryPath = getRelativeDirectory fileVersion.RelativePath RootDirectoryPath let mutable currentDirectoryPath = fileDirectoryPath let mutable continueUpTree = true while continueUpTree do directoryPaths.Add(currentDirectoryPath) |> ignore match getParentPath currentDirectoryPath with | Option.Some parentPath -> currentDirectoryPath <- parentPath | Option.None -> continueUpTree <- false let mutable filesInDirectory = Unchecked.defaultof> if not <| filesByDirectory.TryGetValue(fileDirectoryPath, &filesInDirectory) then filesInDirectory <- ResizeArray() filesByDirectory[fileDirectoryPath] <- filesInDirectory filesInDirectory.Add(fileVersion) fileIndex <- fileIndex + 1 let orderedDirectoryPaths = directoryPaths |> Seq.sortByDescending countSegments |> Seq.toArray let childDirectoriesByParent = Dictionary>(StringComparer.OrdinalIgnoreCase) let mutable directoryPathIndex = 0 while directoryPathIndex < orderedDirectoryPaths.Length do let directoryPath = orderedDirectoryPaths[directoryPathIndex] match getParentPath directoryPath with | Option.Some parentPath -> let mutable childDirectories = Unchecked.defaultof> if not <| childDirectoriesByParent.TryGetValue(parentPath, &childDirectories) then childDirectories <- ResizeArray() childDirectoriesByParent[parentPath] <- childDirectories childDirectories.Add(directoryPath) | Option.None -> () directoryPathIndex <- directoryPathIndex + 1 let computedDirectoryMetadata = Dictionary(StringComparer.OrdinalIgnoreCase) let directoryVersionsToCreate = ResizeArray() let mutable materializationError: GraceError option = Option.None let mutable buildIndex = 0 while buildIndex < orderedDirectoryPaths.Length && materializationError.IsNone do let directoryPath = orderedDirectoryPaths[buildIndex] let mutable childDirectoryPaths = Unchecked.defaultof> let childDirectoryPathsArray = if childDirectoriesByParent.TryGetValue(directoryPath, &childDirectoryPaths) then childDirectoryPaths |> Seq.sortBy id |> Seq.toArray else Array.Empty() let localChildDirectories = List() let childDirectoryIds = List() let mutable childIndex = 0 while childIndex < childDirectoryPathsArray.Length do let childDirectoryPath = childDirectoryPathsArray[childIndex] let childDirectoryId, childSha = computedDirectoryMetadata[childDirectoryPath] childDirectoryIds.Add(childDirectoryId) localChildDirectories.Add( LocalDirectoryVersion.Create childDirectoryId promotionSetDto.OwnerId promotionSetDto.OrganizationId promotionSetDto.RepositoryId childDirectoryPath childSha (List()) (List()) 0L DateTime.UtcNow ) childIndex <- childIndex + 1 let directoryFiles = List() let localDirectoryFiles = List() let mutable filesForDirectory = Unchecked.defaultof> if filesByDirectory.TryGetValue(directoryPath, &filesForDirectory) then let orderedFiles = filesForDirectory |> Seq.sortBy (fun fileVersion -> fileVersion.RelativePath) |> Seq.toArray let mutable orderedFileIndex = 0 while orderedFileIndex < orderedFiles.Length do let fileVersion = orderedFiles[orderedFileIndex] directoryFiles.Add(fileVersion) localDirectoryFiles.Add(fileVersion.ToLocalFileVersion DateTime.UtcNow) orderedFileIndex <- orderedFileIndex + 1 let computedSha = computeSha256ForDirectory directoryPath localChildDirectories localDirectoryFiles let tryReuseDirectoryId (snapshot: DirectorySnapshot) = let mutable existingDirectoryVersion = DirectoryVersion.Default if snapshot.DirectoriesByPath.TryGetValue(directoryPath, &existingDirectoryVersion) && existingDirectoryVersion.Sha256Hash = computedSha then Option.Some existingDirectoryVersion.DirectoryVersionId else Option.None let reusedDirectoryVersionId = [ oursSnapshot theirsSnapshot baseSnapshot ] |> Seq.tryPick tryReuseDirectoryId let directoryVersionId = reusedDirectoryVersionId |> Option.defaultValue (Guid.NewGuid()) if reusedDirectoryVersionId.IsNone then let directoryVersion = DirectoryVersion.Create directoryVersionId promotionSetDto.OwnerId promotionSetDto.OrganizationId promotionSetDto.RepositoryId directoryPath computedSha childDirectoryIds directoryFiles (getDirectorySize directoryFiles) directoryVersionsToCreate.Add(directoryVersion) computedDirectoryMetadata[directoryPath] <- (directoryVersionId, computedSha) buildIndex <- buildIndex + 1 let mutable createIndex = 0 while createIndex < directoryVersionsToCreate.Count && materializationError.IsNone do let directoryVersion = directoryVersionsToCreate[createIndex] let directoryVersionActorProxy = DirectoryVersion.CreateActorProxy directoryVersion.DirectoryVersionId promotionSetDto.RepositoryId this.correlationId match! directoryVersionActorProxy.Handle (Grace.Types.DirectoryVersion.DirectoryVersionCommand.Create(directoryVersion, repositoryDto)) (this.WithActorMetadata metadata) with | Ok _ -> () | Error graceError -> materializationError <- Option.Some graceError createIndex <- createIndex + 1 match materializationError with | Option.Some graceError -> return Error graceError | Option.None -> let mutable rootDirectory = Unchecked.defaultof<(DirectoryVersionId * Sha256Hash)> if computedDirectoryMetadata.TryGetValue(RootDirectoryPath, &rootDirectory) then return Ok(fst rootDirectory) else return Error( (GraceError.Create "Failed to materialize root DirectoryVersion for PromotionSet recompute." metadata.CorrelationId) .enhance (nameof PromotionSetId, promotionSetDto.PromotionSetId) ) } member private this.ComputeAppliedDirectoryVersionForStep ( step: PromotionSetStep, computedAgainstBaseDirectoryVersionId: DirectoryVersionId, conflictResolutionPolicy: ConflictResolutionPolicy, manualDecisionsForStep: ConflictResolutionDecision list option, repositoryDto, metadata: EventMetadata ) = task { let! baseSnapshotResult = this.LoadDirectorySnapshot step.OriginalBaseDirectoryVersionId match baseSnapshotResult with | Error graceError -> return Error(Failed graceError.Error) | Ok baseSnapshot -> let! oursSnapshotResult = this.LoadDirectorySnapshot computedAgainstBaseDirectoryVersionId match oursSnapshotResult with | Error graceError -> return Error(Failed graceError.Error) | Ok oursSnapshot -> let! theirsSnapshotResult = this.LoadDirectorySnapshot step.OriginalPromotion.DirectoryVersionId match theirsSnapshotResult with | Error graceError -> return Error(Failed graceError.Error) | Ok theirsSnapshot -> let mergedFilesByPath = Dictionary(StringComparer.OrdinalIgnoreCase) let conflicts = ResizeArray() let allFilePaths = HashSet(StringComparer.OrdinalIgnoreCase) baseSnapshot.FilesByPath.Keys |> Seq.iter (fun filePath -> allFilePaths.Add(filePath) |> ignore) oursSnapshot.FilesByPath.Keys |> Seq.iter (fun filePath -> allFilePaths.Add(filePath) |> ignore) theirsSnapshot.FilesByPath.Keys |> Seq.iter (fun filePath -> allFilePaths.Add(filePath) |> ignore) let orderedFilePaths = allFilePaths |> Seq.sortBy id |> Seq.toArray let mutable fileBudgetFailure: RecomputeFailure option = Option.None if orderedFilePaths.Length > maxFilesPerStep then fileBudgetFailure <- Option.Some(Failed($"Step {step.StepId} exceeded configured file budget ({orderedFilePaths.Length} > {maxFilesPerStep}).")) let mutable filePathIndex = 0 while filePathIndex < orderedFilePaths.Length && fileBudgetFailure.IsNone do let filePath = orderedFilePaths[filePathIndex] let baseFile = this.TryGetFileVersion(baseSnapshot.FilesByPath, filePath) let oursFile = this.TryGetFileVersion(oursSnapshot.FilesByPath, filePath) let theirsFile = this.TryGetFileVersion(theirsSnapshot.FilesByPath, filePath) let oursChanged = not <| this.FileVersionEquivalent(baseFile, oursFile) let theirsChanged = not <| this.FileVersionEquivalent(baseFile, theirsFile) let setMergedFile (fileVersion: FileVersion option) = match fileVersion with | Option.Some selected -> mergedFilesByPath[filePath] <- selected | Option.None -> mergedFilesByPath.Remove(filePath) |> ignore if not theirsChanged then setMergedFile oursFile elif not oursChanged then setMergedFile theirsFile elif this.FileVersionEquivalent(oursFile, theirsFile) then setMergedFile oursFile else let isBinary = match oursFile, theirsFile with | Option.Some ours, _ | _, Option.Some ours -> ours.IsBinary | _ -> false conflicts.Add( { FilePath = filePath; BaseFile = baseFile; OursFile = oursFile; TheirsFile = theirsFile; IsBinary = isBinary } ) filePathIndex <- filePathIndex + 1 match fileBudgetFailure with | Option.Some recomputeFailure -> return Error recomputeFailure | Option.None when conflicts.Count = 0 -> match! this.MaterializeMergedDirectoryVersion ( repositoryDto, baseSnapshot, oursSnapshot, theirsSnapshot, mergedFilesByPath, metadata ) with | Ok appliedDirectoryVersionId -> return Ok(appliedDirectoryVersionId, StepConflictStatus.NoConflicts, Option.None) | Error graceError -> return Error(Failed graceError.Error) | Option.None -> let acceptedDecisionsByPath = Dictionary(StringComparer.OrdinalIgnoreCase) let manualAcceptedWithoutOverride = HashSet(StringComparer.OrdinalIgnoreCase) let outcomesByPath = Dictionary(StringComparer.OrdinalIgnoreCase) let resolutionMethodByPath = Dictionary(StringComparer.OrdinalIgnoreCase) let modelResolvedFilesByPath = Dictionary(StringComparer.OrdinalIgnoreCase) let decisions = manualDecisionsForStep |> Option.defaultValue [] let mutable decisionIndex = 0 while decisionIndex < decisions.Length do let decision = decisions[decisionIndex] if decision.Accepted then acceptedDecisionsByPath[normalizeFilePath decision.FilePath] <- decision decisionIndex <- decisionIndex + 1 let unresolvedConflicts = ResizeArray() let conflictsNeedingModel = ResizeArray() let mutable resolutionError: GraceError option = Option.None let mutable blockedReason: string option = Option.None let mutable conflictIndex = 0 while conflictIndex < conflicts.Count && resolutionError.IsNone && blockedReason.IsNone do let conflictFile = conflicts[conflictIndex] let normalizedFilePath = normalizeFilePath conflictFile.FilePath let mutable acceptedDecision = Unchecked.defaultof if acceptedDecisionsByPath.TryGetValue(normalizedFilePath, &acceptedDecision) then match acceptedDecision.OverrideContentArtifactId with | Option.Some artifactId -> let! artifactTextResult = this.GetArtifactText(artifactId, metadata) match artifactTextResult with | Error graceError -> resolutionError <- Option.Some graceError | Ok artifactText -> let! overrideFileVersionResult = this.CreateTextOverrideFileVersion(conflictFile.FilePath, artifactText, repositoryDto, metadata) match overrideFileVersionResult with | Error graceError -> resolutionError <- Option.Some graceError | Ok overrideFileVersion -> mergedFilesByPath[conflictFile.FilePath] <- overrideFileVersion outcomesByPath[conflictFile.FilePath] <- { ModelResolution = "Manual override artifact accepted." Confidence = 1.0 Accepted = Option.Some true } resolutionMethodByPath[conflictFile.FilePath] <- ConflictResolutionMethod.ManualOverride | Option.None -> if conflictFile.IsBinary then blockedReason <- Option.Some "Binary file conflicts require manual override content." else manualAcceptedWithoutOverride.Add(conflictFile.FilePath) |> ignore conflictsNeedingModel.Add(conflictFile) else unresolvedConflicts.Add(conflictFile) if not conflictFile.IsBinary then conflictsNeedingModel.Add(conflictFile) conflictIndex <- conflictIndex + 1 match resolutionError with | Option.Some graceError -> return Error(Failed graceError.Error) | Option.None -> let hasUnresolvedConflicts = unresolvedConflicts.Count > 0 let hasUnresolvedBinaryConflict = unresolvedConflicts |> Seq.exists (fun conflict -> conflict.IsBinary) let mutable confidence: float option = Option.None let mutable appliedByPolicy = false let mutable minAutoResolvedConfidence = 1.0 if blockedReason.IsNone && hasUnresolvedBinaryConflict then blockedReason <- Option.Some "Binary file conflicts require manual override and cannot be auto-merged." if blockedReason.IsNone && hasUnresolvedConflicts then match conflictResolutionPolicy with | ConflictResolutionPolicy.NoConflicts _ -> blockedReason <- Option.Some $"Conflict detected at step {step.StepId}, and repository policy is NoConflicts." | ConflictResolutionPolicy.ConflictsAllowed _ -> () if blockedReason.IsNone && conflictsNeedingModel.Count > 0 then let mutable modelFailure: string option = Option.None let mutable modelIndex = 0 while modelIndex < conflictsNeedingModel.Count && modelFailure.IsNone do let conflictFile = conflictsNeedingModel[modelIndex] let! modelResolutionResult = this.ResolveConflictWithModel(repositoryDto, conflictFile, metadata) match modelResolutionResult with | Error errorText -> modelFailure <- Option.Some( sprintf "Model resolution failed at step %O for file '%s': %s" step.StepId conflictFile.FilePath errorText ) | Ok (modelOutcome, resolvedFileVersion) -> outcomesByPath[conflictFile.FilePath] <- modelOutcome modelResolvedFilesByPath[conflictFile.FilePath] <- resolvedFileVersion if manualAcceptedWithoutOverride.Contains(conflictFile.FilePath) then outcomesByPath[conflictFile.FilePath] <- { modelOutcome with Accepted = Option.Some true } resolutionMethodByPath[conflictFile.FilePath] <- ConflictResolutionMethod.ManualOverride match resolvedFileVersion with | Option.Some fileVersion -> mergedFilesByPath[conflictFile.FilePath] <- fileVersion | Option.None -> mergedFilesByPath.Remove(conflictFile.FilePath) |> ignore else resolutionMethodByPath[conflictFile.FilePath] <- ConflictResolutionMethod.ModelSuggested modelIndex <- modelIndex + 1 match modelFailure with | Option.Some reason -> blockedReason <- Option.Some reason | Option.None -> () if blockedReason.IsNone && hasUnresolvedConflicts then match conflictResolutionPolicy with | ConflictResolutionPolicy.NoConflicts _ -> () | ConflictResolutionPolicy.ConflictsAllowed threshold -> let mutable unresolvedIndex = 0 while unresolvedIndex < unresolvedConflicts.Count && blockedReason.IsNone do let unresolvedConflict = unresolvedConflicts[unresolvedIndex] let mutable modelOutcome = Unchecked.defaultof if outcomesByPath.TryGetValue(unresolvedConflict.FilePath, &modelOutcome) then if modelOutcome.Confidence >= float threshold then outcomesByPath[unresolvedConflict.FilePath] <- { modelOutcome with Accepted = Option.Some true } minAutoResolvedConfidence <- min minAutoResolvedConfidence modelOutcome.Confidence appliedByPolicy <- true let mutable resolvedFileVersion = Unchecked.defaultof if modelResolvedFilesByPath.TryGetValue(unresolvedConflict.FilePath, &resolvedFileVersion) then match resolvedFileVersion with | Option.Some fileVersion -> mergedFilesByPath[unresolvedConflict.FilePath] <- fileVersion | Option.None -> mergedFilesByPath.Remove(unresolvedConflict.FilePath) |> ignore else blockedReason <- Option.Some( sprintf "Model resolution did not return content for conflicted file '%s' at step %O." unresolvedConflict.FilePath step.StepId ) else blockedReason <- Option.Some( sprintf "Conflict confidence %.2f is below threshold %.2f at step %O." modelOutcome.Confidence (float threshold) step.StepId ) else blockedReason <- Option.Some( sprintf "Model resolution did not return a proposal for conflicted file '%s' at step %O." unresolvedConflict.FilePath step.StepId ) unresolvedIndex <- unresolvedIndex + 1 if appliedByPolicy then confidence <- Option.Some minAutoResolvedConfidence let conflictFilesForArtifact = conflicts |> Seq.toList let! conflictAnalysesResult = this.BuildConflictAnalyses(repositoryDto, conflictFilesForArtifact, outcomesByPath, resolutionMethodByPath) match conflictAnalysesResult with | Error graceError -> return Error(Failed graceError.Error) | Ok conflictAnalyses -> match blockedReason with | Option.Some reasonText -> let! artifactId = this.CreateConflictArtifact( step, reasonText, confidence, computedAgainstBaseDirectoryVersionId, metadata, manualDecisionsForStep, Option.Some conflictAnalyses ) return Error(Blocked(reasonText, artifactId)) | Option.None -> let resolutionReason = if appliedByPolicy && confidence.IsSome then sprintf "Conflicts auto-resolved by policy at confidence %.2f." confidence.Value elif manualAcceptedWithoutOverride.Count > 0 then "Conflicts resolved through manual review decisions." else "Conflicts resolved." let! conflictArtifactId = this.CreateConflictArtifact( step, resolutionReason, confidence, computedAgainstBaseDirectoryVersionId, metadata, manualDecisionsForStep, Option.Some conflictAnalyses ) match! this.MaterializeMergedDirectoryVersion ( repositoryDto, baseSnapshot, oursSnapshot, theirsSnapshot, mergedFilesByPath, metadata ) with | Ok appliedDirectoryVersionId -> return Ok(appliedDirectoryVersionId, StepConflictStatus.AutoResolved, conflictArtifactId) | Error graceError -> return Error(Failed graceError.Error) } member private this.CreateConflictArtifact ( step: PromotionSetStep, reason: string, confidence: float option, computedAgainstBaseDirectoryVersionId: DirectoryVersionId, metadata: EventMetadata, manualDecisions: ConflictResolutionDecision list option, conflictAnalyses: ConflictAnalysis list option ) = task { try let defaultConflictAnalysis: ConflictAnalysis = { FilePath = "__step__" OriginalHunks = [ { StartLine = 1 EndLine = 1 OursContent = $"{computedAgainstBaseDirectoryVersionId}" TheirsContent = $"{step.OriginalPromotion.DirectoryVersionId}" } ] ProposedResolution = Option.None ResolutionMethod = ConflictResolutionMethod.None } let report = {| promotionSetId = promotionSetDto.PromotionSetId targetBranchId = promotionSetDto.TargetBranchId stepId = step.StepId order = step.Order reason = reason confidence = confidence computedAgainstBaseDirectoryVersionId = computedAgainstBaseDirectoryVersionId originalBaseDirectoryVersionId = step.OriginalBaseDirectoryVersionId originalPromotionDirectoryVersionId = step.OriginalPromotion.DirectoryVersionId conflicts = conflictAnalyses |> Option.defaultValue [ defaultConflictAnalysis ] manualDecisions = (manualDecisions |> Option.defaultValue [] |> List.map (fun decision -> {| filePath = decision.FilePath accepted = decision.Accepted overrideContentArtifactId = decision.OverrideContentArtifactId |})) |} let reportJson = serialize report let artifactId: ArtifactId = Guid.NewGuid() let nowUtc = getCurrentInstant().InUtc() let blobPath = sprintf "grace-artifacts/%04i/%02i/%02i/%02i/%O" nowUtc.Year nowUtc.Month nowUtc.Day nowUtc.Hour artifactId let artifactMetadata: ArtifactMetadata = { ArtifactMetadata.Default with ArtifactId = artifactId OwnerId = promotionSetDto.OwnerId OrganizationId = promotionSetDto.OrganizationId RepositoryId = promotionSetDto.RepositoryId ArtifactType = ArtifactType.ConflictReport MimeType = "application/json" Size = int64 reportJson.Length BlobPath = blobPath CreatedAt = getCurrentInstant () CreatedBy = UserId metadata.Principal } let artifactActorProxy = Artifact.CreateActorProxy artifactId promotionSetDto.RepositoryId this.correlationId match! artifactActorProxy.Handle (ArtifactCommand.Create artifactMetadata) (this.WithActorMetadata metadata) with | Ok _ -> match! this.UploadArtifactPayload(blobPath, reportJson, metadata) with | Ok () -> return Option.Some artifactId | Error uploadError -> log.LogWarning( "{CurrentInstant}: Node: {HostName}; CorrelationId: {CorrelationId}; Conflict artifact metadata was created but payload upload failed for PromotionSetId {PromotionSetId}; ArtifactId {ArtifactId}. Error: {GraceError}.", getCurrentInstantExtended (), getMachineName, metadata.CorrelationId, promotionSetDto.PromotionSetId, artifactId, uploadError ) return Option.Some artifactId | Error graceError -> log.LogWarning( "{CurrentInstant}: Node: {HostName}; CorrelationId: {CorrelationId}; Failed to persist conflict artifact metadata for PromotionSetId {PromotionSetId}. Error: {GraceError}.", getCurrentInstantExtended (), getMachineName, metadata.CorrelationId, promotionSetDto.PromotionSetId, graceError ) return Option.None with | ex -> log.LogError( ex, "{CurrentInstant}: Node: {HostName}; CorrelationId: {CorrelationId}; Exception while creating conflict artifact for PromotionSetId {PromotionSetId}.", getCurrentInstantExtended (), getMachineName, metadata.CorrelationId, promotionSetDto.PromotionSetId ) return Option.None } override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () logActorActivation log this.IdentityString activateStartTime (getActorActivationMessage state.RecordExists) promotionSetDto <- state.State |> Seq.fold (fun dto event -> PromotionSetDto.UpdateDto event dto) promotionSetDto Task.CompletedTask interface IGraceReminderWithGuidKey with member this.ScheduleReminderAsync reminderType delay reminderState correlationId = task { let reminderDto = ReminderDto.Create actorName $"{this.IdentityString}" promotionSetDto.OwnerId promotionSetDto.OrganizationId promotionSetDto.RepositoryId reminderType (getFutureInstant delay) reminderState correlationId do! createReminder reminderDto } :> Task member this.ReceiveReminderAsync(reminder: ReminderDto) : Task> = task { this.correlationId <- reminder.CorrelationId return Error( (GraceError.Create $"{actorName} does not process reminder type {getDiscriminatedUnionCaseName reminder.ReminderType} with state {getDiscriminatedUnionCaseName reminder.State}." this.correlationId) .enhance ("IsRetryable", "false") ) } member private this.ApplyEvent(promotionSetEvent: PromotionSetEvent) = task { let normalizedMetadata = this.WithActorMetadata promotionSetEvent.Metadata let normalizedEvent = { promotionSetEvent with Metadata = normalizedMetadata } let correlationId = normalizedMetadata.CorrelationId try state.State.Add(normalizedEvent) do! state.WriteStateAsync() promotionSetDto <- promotionSetDto |> PromotionSetDto.UpdateDto normalizedEvent let graceEvent = GraceEvent.PromotionSetEvent normalizedEvent do! publishGraceEvent graceEvent normalizedMetadata return this.BuildSuccess("Promotion set command succeeded.", correlationId) with | ex -> log.LogError( ex, "{CurrentInstant}: Node: {HostName}; CorrelationId: {CorrelationId}; Failed to apply event for PromotionSetId: {PromotionSetId}.", getCurrentInstantExtended (), getMachineName, correlationId, promotionSetDto.PromotionSetId ) return Error( (GraceError.CreateWithException ex "Failed while applying PromotionSet event." correlationId) .enhance(nameof RepositoryId, promotionSetDto.RepositoryId) .enhance (nameof PromotionSetId, promotionSetDto.PromotionSetId) ) } member private this.RecomputeSteps ( metadata: EventMetadata, reason: string option, manualResolution: (PromotionSetStepId * ConflictResolutionDecision list) option ) = task { if promotionSetDto.StepsComputationStatus = StepsComputationStatus.Computing then return this.BuildError("PromotionSet steps are already computing.", metadata.CorrelationId) else let! currentTerminalReferenceId, targetBaseDirectoryVersionId = this.GetCurrentTerminalPromotion() if manualResolution.IsNone && promotionSetDto.StepsComputationStatus = StepsComputationStatus.Computed && promotionSetDto.ComputedAgainstParentTerminalPromotionReferenceId = Option.Some currentTerminalReferenceId then return this.BuildSuccess("PromotionSet steps are already computed against the current terminal promotion.", metadata.CorrelationId) else match! this.ApplyEvent { Event = PromotionSetEventType.RecomputeStarted currentTerminalReferenceId; Metadata = metadata } with | Error graceError -> return Error graceError | Ok _ -> let! conflictResolutionPolicy = this.GetConflictResolutionPolicy() let! repositoryDto = this.GetRepositoryDto() let orderedSteps = promotionSetDto.Steps |> List.sortBy (fun step -> step.Order) let computedSteps = ResizeArray() let mutable recomputeFailure: RecomputeFailure option = Option.None let mutable currentHeadDirectoryVersionId = targetBaseDirectoryVersionId let mutable index = 0 let recomputeStopwatch = Stopwatch.StartNew() if orderedSteps.Length > maxStepsPerRecompute then recomputeFailure <- Option.Some(Failed($"Recompute exceeded configured step budget ({orderedSteps.Length} > {maxStepsPerRecompute}).")) while index < orderedSteps.Length && recomputeFailure.IsNone do if recomputeStopwatch.ElapsedMilliseconds > int64 maxTotalTimeMilliseconds then recomputeFailure <- Option.Some( Failed( $"Recompute exceeded total time budget ({recomputeStopwatch.ElapsedMilliseconds} ms > {maxTotalTimeMilliseconds} ms)." ) ) let currentStep = orderedSteps[index] let stepStopwatch = Stopwatch.StartNew() let! hydratedStepResult = this.HydrateStepProvenance currentStep match hydratedStepResult with | Error graceError -> recomputeFailure <- Option.Some(Failed graceError.Error) | Ok hydratedStep -> let computedAgainstBaseDirectoryVersionId = currentHeadDirectoryVersionId let manualDecisionsForStep = match manualResolution with | Option.Some (resolvedStepId, decisions) when resolvedStepId = hydratedStep.StepId -> Option.Some decisions | _ -> Option.None let hasManualOverride = manualDecisionsForStep |> Option.defaultValue [] |> List.exists (fun decision -> decision.Accepted && decision.OverrideContentArtifactId.IsSome) let! manualOverrideValidation = if hasManualOverride then this.ValidateManualOverrideArtifacts(manualDecisionsForStep |> Option.defaultValue [], metadata) else Task.FromResult(Ok()) match manualOverrideValidation with | Error graceError -> recomputeFailure <- Option.Some(Failed graceError.Error) | Ok () -> let! stepComputationResult = this.ComputeAppliedDirectoryVersionForStep( hydratedStep, computedAgainstBaseDirectoryVersionId, conflictResolutionPolicy, manualDecisionsForStep, repositoryDto, metadata ) match stepComputationResult with | Ok (appliedDirectoryVersionId, conflictStatus, conflictArtifactId) -> let computedStep = { hydratedStep with ComputedAgainstBaseDirectoryVersionId = computedAgainstBaseDirectoryVersionId AppliedDirectoryVersionId = appliedDirectoryVersionId ConflictSummaryArtifactId = conflictArtifactId ConflictStatus = conflictStatus } computedSteps.Add(computedStep) currentHeadDirectoryVersionId <- computedStep.AppliedDirectoryVersionId | Error stepFailure -> recomputeFailure <- Option.Some stepFailure if recomputeFailure.IsNone && stepStopwatch.ElapsedMilliseconds > int64 maxStepTimeMilliseconds then recomputeFailure <- Option.Some( Failed( $"Step {currentStep.StepId} exceeded step time budget ({stepStopwatch.ElapsedMilliseconds} ms > {maxStepTimeMilliseconds} ms)." ) ) index <- index + 1 match recomputeFailure with | Option.None -> match! this.ApplyEvent { Event = PromotionSetEventType.StepsUpdated(computedSteps |> Seq.toList, currentTerminalReferenceId) Metadata = metadata } with | Ok graceReturnValue -> return Ok graceReturnValue | Error graceError -> return Error graceError | Option.Some (Blocked (reasonText, artifactId)) -> match! this.ApplyEvent { Event = PromotionSetEventType.Blocked(reasonText, artifactId); Metadata = metadata } with | Ok _ -> return this.BuildError(reasonText, metadata.CorrelationId) | Error graceError -> return Error graceError | Option.Some (Failed reasonText) -> match! this.ApplyEvent { Event = PromotionSetEventType.RecomputeFailed(reasonText, currentTerminalReferenceId); Metadata = metadata } with | Ok _ -> return this.BuildError(reasonText, metadata.CorrelationId) | Error graceError -> return Error graceError } member private this.RollbackCreatedPromotions(createdReferenceIds: List, rollbackReason: string, metadata: EventMetadata) = task { let mutable index = 0 while index < createdReferenceIds.Count do let referenceId = createdReferenceIds[index] let referenceActorProxy = Reference.CreateActorProxy referenceId promotionSetDto.RepositoryId this.correlationId let rollbackMetadata = this.WithActorMetadata metadata rollbackMetadata.Properties[ "ActorId" ] <- $"{referenceId}" match! referenceActorProxy.Handle (ReferenceCommand.DeleteLogical(true, rollbackReason)) rollbackMetadata with | Ok _ -> () | Error graceError -> log.LogWarning( "{CurrentInstant}: Node: {HostName}; CorrelationId: {CorrelationId}; Failed to rollback reference {ReferenceId} for PromotionSetId {PromotionSetId}. Error: {GraceError}", getCurrentInstantExtended (), getMachineName, metadata.CorrelationId, referenceId, promotionSetDto.PromotionSetId, graceError ) index <- index + 1 let branchActorProxy = Branch.CreateActorProxy promotionSetDto.TargetBranchId promotionSetDto.RepositoryId this.correlationId do! branchActorProxy.MarkForRecompute metadata.CorrelationId } member private this.CreatePromotionReference(step: PromotionSetStep, isTerminal: bool, metadata: EventMetadata) = task { let directoryVersionActorProxy = DirectoryVersion.CreateActorProxy step.AppliedDirectoryVersionId promotionSetDto.RepositoryId this.correlationId let! directoryVersionDto = directoryVersionActorProxy.Get this.correlationId if directoryVersionDto.DirectoryVersion.DirectoryVersionId = DirectoryVersionId.Empty then return Error( (GraceError.Create "Applied directory version does not exist." metadata.CorrelationId) .enhance(nameof PromotionSetStepId, step.StepId) .enhance (nameof DirectoryVersionId, step.AppliedDirectoryVersionId) ) else let referenceId: ReferenceId = Guid.NewGuid() let links = ResizeArray() links.Add(ReferenceLinkType.IncludedInPromotionSet promotionSetDto.PromotionSetId) if isTerminal then links.Add(ReferenceLinkType.PromotionSetTerminal promotionSetDto.PromotionSetId) let referenceMetadata = this.WithActorMetadata metadata referenceMetadata.Properties[ "ActorId" ] <- $"{referenceId}" referenceMetadata.Properties[ nameof BranchId ] <- $"{promotionSetDto.TargetBranchId}" let referenceActorProxy = Reference.CreateActorProxy referenceId promotionSetDto.RepositoryId this.correlationId let referenceText = ReferenceText $"PromotionSet {promotionSetDto.PromotionSetId} Step {step.Order}" let referenceCommand = ReferenceCommand.Create( referenceId, promotionSetDto.OwnerId, promotionSetDto.OrganizationId, promotionSetDto.RepositoryId, promotionSetDto.TargetBranchId, step.AppliedDirectoryVersionId, directoryVersionDto.DirectoryVersion.Sha256Hash, ReferenceType.Promotion, referenceText, links ) match! referenceActorProxy.Handle referenceCommand referenceMetadata with | Ok _ -> return Ok referenceId | Error graceError -> return Error graceError } member private this.GetRequiredValidationsForApply() = task { let! validationSets = getValidationSets promotionSetDto.RepositoryId 500 false this.correlationId return validationSets |> List.filter (fun validationSet -> validationSet.TargetBranchId = promotionSetDto.TargetBranchId) |> List.collect (fun validationSet -> validationSet.Validations) |> List.filter (fun validation -> validation.RequiredForApply) |> List.distinctBy (fun validation -> $"{validation.Name.Trim().ToLowerInvariant()}::{validation.Version.Trim().ToLowerInvariant()}") } member private this.EnsureRequiredValidationsPass(metadata: EventMetadata) = task { let! requiredValidations = this.GetRequiredValidationsForApply() if requiredValidations.IsEmpty then return Ok() else let! scopedValidationResults = getValidationResultsForPromotionSetAttempt promotionSetDto.RepositoryId promotionSetDto.PromotionSetId promotionSetDto.StepsComputationAttempt 5000 this.correlationId let hasPass (validationName: string) (validationVersion: string) = scopedValidationResults |> List.exists (fun validationResult -> String.Equals(validationResult.ValidationName, validationName, StringComparison.OrdinalIgnoreCase) && String.Equals(validationResult.ValidationVersion, validationVersion, StringComparison.OrdinalIgnoreCase) && validationResult.Output.Status = ValidationStatus.Pass) let missingOrFailing = requiredValidations |> List.filter (fun validation -> not <| hasPass validation.Name validation.Version) if missingOrFailing.IsEmpty then return Ok() else let details = missingOrFailing |> List.map (fun validation -> $"{validation.Name}:{validation.Version}") |> String.concat ", " return Error( (GraceError.Create $"Required validations have not passed for StepsComputationAttempt {promotionSetDto.StepsComputationAttempt}: {details}." metadata.CorrelationId) .enhance(nameof PromotionSetId, promotionSetDto.PromotionSetId) .enhance ("StepsComputationAttempt", promotionSetDto.StepsComputationAttempt) ) } member private this.ApplyPromotionSet(metadata: EventMetadata) = task { if promotionSetDto.Status = PromotionSetStatus.Succeeded then return this.BuildError("PromotionSet has already been applied successfully.", metadata.CorrelationId) elif promotionSetDto.DeletedAt.IsSome then return this.BuildError("PromotionSet has been deleted and cannot be applied.", metadata.CorrelationId) else let! currentTerminalReferenceId, _ = this.GetCurrentTerminalPromotion() let needsRecompute = promotionSetDto.StepsComputationStatus <> StepsComputationStatus.Computed || promotionSetDto.ComputedAgainstParentTerminalPromotionReferenceId <> Option.Some currentTerminalReferenceId let mutable recomputeError: GraceError option = Option.None if needsRecompute then let! recomputeResult = this.RecomputeSteps(metadata, Option.Some "Apply requested on stale or uncomputed steps.", Option.None) match recomputeResult with | Ok _ -> () | Error graceError -> recomputeError <- Option.Some graceError let mutable preconditionError: GraceError option = recomputeError if preconditionError.IsNone && promotionSetDto.StepsComputationStatus <> StepsComputationStatus.Computed then preconditionError <- Option.Some(GraceError.Create "PromotionSet steps are not computed." metadata.CorrelationId) if preconditionError.IsNone && promotionSetDto.Steps.IsEmpty then preconditionError <- Option.Some(GraceError.Create "PromotionSet does not have any steps to apply." metadata.CorrelationId) if preconditionError.IsNone then match! this.EnsureRequiredValidationsPass metadata with | Ok _ -> () | Error graceError -> preconditionError <- Option.Some graceError if preconditionError.IsSome then return Error preconditionError.Value else match! this.ApplyEvent { Event = PromotionSetEventType.ApplyStarted; Metadata = metadata } with | Error graceError -> return Error graceError | Ok _ -> let createdReferenceIds = List() let orderedSteps = promotionSetDto.Steps |> List.sortBy (fun step -> step.Order) let mutable applyError: GraceError option = Option.None let mutable index = 0 while index < orderedSteps.Length && applyError.IsNone do let step = orderedSteps[index] let isTerminal = index = (orderedSteps.Length - 1) let! createReferenceResult = this.CreatePromotionReference(step, isTerminal, metadata) match createReferenceResult with | Ok referenceId -> createdReferenceIds.Add(referenceId) | Error graceError -> applyError <- Option.Some graceError index <- index + 1 match applyError with | Option.None -> let branchActorProxy = Branch.CreateActorProxy promotionSetDto.TargetBranchId promotionSetDto.RepositoryId this.correlationId do! branchActorProxy.MarkForRecompute metadata.CorrelationId let terminalReferenceId = createdReferenceIds[createdReferenceIds.Count - 1] match! this.ApplyEvent { Event = PromotionSetEventType.Applied terminalReferenceId; Metadata = metadata } with | Error graceError -> return Error graceError | Ok graceReturnValue -> let queueActorProxy = PromotionQueue.CreateActorProxy promotionSetDto.TargetBranchId promotionSetDto.RepositoryId this.correlationId let! queueExists = queueActorProxy.Exists metadata.CorrelationId if queueExists then let dequeueMetadata = this.WithActorMetadata metadata match! queueActorProxy.Handle (PromotionQueueCommand.Dequeue promotionSetDto.PromotionSetId) dequeueMetadata with | Ok _ -> () | Error graceError -> log.LogWarning( "{CurrentInstant}: Node: {HostName}; CorrelationId: {CorrelationId}; Failed to dequeue PromotionSetId {PromotionSetId} after apply. Error: {GraceError}", getCurrentInstantExtended (), getMachineName, metadata.CorrelationId, promotionSetDto.PromotionSetId, graceError ) return Ok graceReturnValue | Option.Some graceError -> do! this.RollbackCreatedPromotions( createdReferenceIds, "PromotionSet apply failed. Rolling back previously created references.", metadata ) match! this.ApplyEvent { Event = PromotionSetEventType.ApplyFailed graceError.Error; Metadata = metadata } with | Ok _ -> return Error graceError | Error applyFailureError -> return Error applyFailureError } interface IHasRepositoryId with member this.GetRepositoryId correlationId = promotionSetDto.RepositoryId |> returnTask interface IPromotionSetActor with member this.Exists correlationId = this.correlationId <- correlationId not <| promotionSetDto.PromotionSetId.Equals(PromotionSetId.Empty) |> returnTask member this.IsDeleted correlationId = this.correlationId <- correlationId promotionSetDto.DeletedAt.IsSome |> returnTask member this.Get correlationId = this.correlationId <- correlationId promotionSetDto |> returnTask member this.GetEvents correlationId = this.correlationId <- correlationId state.State :> IReadOnlyList |> returnTask member this.Handle command metadata = let isValid (promotionSetCommand: PromotionSetCommand) (eventMetadata: EventMetadata) = task { return validateCommandForState state.State promotionSetDto promotionSetCommand eventMetadata } let processCommand (promotionSetCommand: PromotionSetCommand) (eventMetadata: EventMetadata) = task { match promotionSetCommand with | PromotionSetCommand.CreatePromotionSet (promotionSetId, ownerId, organizationId, repositoryId, targetBranchId) -> return! this.ApplyEvent { Event = PromotionSetEventType.Created(promotionSetId, ownerId, organizationId, repositoryId, targetBranchId) Metadata = eventMetadata } | PromotionSetCommand.UpdateInputPromotions promotionPointers -> match! this.ApplyEvent { Event = PromotionSetEventType.InputPromotionsUpdated promotionPointers; Metadata = eventMetadata } with | Error graceError -> return Error graceError | Ok _ -> return! this.RecomputeSteps(eventMetadata, Option.Some "Input promotions changed.", Option.None) | PromotionSetCommand.RecomputeStepsIfStale reason -> return! this.RecomputeSteps(eventMetadata, reason, Option.None) | PromotionSetCommand.ResolveConflicts (stepId, resolutions) -> return! this.RecomputeSteps(eventMetadata, Option.Some "Manual conflict resolutions submitted.", Option.Some(stepId, resolutions)) | PromotionSetCommand.Apply -> return! this.ApplyPromotionSet eventMetadata | PromotionSetCommand.DeleteLogical (force, deleteReason) -> return! this.ApplyEvent { Event = PromotionSetEventType.LogicalDeleted(force, deleteReason); Metadata = eventMetadata } } task { currentCommand <- getDiscriminatedUnionCaseName command this.correlationId <- metadata.CorrelationId RequestContext.Set(Constants.CurrentCommandProperty, getDiscriminatedUnionCaseName command) match! isValid command metadata with | Ok validCommand -> return! processCommand validCommand metadata | Error validationError -> return Error validationError } ================================================ FILE: src/Grace.Actors/Reference.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.ActorProxy open Grace.Actors.Extensions.MemoryCache open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Actors.Timing open Grace.Actors.Types open Grace.Shared open Grace.Shared.Constants open Grace.Shared.Utilities open Grace.Shared.Validation.Errors open Grace.Types.Events open Grace.Types.Reference open Grace.Types.Reminder open Grace.Types.Types open Microsoft.Extensions.Logging open NodaTime open Orleans open Orleans.Runtime open System open System.Collections.Generic open System.Globalization open System.Threading.Tasks module Reference = type ReferenceActor([] state: IPersistentState>) = inherit Grain() static let actorName = ActorName.Reference let log = loggerFactory.CreateLogger("Reference.Actor") let mutable currentCommand = String.Empty let mutable referenceDto = ReferenceDto.Default member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () logActorActivation log this.IdentityString activateStartTime (getActorActivationMessage state.RecordExists) referenceDto <- state.State |> Seq.fold (fun referenceDto event -> ReferenceDto.UpdateDto event referenceDto) referenceDto Task.CompletedTask interface IGraceReminderWithGuidKey with /// Schedules a Grace reminder. member this.ScheduleReminderAsync reminderType delay state correlationId = task { let reminderDto = ReminderDto.Create actorName $"{this.IdentityString}" referenceDto.OwnerId referenceDto.OrganizationId referenceDto.RepositoryId reminderType (getFutureInstant delay) state correlationId do! createReminder reminderDto } :> Task /// Receives a Grace reminder. member this.ReceiveReminderAsync(reminder: ReminderDto) : Task> = task { this.correlationId <- reminder.CorrelationId match reminder.ReminderType, reminder.State with | ReminderTypes.PhysicalDeletion, ReminderState.ReferencePhysicalDeletion physicalDeletionReminderState -> this.correlationId <- physicalDeletionReminderState.CorrelationId // Mark the branch as needing to update its latest references. let branchActorProxy = Branch.CreateActorProxy physicalDeletionReminderState.BranchId physicalDeletionReminderState.RepositoryId this.correlationId do! branchActorProxy.MarkForRecompute physicalDeletionReminderState.CorrelationId // Delete saved state for this actor. do! state.ClearStateAsync() log.LogInformation( "{CurrentInstant}: Node: {hostName}; CorrelationId: {correlationId}; Deleted physical state for reference; RepositoryId: {RepositoryId}; BranchId: {BranchId}; ReferenceId: {ReferenceId}; DirectoryVersionId: {DirectoryVersionId}; deleteReason: {deleteReason}.", getCurrentInstantExtended (), getMachineName, physicalDeletionReminderState.CorrelationId, physicalDeletionReminderState.RepositoryId, physicalDeletionReminderState.BranchId, referenceDto.ReferenceId, physicalDeletionReminderState.DirectoryVersionId, physicalDeletionReminderState.DeleteReason ) this.DeactivateOnIdle() return Ok() | reminderType, state -> return Error( (GraceError.Create $"{actorName} does not process reminder type {getDiscriminatedUnionCaseName reminderType} with state {getDiscriminatedUnionCaseName state}." this.correlationId) .enhance ("IsRetryable", "false") ) } member private this.ApplyEvent(referenceEvent: ReferenceEvent) = task { let correlationId = referenceEvent.Metadata.CorrelationId try // Add the event to the referenceEvents list, and save it to actor state. state.State.Add(referenceEvent) do! state.WriteStateAsync() // Update the referenceDto with the event. referenceDto <- referenceDto |> ReferenceDto.UpdateDto referenceEvent // Publish the event to the rest of the world. let graceEvent = GraceEvent.ReferenceEvent referenceEvent do! publishGraceEvent graceEvent referenceEvent.Metadata // If this is a Save or Checkpoint reference, schedule a physical deletion based on the default delays from the repository. match referenceEvent.Event with | Created (referenceId, ownerId, organizationId, repositoryId, branchId, directoryId, sha256Hash, referenceType, referenceText, links) -> do! match referenceDto.ReferenceType with | ReferenceType.Save -> task { let repositoryActorProxy = Repository.CreateActorProxy referenceDto.OrganizationId referenceDto.RepositoryId correlationId let! repositoryDto = repositoryActorProxy.Get correlationId let reminderState: PhysicalDeletionReminderState = { RepositoryId = referenceDto.RepositoryId BranchId = referenceDto.BranchId DirectoryVersionId = referenceDto.DirectoryId Sha256Hash = referenceDto.Sha256Hash DeleteReason = $"Save: automatic deletion after {repositoryDto.SaveDays} days" CorrelationId = correlationId } do! (this :> IGraceReminderWithGuidKey) .ScheduleReminderAsync ReminderTypes.PhysicalDeletion (Duration.FromDays(float repositoryDto.SaveDays)) (ReminderState.ReferencePhysicalDeletion reminderState) correlationId } | ReferenceType.Checkpoint -> task { let repositoryActorProxy = Repository.CreateActorProxy referenceDto.OrganizationId referenceDto.RepositoryId correlationId let! repositoryDto = repositoryActorProxy.Get correlationId let reminderState: PhysicalDeletionReminderState = { RepositoryId = referenceDto.RepositoryId BranchId = referenceDto.BranchId DirectoryVersionId = referenceDto.DirectoryId Sha256Hash = referenceDto.Sha256Hash DeleteReason = $"Checkpoint: automatic deletion after {repositoryDto.CheckpointDays} days" CorrelationId = correlationId } do! (this :> IGraceReminderWithGuidKey) .ScheduleReminderAsync ReminderTypes.PhysicalDeletion (Duration.FromDays(float repositoryDto.CheckpointDays)) (ReminderState.ReferencePhysicalDeletion reminderState) correlationId } | _ -> () |> returnTask :> Task | _ -> () let graceReturnValue = (GraceReturnValue.Create referenceDto correlationId) .enhance(nameof RepositoryId, referenceDto.RepositoryId) .enhance(nameof BranchId, referenceDto.BranchId) .enhance(nameof ReferenceId, referenceDto.ReferenceId) .enhance(nameof DirectoryVersionId, referenceDto.DirectoryId) .enhance(nameof ReferenceType, getDiscriminatedUnionCaseName referenceDto.ReferenceType) .enhance (nameof ReferenceEventType, getDiscriminatedUnionFullName referenceEvent.Event) return Ok graceReturnValue with | ex -> log.LogError( ex, "{CurrentInstant}: Node: {hostName}; CorrelationId: {correlationId}; Failed to apply event {eventType} for reference {referenceId} in repository {repositoryId} on branch {branchId} with directory version {directoryVersionId}.", getCurrentInstantExtended (), getMachineName, correlationId, getDiscriminatedUnionCaseName referenceEvent.Event, referenceDto.ReferenceId, referenceDto.RepositoryId, referenceDto.BranchId, referenceDto.DirectoryId ) let graceError = (GraceError.CreateWithException ex (getErrorMessage ReferenceError.FailedWhileApplyingEvent) correlationId) .enhance(nameof RepositoryId, referenceDto.RepositoryId) .enhance(nameof BranchId, referenceDto.BranchId) .enhance(nameof ReferenceId, referenceDto.ReferenceId) .enhance(nameof DirectoryVersionId, referenceDto.DirectoryId) .enhance(nameof ReferenceType, getDiscriminatedUnionCaseName referenceDto.ReferenceType) .enhance (nameof ReferenceEventType, getDiscriminatedUnionFullName referenceEvent.Event) return Error graceError } interface IHasRepositoryId with member this.GetRepositoryId correlationId = referenceDto.RepositoryId |> returnTask interface IReferenceActor with member this.Exists correlationId = this.correlationId <- correlationId not <| referenceDto.ReferenceId.Equals(ReferenceDto.Default.ReferenceId) |> returnTask member this.Get correlationId = this.correlationId <- correlationId referenceDto |> returnTask member this.GetReferenceType correlationId = this.correlationId <- correlationId referenceDto.ReferenceType |> returnTask member this.IsDeleted correlationId = this.correlationId <- correlationId referenceDto.DeletedAt.IsSome |> returnTask member this.Handle command metadata = let isValid (command: ReferenceCommand) (metadata: EventMetadata) = task { if state.State.Exists(fun ev -> ev.Metadata.CorrelationId = metadata.CorrelationId) then return Error(GraceError.Create (getErrorMessage ReferenceError.DuplicateCorrelationId) metadata.CorrelationId) else match command with | Create (referenceId, ownerId, organizationId, repositoryId, branchId, directoryId, sha256Hash, referenceType, referenceText, links) -> match referenceDto.UpdatedAt with | Some _ -> return Error(GraceError.Create (getErrorMessage ReferenceError.ReferenceAlreadyExists) metadata.CorrelationId) | None -> return Ok command | _ -> match referenceDto.UpdatedAt with | Some _ -> return Ok command | None -> return Error(GraceError.Create (getErrorMessage ReferenceError.ReferenceIdDoesNotExist) metadata.CorrelationId) } let processCommand (command: ReferenceCommand) (metadata: EventMetadata) = task { let! referenceEventType = task { match command with | Create (referenceId, ownerId, organizationId, repositoryId, branchId, directoryId, sha256Hash, referenceType, referenceText, links) -> return Created( referenceId, ownerId, organizationId, repositoryId, branchId, directoryId, sha256Hash, referenceType, referenceText, links ) | AddLink link -> return LinkAdded link | RemoveLink link -> return LinkRemoved link | DeleteLogical (force, deleteReason) -> let tryGetLogicalDeleteDaysFromMetadata () = match metadata.Properties.TryGetValue("RepositoryLogicalDeleteDays") with | true, value -> let mutable parsed = 0.0f if Single.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, &parsed) then Some parsed else None | _ -> None let! logicalDeleteDays = match tryGetLogicalDeleteDaysFromMetadata () with | Some days -> Task.FromResult days | None -> task { let repositoryActorProxy = Repository.CreateActorProxy referenceDto.OrganizationId referenceDto.RepositoryId this.correlationId let! repositoryDto = repositoryActorProxy.Get this.correlationId return repositoryDto.LogicalDeleteDays } let reminderState: PhysicalDeletionReminderState = { RepositoryId = referenceDto.RepositoryId BranchId = referenceDto.BranchId DirectoryVersionId = referenceDto.DirectoryId Sha256Hash = referenceDto.Sha256Hash DeleteReason = deleteReason CorrelationId = metadata.CorrelationId } do! (this :> IGraceReminderWithGuidKey) .ScheduleReminderAsync ReminderTypes.PhysicalDeletion (Duration.FromDays(float logicalDeleteDays)) (ReminderState.ReferencePhysicalDeletion reminderState) metadata.CorrelationId return LogicalDeleted(force, deleteReason) | DeletePhysical -> // Delete the actor state and mark the actor as deactivated. do! state.ClearStateAsync() this.DeactivateOnIdle() return PhysicalDeleted | Undelete -> return Undeleted } let referenceEvent = { Event = referenceEventType; Metadata = metadata } let! returnValue = this.ApplyEvent referenceEvent return returnValue } task { currentCommand <- $"{getDiscriminatedUnionCaseName command} {getDiscriminatedUnionCaseName referenceDto.ReferenceType}" this.correlationId <- metadata.CorrelationId match! isValid command metadata with | Ok command -> return! processCommand command metadata | Error error -> return Error error } ================================================ FILE: src/Grace.Actors/Reminder.Actor.fs ================================================ namespace Grace.Actors open Orleans open Orleans.Runtime open Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.ActorProxy open Grace.Actors.Extensions.MemoryCache open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Actors.Types open Grace.Shared open Grace.Shared.Constants open Grace.Types.Reminder open Grace.Types.Types open Grace.Shared.Utilities open Microsoft.Extensions.Logging open NodaTime open System open System.Collections.Concurrent open System.Threading.Tasks module Reminder = /// Orleans implementation of the ReminderActor. type ReminderActor([] reminderState: IPersistentState) = inherit Grain() let log = loggerFactory.CreateLogger("Reminder.Actor") member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () logActorActivation log this.IdentityString activateStartTime (getActorActivationMessage reminderState.RecordExists) Task.CompletedTask interface IReminderActor with member this.Create (reminder: ReminderDto) (correlationId: CorrelationId) = task { try reminderState.State.Reminder <- reminder do! reminderState.WriteStateAsync() log.LogTrace( "{CurrentInstant}: Node: {HostName}; CorrelationId: {CorrelationId}; Created reminder {ReminderId}. Actor {ActorName}||{ActorId}.", getCurrentInstantExtended (), getMachineName, reminder.CorrelationId, reminder.ReminderId, reminder.ActorName, reminder.ActorId ) return () with | ex -> log.LogError( ex, "{CurrentInstant}: Node: {HostName}; CorrelationId: {CorrelationId}; Error creating reminder {ReminderId}. Actor {ActorName}||{ActorId}.", getCurrentInstantExtended (), getMachineName, correlationId, reminder.ReminderId, reminder.ActorName, reminder.ActorId ) return () } :> Task member this.Delete(correlationId: CorrelationId) = task { let reminderDto = reminderState.State.Reminder try this.correlationId <- correlationId do! reminderState.ClearStateAsync() if not reminderState.RecordExists then log.LogInformation( "{CurrentInstant}: Node: {HostName}; CorrelationId: {CorrelationId}; Deleted reminder {ReminderId}. Actor {ActorName}||{ActorId}.", getCurrentInstantExtended (), getMachineName, correlationId, reminderDto.ReminderId, reminderDto.ActorName, reminderDto.ActorId ) else log.LogWarning( "{CurrentInstant}: Node: {HostName}; CorrelationId: {CorrelationId}; State for Reminder {ReminderId} was not deleted. It may not have been found. Actor {ActorName}||{ActorId}.", getCurrentInstantExtended (), getMachineName, correlationId, reminderDto.ReminderId, reminderDto.ActorName, reminderDto.ActorId ) return () with | ex -> log.LogError( "{CurrentInstant}: Node: {HostName}; CorrelationId: {CorrelationId}; Error deleting reminder {ReminderId}. Actor {ActorName}||{ActorId}. {ExceptionDetails}", getCurrentInstantExtended (), getMachineName, correlationId, reminderDto.ReminderId, reminderDto.ActorName, reminderDto.ActorId, ExceptionResponse.Create ex ) return () } :> Task member this.Exists(correlationId: CorrelationId) : Task = this.correlationId <- correlationId if reminderState.State.Reminder.ReminderTime = Instant.MinValue then false |> returnTask else true |> returnTask member this.Get(correlationId: CorrelationId) = this.correlationId <- correlationId reminderState.State.Reminder |> returnTask member this.Remind(correlationId: CorrelationId) : Task> = task { let reminderDto = reminderState.State.Reminder try this.correlationId <- correlationId // Parse the Guid from the ActorId. Example: "referenceactor/da3926330c394275813d95e390a5c374" let actorId = if reminderDto.ActorName = ActorName.Diff then // Diff actors have a different ActorId format: "directoryVersionId1*directoryVersionId2" Guid.Empty else Guid.ParseExact(reminderDto.ActorId.Split("/").[1], "N") match reminderDto.ActorName with | ActorName.Owner -> let ownerActorProxy = Owner.CreateActorProxy actorId correlationId return! ownerActorProxy.ReceiveReminderAsync reminderDto | ActorName.Organization -> let organizationActorProxy = Organization.CreateActorProxy actorId correlationId return! organizationActorProxy.ReceiveReminderAsync reminderDto | ActorName.Repository -> let repositoryActorProxy = Repository.CreateActorProxy actorId reminderDto.RepositoryId correlationId return! repositoryActorProxy.ReceiveReminderAsync reminderDto | ActorName.Branch -> let branchActorProxy = Branch.CreateActorProxy actorId reminderDto.RepositoryId correlationId return! branchActorProxy.ReceiveReminderAsync reminderDto | ActorName.DirectoryVersion -> let directoryVersionActorProxy = DirectoryVersion.CreateActorProxy actorId reminderDto.RepositoryId correlationId return! directoryVersionActorProxy.ReceiveReminderAsync reminderDto | ActorName.Reference -> let referenceActorProxy = Reference.CreateActorProxy actorId reminderDto.RepositoryId correlationId return! referenceActorProxy.ReceiveReminderAsync reminderDto | ActorName.Diff -> // Example reminderDto.ActorId: "diffactor/15b50c95-7306-4ecb-9850-a0a5dc7419cf*1e7b6f83-4715-42f8-ba0b-9b0262356f08" let directoryVersionIds = reminderDto.ActorId.Split("/").[1].Split("*") let diffActorProxy = Diff.CreateActorProxy (Guid.ParseExact(directoryVersionIds[0], "D")) // "D" = 32 digits separated by hyphens (Guid.ParseExact(directoryVersionIds[1], "D")) reminderDto.OwnerId reminderDto.OrganizationId reminderDto.RepositoryId correlationId return! diffActorProxy.ReceiveReminderAsync reminderDto | _ -> return Ok() with | ex -> log.LogError( "{CurrentInstant}: Node: {HostName}; CorrelationId: {CorrelationId}; Error reminding actor {ActorName}||{ActorId}. {ExceptionDetails}", getCurrentInstantExtended (), getMachineName, correlationId, reminderDto.ActorName, reminderDto.ActorId, ExceptionResponse.Create ex ) return Error( (GraceError.Create "Failed to execute reminder." correlationId) .enhance("reminder", (serialize reminderDto)) .enhance ("exception", $"{ExceptionResponse.Create ex}") ) } ================================================ FILE: src/Grace.Actors/Repository.Actor.fs ================================================ namespace Grace.Actors open FSharp.Control open FSharpPlus open Grace.Actors.Constants open Grace.Actors.Interfaces open Grace.Actors.Context open Grace.Actors.Extensions.ActorProxy open Grace.Actors.Extensions.MemoryCache open Grace.Actors.Services open Grace.Actors.Types open Grace.Shared open Grace.Shared.Combinators open Grace.Shared.Constants open Grace.Shared.Resources.Text open Grace.Shared.Resources.Utilities open Grace.Types.Branch open Grace.Types.DirectoryVersion open Grace.Types.Reminder open Grace.Types.Repository open Grace.Types.Events open Grace.Types.Types open Grace.Shared.Utilities open Grace.Shared.Validation.Errors open Microsoft.Extensions.Logging open NodaTime open Orleans open Orleans.Runtime open System open System.Collections.Concurrent open System.Collections.Generic open System.Globalization open System.Linq open System.Text open System.Text.Json open System.Threading.Tasks open System.Runtime.Serialization open Grace.Shared.Services module Repository = type RepositoryActor([] state: IPersistentState>) = inherit Grain() static let actorName = ActorName.Repository let log = loggerFactory.CreateLogger("Repository.Actor") let mutable repositoryDto = RepositoryDto.Default member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () logActorActivation log this.IdentityString activateStartTime (getActorActivationMessage state.RecordExists) repositoryDto <- state.State |> Seq.fold (fun repositoryDto repositoryEvent -> repositoryDto |> RepositoryDto.UpdateDto repositoryEvent) RepositoryDto.Default Task.CompletedTask member private this.ApplyEvent repositoryEvent = task { try // Add the new event to the list of events, and write the state to storage. state.State.Add repositoryEvent do! state.WriteStateAsync() // Update the repositoryDto with the new event. repositoryDto <- repositoryDto |> RepositoryDto.UpdateDto repositoryEvent /// Concatenates repository errors into a single GraceError instance. let processGraceError (repositoryError: RepositoryError) repositoryEvent previousGraceError = Error( GraceError.Create $"{getErrorMessage repositoryError}{Environment.NewLine}{previousGraceError.Error}" repositoryEvent.Metadata.CorrelationId ) // If we're creating a repository, we need to create the default branch, the initial promotion, and the initial directory. // Otherwise, just pass the event through. let handleEvent = task { match repositoryEvent.Event with | Created (name, repositoryId, ownerId, organizationId, objectStorageProvider) -> // Create the default branch. let branchId = (Guid.NewGuid()) let branchActor = Branch.CreateActorProxy branchId repositoryDto.RepositoryId this.correlationId // Only allow promotions and tags on the initial branch. let initialBranchPermissions = [| ReferenceType.Promotion ReferenceType.Tag ReferenceType.External |] let createInitialBranchCommand = BranchCommand.Create( branchId, InitialBranchName, DefaultParentBranchId, ReferenceId.Empty, ownerId, organizationId, repositoryId, initialBranchPermissions ) match! branchActor.Handle createInitialBranchCommand repositoryEvent.Metadata with | Ok branchGraceReturn -> logToConsole $"In Repository.Actor.handleEvent: Successfully created the new branch." // Create an empty directory version, and use that for the initial promotion let emptyDirectoryId = DirectoryVersionId.NewGuid() let emptySha256Hash = computeSha256ForDirectory RootDirectoryPath (List()) (List()) let directoryVersionActorProxy = DirectoryVersion.CreateActorProxy emptyDirectoryId repositoryDto.RepositoryId this.correlationId let emptyDirectoryVersion = DirectoryVersion.Create emptyDirectoryId repositoryDto.OwnerId repositoryDto.OrganizationId repositoryDto.RepositoryId RootDirectoryPath emptySha256Hash (List()) (List()) 0L let! directoryResult = directoryVersionActorProxy.Handle (DirectoryVersionCommand.Create(emptyDirectoryVersion, repositoryDto)) repositoryEvent.Metadata logToConsole $"In Repository.Actor.handleEvent: Successfully created the empty directory version." let! promotionResult = branchActor.Handle (BranchCommand.Promote( emptyDirectoryId, emptySha256Hash, (getLocalizedString StringResourceName.InitialPromotionMessage) )) repositoryEvent.Metadata logToConsole $"In Repository.Actor.handleEvent: After trying to create the first promotion." match directoryResult, promotionResult with | (Ok directoryVersionGraceReturnValue, Ok promotionGraceReturnValue) -> logToConsole $"In Repository.Actor.handleEvent: Successfully created the initial promotion." //logToConsole $"promotionGraceReturnValue.Properties:" //promotionGraceReturnValue.Properties //|> Seq.iter (fun kv -> logToConsole $" {kv.Key}: {kv.Value}") // Set current, empty directory as the based-on reference. let referenceId = Guid.Parse($"{promotionGraceReturnValue.Properties[nameof ReferenceId]}") //logToConsole $"In Repository.Actor.handleEvent: Before trying to rebase the initial branch." //let! rebaseResult = branchActor.Handle (Commands.Branch.BranchCommand.Rebase(referenceId)) repositoryEvent.Metadata //logToConsole $"In Repository.Actor.handleEvent: After trying to rebase the initial branch." //match rebaseResult with //| Ok rebaseGraceReturn -> return Ok(branchId, referenceId) //| Error graceError -> return processGraceError FailedRebasingInitialBranch repositoryEvent graceError return Ok(branchId, referenceId) | (_, Error graceError) -> return processGraceError FailedCreatingInitialPromotion repositoryEvent graceError | (Error graceError, _) -> return processGraceError FailedCreatingEmptyDirectoryVersion repositoryEvent graceError | Error graceError -> logToConsole $"In Repository.Actor.handleEvent: Failed to create the new branch." return processGraceError FailedCreatingInitialBranch repositoryEvent graceError | _ -> return Ok(BranchId.Empty, ReferenceId.Empty) } match! handleEvent with | Ok (branchId, referenceId) -> // Publish the event to the rest of the world. let graceEvent = GraceEvent.RepositoryEvent repositoryEvent do! publishGraceEvent graceEvent repositoryEvent.Metadata let returnValue = GraceReturnValue.Create $"Repository command succeeded." repositoryEvent.Metadata.CorrelationId returnValue .enhance(nameof OwnerId, repositoryDto.OwnerId) .enhance(nameof OrganizationId, repositoryDto.OrganizationId) .enhance(nameof RepositoryId, repositoryDto.RepositoryId) .enhance(nameof RepositoryName, repositoryDto.RepositoryName) .enhance (nameof RepositoryEventType, getDiscriminatedUnionFullName repositoryEvent.Event) |> ignore if branchId <> BranchId.Empty then returnValue .enhance(nameof BranchId, branchId) .enhance(nameof BranchName, Constants.InitialBranchName) .enhance (nameof ReferenceId, referenceId) |> ignore returnValue.Properties.Add("EventType", getDiscriminatedUnionFullName repositoryEvent.Event) return Ok returnValue | Error graceError -> return Error graceError with | ex -> let exceptionResponse = ExceptionResponse.Create ex let graceError = GraceError.Create (getErrorMessage RepositoryError.FailedWhileApplyingEvent) repositoryEvent.Metadata.CorrelationId graceError .enhance( "Exception details", exceptionResponse.``exception`` + exceptionResponse.innerException ) .enhance(nameof OwnerId, repositoryDto.OwnerId) .enhance(nameof OrganizationId, repositoryDto.OrganizationId) .enhance(nameof RepositoryId, repositoryDto.RepositoryId) .enhance(nameof RepositoryName, repositoryDto.RepositoryName) .enhance (nameof RepositoryEventType, getDiscriminatedUnionFullName repositoryEvent.Event) |> ignore return Error graceError } /// Deletes all of the branches provided, by sending a DeleteLogical command to each branch. member private this.LogicalDeleteBranches(branches: BranchDto array, metadata: EventMetadata, deleteReason: DeleteReason) = task { let results = ConcurrentQueue>() // Loop through each branch and send a DeleteLogical command to it. do! Parallel.ForEachAsync( branches, Constants.ParallelOptions, (fun branch ct -> ValueTask( task { if branch.DeletedAt |> Option.isNone then let branchActor = Branch.CreateActorProxy branch.BranchId branch.RepositoryId this.correlationId let childMetadata = EventMetadata.New metadata.CorrelationId GraceSystemUser childMetadata.Properties[ nameof RepositoryId ] <- $"{repositoryDto.RepositoryId}" childMetadata.Properties[ "RepositoryLogicalDeleteDays" ] <- repositoryDto.LogicalDeleteDays.ToString("F", CultureInfo.InvariantCulture) let! result = branchActor.Handle (BranchCommand.DeleteLogical( true, $"Cascaded from deleting repository. ownerId: {repositoryDto.OwnerId}; organizationId: {repositoryDto.OrganizationId}; repositoryId: {repositoryDto.RepositoryId}; repositoryName: {repositoryDto.RepositoryName}; deleteReason: {deleteReason}", false, None )) childMetadata results.Enqueue(result) } )) ) // Check if any of the results were errors, and take the first one if so. let overallResult = results |> Seq.tryPick (fun result -> match result with | Ok _ -> None | Error error -> Some(error)) match overallResult with | None -> return Ok() | Some error -> return Error error } interface IHasRepositoryId with member this.GetRepositoryId correlationId = repositoryDto.RepositoryId |> returnTask interface IGraceReminderWithGuidKey with /// Schedules a Grace reminder. member this.ScheduleReminderAsync reminderType delay state correlationId = task { let reminder = ReminderDto.Create actorName $"{this.IdentityString}" repositoryDto.OwnerId repositoryDto.OrganizationId repositoryDto.RepositoryId reminderType (getFutureInstant delay) state correlationId do! createReminder reminder } :> Task /// Receives a Grace reminder. member this.ReceiveReminderAsync(reminder: ReminderDto) : Task> = task { match reminder.ReminderType, reminder.State with | ReminderTypes.PhysicalDeletion, ReminderState.RepositoryPhysicalDeletion physicalDeletionReminderState -> this.correlationId <- physicalDeletionReminderState.CorrelationId do! state.ClearStateAsync() log.LogInformation( "{CurrentInstant}: Node: {hostName}; CorrelationId: {correlationId}; Deleted physical state for repository; RepositoryId: {}; RepositoryName: {}; OrganizationId: {organizationId}; OwnerId: {ownerId}; deleteReason: {deleteReason}.", getCurrentInstantExtended (), getMachineName, physicalDeletionReminderState.CorrelationId, repositoryDto.RepositoryId, repositoryDto.RepositoryName, repositoryDto.OrganizationId, repositoryDto.OwnerId, physicalDeletionReminderState.DeleteReason ) this.DeactivateOnIdle() return Ok() | reminderType, state -> return Error( GraceError.Create $"{actorName} does not process reminder type {getDiscriminatedUnionCaseName reminderType} with state {getDiscriminatedUnionCaseName state}." this.correlationId ) } interface IExportable with member this.Export() = task { try if state.State.Count > 0 then return Ok state.State else return Error ExportError.EventListIsEmpty with | ex -> return Error(ExportError.Exception(ExceptionResponse.Create ex)) } member this.Import(events: IReadOnlyList) = task { try state.State.Clear() state.State.AddRange(events) do! state.WriteStateAsync() return Ok events.Count with | ex -> return Error(ImportError.Exception(ExceptionResponse.Create ex)) } interface IRevertable with member this.RevertBack (eventsToRevert: int) (persist: PersistAction) = task { try let repositoryEvents = state.State if repositoryEvents.Count > 0 then let eventsToKeep = repositoryEvents.Count - eventsToRevert if eventsToKeep <= 0 then return Error RevertError.OutOfRange else let revertedEvents = repositoryEvents.Take eventsToKeep let newRepositoryDto = revertedEvents.Aggregate(RepositoryDto.Default, (fun state evnt -> (RepositoryDto.UpdateDto evnt state))) match persist with | PersistAction.Save -> state.State.Clear() state.State.AddRange revertedEvents do! state.WriteStateAsync() | DoNotSave -> () return Ok newRepositoryDto else return Error RevertError.EmptyEventList with | ex -> return Error(RevertError.Exception(ExceptionResponse.Create ex)) } member this.RevertToInstant (whenToRevertTo: Instant) (persist: PersistAction) = task { try let repositoryEvents = state.State if repositoryEvents.Count > 0 then let revertedEvents = repositoryEvents.Where(fun evnt -> evnt.Metadata.Timestamp < whenToRevertTo) if revertedEvents.Count() = 0 then return Error RevertError.OutOfRange else let newRepositoryDto = revertedEvents |> Seq.fold (fun state evnt -> (RepositoryDto.UpdateDto evnt state)) RepositoryDto.Default match persist with | PersistAction.Save -> task { state.State.Clear() state.State.AddRange revertedEvents do! state.WriteStateAsync() } |> ignore | DoNotSave -> () return Ok newRepositoryDto else return Error RevertError.EmptyEventList with | ex -> return Error(RevertError.Exception(ExceptionResponse.Create ex)) } member this.EventCount() = task { return state.State.Count } interface IRepositoryActor with member this.Get correlationId = this.correlationId <- correlationId repositoryDto |> returnTask member this.GetObjectStorageProvider correlationId = this.correlationId <- correlationId repositoryDto.ObjectStorageProvider |> returnTask member this.Exists correlationId = this.correlationId <- correlationId repositoryDto.UpdatedAt.IsSome |> returnTask member this.IsEmpty correlationId = this.correlationId <- correlationId repositoryDto.InitializedAt.IsNone |> returnTask member this.IsDeleted correlationId = this.correlationId <- correlationId repositoryDto.DeletedAt.IsSome |> returnTask member this.Handle command metadata = let isValid command (metadata: EventMetadata) = task { if state.State.Exists(fun ev -> ev.Metadata.CorrelationId = metadata.CorrelationId) then return Error(GraceError.Create (getErrorMessage RepositoryError.DuplicateCorrelationId) metadata.CorrelationId) else match command with | RepositoryCommand.Create (_, _, _, _, _) -> match repositoryDto.UpdatedAt with | Some _ -> return Error(GraceError.Create (getErrorMessage RepositoryError.RepositoryIdAlreadyExists) metadata.CorrelationId) | None -> return Ok command | _ -> match repositoryDto.UpdatedAt with | Some _ -> return Ok command | None -> return Error(GraceError.Create (getErrorMessage RepositoryError.RepositoryIdDoesNotExist) metadata.CorrelationId) } let processCommand command (metadata: EventMetadata) = task { try let! event = task { match command with | Create (repositoryName, repositoryId, ownerId, organizationId, objectStorageProvider) -> return Created(repositoryName, repositoryId, ownerId, organizationId, objectStorageProvider) | Initialize -> return Initialized | SetObjectStorageProvider objectStorageProvider -> return ObjectStorageProviderSet objectStorageProvider | SetStorageAccountName storageAccountName -> return StorageAccountNameSet storageAccountName | SetStorageContainerName containerName -> return StorageContainerNameSet containerName | SetRepositoryStatus repositoryStatus -> return RepositoryStatusSet repositoryStatus | SetRepositoryType repositoryType -> return RepositoryTypeSet repositoryType | SetAllowsLargeFiles allowsLargeFiles -> return AllowsLargeFilesSet allowsLargeFiles | SetAnonymousAccess anonymousAccess -> return AnonymousAccessSet anonymousAccess | SetRecordSaves recordSaves -> return RecordSavesSet recordSaves | SetDefaultServerApiVersion version -> return DefaultServerApiVersionSet version | SetDefaultBranchName defaultBranchName -> return DefaultBranchNameSet defaultBranchName | SetLogicalDeleteDays days -> return LogicalDeleteDaysSet days | SetSaveDays days -> return SaveDaysSet days | SetCheckpointDays days -> return CheckpointDaysSet days | SetDirectoryVersionCacheDays days -> return DirectoryVersionCacheDaysSet days | SetDiffCacheDays days -> return DiffCacheDaysSet days | SetName repositoryName -> return NameSet repositoryName | SetDescription description -> return DescriptionSet description | SetConflictResolutionPolicy policy -> return ConflictResolutionPolicySet policy | DeleteLogical (force, deleteReason) -> // Get the list of branches that aren't already deleted. let! branches = getBranches repositoryDto.OwnerId repositoryDto.OrganizationId repositoryDto.RepositoryId Int32.MaxValue false metadata.CorrelationId // If any branches are not already deleted, and we're not forcing the deletion, then throw an exception. if not <| force && branches.Length > 0 && branches.Any(fun branch -> branch.DeletedAt |> Option.isNone) then return LogicalDeleted(force, deleteReason) else // We have --force specified, so delete the branches that aren't already deleted. match! this.LogicalDeleteBranches(branches, metadata, deleteReason) with | Ok _ -> let physicalDeletionReminderState = { DeleteReason = deleteReason; CorrelationId = metadata.CorrelationId } do! (this :> IGraceReminderWithGuidKey) .ScheduleReminderAsync ReminderTypes.PhysicalDeletion (Duration.FromDays(float repositoryDto.LogicalDeleteDays)) (ReminderState.RepositoryPhysicalDeletion physicalDeletionReminderState) metadata.CorrelationId () | Error error -> raise (ApplicationException($"{error}")) return LogicalDeleted(force, deleteReason) | DeletePhysical -> // Delete the state from storage, and deactivate the actor. do! state.ClearStateAsync() this.DeactivateOnIdle() return PhysicalDeleted | RepositoryCommand.Undelete -> return Undeleted } return! this.ApplyEvent { Event = event; Metadata = metadata } with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}{Environment.NewLine}{metadata}" metadata.CorrelationId) } task { this.correlationId <- metadata.CorrelationId RequestContext.Set(Constants.CurrentCommandProperty, getDiscriminatedUnionCaseName command) match! isValid command metadata with | Ok command -> return! processCommand command metadata | Error error -> return Error error } ================================================ FILE: src/Grace.Actors/Repository.Actor.fs (ApplyEvent Method) ================================================ ================================================ FILE: src/Grace.Actors/RepositoryName.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.MemoryCache open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Types.Types open Grace.Shared.Utilities open Microsoft.Extensions.Logging open NodaTime open Orleans open Orleans.Runtime open System open System.Threading.Tasks module RepositoryName = type RepositoryNameActor() = inherit Grain() static let actorName = ActorName.RepositoryName let log = loggerFactory.CreateLogger("RepositoryName.Actor") let mutable cachedRepositoryId: RepositoryId option = None member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () logActorActivation log this.IdentityString activateStartTime "In-memory only" Task.CompletedTask interface IRepositoryNameActor with member this.GetRepositoryId correlationId = this.correlationId <- correlationId cachedRepositoryId |> returnTask member this.SetRepositoryId (repositoryId: RepositoryId) correlationId = this.correlationId <- correlationId cachedRepositoryId <- Some repositoryId Task.CompletedTask ================================================ FILE: src/Grace.Actors/RepositoryPermission.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Interfaces open Grace.Shared.Constants open Grace.Shared.Utilities open Grace.Types.Authorization open Grace.Types.Types open Microsoft.Extensions.Logging open Orleans open Orleans.Runtime open System open System.Threading.Tasks module RepositoryPermission = let ActorName = ActorName.RepositoryPermission [] type RepositoryPermissionState = { PathPermissions: PathPermission list } module RepositoryPermissionState = let Empty = { PathPermissions = [] } type RepositoryPermissionActor ( [] state: IPersistentState ) = inherit Grain() let log = loggerFactory.CreateLogger("RepositoryPermission.Actor") let mutable permissionState = RepositoryPermissionState.Empty override this.OnActivateAsync(ct) = permissionState <- if state.RecordExists then state.State else RepositoryPermissionState.Empty Task.CompletedTask member private this.SaveState() = task { state.State <- permissionState if permissionState.PathPermissions |> List.isEmpty then do! DefaultAsyncRetryPolicy.ExecuteAsync(fun () -> state.ClearStateAsync()) else do! DefaultAsyncRetryPolicy.ExecuteAsync(fun () -> state.WriteStateAsync()) } member private this.Upsert (pathPermission: PathPermission) (metadata: EventMetadata) = task { let normalizedPath = normalizeFilePath pathPermission.Path let normalizedPermission = { pathPermission with Path = normalizedPath } let updated = permissionState.PathPermissions |> List.filter (fun existing -> normalizeFilePath existing.Path <> normalizedPath) permissionState <- { permissionState with PathPermissions = normalizedPermission :: updated } do! this.SaveState() let returnValue = GraceReturnValue.Create permissionState.PathPermissions metadata.CorrelationId return Ok returnValue } member private this.Remove (path: RelativePath) (metadata: EventMetadata) = task { let normalizedPath = normalizeFilePath path let updated = permissionState.PathPermissions |> List.filter (fun existing -> normalizeFilePath existing.Path <> normalizedPath) permissionState <- { permissionState with PathPermissions = updated } do! this.SaveState() let returnValue = GraceReturnValue.Create permissionState.PathPermissions metadata.CorrelationId return Ok returnValue } member private this.List (pathFilter: RelativePath option) (metadata: EventMetadata) = task { let filtered = match pathFilter with | None -> permissionState.PathPermissions | Some value -> let normalizedPath = normalizeFilePath value permissionState.PathPermissions |> List.filter (fun existing -> normalizeFilePath existing.Path = normalizedPath) let returnValue = GraceReturnValue.Create filtered metadata.CorrelationId return Ok returnValue } interface IRepositoryPermissionActor with member this.Handle command metadata = match command with | RepositoryPermissionCommand.UpsertPathPermission pathPermission -> this.Upsert pathPermission metadata | RepositoryPermissionCommand.RemovePathPermission path -> this.Remove path metadata | RepositoryPermissionCommand.ListPathPermissions path -> this.List path metadata member this.GetPathPermissions pathFilter correlationId = let filtered = match pathFilter with | None -> permissionState.PathPermissions | Some value -> let normalizedPath = normalizeFilePath value permissionState.PathPermissions |> List.filter (fun existing -> normalizeFilePath existing.Path = normalizedPath) filtered |> returnTask ================================================ FILE: src/Grace.Actors/Review.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.ActorProxy open Grace.Actors.Extensions.MemoryCache open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Actors.Types open Grace.Shared open Grace.Shared.Constants open Grace.Shared.Utilities open Grace.Shared.Validation.Errors open Grace.Types.Events open Grace.Types.Review open Grace.Types.Types open Microsoft.Extensions.Logging open NodaTime open Orleans open Orleans.Runtime open System open System.Collections.Generic open System.Threading.Tasks module Review = type ReviewActor([] state: IPersistentState>) = inherit Grain() static let actorName = ActorName.Review let log = loggerFactory.CreateLogger("Review.Actor") let mutable currentCommand = String.Empty let mutable reviewNotes: ReviewNotes option = None let mutable checkpoints: ReviewCheckpoint list = [] member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () logActorActivation log this.IdentityString activateStartTime (getActorActivationMessage state.RecordExists) let applyToState (reviewEvent: ReviewEvent) = match reviewEvent.Event with | NotesUpserted notes -> let createdAt = if notes.CreatedAt = Constants.DefaultTimestamp then reviewEvent.Metadata.Timestamp else notes.CreatedAt let updatedNotes = { notes with CreatedAt = createdAt; UpdatedAt = Some reviewEvent.Metadata.Timestamp } reviewNotes <- Some updatedNotes | FindingResolved (findingId, resolutionState, resolvedBy, note) -> reviewNotes <- reviewNotes |> Option.map (fun notes -> let updatedFindings = notes.Findings |> List.map (fun finding -> if finding.FindingId = findingId then { finding with ResolutionState = resolutionState ResolvedBy = Some resolvedBy ResolvedAt = Some reviewEvent.Metadata.Timestamp ResolutionNote = note } else finding) { notes with Findings = updatedFindings; UpdatedAt = Some reviewEvent.Metadata.Timestamp }) | CheckpointAdded checkpoint -> checkpoints <- checkpoints @ [ checkpoint ] state.State |> Seq.iter applyToState Task.CompletedTask member private this.ApplyEvent(reviewEvent: ReviewEvent) = task { let correlationId = reviewEvent.Metadata.CorrelationId try state.State.Add(reviewEvent) do! state.WriteStateAsync() match reviewEvent.Event with | NotesUpserted notes -> let createdAt = if notes.CreatedAt = Constants.DefaultTimestamp then reviewEvent.Metadata.Timestamp else notes.CreatedAt let updatedNotes = { notes with CreatedAt = createdAt; UpdatedAt = Some reviewEvent.Metadata.Timestamp } reviewNotes <- Some updatedNotes | FindingResolved (findingId, resolutionState, resolvedBy, note) -> reviewNotes <- reviewNotes |> Option.map (fun notes -> let updatedFindings = notes.Findings |> List.map (fun finding -> if finding.FindingId = findingId then { finding with ResolutionState = resolutionState ResolvedBy = Some resolvedBy ResolvedAt = Some reviewEvent.Metadata.Timestamp ResolutionNote = note } else finding) { notes with Findings = updatedFindings; UpdatedAt = Some reviewEvent.Metadata.Timestamp }) | CheckpointAdded checkpoint -> checkpoints <- checkpoints @ [ checkpoint ] let graceEvent = GraceEvent.ReviewEvent reviewEvent do! publishGraceEvent graceEvent reviewEvent.Metadata let returnValue = (GraceReturnValue.Create "Review command succeeded." correlationId) .enhance( nameof ReviewNotesId, reviewNotes |> Option.map (fun notes -> notes.ReviewNotesId) |> Option.defaultValue (ReviewNotesId.Empty) ) .enhance (nameof ReviewEventType, getDiscriminatedUnionFullName reviewEvent.Event) return Ok returnValue with | ex -> log.LogError( ex, "{CurrentInstant}: Node: {hostName}; CorrelationId: {correlationId}; Failed to apply event {eventType} for review.", getCurrentInstantExtended (), getMachineName, correlationId, getDiscriminatedUnionCaseName reviewEvent.Event ) let graceError = (GraceError.CreateWithException ex (ReviewError.getErrorMessage ReviewError.FailedWhileApplyingEvent) correlationId) .enhance ( nameof ReviewNotesId, reviewNotes |> Option.map (fun notes -> notes.ReviewNotesId) |> Option.defaultValue (ReviewNotesId.Empty) ) return Error graceError } interface IHasRepositoryId with member this.GetRepositoryId correlationId = let repositoryId = reviewNotes |> Option.map (fun notes -> notes.RepositoryId) |> Option.defaultValue RepositoryId.Empty repositoryId |> returnTask interface IReviewActor with member this.GetNotes correlationId = this.correlationId <- correlationId reviewNotes |> returnTask member this.GetCheckpoints correlationId = this.correlationId <- correlationId (checkpoints :> IReadOnlyList) |> returnTask member this.Handle command metadata = let isValid (command: ReviewCommand) (metadata: EventMetadata) = task { if state.State.Exists(fun ev -> ev.Metadata.CorrelationId = metadata.CorrelationId) then return Error(GraceError.Create (ReviewError.getErrorMessage ReviewError.DuplicateCorrelationId) metadata.CorrelationId) else match command with | UpsertNotes _ -> return Ok command | AddCheckpoint _ -> return Ok command | ResolveFinding (findingId, _, _, _) -> match reviewNotes with | None -> return Error(GraceError.Create (ReviewError.getErrorMessage ReviewError.ReviewNotesDoesNotExist) metadata.CorrelationId) | Some notes -> let exists = notes.Findings |> List.exists (fun finding -> finding.FindingId = findingId) if exists then return Ok command else return Error(GraceError.Create (ReviewError.getErrorMessage ReviewError.FindingDoesNotExist) metadata.CorrelationId) } let processCommand (command: ReviewCommand) (metadata: EventMetadata) = task { let! reviewEventType = task { match command with | UpsertNotes notes -> return NotesUpserted notes | ResolveFinding (findingId, resolutionState, resolvedBy, note) -> return FindingResolved(findingId, resolutionState, resolvedBy, note) | AddCheckpoint checkpoint -> return CheckpointAdded checkpoint } let reviewEvent = { Event = reviewEventType; Metadata = metadata } return! this.ApplyEvent reviewEvent } task { currentCommand <- getDiscriminatedUnionCaseName command this.correlationId <- metadata.CorrelationId RequestContext.Set(Constants.CurrentCommandProperty, getDiscriminatedUnionCaseName command) match! isValid command metadata with | Ok command -> return! processCommand command metadata | Error error -> return Error error } ================================================ FILE: src/Grace.Actors/Services.Actor.fs ================================================ namespace Grace.Actors open Azure.Core open Azure.Identity open Azure.Messaging.ServiceBus open Azure.Storage open Azure.Storage.Blobs open Azure.Storage.Blobs.Specialized open Azure.Storage.Sas open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.ActorProxy open Grace.Actors.Extensions.MemoryCache open Grace.Actors.Interfaces open Grace.Actors.Timing open Grace.Actors.Types open Grace.Shared open Grace.Shared.AzureEnvironment open Grace.Shared.Constants open Grace.Types.Branch open Grace.Types.DirectoryVersion open Grace.Types.Events open Grace.Types.Reference open Grace.Types.Reminder open Grace.Types.Repository open Grace.Types.Organization open Grace.Types.Owner open Grace.Types.Types open Grace.Types.Validation open Grace.Shared.Utilities open Microsoft.Azure.Cosmos open Microsoft.Azure.Cosmos.Linq open Microsoft.Extensions.Caching.Memory open Microsoft.Extensions.Logging open NodaTime open Orleans.Runtime open System open System.Collections.Concurrent open System.Collections.Generic open System.Diagnostics open System.IO open System.Linq open System.Net open System.Net.Http open System.Net.Http.Json open System.Net.Security open System.Text open System.Text.Json open System.Threading.Tasks open System.Threading open System open Microsoft.Extensions.DependencyInjection open System.Runtime.Serialization open System.Reflection open System.Text.RegularExpressions open Microsoft.Azure.Amqp open Azure.Core.Amqp module Services = type ServerGraceIndex = Dictionary type OwnerIdRecord = { OwnerId: string } type OrganizationIdRecord = { organizationId: string } type RepositoryIdRecord = { repositoryId: string } type BranchIdRecord = { branchId: string } type OrganizationDtoValue() = member val public value = OrganizationDto.Default with get, set type RepositoryDtoValue() = member val public value = RepositoryDto.Default with get, set type BranchDtoValue() = member val public value = BranchDto.Default with get, set type BranchIdValue() = member val public branchId = BranchId.Empty with get, set type ActorIdValue() = member val public id = String.Empty with get, set type OwnerEventValue() = member val public State: OwnerEvent array = Array.Empty() with get, set type OrganizationEventValue() = member val public State: OrganizationEvent array = Array.Empty() with get, set type RepositoryEventValue() = member val public State: RepositoryEvent array = Array.Empty() with get, set type BranchEventValue() = member val public State: BranchEvent array = Array.Empty() with get, set type ReferenceEventValue() = member val public State: ReferenceEvent array = Array.Empty() with get, set type DirectoryVersionEventValue() = member val public State: DirectoryVersionEvent array = Array.Empty() with get, set type DirectoryVersionValue() = member val public value = DirectoryVersion.Default with get, set type ReminderValue() = member val public Reminder: ReminderDto = ReminderDto.Default with get, set /// Dictionary for caching blob container clients let containerClients = new ConcurrentDictionary() /// Shared key credential for Azure Storage when available. let private sharedKeyCredential = lazy AzureEnvironment.storageAccountKey |> Option.map (fun accountKey -> StorageSharedKeyCredential(AzureEnvironment.storageEndpoints.AccountName, accountKey)) /// Logger instance for the Services.Actor module. let private log = loggerFactory.CreateLogger("Services.Actor") let private defaultAzureCredential = lazy (DefaultAzureCredential()) let private serviceBusClient = lazy let settings = pubSubSettings.AzureServiceBus.Value if settings.UseManagedIdentity then let fullyQualifiedNamespace = if not (String.IsNullOrWhiteSpace settings.FullyQualifiedNamespace) then settings.FullyQualifiedNamespace else AzureEnvironment.tryGetServiceBusFullyQualifiedNamespace () |> Option.defaultWith (fun () -> invalidOp "Azure Service Bus namespace is required for managed identity.") logToConsole $"Creating ServiceBusClient with Managed Identity for namespace: {fullyQualifiedNamespace}." ServiceBusClient(fullyQualifiedNamespace, defaultAzureCredential.Value) else logToConsole "Creating ServiceBusClient with connection string." ServiceBusClient(settings.ConnectionString) let private serviceBusSender = lazy (serviceBusClient.Value.CreateSender(pubSubSettings.AzureServiceBus.Value.TopicName)) /// Publishes a GraceEvent to the configured pub-sub system. let publishGraceEvent (graceEvent: GraceEvent) (metadata: EventMetadata) = task { match pubSubSettings.System with | GracePubSubSystem.AzureServiceBus -> match pubSubSettings.AzureServiceBus with | Some sbSettings -> try let payload = JsonSerializer.SerializeToUtf8Bytes(graceEvent, Constants.JsonSerializerOptions) let message = ServiceBusMessage(payload) message.ContentType <- "application/json" message.Subject <- "GraceEvent" message.CorrelationId <- metadata.CorrelationId message.MessageId <- $"{metadata.CorrelationId}-{getCurrentInstant().ToUnixTimeMilliseconds}" //Guid.NewGuid().ToString("N") message.ApplicationProperties[ "graceEventType" ] <- getDiscriminatedUnionFullName graceEvent for kvp in metadata.Properties do message.ApplicationProperties[ kvp.Key ] <- kvp.Value do! serviceBusSender.Value.SendMessageAsync(message) log.LogInformation( "{CurrentInstant}: Published GraceEvent via Azure Service Bus. CorrelationId: {CorrelationId}; EventType: {EventType}.", getCurrentInstantExtended (), metadata.CorrelationId, getDiscriminatedUnionCaseName graceEvent ) with | ex -> log.LogError( ex, "{CurrentInstant}: Failed publishing GraceEvent via Azure Service Bus. CorrelationId: {CorrelationId}; EventType: {EventType}.", getCurrentInstantExtended (), metadata.CorrelationId, getDiscriminatedUnionCaseName graceEvent ) | None -> log.LogWarning( "Azure Service Bus selected but settings were not provided; dropping GraceEvent {EventType}.", getDiscriminatedUnionCaseName graceEvent ) | GracePubSubSystem.UnknownPubSubProvider -> log.LogDebug( "Pub-sub system disabled; dropping GraceEvent {EventType} with CorrelationId: {CorrelationId}.", getDiscriminatedUnionCaseName graceEvent, metadata.CorrelationId ) | otherSystem -> log.LogWarning( "Grace pub-sub system {System} not yet implemented; dropping GraceEvent {EventType} with CorrelationId: {CorrelationId}.", getDiscriminatedUnionCaseName otherSystem, getDiscriminatedUnionCaseName graceEvent, metadata.CorrelationId ) } :> Task /// Prints a Cosmos DB QueryDefinition with parameters replaced for easier debugging. let printQueryDefinition (queryDefinition: QueryDefinition) = let sb = stringBuilderPool.Get() try sb.Append(queryDefinition.QueryText) |> ignore queryDefinition.GetQueryParameters() |> Seq.iter (fun struct (name, value: obj) -> match value with | :? int64 as intValue -> sb.Replace(name, $"{intValue}") | :? int as intValue -> sb.Replace(name, $"{intValue}") | :? double as doubleValue -> sb.Replace(name, $"{doubleValue}") | :? bool as boolValue -> sb.Replace(name, $"{boolValue.ToString().ToLower()}") | :? single as floatValue -> sb.Replace(name, $"{floatValue}") | :? Guid as guidValue -> sb.Replace(name, $"\"{guidValue}\"") | _ -> sb.Replace(name, $"\"{value}\"") |> ignore) // Replaces any leading spaces or tabs at the start of each line with four spaces (multiline) and then trims leading/trailing whitespace from the entire multi-line string. let trimmedSql = Regex .Replace(sb.ToString(), @"(?m)^[ \t]+", " ") .Trim() trimmedSql finally stringBuilderPool.Return sb /// Custom QueryRequestOptions that requests Index Metrics only in DEBUG build. let queryRequestOptions = QueryRequestOptions() #if DEBUG queryRequestOptions.PopulateIndexMetrics <- true #endif /// Gets an Azure Blob Storage container client for the container that holds the object files for the given repository. let getContainerClient (repositoryDto: RepositoryDto) correlationId = task { let containerName = $"{repositoryDto.RepositoryId}" let key = $"Con:{repositoryDto.StorageAccountName}-{containerName}" let! blobContainerClient = memoryCache.GetOrCreateAsync( key, fun cacheEntry -> task { let blobContainerClient = Context.blobServiceClient.GetBlobContainerClient(containerName) let ownerActorProxy = Owner.CreateActorProxy repositoryDto.OwnerId CorrelationId.Empty let! ownerDto = ownerActorProxy.Get correlationId let organizationActorProxy = Organization.CreateActorProxy repositoryDto.OrganizationId CorrelationId.Empty let! organizationDto = organizationActorProxy.Get correlationId let metadata = Dictionary(StringComparer.OrdinalIgnoreCase) :> IDictionary metadata[nameof OwnerId] <- $"{repositoryDto.OwnerId}" metadata[nameof OwnerName] <- $"{ownerDto.OwnerName}" metadata[nameof OrganizationId] <- $"{repositoryDto.OrganizationId}" metadata[nameof OrganizationName] <- $"{organizationDto.OrganizationName}" metadata[nameof RepositoryId] <- $"{repositoryDto.RepositoryId}" metadata[nameof RepositoryName] <- $"{repositoryDto.RepositoryName}" let! azureResponse = blobContainerClient.CreateIfNotExistsAsync(publicAccessType = Models.PublicAccessType.None, metadata = metadata) // This cacheEntry can (and should) last for longer than Grace's default expiration time. // StorageAccountNames and container names are stable. // However, we don't want to clog up memoryCache for too long with each client. // Aiming for a good balance, so keeping it for 10 minutes seems reasonable. cacheEntry.AbsoluteExpiration <- DateTimeOffset.UtcNow.Add(TimeSpan.FromMinutes(10.0)) return blobContainerClient } ) return blobContainerClient } /// Gets an Azure Blob Storage client instance for the given repository and file version. let getAzureBlobClient (repositoryDto: RepositoryDto) (blobName: string) (correlationId: CorrelationId) = task { //logToConsole $"* In getAzureBlobClient; repositoryId: {repositoryDto.RepositoryId}; fileVersion: {fileVersion.RelativePath}." let! containerClient = getContainerClient repositoryDto correlationId return containerClient.GetBlockBlobClient blobName } let getAzureBlobClientForFileVersion (repositoryDto: RepositoryDto) (fileVersion: FileVersion) (correlationId: CorrelationId) = task { let blobName = $"{fileVersion.RelativePath}/{fileVersion.GetObjectFileName}" return! getAzureBlobClient repositoryDto blobName correlationId } /// Creates a full URI for a specific file version. let private createAzureBlobSasUri (repositoryDto: RepositoryDto) (blobName: string) (permission: BlobSasPermissions) (correlationId: CorrelationId) = task { let! blobContainerClient = getContainerClient repositoryDto correlationId let blobSasBuilder = BlobSasBuilder( permissions = permission, expiresOn = DateTimeOffset.UtcNow.Add(TimeSpan.FromMinutes(SharedAccessSignatureExpiration)), StartsOn = DateTimeOffset.UtcNow.Subtract(TimeSpan.FromSeconds(15.0)), BlobContainerName = blobContainerClient.Name, BlobName = blobName ) let! sasUri = if AzureEnvironment.useManagedIdentityForStorage then task { // For managed identity, we need to get a user delegation key first let blobServiceClient = blobContainerClient.GetParentBlobServiceClient() let! userDelegationKey = blobServiceClient.GetUserDelegationKeyAsync( DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.Add(TimeSpan.FromMinutes(SharedAccessSignatureExpiration)) ) let sasQueryParameters = blobSasBuilder.ToSasQueryParameters(userDelegationKey.Value, blobServiceClient.AccountName) return Uri($"{blobContainerClient.Uri}/{blobName}?{sasQueryParameters}") } else task { match sharedKeyCredential.Value with | Some credential -> let sasQueryParameters = blobSasBuilder.ToSasQueryParameters(credential) return Uri($"{blobContainerClient.Uri}/{blobName}?{sasQueryParameters}") | None when blobContainerClient.CanGenerateSasUri -> return blobContainerClient.GenerateSasUri(blobSasBuilder) | None -> return raise ( InvalidOperationException( "Azure Blob shared key is not configured and the current blob client cannot generate SAS. Configure grace__azure_storage__key, include AccountKey in grace__azure_storage__connectionstring, or enable managed identity for storage." ) ) } return UriWithSharedAccessSignature($"{sasUri}") } let azureBlobReadPermissions = (BlobSasPermissions.Read ||| BlobSasPermissions.List) // These are the minimum permissions needed to read a file. /// Gets a full Uri, including shared access signature, for reading from the object storage provider. let getUriWithReadSharedAccessSignature (repositoryDto: RepositoryDto) (blobName: string) (correlationId: CorrelationId) = task { match repositoryDto.ObjectStorageProvider with | AzureBlobStorage -> let! sas = createAzureBlobSasUri repositoryDto blobName azureBlobReadPermissions correlationId return sas | AWSS3 -> return UriWithSharedAccessSignature(String.Empty) | GoogleCloudStorage -> return UriWithSharedAccessSignature(String.Empty) | ObjectStorageProvider.Unknown -> return UriWithSharedAccessSignature(String.Empty) } /// Gets a full Uri, including shared access signature, for reading from the object storage provider. let getUriWithReadSharedAccessSignatureForFileVersion (repositoryDto: RepositoryDto) (fileVersion: FileVersion) (correlationId: CorrelationId) = task { let blobName = $"{fileVersion.RelativePath}/{fileVersion.GetObjectFileName}" return! getUriWithReadSharedAccessSignature repositoryDto blobName correlationId } /// The permissions we need to create, write, or tag blobs. Includes read permission to allow for calls to .ExistsAsync(). let azureBlobWritePermissions = (BlobSasPermissions.Create ||| BlobSasPermissions.Write ||| BlobSasPermissions.Tag ||| BlobSasPermissions.Read) /// Gets a full Uri, including shared access signature, for writing from the object storage provider. let getUriWithWriteSharedAccessSignature (repositoryDto: RepositoryDto) (blobName: string) (correlationId: CorrelationId) = task { match repositoryDto.ObjectStorageProvider with | AWSS3 -> return UriWithSharedAccessSignature(String.Empty) | AzureBlobStorage -> let! sas = createAzureBlobSasUri repositoryDto blobName azureBlobWritePermissions correlationId return sas | GoogleCloudStorage -> return UriWithSharedAccessSignature(String.Empty) | ObjectStorageProvider.Unknown -> return UriWithSharedAccessSignature(String.Empty) } /// Gets a full Uri, including shared access signature, for writing from the object storage provider. let getUriWithWriteSharedAccessSignatureForFileVersion (repositoryDto: RepositoryDto) (fileVersion: FileVersion) (correlationId: CorrelationId) = task { let blobName = $"{fileVersion.RelativePath}/{fileVersion.GetObjectFileName}" return! getUriWithWriteSharedAccessSignature repositoryDto blobName correlationId } /// Checks whether an owner name exists in the system. let ownerNameExists (ownerName: string) cacheResultIfNotFound (correlationId: CorrelationId) = task { let ownerNameActorProxy = OwnerName.CreateActorProxy ownerName correlationId match! ownerNameActorProxy.GetOwnerId correlationId with | Some ownerId -> return true | None -> // Check if the owner name exists in the database. // If it does, we need to add it to the memoryCache, and store the ownerId in the OwnerNameActor. // If it does not, we need to add it to the memoryCache with a false value. // We have to call into Actor storage to get the OwnerId. match actorStateStorageProvider with | Unknown -> return false | AzureCosmosDb -> let owners = List() let queryDefinition = QueryDefinition( """ SELECT c.State FROM c WHERE (STRINGEQUALS(c.State[0].Event.created.ownerName, @ownerName, true) OR STRINGEQUALS(c.State[0].Event.setName.ownerName, @ownerName, true)) AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey """ ) .WithParameter("@ownerName", ownerName) .WithParameter("@grainType", StateName.Owner) .WithParameter("@partitionKey", StateName.Owner) //logToConsole $"QueryDefinition in ownerNameExists:{Environment.NewLine}{printQueryDefinition queryDefinition}" let iterator = DefaultRetryPolicy.Execute (fun () -> cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions)) while iterator.HasMoreResults do let! result = iterator.ReadNextAsync() let ownersThatMatchName = result.Resource ownersThatMatchName |> Seq.iter (fun eventsForOneOwner -> let ownerDto = eventsForOneOwner.State |> Seq.fold (fun ownerDto ownerEvent -> ownerDto |> OwnerDto.UpdateDto ownerEvent) OwnerDto.Default owners.Add(ownerDto)) let ownerWithName = owners.FirstOrDefault( (fun owner -> String.Equals(owner.OwnerName, ownerName, StringComparison.InvariantCultureIgnoreCase)), OwnerDto.Default ) if String.IsNullOrEmpty(ownerWithName.OwnerName) then logToConsole $"Did not find ownerId using OwnerName {ownerName}. cacheResultIfNotFound: {cacheResultIfNotFound}." // We didn't find the OwnerId, so add this OwnerName to the MemoryCache and indicate that we have already checked. //use newCacheEntry = // memoryCache.CreateEntry( // $"OwN:{ownerName}", // Value = MemoryCache.EntityDoesNotExist, // AbsoluteExpirationRelativeToNow = MemoryCache.DefaultExpirationTime // ) return false else // Add this OwnerName and OwnerId to the MemoryCache. memoryCache.CreateOwnerNameEntry ownerName ownerWithName.OwnerId memoryCache.CreateOwnerIdEntry ownerWithName.OwnerId MemoryCache.Exists // Set the OwnerId in the OwnerName actor. do! ownerNameActorProxy.SetOwnerId ownerWithName.OwnerId correlationId return true | MongoDB -> return false } /// Checks whether an organization has been deleted by querying the actor, and updates the MemoryCache with the result. let ownerIsDeleted (ownerId: string) correlationId = task { let ownerGuid = OwnerId.Parse(ownerId) let ownerActorProxy = Owner.CreateActorProxy ownerGuid correlationId let! isDeleted = ownerActorProxy.IsDeleted correlationId if isDeleted then memoryCache.CreateDeletedOwnerIdEntry ownerGuid MemoryCache.DoesNotExist return Some ownerId else memoryCache.CreateDeletedOwnerIdEntry ownerGuid MemoryCache.Exists return None } /// Checks whether an owner exists by querying the actor, and updates the MemoryCache with the result. let ownerExists (ownerId: string) correlationId = task { // Call the Owner actor to check if the owner exists. let ownerGuid = Guid.Parse(ownerId) let ownerActorProxy = Owner.CreateActorProxy ownerGuid correlationId let! exists = ownerActorProxy.Exists correlationId if exists then // Add this OwnerId to the MemoryCache. memoryCache.CreateOwnerIdEntry ownerGuid MemoryCache.Exists return Some ownerId else return None } /// Gets the OwnerId by checking for the existence of OwnerId if provided, or searching by OwnerName. let resolveOwnerId (ownerId: string) (ownerName: string) (correlationId: CorrelationId) = task { let mutable ownerGuid = Guid.Empty if not <| String.IsNullOrEmpty(ownerId) && Guid.TryParse(ownerId, &ownerGuid) then // Check if we have this owner id in MemoryCache. match memoryCache.GetOwnerIdEntry ownerGuid with | Some value -> match value with | MemoryCache.Exists -> return Some ownerId | MemoryCache.DoesNotExist -> return None | _ -> return! ownerExists ownerId correlationId | None -> return! ownerExists ownerId correlationId elif String.IsNullOrEmpty(ownerName) then // We have no OwnerId or OwnerName to resolve. return None else // Check if we have this owner name in MemoryCache. match memoryCache.GetOwnerNameEntry ownerName with | Some ownerGuid -> // We have already checked and the owner exists. memoryCache.CreateOwnerIdEntry ownerGuid MemoryCache.Exists return Some $"{ownerGuid}" | None -> // Check if we have an active OwnerName actor with a cached result. let ownerNameActorProxy = OwnerName.CreateActorProxy ownerName correlationId match! ownerNameActorProxy.GetOwnerId correlationId with | Some ownerId -> // Add this OwnerName and OwnerId to the MemoryCache. memoryCache.CreateOwnerNameEntry ownerName ownerId memoryCache.CreateOwnerIdEntry ownerGuid MemoryCache.Exists return Some $"{ownerId}" | None -> let! nameExists = ownerNameExists ownerName true correlationId if nameExists then // We have already checked and the owner exists. match memoryCache.GetOwnerNameEntry ownerName with | Some ownerGuid -> return Some $"{ownerGuid}" | None -> // This should never happen, because we just populated the cache in ownerNameExists. return None else // The owner name does not exist. return None } /// Checks whether an organization is deleted by querying the actor, and updates the MemoryCache with the result. let organizationIsDeleted (organizationId: string) correlationId = task { let organizationGuid = Guid.Parse(organizationId) let organizationActorProxy = Organization.CreateActorProxy organizationGuid correlationId let! isDeleted = organizationActorProxy.IsDeleted correlationId let organizationGuid = OrganizationId.Parse(organizationId) if isDeleted then memoryCache.CreateDeletedOrganizationIdEntry organizationGuid MemoryCache.DoesNotExist return Some organizationId else memoryCache.CreateDeletedOrganizationIdEntry organizationGuid MemoryCache.Exists return None } /// Checks whether an organization exists by querying the actor, and updates the MemoryCache with the result. let organizationExists (organizationId: string) correlationId = task { // Call the Organization actor to check if the organization exists. let organizationGuid = Guid.Parse(organizationId) let organizationActorProxy = Organization.CreateActorProxy organizationGuid correlationId let! exists = organizationActorProxy.Exists correlationId if exists then // Add this OrganizationId to the MemoryCache. memoryCache.CreateOrganizationIdEntry (OrganizationId.Parse(organizationId)) MemoryCache.Exists return Some organizationId else return None } /// Gets the OrganizationId by either returning OrganizationId if provided, or searching by OrganizationName. let resolveOrganizationId (ownerId: OwnerId) (organizationId: string) (organizationName: string) (correlationId: CorrelationId) = task { let mutable organizationGuid = Guid.Empty if not <| String.IsNullOrEmpty(organizationId) && Guid.TryParse(organizationId, &organizationGuid) then match memoryCache.GetOrganizationIdEntry organizationGuid with | Some value -> match value with | MemoryCache.Exists -> return Some organizationId | MemoryCache.DoesNotExist -> return None | _ -> return! organizationExists organizationId correlationId | None -> return! organizationExists organizationId correlationId elif String.IsNullOrEmpty(organizationName) then // We have no OrganizationId or OrganizationName to resolve. return None else // Check if we have this organization name in MemoryCache. match memoryCache.GetOrganizationNameEntry organizationName with | Some organizationGuid -> if organizationGuid.Equals(MemoryCache.EntityDoesNotExistGuid) then // We have already checked and the organization does not exist. return None else memoryCache.CreateOrganizationIdEntry organizationGuid MemoryCache.Exists return Some $"{organizationGuid}" | None -> // Check if we have an active OrganizationName actor with a cached result. let organizationNameActorProxy = OrganizationName.CreateActorProxy ownerId organizationName correlationId match! organizationNameActorProxy.GetOrganizationId correlationId with | Some organizationId -> // Add this OrganizationName and OrganizationId to the MemoryCache. memoryCache.CreateOrganizationNameEntry organizationName organizationId memoryCache.CreateOrganizationIdEntry organizationId MemoryCache.Exists return Some $"{organizationId}" | None -> // We have to call into Actor storage to get the OrganizationId. match actorStateStorageProvider with | Unknown -> return None | AzureCosmosDb -> let queryDefinition = QueryDefinition( """ SELECT c.State[0].Event.created.organizationId AS OrganizationId FROM c WHERE STRINGEQUALS(c.State[0].Event.created.organizationName, @organizationName, true) AND STRINGEQUALS(c.State[0].Event.created.ownerId, @ownerId, true) AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey """ ) .WithParameter("@organizationName", organizationName) .WithParameter("@ownerId", ownerId) .WithParameter("@grainType", StateName.Organization) .WithParameter("@partitionKey", StateName.Organization) let iterator = DefaultRetryPolicy.Execute(fun () -> cosmosContainer.GetItemQueryIterator(queryDefinition)) if iterator.HasMoreResults then let! currentResultSet = iterator.ReadNextAsync() let organizationId = currentResultSet .FirstOrDefault( { organizationId = String.Empty } ) .organizationId if String.IsNullOrEmpty(organizationId) then // We didn't find the OrganizationId, so add this OrganizationName to the MemoryCache and indicate that we have already checked. memoryCache.CreateOrganizationNameEntry organizationName MemoryCache.EntityDoesNotExistGuid return None else // Add this OrganizationName and OrganizationId to the MemoryCache. organizationGuid <- Guid.Parse(organizationId) memoryCache.CreateOrganizationNameEntry organizationName organizationGuid memoryCache.CreateOrganizationIdEntry organizationGuid MemoryCache.Exists do! organizationNameActorProxy.SetOrganizationId organizationGuid correlationId return Some organizationId else return None | MongoDB -> return None } /// Checks whether a repository has been deleted by querying the actor, and updates the MemoryCache with the result. let repositoryIsDeleted organizationId (repositoryId: string) correlationId = task { // Call the Repository actor to check if the repository is deleted. let repositoryGuid = Guid.Parse(repositoryId) let repositoryActorProxy = Repository.CreateActorProxy organizationId repositoryGuid correlationId let! isDeleted = repositoryActorProxy.IsDeleted correlationId if isDeleted then memoryCache.CreateDeletedRepositoryIdEntry repositoryGuid MemoryCache.DoesNotExist return Some repositoryId else memoryCache.CreateDeletedRepositoryIdEntry repositoryGuid MemoryCache.Exists return None } /// Checks whether a repository exists by querying the actor, and updates the MemoryCache with the result. let repositoryExists organizationId (repositoryId: string) correlationId = task { // Call the Repository actor to check if the repository exists. let repositoryGuid = Guid.Parse(repositoryId) let repositoryActorProxy = Repository.CreateActorProxy organizationId repositoryGuid correlationId let! exists = repositoryActorProxy.Exists correlationId if exists then // Add this RepositoryId to the MemoryCache. memoryCache.CreateRepositoryIdEntry repositoryGuid MemoryCache.Exists return Some repositoryGuid else return None } /// Gets the RepositoryId by returning RepositoryId if provided, or searching by RepositoryName within the provided owner and organization. let resolveRepositoryId (ownerId: OwnerId) (organizationId: OrganizationId) (repositoryId: string) (repositoryName: string) (correlationId: CorrelationId) = task { let mutable repositoryGuid = Guid.Empty if not <| String.IsNullOrEmpty(repositoryId) && Guid.TryParse(repositoryId, &repositoryGuid) then match memoryCache.GetRepositoryIdEntry repositoryGuid with | Some value -> match value with | MemoryCache.Exists -> return Some repositoryGuid | MemoryCache.DoesNotExist -> return None | _ -> return! repositoryExists organizationId repositoryId correlationId | None -> return! repositoryExists organizationId repositoryId correlationId elif String.IsNullOrEmpty(repositoryName) then // We don't have a RepositoryId or RepositoryName, so we can't resolve the RepositoryId. return None else match memoryCache.GetRepositoryNameEntry repositoryName with | Some repositoryGuid -> if repositoryGuid.Equals(Constants.MemoryCache.EntityDoesNotExist) then // We have already checked and the repository does not exist. return None else // We have already checked and the repository exists. memoryCache.CreateRepositoryIdEntry repositoryGuid MemoryCache.Exists return Some repositoryGuid | None -> // Check if we have an active RepositoryName actor with a cached result. let repositoryNameActorProxy = RepositoryName.CreateActorProxy ownerId organizationId repositoryName correlationId match! repositoryNameActorProxy.GetRepositoryId correlationId with | Some repositoryId -> memoryCache.CreateRepositoryNameEntry repositoryName repositoryId memoryCache.CreateRepositoryIdEntry repositoryId MemoryCache.Exists return Some repositoryId | None -> // We have to call into Actor storage to get the RepositoryId. match actorStateStorageProvider with | Unknown -> return None | AzureCosmosDb -> let queryDefinition = QueryDefinition( """ SELECT c.State[0].Event.created.repositoryId AS RepositoryId FROM c WHERE STRINGEQUALS(c.State[0].Event.created.repositoryName, @repositoryName, true) AND STRINGEQUALS(c.State[0].Event.created.organizationId, @organizationId, true) AND STRINGEQUALS(c.State[0].Event.created.ownerId, @ownerId, true) AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey """ ) .WithParameter("@repositoryName", repositoryName) .WithParameter("@organizationId", organizationId) .WithParameter("@ownerId", ownerId) .WithParameter("@grainType", StateName.Repository) .WithParameter("@partitionKey", organizationId) let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition) if iterator.HasMoreResults then let! currentResultSet = iterator.ReadNextAsync() let repositoryIdString = currentResultSet .FirstOrDefault( { repositoryId = String.Empty } ) .repositoryId if String.IsNullOrEmpty(repositoryIdString) then // We didn't find the RepositoryId, so add this RepositoryName to the MemoryCache and indicate that we have already checked. memoryCache.CreateRepositoryNameEntry repositoryName MemoryCache.EntityDoesNotExistGuid return None else // Add this RepositoryName and RepositoryId to the MemoryCache. let repositoryId = Guid.Parse(repositoryIdString) memoryCache.CreateRepositoryNameEntry repositoryName repositoryId memoryCache.CreateRepositoryIdEntry repositoryId MemoryCache.Exists // Set the RepositoryId in the RepositoryName actor. do! repositoryNameActorProxy.SetRepositoryId repositoryId correlationId return Some repositoryId else return None | MongoDB -> return None } /// Checks whether a branch has been deleted by querying the actor, and updates the MemoryCache with the result. let branchIsDeleted (branchId: string) repositoryId correlationId = task { let branchGuid = Guid.Parse(branchId) let branchActorProxy = Branch.CreateActorProxy branchGuid repositoryId correlationId let! isDeleted = branchActorProxy.IsDeleted correlationId if isDeleted then memoryCache.CreateDeletedBranchIdEntry branchGuid MemoryCache.DoesNotExist return Some branchId else memoryCache.CreateDeletedBranchIdEntry branchGuid MemoryCache.Exists return None } /// Checks whether a branch exists by querying the actor, and updates the MemoryCache with the result. let branchExists (branchId: BranchId) repositoryId correlationId = task { // Call the Branch actor to check if the branch exists. let branchActorProxy = Branch.CreateActorProxy branchId repositoryId correlationId let! exists = branchActorProxy.Exists correlationId if exists then // Add this BranchId to the MemoryCache. memoryCache.CreateBranchIdEntry branchId MemoryCache.Exists return Some branchId else return None } /// Gets the BranchId by returning BranchId if provided, or searching by BranchName within the provided repository. let resolveBranchId ownerId organizationId (repositoryId: RepositoryId) branchIdString branchName (correlationId: CorrelationId) = task { let mutable branchGuid = Guid.Empty if not <| String.IsNullOrEmpty(branchIdString) && Guid.TryParse(branchIdString, &branchGuid) then // We have a BranchId, so check if it exists. match memoryCache.GetBranchIdEntry branchGuid with | Some value -> match value with | MemoryCache.Exists -> return Some branchGuid | MemoryCache.DoesNotExist -> return None | _ -> return! branchExists branchGuid repositoryId correlationId | None -> return! branchExists branchGuid repositoryId correlationId elif String.IsNullOrEmpty(branchName) then // We don't have a BranchId or BranchName, so we can't resolve the BranchId. return None else // We have no BranchId, but we do have a BranchName. // Check if we have an active BranchName actor with a cached result. match memoryCache.GetBranchNameEntry(repositoryId, branchName) with | Some branchGuid -> // We have a cached result. if branchGuid.Equals(Constants.MemoryCache.EntityDoesNotExist) then // We have already checked and the branch does not exist. return None else // We have already checked and the branch exists. return Some branchGuid | None -> // The BranchName was not in the MemoryCache on this node, but we may have it in a BranchName actor. let branchNameActorProxy = BranchName.CreateActorProxy repositoryId branchName correlationId match! branchNameActorProxy.GetBranchId correlationId with | Some branchId -> // We have an active BranchName actor with the BranchId cached. memoryCache.CreateBranchNameEntry(repositoryId, branchName, branchId) memoryCache.CreateBranchIdEntry branchId MemoryCache.Exists return Some branchId | None -> // The BranchName actor was not active, so we have to search the database. match actorStateStorageProvider with | Unknown -> return None | AzureCosmosDb -> let queryDefinition = QueryDefinition( """ SELECT c.State[0].Event.created.branchId AS BranchId FROM c WHERE STRINGEQUALS(c.State[0].Event.created.branchName, @branchName, true) AND STRINGEQUALS(c.State[0].Event.created.organizationId, @organizationId, true) AND STRINGEQUALS(c.State[0].Event.created.ownerId, @ownerId, true) AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey """ ) .WithParameter("@branchName", branchName) .WithParameter("@organizationId", organizationId) .WithParameter("@ownerId", ownerId) .WithParameter("@grainType", StateName.Branch) .WithParameter("@partitionKey", repositoryId) let iterator = DefaultRetryPolicy.Execute(fun () -> cosmosContainer.GetItemQueryIterator(queryDefinition)) //logToConsole $"QueryDefinition in resolveBranchId:{Environment.NewLine}{printQueryDefinition queryDefinition}" if iterator.HasMoreResults then let! currentResultSet = iterator.ReadNextAsync() let branchId = currentResultSet .FirstOrDefault( { branchId = String.Empty } ) .branchId if String.IsNullOrEmpty(branchId) then // We didn't find the BranchId. return None else // Add this BranchName and BranchId to the MemoryCache. branchGuid <- Guid.Parse(branchId) // Add this BranchName and BranchId to the MemoryCache. memoryCache.CreateBranchNameEntry(repositoryId, branchName, branchGuid) memoryCache.CreateBranchIdEntry branchGuid MemoryCache.Exists // Set the BranchId in the BranchName actor. do! branchNameActorProxy.SetBranchId branchGuid correlationId //logToConsole $"BranchName actor was not active. BranchName: {branchName}; BranchId: {branchGuid}." return Some branchGuid else return None | MongoDB -> return None } /// Creates a CosmosDB SQL WHERE clause that includes or excludes deleted entities. let includeDeletedEntitiesClause includeDeletedEntities = if includeDeletedEntities then // If includeDeletedEntities is true, we don't need to filter out deleted entities. String.Empty else // We're checking to see if: // (count of logicalDeletes) = (count of undeletes) // // Usually, of course, both counts are 0, but it's not as simple as checking if there's a delete event. // We can tell if an entity is deleted by checking those counts. """ AND ( (SELECT VALUE COUNT(1) FROM c JOIN e in c.State WHERE IS_DEFINED(e.Event.logicalDeleted)) <= (SELECT VALUE COUNT(1) FROM c JOIN e in c.State WHERE IS_DEFINED(e.Event.undeleted)) ) """ /// Gets a list of organizations for the specified owner. let getOrganizations (ownerId: OwnerId) (maxCount: int) includeDeleted = task { let organizations = List() match actorStateStorageProvider with | Unknown -> () | AzureCosmosDb -> let indexMetrics = stringBuilderPool.Get() let requestCharge = stringBuilderPool.Get() try try let queryDefinition = QueryDefinition( $""" SELECT TOP @maxCount c.State FROM c WHERE STRINGEQUALS(c.State[0].Event.created.ownerId, @ownerId, true) AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey {includeDeletedEntitiesClause includeDeleted} """ ) .WithParameter("@maxCount", maxCount) .WithParameter("@ownerId", ownerId) .WithParameter("@grainType", StateName.Organization) .WithParameter("@partitionKey", StateName.Organization) let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) while iterator.HasMoreResults do let! results = iterator.ReadNextAsync() indexMetrics.Append($"{results.IndexMetrics}, ") |> ignore requestCharge.Append($"{results.RequestCharge:F3}, ") |> ignore let eventsForAllOrganizations = results.Resource eventsForAllOrganizations |> Seq.iter (fun eventsForOneOrganization -> let organizationDto = eventsForOneOrganization.State |> Seq.fold (fun organizationDto organizationEvent -> organizationDto |> OrganizationDto.UpdateDto organizationEvent) OrganizationDto.Default organizations.Add(organizationDto)) if (indexMetrics.Length >= 2) && (requestCharge.Length >= 2) && Activity.Current <> null then Activity .Current .SetTag("indexMetrics", $"{indexMetrics.Remove(indexMetrics.Length - 2, 2)}") .SetTag("requestCharge", $"{requestCharge.Remove(requestCharge.Length - 2, 2)}") |> ignore with | ex -> logToConsole $"Got an exception." logToConsole $"{ExceptionResponse.Create ex}" finally stringBuilderPool.Return(indexMetrics) stringBuilderPool.Return(requestCharge) | MongoDB -> () return organizations .OrderBy(fun o -> o.OrganizationName) .ToArray() } /// Checks if the specified organization name is unique for the specified owner. let organizationNameIsUnique<'T> (ownerId: string) (organizationName: string) (correlationId: CorrelationId) = task { match actorStateStorageProvider with | Unknown -> return Ok false | AzureCosmosDb -> try let organizations = List() let queryDefinition = QueryDefinition( """ SELECT c.State FROM c WHERE (STRINGEQUALS(c.State[0].Event.created.organizationName, @organizationName, true) OR STRINGEQUALS(c.State[0].Event.setName.organizationName, @organizationName, true)) AND STRINGEQUALS(c.State[0].Event.created.ownerId, @ownerId, true) AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey """ ) .WithParameter("@organizationName", organizationName) .WithParameter("@ownerId", ownerId) .WithParameter("@grainType", StateName.Organization) .WithParameter("@partitionKey", StateName.Organization) let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) while iterator.HasMoreResults do let! result = iterator.ReadNextAsync() let eventsForAllOrganizations = result.Resource eventsForAllOrganizations |> Seq.iter (fun eventsForOneOrganization -> let organizationDto = eventsForOneOrganization.State |> Seq.fold (fun organizationDto organizationEvent -> organizationDto |> OrganizationDto.UpdateDto organizationEvent) OrganizationDto.Default organizations.Add(organizationDto)) let organizationWithName = organizations.FirstOrDefault( (fun o -> String.Equals(o.OrganizationName, organizationName, StringComparison.OrdinalIgnoreCase)), OrganizationDto.Default ) if String.IsNullOrEmpty(organizationWithName.OrganizationName) then // The organization name is unique. return Ok true else // The organization name is not unique. return Ok false with | ex -> return Error $"{ExceptionResponse.Create ex}" | MongoDB -> return Ok false } /// Checks if the specified repository name is unique for the specified organization. let repositoryNameIsUnique<'T> (ownerId: string) (organizationId: string) (repositoryName: string) (correlationId: CorrelationId) = task { match actorStateStorageProvider with | Unknown -> return Ok false | AzureCosmosDb -> try let repositories = List() let queryDefinition = QueryDefinition( """ SELECT c.State FROM c WHERE (STRINGEQUALS(c.State[0].Event.created.repositoryName, @repositoryName, true) OR STRINGEQUALS(c.State[0].Event.setName.repositoryName, @repositoryName, true)) AND STRINGEQUALS(c.State[0].Event.created.organizationId, @organizationId, true) AND STRINGEQUALS(c.State[0].Event.created.ownerId, @ownerId, true) AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey """ ) .WithParameter("@repositoryName", repositoryName) .WithParameter("@organizationId", organizationId) .WithParameter("@ownerId", ownerId) .WithParameter("@grainType", StateName.Repository) .WithParameter("@partitionKey", organizationId) let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) while iterator.HasMoreResults do let! result = iterator.ReadNextAsync() let eventsForAllRepositories = result.Resource eventsForAllRepositories |> Seq.iter (fun eventsForOneRepository -> let repositoryDto = eventsForOneRepository.State |> Seq.fold (fun repositoryDto repositoryEvent -> repositoryDto |> RepositoryDto.UpdateDto repositoryEvent) RepositoryDto.Default repositories.Add(repositoryDto)) let repositoryWithName = repositories.FirstOrDefault( (fun o -> String.Equals(o.RepositoryName, repositoryName, StringComparison.OrdinalIgnoreCase)), RepositoryDto.Default ) if String.IsNullOrEmpty(repositoryWithName.RepositoryName) then // The repository name is unique. return Ok true else return Ok true // This else should never be hit. with | ex -> return Error $"{ExceptionResponse.Create ex}" | MongoDB -> return Ok false } /// Gets a list of repositories for the specified organization. let getRepositories (ownerId: OwnerId) (organizationId: OrganizationId) (maxCount: int) includeDeleted = task { let repositories = List() match actorStateStorageProvider with | Unknown -> () | AzureCosmosDb -> let indexMetrics = stringBuilderPool.Get() let requestCharge = stringBuilderPool.Get() try try let queryDefinition = QueryDefinition( $""" SELECT TOP @maxCount c.State FROM c WHERE STRINGEQUALS(c.State[0].Event.created.organizationId, @organizationId, true) AND STRINGEQUALS(c.State[0].Event.created.ownerId, @ownerId, true) {includeDeletedEntitiesClause includeDeleted} AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey """ ) .WithParameter("@ownerId", ownerId) .WithParameter("@organizationId", organizationId) .WithParameter("@maxCount", maxCount) .WithParameter("@grainType", StateName.Repository) .WithParameter("@partitionKey", organizationId) let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) while iterator.HasMoreResults do let! results = iterator.ReadNextAsync() indexMetrics.Append($"{results.IndexMetrics}, ") |> ignore requestCharge.Append($"{results.RequestCharge:F3}, ") |> ignore let eventsForAllRepositories = results.Resource eventsForAllRepositories |> Seq.iter (fun eventsForOneRepository -> let repositoryDto = eventsForOneRepository.State |> Array.fold (fun repositoryDto repositoryEvent -> repositoryDto |> RepositoryDto.UpdateDto repositoryEvent) RepositoryDto.Default repositories.Add(repositoryDto)) if (indexMetrics.Length >= 2) && (requestCharge.Length >= 2) && Activity.Current <> null then Activity .Current .SetTag("indexMetrics", $"{indexMetrics.Remove(indexMetrics.Length - 2, 2)}") .SetTag("requestCharge", $"{requestCharge.Remove(requestCharge.Length - 2, 2)}") |> ignore with | ex -> logToConsole $"Got an exception." logToConsole $"{ExceptionResponse.Create ex}" finally stringBuilderPool.Return(indexMetrics) stringBuilderPool.Return(requestCharge) | MongoDB -> () return repositories .OrderBy(fun r -> r.RepositoryName) .ToArray() } let internal isNotDeletedReference (referenceDto: ReferenceDto) = referenceDto.DeletedAt.IsNone let internal hasPromotionSetTerminalLink (referenceDto: ReferenceDto) = referenceDto.Links |> Seq.exists (fun link -> match link with | ReferenceLinkType.PromotionSetTerminal _ -> true | _ -> false) let internal tryGetLatestNotDeletedReference (references: seq) = references |> Seq.tryFind isNotDeletedReference let internal tryGetLatestEffectivePromotionReference (references: seq) = references |> Seq.tryFind (fun referenceDto -> isNotDeletedReference referenceDto && hasPromotionSetTerminalLink referenceDto) /// Gets a list of references that match a provided SHA-256 hash. let getReferencesBySha256Hash (repositoryId: RepositoryId) (branchId: BranchId) (sha256Hash: Sha256Hash) (maxCount: int) = task { let references = List() match actorStateStorageProvider with | Unknown -> () | AzureCosmosDb -> let indexMetrics = stringBuilderPool.Get() let requestCharge = stringBuilderPool.Get() try let queryDefinition = QueryDefinition( """ SELECT TOP @maxCount c.State FROM c WHERE STRINGEQUALS(c.State[0].Event.created.BranchId, @branchId, true) AND STARTSWITH(c.State[0].Event.created.Sha256Hash, @sha256Hash, true) AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey ORDER BY c.State[0].Event.created.CreatedAt DESC """ ) .WithParameter("@maxCount", maxCount) .WithParameter("@sha256Hash", sha256Hash) .WithParameter("@branchId", branchId) .WithParameter("@grainType", StateName.Reference) .WithParameter("@partitionKey", repositoryId) let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) while iterator.HasMoreResults do let! results = iterator.ReadNextAsync() indexMetrics.Append($"{results.IndexMetrics}, ") |> ignore requestCharge.Append($"{results.RequestCharge:F3}, ") |> ignore let eventsForAllReferences = results.Resource eventsForAllReferences |> Seq.iter (fun eventsForOneReference -> let referenceDto = eventsForOneReference.State |> Array.fold (fun referenceDto referenceEvent -> referenceDto |> ReferenceDto.UpdateDto referenceEvent) ReferenceDto.Default if isNotDeletedReference referenceDto then references.Add(referenceDto)) if (indexMetrics.Length >= 2) && (requestCharge.Length >= 2) && Activity.Current <> null then Activity .Current .SetTag("indexMetrics", $"{indexMetrics.Remove(indexMetrics.Length - 2, 2)}") .SetTag("requestCharge", $"{requestCharge.Remove(requestCharge.Length - 2, 2)}") |> ignore finally stringBuilderPool.Return(indexMetrics) stringBuilderPool.Return(requestCharge) | MongoDB -> () return references .OrderBy(fun reference -> reference.CreatedAt) .ToArray() } /// Gets a reference by its SHA-256 hash. let getReferenceBySha256Hash (repositoryId: RepositoryId) (branchId: BranchId) (sha256Hash: Sha256Hash) = task { let! references = getReferencesBySha256Hash repositoryId branchId sha256Hash 1 if references.Length > 0 then return Some references[0] else return None } /// Gets a list of references for a given branch. let getReferences (repositoryId: RepositoryId) (branchId: BranchId) (maxCount: int) (correlationId: CorrelationId) = task { let references = List() match actorStateStorageProvider with | Unknown -> () | AzureCosmosDb -> let indexMetrics = stringBuilderPool.Get() let requestCharge = stringBuilderPool.Get() try let queryDefinition = QueryDefinition( """ SELECT TOP @maxCount c.State FROM c WHERE STRINGEQUALS(c.State[0].Event.created.BranchId, @branchId, true) AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey ORDER BY c.State[0].Event.created.CreatedAt DESC """ ) .WithParameter("@maxCount", maxCount) .WithParameter("@branchId", branchId) .WithParameter("@grainType", StateName.Reference) .WithParameter("@partitionKey", repositoryId) let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) while iterator.HasMoreResults do addTiming TimingFlag.BeforeStorageQuery "getReferences" correlationId let! results = iterator.ReadNextAsync() indexMetrics.Append($"{results.IndexMetrics}, ") |> ignore requestCharge.Append($"{results.RequestCharge:F3}, ") |> ignore let eventsForAllReferences = results.Resource eventsForAllReferences |> Seq.iter (fun eventsForOneReference -> let referenceDto = eventsForOneReference.State |> Array.fold (fun referenceDto referenceEvent -> referenceDto |> ReferenceDto.UpdateDto referenceEvent) ReferenceDto.Default references.Add(referenceDto)) //logToConsole // $"In Services.Actor.getReferences: BranchId: {branchId}; RepositoryId: {repositoryId}; Retrieved {references.Count} references.{Environment.NewLine}{printQueryDefinition queryDefinition}{Environment.NewLine}{serialize references}" if indexMetrics.Length >= 2 && requestCharge.Length >= 2 && Activity.Current <> null then Activity.Current.SetTag("indexMetrics", $"{indexMetrics.Remove(indexMetrics.Length - 2, 2)}") |> ignore Activity.Current.SetTag("requestCharge", $"{requestCharge.Remove(requestCharge.Length - 2, 2)}") |> ignore finally stringBuilderPool.Return(indexMetrics) stringBuilderPool.Return(requestCharge) | MongoDB -> () return references .OrderBy(fun reference -> reference.CreatedAt) .ToArray() } type DocumentIdentifier() = member val id = String.Empty with get, set member val PartitionKey = String.Empty with get, set type PartitionKeyIdentifier() = member val PartitionKey = String.Empty with get, set /// Deletes all documents from CosmosDb. /// /// **** This method is implemented only in Debug configuration. It is a no-op in Release configuration. **** let deleteAllFromCosmosDBThatMatch (queryDefinition: QueryDefinition) = task { #if DEBUG let failed = List() try let itemRequestOptions = ItemRequestOptions(AddRequestHeaders = fun headers -> headers.Add(Constants.CorrelationIdHeaderKey, "deleteAllFromCosmosDBThatMatch")) let mutable totalRecordsDeleted = 0 let overallStartTime = getCurrentInstant () let deleteQueryRequestOptions = queryRequestOptions.ShallowCopy() :?> QueryRequestOptions deleteQueryRequestOptions.MaxItemCount <- 1000 logToConsole $"cosmosContainer.Id: {cosmosContainer.Id}; cosmosContainer.Database.Id: {cosmosContainer.Database.Id}; cosmosContainer.Database.Client.Endpoint: {cosmosContainer.Database.Client.Endpoint}." let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = deleteQueryRequestOptions) while iterator.HasMoreResults do let batchStartTime = getCurrentInstant () let! batchResults = iterator.ReadNextAsync() let mutable totalRequestCharge = 0L //logToConsole $"In Services.deleteAllFromCosmosDB(): Current batch size: {batchResults.Resource.Count()}." do! Parallel.ForEachAsync( batchResults, (fun document ct -> ValueTask( task { use! deleteResponse = cosmosContainer.DeleteAllItemsByPartitionKeyStreamAsync(PartitionKey(document.PartitionKey), itemRequestOptions) if deleteResponse.IsSuccessStatusCode then log.LogInformation( "Succeeded to delete PartitionKey {PartitionKey}. StatusCode: {statusCode}.", document.PartitionKey, deleteResponse.StatusCode ) else failed.Add(document.PartitionKey) log.LogError( "Failed to delete PartitionKey {PartitionKey}. StatusCode: {statusCode}; Error: {ErrorMessage}.", document.PartitionKey, deleteResponse.StatusCode, deleteResponse.ErrorMessage ) } )) ) //let duration_s = getCurrentInstant().Minus(batchStartTime).TotalSeconds //let overall_duration_s = getCurrentInstant().Minus(overallStartTime).TotalSeconds //let rps = float (batchResults.Resource.Count()) / duration_s //totalRecordsDeleted <- totalRecordsDeleted + batchResults.Resource.Count() //let overallRps = float totalRecordsDeleted / overall_duration_s //logToConsole // $"In Services.deleteAllFromCosmosDBThatMatch(): batch duration (s): {duration_s:F3}; batch requests/second: {rps:F3}; failed.Count: {failed.Count}; totalRequestCharge: {float totalRequestCharge / 1000.0:F2}; totalRecordsDeleted: {totalRecordsDeleted}; overall duration (m): {overall_duration_s / 60.0:F3}; overall requests/second: {overallRps:F3}." return failed with | ex -> failed.Add((ExceptionResponse.Create ex).``exception``) return failed #else return List([ "Not implemented" ]) #endif } /// Deletes all documents from CosmosDB. /// /// **** This method is implemented only in Debug configuration. It is a no-op in Release configuration. **** let deleteAllFromCosmosDb () = task { #if DEBUG let queryDefinition = QueryDefinition("""SELECT DISTINCT c.PartitionKey FROM c ORDER BY c.PartitionKey""") return! deleteAllFromCosmosDBThatMatch queryDefinition #else return List([ "Not implemented" ]) #endif } /// Deletes all Reminders from CosmosDB. /// /// **** This method is implemented only in Debug configuration. It is a no-op in Release configuration. **** let deleteAllRemindersFromCosmosDb () = task { #if DEBUG let queryDefinition = QueryDefinition("""SELECT c.id, c.PartitionKey FROM c WHERE c.GrainType = "Rmd" ORDER BY c.PartitionKey""") return! deleteAllFromCosmosDBThatMatch queryDefinition #else return List([ "Not implemented" ]) #endif } /// Gets a list of references of a given ReferenceType for a branch. let getReferencesByType (referenceType: ReferenceType) (repositoryId: RepositoryId) (branchId: BranchId) (maxCount: int) (correlationId: CorrelationId) = task { let references = List() match actorStateStorageProvider with | Unknown -> () | AzureCosmosDb -> let indexMetrics = stringBuilderPool.Get() let requestCharge = stringBuilderPool.Get() try let queryDefinition = QueryDefinition( """ SELECT TOP @maxCount c.State FROM c WHERE STRINGEQUALS(c.State[0].Event.created.BranchId, @branchId, true) AND STRINGEQUALS(c.State[0].Event.created.ReferenceType, @referenceType, true) AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey ORDER BY c.State[0].Event.created.CreatedAt DESC """ ) .WithParameter("@maxCount", maxCount) .WithParameter("@branchId", branchId) .WithParameter("@referenceType", getDiscriminatedUnionCaseName referenceType) .WithParameter("@grainType", StateName.Reference) .WithParameter("@partitionKey", repositoryId) let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) while iterator.HasMoreResults do addTiming TimingFlag.BeforeStorageQuery "getReferencesByType" correlationId let! results = iterator.ReadNextAsync() addTiming TimingFlag.AfterStorageQuery "getReferencesByType" correlationId indexMetrics.Append($"{results.IndexMetrics}, ") |> ignore requestCharge.Append($"{results.RequestCharge:F3}, ") |> ignore let eventsForAllReferences = results.Resource eventsForAllReferences |> Seq.iter (fun eventsForOneReference -> let referenceDto = eventsForOneReference.State |> Array.fold (fun referenceDto referenceEvent -> referenceDto |> ReferenceDto.UpdateDto referenceEvent) ReferenceDto.Default references.Add(referenceDto)) if indexMetrics.Length >= 2 && requestCharge.Length >= 2 && Activity.Current <> null then Activity .Current .SetTag("indexMetrics", $"{indexMetrics.Remove(indexMetrics.Length - 2, 2)}") .SetTag("requestCharge", $"{requestCharge.Remove(requestCharge.Length - 2, 2)}") |> ignore finally stringBuilderPool.Return(indexMetrics) stringBuilderPool.Return(requestCharge) | MongoDB -> () return references .OrderBy(fun reference -> reference.CreatedAt) .ToArray() } let getPromotions = getReferencesByType ReferenceType.Promotion let getCommits = getReferencesByType ReferenceType.Commit let getCheckpoints = getReferencesByType ReferenceType.Checkpoint let getSaves = getReferencesByType ReferenceType.Save let getTags = getReferencesByType ReferenceType.Tag let getExternals = getReferencesByType ReferenceType.External let getRebases = getReferencesByType ReferenceType.Rebase let getLatestReference repositoryId branchId = task { match actorStateStorageProvider with | Unknown -> return None | AzureCosmosDb -> let indexMetrics = stringBuilderPool.Get() let requestCharge = stringBuilderPool.Get() try let mutable latestReference: ReferenceDto option = None let queryDefinition = QueryDefinition( """ SELECT c.State FROM c WHERE STRINGEQUALS(c.State[0].Event.created.BranchId, @branchId, true) AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey ORDER BY c.State[0].Event.created.CreatedAt DESC """ ) .WithParameter("@branchId", branchId) .WithParameter("@grainType", StateName.Reference) .WithParameter("@partitionKey", repositoryId) let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) while iterator.HasMoreResults && latestReference.IsNone do let! results = iterator.ReadNextAsync() indexMetrics.Append($"{results.IndexMetrics}, ") |> ignore requestCharge.Append($"{results.RequestCharge:F3}, ") |> ignore let eventsForAllReferences = results.Resource |> Seq.toArray let mutable index = 0 while index < eventsForAllReferences.Length && latestReference.IsNone do let referenceDto = eventsForAllReferences[index].State |> Array.fold (fun current referenceEvent -> current |> ReferenceDto.UpdateDto referenceEvent) ReferenceDto.Default if isNotDeletedReference referenceDto then latestReference <- Some referenceDto index <- index + 1 if (indexMetrics.Length >= 2) && (requestCharge.Length >= 2) && Activity.Current <> null then Activity .Current .SetTag("indexMetrics", $"{indexMetrics.Remove(indexMetrics.Length - 2, 2)}") .SetTag("requestCharge", $"{requestCharge.Remove(requestCharge.Length - 2, 2)}") |> ignore return latestReference finally stringBuilderPool.Return(indexMetrics) stringBuilderPool.Return(requestCharge) | MongoDB -> return None } /// Gets the latest reference for a given ReferenceType in a branch. let getLatestReferenceByReferenceTypes (referenceTypes: ReferenceType array) (repositoryId: RepositoryId) (branchId: BranchId) = task { let referenceDtos = ConcurrentDictionary(referenceTypes.Length, referenceTypes.Length) match actorStateStorageProvider with | Unknown -> () | AzureCosmosDb -> // Collect per-task metrics in thread-safe bags to avoid concurrent mutation of a single StringBuilder. let indexMetricsBag = ConcurrentBag() let requestChargeBag = ConcurrentBag() try // Run queries in parallel; each task adds metrics to the concurrent bags. do! Parallel.ForEachAsync( referenceTypes, Constants.ParallelOptions, (fun referenceType ct -> ValueTask( task { let queryDefinition = QueryDefinition( """ SELECT c.State FROM c WHERE STRINGEQUALS(c.State[0].Event.created.BranchId, @branchId, true) AND STRINGEQUALS(c.State[0].Event.created.ReferenceType, @referenceType, true) AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey ORDER BY c.State[0].Event.created.CreatedAt DESC """ ) .WithParameter("@branchId", branchId) .WithParameter("@referenceType", getDiscriminatedUnionCaseName referenceType) .WithParameter("@grainType", StateName.Reference) .WithParameter("@partitionKey", repositoryId) let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) let mutable foundForType = false while iterator.HasMoreResults && not foundForType do let! results = iterator.ReadNextAsync() // Save metrics into concurrent bags (thread-safe). indexMetricsBag.Add(results.IndexMetrics) requestChargeBag.Add($"{results.RequestCharge:F3}") let eventsForAllReferences = results.Resource |> Seq.toArray let mutable index = 0 while index < eventsForAllReferences.Length && not foundForType do let referenceDto = eventsForAllReferences[index].State |> Array.fold (fun current referenceEvent -> current |> ReferenceDto.UpdateDto referenceEvent) ReferenceDto.Default if isNotDeletedReference referenceDto then referenceDtos.TryAdd(referenceType, referenceDto) |> ignore foundForType <- true index <- index + 1 } )) ) // Merge collected metrics after parallel work and set Activity tags. let indexMetricsSb = stringBuilderPool.Get() let requestChargeSb = stringBuilderPool.Get() try for m in indexMetricsBag do indexMetricsSb.Append($"{m}, ") |> ignore for r in requestChargeBag do requestChargeSb.Append($"{r}, ") |> ignore if (indexMetricsSb.Length >= 2) && (requestChargeSb.Length >= 2) && Activity.Current <> null then Activity .Current .SetTag("indexMetrics", $"{indexMetricsSb.Remove(indexMetricsSb.Length - 2, 2)}") .SetTag("requestCharge", $"{requestChargeSb.Remove(requestChargeSb.Length - 2, 2)}") |> ignore finally stringBuilderPool.Return(indexMetricsSb) stringBuilderPool.Return(requestChargeSb) finally () | MongoDB -> () return referenceDtos :> IReadOnlyDictionary } /// Gets the latest reference for a given ReferenceType in a branch. let getLatestReferenceByType (referenceType: ReferenceType) (repositoryId: RepositoryId) (branchId: BranchId) = task { match actorStateStorageProvider with | Unknown -> return None | AzureCosmosDb -> let indexMetrics = stringBuilderPool.Get() let requestCharge = stringBuilderPool.Get() try let mutable latestReference: ReferenceDto option = None let queryDefinition = QueryDefinition( """ SELECT c.State FROM c WHERE STRINGEQUALS(c.State[0].Event.created.BranchId, @branchId, true) AND STRINGEQUALS(c.State[0].Event.created.ReferenceType, @referenceType, true) AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey ORDER BY c.State[0].Event.created.CreatedAt DESC """ ) .WithParameter("@branchId", branchId) .WithParameter("@referenceType", getDiscriminatedUnionCaseName referenceType) .WithParameter("@grainType", StateName.Reference) .WithParameter("@partitionKey", repositoryId) let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) while iterator.HasMoreResults && latestReference.IsNone do let! results = DefaultAsyncRetryPolicy.ExecuteAsync(fun () -> iterator.ReadNextAsync()) indexMetrics.Append($"{results.IndexMetrics}, ") |> ignore requestCharge.Append($"{results.RequestCharge:F3}, ") |> ignore let eventsForAllReferences = results.Resource |> Seq.toArray let mutable index = 0 while index < eventsForAllReferences.Length && latestReference.IsNone do let referenceDto = eventsForAllReferences[index].State |> Array.fold (fun current referenceEvent -> current |> ReferenceDto.UpdateDto referenceEvent) ReferenceDto.Default if isNotDeletedReference referenceDto then latestReference <- Some referenceDto index <- index + 1 if (indexMetrics.Length >= 2) && (requestCharge.Length >= 2) && Activity.Current <> null then Activity .Current .SetTag("indexMetrics", $"{indexMetrics.Remove(indexMetrics.Length - 2, 2)}") .SetTag("requestCharge", $"{requestCharge.Remove(requestCharge.Length - 2, 2)}") |> ignore return latestReference finally stringBuilderPool.Return(indexMetrics) stringBuilderPool.Return(requestCharge) | MongoDB -> return None } /// Gets the latest promotion from a branch. let getLatestPromotion (repositoryId: RepositoryId) (branchId: BranchId) = task { match actorStateStorageProvider with | Unknown -> return None | AzureCosmosDb -> let indexMetrics = stringBuilderPool.Get() let requestCharge = stringBuilderPool.Get() try let mutable latestPromotion: ReferenceDto option = None let queryDefinition = QueryDefinition( """ SELECT c.State FROM c WHERE STRINGEQUALS(c.State[0].Event.created.BranchId, @branchId, true) AND STRINGEQUALS(c.State[0].Event.created.ReferenceType, @referenceType, true) AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey ORDER BY c.State[0].Event.created.CreatedAt DESC """ ) .WithParameter("@branchId", branchId) .WithParameter("@referenceType", getDiscriminatedUnionCaseName ReferenceType.Promotion) .WithParameter("@grainType", StateName.Reference) .WithParameter("@partitionKey", repositoryId) let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) while iterator.HasMoreResults && latestPromotion.IsNone do let! results = DefaultAsyncRetryPolicy.ExecuteAsync(fun () -> iterator.ReadNextAsync()) indexMetrics.Append($"{results.IndexMetrics}, ") |> ignore requestCharge.Append($"{results.RequestCharge:F3}, ") |> ignore let eventsForAllReferences = results.Resource |> Seq.toArray let mutable index = 0 while index < eventsForAllReferences.Length && latestPromotion.IsNone do let referenceDto = eventsForAllReferences[index].State |> Array.fold (fun current referenceEvent -> current |> ReferenceDto.UpdateDto referenceEvent) ReferenceDto.Default if isNotDeletedReference referenceDto && hasPromotionSetTerminalLink referenceDto then latestPromotion <- Some referenceDto index <- index + 1 if (indexMetrics.Length >= 2) && (requestCharge.Length >= 2) && Activity.Current <> null then Activity .Current .SetTag("indexMetrics", $"{indexMetrics.Remove(indexMetrics.Length - 2, 2)}") .SetTag("requestCharge", $"{requestCharge.Remove(requestCharge.Length - 2, 2)}") |> ignore return latestPromotion finally stringBuilderPool.Return(indexMetrics) stringBuilderPool.Return(requestCharge) | MongoDB -> return None } /// Gets the latest commit from a branch. let getLatestCommit = getLatestReferenceByType ReferenceType.Commit /// Gets the latest checkpoint from a branch. let getLatestCheckpoint = getLatestReferenceByType ReferenceType.Checkpoint /// Gets the latest save from a branch. let getLatestSave = getLatestReferenceByType ReferenceType.Save /// Gets the latest tag from a branch. let getLatestTag = getLatestReferenceByType ReferenceType.Tag /// Gets the latest external from a branch. let getLatestExternal = getLatestReferenceByType ReferenceType.External /// Gets the latest rebase from a branch. let getLatestRebase = getLatestReferenceByType ReferenceType.Rebase /// Gets a list of branches for a given repository. let getBranches (ownerId: OwnerId) (organizationId: OrganizationId) (repositoryId: RepositoryId) (maxCount: int) includeDeleted correlationId = task { let branches = ConcurrentDictionary() let branchIds = List() match actorStateStorageProvider with | Unknown -> () | AzureCosmosDb -> let indexMetrics = stringBuilderPool.Get() let requestCharge = stringBuilderPool.Get() try try // First, get all of the branches for the repository. let queryDefinition = QueryDefinition( $""" SELECT TOP @maxCount c.State[0].Event.created.branchId FROM c WHERE STRINGEQUALS(c.State[0].Event.created.ownerId, @ownerId, true) AND STRINGEQUALS(c.State[0].Event.created.organizationId, @organizationId, true) AND LENGTH(c.State[0].Event.created.branchName) > 0 {includeDeletedEntitiesClause includeDeleted} AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey """ ) .WithParameter("@maxCount", maxCount) .WithParameter("@ownerId", ownerId) .WithParameter("@organizationId", organizationId) .WithParameter("@grainType", StateName.Branch) .WithParameter("@partitionKey", repositoryId) let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) while iterator.HasMoreResults do addTiming TimingFlag.BeforeStorageQuery "getBranches" correlationId let! results = iterator.ReadNextAsync() addTiming TimingFlag.AfterStorageQuery "getBranches" correlationId indexMetrics.Append($"{results.IndexMetrics}, ") |> ignore requestCharge.Append($"{results.RequestCharge:F3}, ") |> ignore let branchIdValues = results.Resource for branchIdValue in branchIdValues do branchIds.Add(branchIdValue.branchId) do! Parallel.ForEachAsync( branchIds, (fun branchId ct -> ValueTask( task { let actorProxy = Branch.CreateActorProxy branchId repositoryId correlationId let! branchDto = actorProxy.Get correlationId branches[branchDto.BranchId] <- branchDto } )) ) if indexMetrics.Length >= 2 && requestCharge.Length >= 2 && Activity.Current <> null then Activity .Current .SetTag("indexMetrics", $"{indexMetrics.Remove(indexMetrics.Length - 2, 2)}") .SetTag("requestCharge", $"{requestCharge.Remove(requestCharge.Length - 2, 2)}") |> ignore with | ex -> logToConsole $"Got an exception." logToConsole $"{ExceptionResponse.Create ex}" finally stringBuilderPool.Return(indexMetrics) stringBuilderPool.Return(requestCharge) | MongoDB -> () return branches .Values .OrderBy(fun branchDto -> branchDto.UpdatedAt) .ToArray() } /// Gets a DirectoryVersion by searching using a Sha256Hash value. let getDirectoryVersionBySha256Hash (repositoryId: RepositoryId) (sha256Hash: Sha256Hash) correlationId = task { let mutable directoryVersion = DirectoryVersion.Default match actorStateStorageProvider with | Unknown -> () | AzureCosmosDb -> let indexMetrics = stringBuilderPool.Get() let requestCharge = stringBuilderPool.Get() try let queryDefinition = QueryDefinition( """ SELECT TOP 1 c.State FROM c WHERE STARTSWITH(c.State[0].Event.created.Sha256Hash, @sha256Hash, true) AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey ORDER BY c.State[0].Event.created.CreatedAt DESC """ ) .WithParameter("@sha256Hash", sha256Hash) .WithParameter("@grainType", StateName.DirectoryVersion) .WithParameter("@partitionKey", repositoryId) try let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) let directoryVersionDtos = List() while iterator.HasMoreResults do let! results = DefaultAsyncRetryPolicy.ExecuteAsync(fun () -> iterator.ReadNextAsync()) indexMetrics.Append($"{results.IndexMetrics}, ") |> ignore requestCharge.Append($"{results.RequestCharge:F3}, ") |> ignore let eventsForAllDirectories = results.Resource eventsForAllDirectories |> Seq.iter (fun eventsForOneDirectory -> let directoryVersionDto = eventsForOneDirectory.State |> Array.fold (fun directoryVersionDto directoryEvent -> directoryVersionDto |> DirectoryVersionDto.UpdateDto directoryEvent) DirectoryVersionDto.Default directoryVersionDtos.Add(directoryVersionDto)) if directoryVersionDtos.Count > 0 then directoryVersion <- directoryVersionDtos[0].DirectoryVersion if (indexMetrics.Length >= 2) && (requestCharge.Length >= 2) && Activity.Current <> null then Activity .Current .SetTag("indexMetrics", $"{indexMetrics.Remove(indexMetrics.Length - 2, 2)}") .SetTag("requestCharge", $"{requestCharge.Remove(requestCharge.Length - 2, 2)}") |> ignore with | ex -> log.LogError( ex, "{CurrentInstant}: Exception in Services.getDirectoryBySha256Hash(). QueryDefinition: {queryDefinition}", getCurrentInstantExtended (), (serialize queryDefinition) ) finally stringBuilderPool.Return(indexMetrics) stringBuilderPool.Return(requestCharge) | MongoDB -> () if directoryVersion.DirectoryVersionId <> DirectoryVersion.Default.DirectoryVersionId then return Some directoryVersion else return None } /// Gets the most recent DirectoryVersion with HashesValidated = true by RelativePath. let getMostRecentDirectoryVersionByRelativePath (repositoryId: RepositoryId) (relativePath: RelativePath) correlationId = task { let mutable directoryVersion = DirectoryVersion.Default match actorStateStorageProvider with | Unknown -> () | AzureCosmosDb -> let indexMetrics = stringBuilderPool.Get() let requestCharge = stringBuilderPool.Get() try let queryDefinition = QueryDefinition( """ SELECT TOP 1 c.State FROM c WHERE STRINGEQUALS(c.State[0].Event.created.RelativePath, @relativePath, true) AND c.State[0].Event.created.HashesValidated = true AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey ORDER BY c.State[0].Event.created.CreatedAt DESC """ ) .WithParameter("@relativePath", relativePath) .WithParameter("@grainType", StateName.DirectoryVersion) .WithParameter("@partitionKey", repositoryId) let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) while iterator.HasMoreResults do let! results = iterator.ReadNextAsync() indexMetrics.Append($"{results.IndexMetrics}, ") |> ignore requestCharge.Append($"{results.RequestCharge:F3}, ") |> ignore let eventsForAllDirectories = results.Resource eventsForAllDirectories |> Seq.iter (fun eventsForOneDirectory -> let directoryVersionDto = eventsForOneDirectory.State |> Array.fold (fun directoryVersionDto directoryEvent -> directoryVersionDto |> DirectoryVersionDto.UpdateDto directoryEvent) DirectoryVersionDto.Default directoryVersion <- directoryVersionDto.DirectoryVersion) if (indexMetrics.Length >= 2) && (requestCharge.Length >= 2) && Activity.Current <> null then Activity .Current .SetTag("indexMetrics", $"{indexMetrics.Remove(indexMetrics.Length - 2, 2)}") .SetTag("requestCharge", $"{requestCharge.Remove(requestCharge.Length - 2, 2)}") |> ignore finally stringBuilderPool.Return(indexMetrics) stringBuilderPool.Return(requestCharge) | MongoDB -> () if directoryVersion.DirectoryVersionId <> DirectoryVersion.Default.DirectoryVersionId then return Some directoryVersion else return None } /// Gets a Root DirectoryVersion by searching using a Sha256Hash value. let getRootDirectoryVersionBySha256Hash (repositoryId: RepositoryId) (sha256Hash: Sha256Hash) correlationId = task { let mutable directoryVersion = DirectoryVersion.Default match actorStateStorageProvider with | Unknown -> () | AzureCosmosDb -> let indexMetrics = stringBuilderPool.Get() let requestCharge = stringBuilderPool.Get() try let queryDefinition = QueryDefinition( $""" SELECT TOP 1 c.State FROM c WHERE STARTSWITH(c.State[0].Event.created.Sha256Hash, @sha256Hash, true) AND STRINGEQUALS(c.State[0].Event.created.RelativePath, @relativePath, true) AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey ORDER BY c.State[0].Event.created.CreatedAt DESC """ ) .WithParameter("@sha256Hash", sha256Hash) .WithParameter("@relativePath", Constants.RootDirectoryPath) .WithParameter("@grainType", StateName.DirectoryVersion) .WithParameter("@partitionKey", repositoryId) try let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) let directoryVersionDtos = List() while iterator.HasMoreResults do let! results = iterator.ReadNextAsync() indexMetrics.Append($"{results.IndexMetrics}, ") |> ignore requestCharge.Append($"{results.RequestCharge:F3}, ") |> ignore let eventsForAllDirectories = results.Resource eventsForAllDirectories |> Seq.iter (fun eventsForOneDirectory -> let directoryVersionDto = eventsForOneDirectory.State |> Array.fold (fun directoryVersionDto directoryEvent -> directoryVersionDto |> DirectoryVersionDto.UpdateDto directoryEvent) DirectoryVersionDto.Default directoryVersionDtos.Add(directoryVersionDto)) if directoryVersionDtos.Count > 0 then directoryVersion <- directoryVersionDtos[0].DirectoryVersion if (indexMetrics.Length >= 2) && (requestCharge.Length >= 2) && Activity.Current <> null then Activity .Current .SetTag("indexMetrics", $"{indexMetrics.Remove(indexMetrics.Length - 2, 2)}") .SetTag("requestCharge", $"{requestCharge.Remove(requestCharge.Length - 2, 2)}") |> ignore with | ex -> let parameters = queryDefinition.GetQueryParameters() |> Seq.fold (fun (state: StringBuilder) (struct (k, v)) -> state.Append($"{k} = {v}; ")) (StringBuilder()) log.LogError( ex, "{CurrentInstant}: Exception in Services.getRootDirectoryBySha256Hash(). QueryText: {queryText}. Parameters: {parameters}", getCurrentInstantExtended (), (queryDefinition.QueryText), parameters.ToString() ) finally stringBuilderPool.Return(indexMetrics) stringBuilderPool.Return(requestCharge) | MongoDB -> () if directoryVersion.DirectoryVersionId <> DirectoryVersion.Default.DirectoryVersionId then return Some directoryVersion else return None } /// Gets a Root DirectoryVersion by searching using a Sha256Hash value. let getRootDirectoryVersionByReferenceId (repositoryId: RepositoryId) (referenceId: ReferenceId) correlationId = task { let referenceActorProxy = Reference.CreateActorProxy referenceId repositoryId correlationId let! referenceDto = referenceActorProxy.Get correlationId return! getRootDirectoryVersionBySha256Hash repositoryId referenceDto.Sha256Hash correlationId } /// Checks if all of the supplied DirectoryVersionIds exist. let directoryVersionIdsExist (repositoryId: RepositoryId) (directoryVersionIds: IEnumerable) correlationId = task { match actorStateStorageProvider with | Unknown -> return false | AzureCosmosDb -> let mutable requestCharge = 0.0 let mutable allExist = true let directoryVersionIdQueue = Queue(directoryVersionIds) while directoryVersionIdQueue.Count > 0 && allExist do let directoryVersionId = directoryVersionIdQueue.Dequeue() let queryDefinition = QueryDefinition( """ SELECT c.State FROM c WHERE STRINGEQUALS(c.State[0].Event.created.DirectoryVersionId, @directoryVersionId, true) AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey """ ) .WithParameter("@directoryVersionId", $"{directoryVersionId}") .WithParameter("@grainType", StateName.DirectoryVersion) .WithParameter("@partitionKey", repositoryId) let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) while iterator.HasMoreResults do let! results = iterator.ReadNextAsync() requestCharge <- requestCharge + results.RequestCharge if not <| results.Resource.Any() then allExist <- false Activity .Current .SetTag("allExist", $"{allExist}") .SetTag("totalRequestCharge", $"{requestCharge}") |> ignore return allExist | MongoDB -> return false } /// Gets a list of ReferenceDtos based on ReferenceIds. The list is returned in the same order as the supplied ReferenceIds. let getReferencesByReferenceId (repositoryId: RepositoryId) (referenceIds: IEnumerable) (maxCount: int) (correlationId: CorrelationId) = task { let referenceDtos = List() if referenceIds.Count() > 0 then match actorStateStorageProvider with | Unknown -> () | AzureCosmosDb -> let mutable requestCharge = 0.0 let mutable clientElapsedTime = TimeSpan.Zero let queryText = stringBuilderPool.Get() try // In order to build the IN clause, we need to create a parameter for each referenceId. // (I tried just using string concatenation, it didn't work for some reason. Anyway...) // The query starts with: queryText.Append( @"SELECT TOP @maxCount c.State FROM c WHERE c.GrainType = @grainType AND c.PartitionKey = @partitionKey AND c.State[0].Event.created.ReferenceId IN (" ) |> ignore // Then we add a parameter for each referenceId. referenceIds .Where(fun referenceId -> not <| referenceId.Equals(ReferenceId.Empty)) .Distinct() |> Seq.iteri (fun i referenceId -> queryText.Append($"@referenceId{i},") |> ignore) // Then we remove the last comma and close the parenthesis. queryText .Remove(queryText.Length - 1, 1) .Append(")") |> ignore // Create the query definition. let queryDefinition = QueryDefinition(queryText.ToString()) .WithParameter("@maxCount", referenceIds.Count()) .WithParameter("@grainType", StateName.Reference) .WithParameter("@partitionKey", repositoryId) // Add a .WithParameter for each referenceId. referenceIds .Where(fun referenceId -> not <| referenceId.Equals(ReferenceId.Empty)) .Distinct() |> Seq.iteri (fun i referenceId -> queryDefinition.WithParameter($"@referenceId{i}", $"{referenceId}") |> ignore) //logToConsole $"In getReferencesByReferenceId(): QueryText:{Environment.NewLine}{printQueryDefinition queryDefinition}." // Execute the query. let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) // The query will return fewer results than the number of referenceIds if the supplied referenceIds have duplicates. // This is normal for `grace status` (BasedOn and Latest Promotion are likely to be the same, for instance). // We need to gather the query results, and then iterate through the referenceId's to return the dto's in the same order. let queryResults = Dictionary() while iterator.HasMoreResults do addTiming TimingFlag.BeforeStorageQuery "getReferencesByReferenceId" correlationId let! results = iterator.ReadNextAsync() addTiming TimingFlag.AfterStorageQuery "getReferencesByReferenceId" correlationId requestCharge <- requestCharge + results.RequestCharge clientElapsedTime <- clientElapsedTime + results.Diagnostics.GetClientElapsedTime() let eventsForAllReferences = results.Resource eventsForAllReferences |> Seq.iter (fun eventsForOneReference -> let referenceDto = eventsForOneReference.State |> Array.fold (fun referenceDto referenceEvent -> referenceDto |> ReferenceDto.UpdateDto referenceEvent) ReferenceDto.Default queryResults.Add(referenceDto.ReferenceId, referenceDto)) // Add the results to the list in the same order as the supplied referenceIds. referenceIds |> Seq.iter (fun referenceId -> if referenceId <> ReferenceId.Empty then if queryResults.ContainsKey(referenceId) then referenceDtos.Add(queryResults[referenceId]) else // In case the caller supplied an empty referenceId, add a default ReferenceDto. referenceDtos.Add(ReferenceDto.Default)) Activity .Current .SetTag("referenceDtos.Count", $"{referenceDtos.Count}") .SetTag("clientElapsedTime", $"{clientElapsedTime}") .SetTag("totalRequestCharge", $"{requestCharge}") |> ignore finally stringBuilderPool.Return(queryText) | MongoDB -> () return referenceDtos } /// Gets a list of BranchDtos based on BranchIds. let getBranchesByBranchId (repositoryId: RepositoryId) (branchIds: IEnumerable) (maxCount: int) includeDeleted = task { let branchDtos = List() match actorStateStorageProvider with | Unknown -> () | AzureCosmosDb -> let mutable requestCharge = 0.0 let branchIdStack = Queue(branchIds) while branchIdStack.Count > 0 do let branchId = branchIdStack.Dequeue() let queryDefinition = QueryDefinition( $""" SELECT TOP @maxCount c.State FROM c WHERE STRINGEQUALS(c.State[0].Event.created.BranchId, @branchId, true) AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey {includeDeletedEntitiesClause includeDeleted} """ ) .WithParameter("@maxCount", maxCount) .WithParameter("@branchId", $"{branchId}") .WithParameter("@grainType", StateName.Branch) .WithParameter("@partitionKey", repositoryId) let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) while iterator.HasMoreResults do let! results = iterator.ReadNextAsync() requestCharge <- requestCharge + results.RequestCharge let eventsForAllBranches = results.Resource eventsForAllBranches |> Seq.iter (fun eventsForOneBranch -> let branchDto = eventsForOneBranch.State |> Array.fold (fun branchDto branchEvent -> branchDto |> BranchDto.UpdateDto branchEvent) BranchDto.Default branchDtos.Add(branchDto)) if Activity.Current <> null then Activity .Current .SetTag("referenceDtos.Count", $"{branchDtos.Count}") .SetTag("totalRequestCharge", $"{requestCharge}") |> ignore | MongoDB -> () return branchDtos .OrderBy(fun branchDto -> branchDto.BranchName) .ToArray() } /// Gets a list of child BranchDtos for a given parent branch. let getChildBranches (repositoryId: RepositoryId) (parentBranchId: BranchId) (maxCount: int) includeDeleted correlationId = task { let childBranches = List() match actorStateStorageProvider with | Unknown -> () | AzureCosmosDb -> let mutable requestCharge = 0.0 try let queryDefinition = QueryDefinition( $""" SELECT TOP @maxCount c.State FROM c WHERE STRINGEQUALS(c.State[0].Event.created.parentBranchId, @parentBranchId, true) AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey {includeDeletedEntitiesClause includeDeleted} """ ) .WithParameter("@maxCount", maxCount) .WithParameter("@parentBranchId", $"{parentBranchId}") .WithParameter("@grainType", StateName.Branch) .WithParameter("@partitionKey", repositoryId) let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) while iterator.HasMoreResults do addTiming TimingFlag.BeforeStorageQuery "getChildBranches" correlationId let! results = iterator.ReadNextAsync() addTiming TimingFlag.AfterStorageQuery "getChildBranches" correlationId requestCharge <- requestCharge + results.RequestCharge let eventsForAllBranches = results.Resource eventsForAllBranches |> Seq.iter (fun eventsForOneBranch -> let branchDto = eventsForOneBranch.State |> Array.fold (fun branchDto branchEvent -> branchDto |> BranchDto.UpdateDto branchEvent) BranchDto.Default childBranches.Add(branchDto)) if (Activity.Current <> null) then Activity .Current .SetTag("childBranches.Count", $"{childBranches.Count}") .SetTag("totalRequestCharge", $"{requestCharge}") |> ignore with | ex -> log.LogError( ex, "{CurrentInstant}: Error in getChildBranches. CorrelationId: {correlationId}.", getCurrentInstantExtended (), correlationId ) | MongoDB -> () return childBranches .OrderBy(fun branchDto -> branchDto.BranchName) .ToArray() } /// Gets validation sets for a repository. let getValidationSets (repositoryId: RepositoryId) (maxCount: int) includeDeleted correlationId = task { let validationSets = ConcurrentBag() let validationSetIds = List() match actorStateStorageProvider with | Unknown -> () | AzureCosmosDb -> try let queryDefinition = QueryDefinition( """ SELECT TOP @maxCount c.id FROM c WHERE c.GrainType = @grainType AND c.PartitionKey = @partitionKey """ ) .WithParameter("@maxCount", maxCount) .WithParameter("@grainType", StateName.ValidationSet) .WithParameter("@partitionKey", repositoryId) let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) while iterator.HasMoreResults do let! results = iterator.ReadNextAsync() results.Resource |> Seq.iter (fun value -> let mutable parsed = Guid.Empty if String.IsNullOrWhiteSpace value.id |> not && Guid.TryParse(value.id, &parsed) && parsed <> Guid.Empty then validationSetIds.Add(parsed)) with | ex -> log.LogError( ex, "{CurrentInstant}: Error in getValidationSets. CorrelationId: {correlationId}; RepositoryId: {repositoryId}.", getCurrentInstantExtended (), correlationId, repositoryId ) | MongoDB -> () do! Parallel.ForEachAsync( validationSetIds, (fun validationSetId ct -> ValueTask( task { let actorProxy = ValidationSet.CreateActorProxy validationSetId repositoryId correlationId let! validationSet = actorProxy.Get correlationId match validationSet with | Some dto when includeDeleted || dto.DeletedAt.IsNone -> validationSets.Add(dto) | _ -> () } )) ) return validationSets |> Seq.sortByDescending (fun dto -> dto.CreatedAt) |> Seq.toList } /// Gets validation results for a PromotionSet and computation attempt. let getValidationResultsForPromotionSetAttempt (repositoryId: RepositoryId) (promotionSetId: PromotionSetId) (stepsComputationAttempt: int) (maxCount: int) correlationId = task { let validationResults = ConcurrentBag() let validationResultIds = List() match actorStateStorageProvider with | Unknown -> () | AzureCosmosDb -> try let queryDefinition = QueryDefinition( """ SELECT TOP @maxCount c.id FROM c WHERE c.GrainType = @grainType AND c.PartitionKey = @partitionKey """ ) .WithParameter("@maxCount", maxCount) .WithParameter("@grainType", StateName.ValidationResult) .WithParameter("@partitionKey", repositoryId) let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) while iterator.HasMoreResults do let! results = iterator.ReadNextAsync() results.Resource |> Seq.iter (fun value -> let mutable parsed = Guid.Empty if String.IsNullOrWhiteSpace value.id |> not && Guid.TryParse(value.id, &parsed) && parsed <> Guid.Empty then validationResultIds.Add(parsed)) with | ex -> log.LogError( ex, "{CurrentInstant}: Error in getValidationResultsForPromotionSetAttempt. CorrelationId: {correlationId}; RepositoryId: {repositoryId}; PromotionSetId: {promotionSetId}.", getCurrentInstantExtended (), correlationId, repositoryId, promotionSetId ) | MongoDB -> () do! Parallel.ForEachAsync( validationResultIds, (fun validationResultId ct -> ValueTask( task { let actorProxy = ValidationResult.CreateActorProxy validationResultId repositoryId correlationId let! validationResult = actorProxy.Get correlationId match validationResult with | Some dto when dto.PromotionSetId = Some promotionSetId && dto.StepsComputationAttempt = Some stepsComputationAttempt -> validationResults.Add(dto) | _ -> () } )) ) return validationResults |> Seq.sortByDescending (fun dto -> dto.CreatedAt) |> Seq.toList } let getWorkItemIdByNumber (repositoryId: RepositoryId) (workItemNumber: WorkItemNumber) (correlationId: CorrelationId) = task { match actorStateStorageProvider with | Unknown -> return None | AzureCosmosDb -> try let queryDefinition = QueryDefinition( """ SELECT TOP 1 c.id FROM c WHERE c.GrainType = @grainType AND c.PartitionKey = @partitionKey AND c.State[0].Event.created.workItemNumber = @workItemNumber """ ) .WithParameter("@grainType", StateName.WorkItem) .WithParameter("@partitionKey", $"{repositoryId}") .WithParameter("@workItemNumber", workItemNumber) let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) if iterator.HasMoreResults then let! results = iterator.ReadNextAsync() let actorId = results.Resource |> Seq.tryHead |> Option.map (fun value -> value.id) |> Option.defaultValue String.Empty if String.IsNullOrWhiteSpace(actorId) then return None else let mutable workItemId = Guid.Empty if Guid.TryParse(actorId, &workItemId) && workItemId <> Guid.Empty then return Some workItemId else return None else return None with | ex -> log.LogError( ex, "{CurrentInstant}: Error in getWorkItemIdByNumber. CorrelationId: {correlationId}; RepositoryId: {repositoryId}; WorkItemNumber: {workItemNumber}.", getCurrentInstantExtended (), correlationId, repositoryId, workItemNumber ) return None | MongoDB -> return None } /// Gets a list of reminders for a repository, with optional filtering. let getReminders (graceIds: GraceIds) (maxCount: int) (reminderTypeFilter: string option) (actorNameFilter: string option) (dueAfter: Instant option) (dueBefore: Instant option) (correlationId: CorrelationId) = task { let reminders = List() match actorStateStorageProvider with | Unknown -> () | AzureCosmosDb -> let indexMetrics = stringBuilderPool.Get() let requestCharge = stringBuilderPool.Get() try try // Build the query dynamically based on provided filters let queryBuilder = StringBuilder() queryBuilder.Append( """ SELECT TOP @maxCount c.State.Reminder FROM c WHERE STRINGEQUALS(c.State.Reminder.OwnerId, @ownerId, true) AND STRINGEQUALS(c.State.Reminder.OrganizationId, @organizationId, true) AND STRINGEQUALS(c.State.Reminder.RepositoryId, @repositoryId, true) AND c.GrainType = @grainType AND c.PartitionKey = @partitionKey """ ) |> ignore // Add optional filters if reminderTypeFilter.IsSome && not (String.IsNullOrEmpty(reminderTypeFilter.Value)) then queryBuilder.Append(" AND STRINGEQUALS(c.State.Reminder.ReminderType, @reminderType, true)") |> ignore if actorNameFilter.IsSome && not (String.IsNullOrEmpty(actorNameFilter.Value)) then queryBuilder.Append(" AND STRINGEQUALS(c.State.Reminder.ActorName, @actorName, true)") |> ignore if dueAfter.IsSome then queryBuilder.Append(" AND c.State.Reminder.ReminderTime >= @dueAfter") |> ignore if dueBefore.IsSome then queryBuilder.Append(" AND c.State.Reminder.ReminderTime <= @dueBefore") |> ignore queryBuilder.Append(" ORDER BY c.State.Reminder.ReminderTime ASC") |> ignore let queryDefinition = QueryDefinition(queryBuilder.ToString()) .WithParameter("@maxCount", maxCount) .WithParameter("@ownerId", graceIds.OwnerIdString) .WithParameter("@organizationId", graceIds.OrganizationIdString) .WithParameter("@repositoryId", graceIds.RepositoryIdString) .WithParameter("@grainType", StateName.Reminder) .WithParameter("@partitionKey", StateName.Reminder) // Add optional parameters if reminderTypeFilter.IsSome && not (String.IsNullOrEmpty(reminderTypeFilter.Value)) then queryDefinition.WithParameter("@reminderType", reminderTypeFilter.Value) |> ignore if actorNameFilter.IsSome && not (String.IsNullOrEmpty(actorNameFilter.Value)) then queryDefinition.WithParameter("@actorName", actorNameFilter.Value) |> ignore if dueAfter.IsSome then queryDefinition.WithParameter("@dueAfter", dueAfter.Value.ToUnixTimeTicks()) |> ignore if dueBefore.IsSome then queryDefinition.WithParameter("@dueBefore", dueBefore.Value.ToUnixTimeTicks()) |> ignore let iterator = cosmosContainer.GetItemQueryIterator(queryDefinition, requestOptions = queryRequestOptions) while iterator.HasMoreResults do let! results = iterator.ReadNextAsync() indexMetrics.Append($"{results.IndexMetrics}, ") |> ignore requestCharge.Append($"{results.RequestCharge:F3}, ") |> ignore let reminderValues = results.Resource reminderValues |> Seq.iter (fun reminderValue -> reminders.Add(reminderValue.Reminder)) if (indexMetrics.Length >= 2) && (requestCharge.Length >= 2) && Activity.Current <> null then Activity .Current .SetTag("indexMetrics", $"{indexMetrics.Remove(indexMetrics.Length - 2, 2)}") .SetTag("requestCharge", $"{requestCharge.Remove(requestCharge.Length - 2, 2)}") |> ignore with | ex -> log.LogError( ex, "{CurrentInstant}: Error in getReminders. CorrelationId: {correlationId}.", getCurrentInstantExtended (), correlationId ) finally stringBuilderPool.Return(indexMetrics) stringBuilderPool.Return(requestCharge) | MongoDB -> () logToConsole $"Found {reminders.Count} reminders for RepositoryId {graceIds.RepositoryIdString}." logToConsole $"Reminders: {serialize reminders}." return reminders.ToArray() } /// Gets a single reminder by its ID. let getReminderById (reminderId: ReminderId) (correlationId: CorrelationId) = task { let reminderActorProxy = Reminder.CreateActorProxy reminderId correlationId let! exists = reminderActorProxy.Exists correlationId if exists then let! reminderDto = reminderActorProxy.Get correlationId return Some reminderDto else return None } /// Deletes a reminder by its ID. let deleteReminder (reminderId: ReminderId) (correlationId: CorrelationId) = task { let reminderActorProxy = Reminder.CreateActorProxy reminderId correlationId let! exists = reminderActorProxy.Exists correlationId if exists then do! reminderActorProxy.Delete correlationId return Ok() else return Error "Reminder not found." } /// Creates a new reminder actor instance. let createReminder (reminderDto: ReminderDto) = task { let reminderActorProxy = Reminder.CreateActorProxy reminderDto.ReminderId reminderDto.CorrelationId do! reminderActorProxy.Create reminderDto reminderDto.CorrelationId } :> Task /// Gets the CorrelationId from an Orleans grain's RequestContext. let getCorrelationId () = match RequestContext.Get(Constants.CorrelationId) with | :? string as s -> s | _ -> String.Empty /// Gets the ActorName from an Orleans grain's RequestContext. let getActorName () = match RequestContext.Get(Constants.ActorNameProperty) with | :? string as s -> s | _ -> String.Empty /// Gets the CurrentCommand from an Orleans grain's RequestContext. let getCurrentCommand () = match RequestContext.Get(Constants.CurrentCommandProperty) with | :? string as s -> s | _ -> String.Empty /// Gets the OrganizationId from an Orleans grain's RequestContext. let getOrganizationId () = match RequestContext.Get(nameof OrganizationId) with | :? OrganizationId as organizationId -> organizationId | _ -> Guid.Empty /// Gets the RepositoryId from an Orleans grain's RequestContext. let getRepositoryId () = match RequestContext.Get(nameof RepositoryId) with | :? RepositoryId as repositoryId -> repositoryId | _ -> Guid.Empty /// Gets a message that says whether an actor's state was retrieved from the database. let getActorActivationMessage recordExists = if recordExists then "Retrieved from database" else "Not found in database" /// Logs the activation of an actor. let logActorActivation (log: ILogger) (grainIdentity: string) (activationStartTime: Instant) (message: string) = log.LogInformation( "{CurrentInstant}: Node: {HostName}; Duration: {duration_ms}ms; CorrelationId: {correlationId}; Activated {GrainIdentity}. {message}.", getCurrentInstantExtended (), getMachineName, (getDurationRightAligned_ms activationStartTime), getCorrelationId (), grainIdentity, message ) ================================================ FILE: src/Grace.Actors/Timing.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Context open Grace.Actors.Types open Grace.Types.Types open Grace.Shared.Utilities open System open System.Collections.Generic open System.Linq open System.Reflection open System.Diagnostics module Timing = let publishTimings sb = let message = sb.ToString() logToConsole message let addTiming flag actorStateName correlationId = //let timingList = timings.GetOrAdd(correlationId, (fun _ -> List())) //let timing = Timing.Create flag actorStateName //timingList.Add(timing) () let reportTimings path correlationId = let mutable timingList = null let sb = stringBuilderPool.Get() try if timings.TryGetValue(correlationId, &timingList) then match timingList.Count with | 0 | 1 -> () | _ -> sb .AppendLine() .AppendLine(String.replicate 80 "=") |> ignore sb.AppendLine($"CorrelationId: {correlationId}; Path: {path}; Timings: {timingList.Count} ") |> ignore sb.AppendLine(String.replicate 80 "-") |> ignore sb.AppendLine($" {formatInstantExtended timingList[0].Time}: {getDiscriminatedUnionCaseName timingList[0].Flag}") |> ignore for i in 1 .. timingList.Count - 1 do let previousTiming = timingList[i - 1] let currentTiming = timingList[i] logToConsole $"*******In reportTimings: correlationId: {correlationId}; timingList.Count: {timingList.Count}; i: {i}." let previousActorStateName = if String.IsNullOrEmpty(previousTiming.ActorStateName) then String.Empty else ":" + previousTiming.ActorStateName let currentActorStateName = if String.IsNullOrEmpty(currentTiming.ActorStateName) then String.Empty else ":" + currentTiming.ActorStateName let milliseconds = $"{(timingList[i].Time - previousTiming.Time) .TotalMilliseconds:F3}" let paddedDuration = (String.replicate (Math.Max(7 - milliseconds.Length, 0)) " ") + milliseconds // Right-align, 7 characters. sb.AppendLine( $" {formatInstantExtended currentTiming.Time}: Duration: {paddedDuration}ms; {getDiscriminatedUnionCaseName previousTiming.Flag}{previousActorStateName} -> {getDiscriminatedUnionCaseName currentTiming.Flag}{currentActorStateName}" ) |> ignore let duration = timingList .Last() .Time.Minus(timingList.First().Time) let milliseconds = $"{duration.TotalMilliseconds:F3}" let paddedDuration = (String.replicate (Math.Max(7 - milliseconds.Length, 0)) " ") + milliseconds // Right-align, 7 characters. let space = " " sb.AppendLine(String.replicate 80 "-") |> ignore if duration.TotalMilliseconds > 500.0 then sb.AppendLine($"{String.replicate 32 space}Total: {paddedDuration}ms ##########") else sb.AppendLine($"{String.replicate 32 space}Total: {paddedDuration}ms") |> ignore sb.AppendLine(String.replicate 80 "=") |> ignore // Write the timings. if sb.Length > 0 then publishTimings sb finally stringBuilderPool.Return(sb) let removeTiming correlationId = let mutable x = null timings.TryRemove(correlationId, &x) |> ignore ================================================ FILE: src/Grace.Actors/Types.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Types.Types open Grace.Shared.Utilities open NodaTime open Orleans open System open System.Runtime.Serialization open System.Runtime.CompilerServices module Types = type TimingFlag = | Initial | BeforeRetrieveState | AfterRetrieveState | BeforeSaveState | AfterSaveState | BeforeStorageQuery | AfterStorageQuery | BeforeGettingCorrelationIdFromMemoryCache | AfterGettingCorrelationIdFromMemoryCache | BeforeSettingCorrelationIdInMemoryCache | AfterSettingCorrelationIdInMemoryCache | Final type Timing = { Time: Instant ActorStateName: string Flag: TimingFlag } static member Create (flag: TimingFlag) actorStateName = { Time = getCurrentInstant (); ActorStateName = actorStateName; Flag = flag } ================================================ FILE: src/Grace.Actors/User.Actor.fs ================================================ namespace Grace.Actors open Grace.Shared open Grace.Shared.Constants open Grace.Shared.Utilities open NodaTime open System module User = [] type UserDto(userId, emailAddress, isPrivateDefault, isSuspended, isDeactivated, defaultTimeZone, updatedAt) = new() = UserDto(Guid.NewGuid(), String.Empty, false, false, false, TimeZoneInfo.Utc.Id, getCurrentInstant ()) member val public UserId: Guid = userId with get, set member val public EmailAddress: string = emailAddress with get, set member val public IsPrivateDefault: bool = isPrivateDefault with get, set member val public IsSuspended: bool = isSuspended with get, set member val public IsDeactivated: bool = isDeactivated with get, set member val public DefaultTimeZone: string = defaultTimeZone with get, set member val public UpdatedAt: Instant = updatedAt with get, set type User() = let x = 0 ================================================ FILE: src/Grace.Actors/ValidationResult.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Actors.Types open Grace.Shared open Grace.Shared.Constants open Grace.Shared.Utilities open Grace.Types.Events open Grace.Types.Types open Grace.Types.Validation open Microsoft.Extensions.Logging open Orleans open Orleans.Runtime open System open System.Collections.Generic open System.Threading.Tasks module ValidationResult = let internal hasDuplicateCorrelationId (events: seq) (metadata: EventMetadata) = events |> Seq.exists (fun ev -> ev.Metadata.CorrelationId = metadata.CorrelationId) type ValidationResultActor ( [] state: IPersistentState> ) = inherit Grain() static let actorName = ActorName.ValidationResult let log = loggerFactory.CreateLogger("ValidationResult.Actor") let mutable currentCommand = String.Empty let mutable validationResult = ValidationResultDto.Default member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () logActorActivation log this.IdentityString activateStartTime (getActorActivationMessage state.RecordExists) validationResult <- state.State |> Seq.fold (fun dto event -> ValidationResultDto.UpdateDto event dto) validationResult Task.CompletedTask member private this.ApplyEvent(validationResultEvent: ValidationResultEvent) = task { let correlationId = validationResultEvent.Metadata.CorrelationId try state.State.Add(validationResultEvent) do! state.WriteStateAsync() validationResult <- validationResult |> ValidationResultDto.UpdateDto validationResultEvent let graceEvent = GraceEvent.ValidationResultEvent validationResultEvent do! publishGraceEvent graceEvent validationResultEvent.Metadata let graceReturnValue: GraceReturnValue = (GraceReturnValue.Create "Validation result command succeeded." correlationId) .enhance(nameof RepositoryId, validationResult.RepositoryId) .enhance (nameof ValidationResultId, validationResult.ValidationResultId) return Ok graceReturnValue with | ex -> log.LogError( ex, "{CurrentInstant}: Node: {HostName}; CorrelationId: {CorrelationId}; Failed to apply event for ValidationResultId: {ValidationResultId}.", getCurrentInstantExtended (), getMachineName, correlationId, validationResult.ValidationResultId ) return Error( (GraceError.CreateWithException ex "Failed while applying ValidationResult event." correlationId) .enhance(nameof RepositoryId, validationResult.RepositoryId) .enhance (nameof ValidationResultId, validationResult.ValidationResultId) ) } interface IHasRepositoryId with member this.GetRepositoryId correlationId = validationResult.RepositoryId |> returnTask interface IValidationResultActor with member this.Exists correlationId = this.correlationId <- correlationId not <| validationResult.ValidationResultId.Equals(ValidationResultId.Empty) |> returnTask member this.Get correlationId = this.correlationId <- correlationId if validationResult.ValidationResultId = ValidationResultId.Empty then Option.None else Some validationResult |> returnTask member this.GetEvents correlationId = this.correlationId <- correlationId state.State :> IReadOnlyList |> returnTask member this.Handle command metadata = let isValid (validationResultCommand: ValidationResultCommand) (eventMetadata: EventMetadata) = task { if hasDuplicateCorrelationId state.State eventMetadata then return Error(GraceError.Create "Duplicate correlation ID for ValidationResult command." eventMetadata.CorrelationId) else match validationResultCommand with | ValidationResultCommand.Record _ -> return Ok validationResultCommand } let processCommand (validationResultCommand: ValidationResultCommand) (eventMetadata: EventMetadata) = task { let eventType = match validationResultCommand with | ValidationResultCommand.Record validationResultDto -> ValidationResultEventType.Recorded validationResultDto let validationResultEvent: ValidationResultEvent = { Event = eventType; Metadata = eventMetadata } return! this.ApplyEvent validationResultEvent } task { currentCommand <- getDiscriminatedUnionCaseName command this.correlationId <- metadata.CorrelationId match! isValid command metadata with | Ok validCommand -> return! processCommand validCommand metadata | Error validationError -> return Error validationError } ================================================ FILE: src/Grace.Actors/ValidationSet.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Actors.Types open Grace.Shared open Grace.Shared.Constants open Grace.Shared.Utilities open Grace.Types.Events open Grace.Types.Types open Grace.Types.Validation open Microsoft.Extensions.Logging open Orleans open Orleans.Runtime open System open System.Collections.Generic open System.Threading.Tasks module ValidationSet = type ValidationSetActor([] state: IPersistentState>) = inherit Grain() static let actorName = ActorName.ValidationSet let log = loggerFactory.CreateLogger("ValidationSet.Actor") let mutable currentCommand = String.Empty let mutable validationSet = ValidationSetDto.Default member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () logActorActivation log this.IdentityString activateStartTime (getActorActivationMessage state.RecordExists) validationSet <- state.State |> Seq.fold (fun dto event -> ValidationSetDto.UpdateDto event dto) validationSet Task.CompletedTask member private this.ApplyEvent(validationSetEvent: ValidationSetEvent) = task { let correlationId = validationSetEvent.Metadata.CorrelationId try state.State.Add(validationSetEvent) do! state.WriteStateAsync() validationSet <- validationSet |> ValidationSetDto.UpdateDto validationSetEvent let graceEvent = GraceEvent.ValidationSetEvent validationSetEvent do! publishGraceEvent graceEvent validationSetEvent.Metadata let graceReturnValue: GraceReturnValue = (GraceReturnValue.Create "Validation set command succeeded." correlationId) .enhance(nameof RepositoryId, validationSet.RepositoryId) .enhance (nameof ValidationSetId, validationSet.ValidationSetId) return Ok graceReturnValue with | ex -> log.LogError( ex, "{CurrentInstant}: Node: {HostName}; CorrelationId: {CorrelationId}; Failed to apply event for ValidationSetId: {ValidationSetId}.", getCurrentInstantExtended (), getMachineName, correlationId, validationSet.ValidationSetId ) return Error( (GraceError.CreateWithException ex "Failed while applying ValidationSet event." correlationId) .enhance(nameof RepositoryId, validationSet.RepositoryId) .enhance (nameof ValidationSetId, validationSet.ValidationSetId) ) } interface IHasRepositoryId with member this.GetRepositoryId correlationId = validationSet.RepositoryId |> returnTask interface IValidationSetActor with member this.Exists correlationId = this.correlationId <- correlationId not <| validationSet.ValidationSetId.Equals(ValidationSetId.Empty) |> returnTask member this.IsDeleted correlationId = this.correlationId <- correlationId validationSet.DeletedAt.IsSome |> returnTask member this.Get correlationId = this.correlationId <- correlationId if validationSet.ValidationSetId = ValidationSetId.Empty then Option.None else Some validationSet |> returnTask member this.GetEvents correlationId = this.correlationId <- correlationId state.State :> IReadOnlyList |> returnTask member this.Handle command metadata = let isValid (validationSetCommand: ValidationSetCommand) (eventMetadata: EventMetadata) = task { if state.State.Exists(fun ev -> ev.Metadata.CorrelationId = eventMetadata.CorrelationId) then return Error(GraceError.Create "Duplicate correlation ID for ValidationSet command." eventMetadata.CorrelationId) else match validationSetCommand with | ValidationSetCommand.Create _ when validationSet.ValidationSetId <> ValidationSetId.Empty -> return Error(GraceError.Create "ValidationSet already exists." eventMetadata.CorrelationId) | _ -> return Ok validationSetCommand } let processCommand (validationSetCommand: ValidationSetCommand) (eventMetadata: EventMetadata) = task { let eventType = match validationSetCommand with | ValidationSetCommand.Create validationSetDto -> ValidationSetEventType.Created validationSetDto | ValidationSetCommand.Update validationSetDto -> ValidationSetEventType.Updated validationSetDto | ValidationSetCommand.DeleteLogical (force, deleteReason) -> ValidationSetEventType.LogicalDeleted(force, deleteReason) let validationSetEvent: ValidationSetEvent = { Event = eventType; Metadata = eventMetadata } return! this.ApplyEvent validationSetEvent } task { currentCommand <- getDiscriminatedUnionCaseName command this.correlationId <- metadata.CorrelationId match! isValid command metadata with | Ok validCommand -> return! processCommand validCommand metadata | Error validationError -> return Error validationError } ================================================ FILE: src/Grace.Actors/WorkItem.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Extensions.ActorProxy open Grace.Actors.Extensions.MemoryCache open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Actors.Types open Grace.Shared open Grace.Shared.Constants open Grace.Shared.Utilities open Grace.Shared.Validation.Errors open Grace.Types.Events open Grace.Types.Types open Grace.Types.WorkItem open Microsoft.Extensions.Logging open NodaTime open Orleans open Orleans.Runtime open System open System.Collections.Generic open System.Threading.Tasks module WorkItem = let internal hasDuplicateCorrelationId (events: seq) (metadata: EventMetadata) = events |> Seq.exists (fun ev -> ev.Metadata.CorrelationId = metadata.CorrelationId) type WorkItemActor([] state: IPersistentState>) = inherit Grain() static let actorName = ActorName.WorkItem let log = loggerFactory.CreateLogger("WorkItem.Actor") let mutable currentCommand = String.Empty let mutable workItemDto = WorkItemDto.Default member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () logActorActivation log this.IdentityString activateStartTime (getActorActivationMessage state.RecordExists) workItemDto <- state.State |> Seq.fold (fun dto ev -> WorkItemDto.UpdateDto ev dto) workItemDto Task.CompletedTask member private this.ApplyEvent(workItemEvent: WorkItemEvent) = task { let correlationId = workItemEvent.Metadata.CorrelationId try state.State.Add(workItemEvent) do! state.WriteStateAsync() workItemDto <- workItemDto |> WorkItemDto.UpdateDto workItemEvent let graceEvent = GraceEvent.WorkItemEvent workItemEvent do! publishGraceEvent graceEvent workItemEvent.Metadata let returnValue = (GraceReturnValue.Create "Work item command succeeded." correlationId) .enhance(nameof RepositoryId, workItemDto.RepositoryId) .enhance(nameof WorkItemId, workItemDto.WorkItemId) .enhance (nameof WorkItemEventType, getDiscriminatedUnionFullName workItemEvent.Event) return Ok returnValue with | ex -> log.LogError( ex, "{CurrentInstant}: Node: {hostName}; CorrelationId: {correlationId}; Failed to apply event {eventType} for work item {workItemId}.", getCurrentInstantExtended (), getMachineName, correlationId, getDiscriminatedUnionCaseName workItemEvent.Event, workItemDto.WorkItemId ) let graceError = (GraceError.CreateWithException ex (WorkItemError.getErrorMessage WorkItemError.FailedWhileApplyingEvent) correlationId) .enhance (nameof WorkItemId, workItemDto.WorkItemId) return Error graceError } interface IHasRepositoryId with member this.GetRepositoryId correlationId = workItemDto.RepositoryId |> returnTask interface IWorkItemActor with member this.Exists correlationId = this.correlationId <- correlationId not <| workItemDto.WorkItemId.Equals(WorkItemDto.Default.WorkItemId) |> returnTask member this.Get correlationId = this.correlationId <- correlationId workItemDto |> returnTask member this.GetEvents correlationId = this.correlationId <- correlationId state.State :> IReadOnlyList |> returnTask member this.Handle command metadata = let isValid (command: WorkItemCommand) (metadata: EventMetadata) = task { if hasDuplicateCorrelationId state.State metadata then return Error(GraceError.Create (WorkItemError.getErrorMessage WorkItemError.DuplicateCorrelationId) metadata.CorrelationId) else match command with | Create _ -> if workItemDto.WorkItemId <> WorkItemId.Empty then return Error(GraceError.Create (WorkItemError.getErrorMessage WorkItemError.WorkItemAlreadyExists) metadata.CorrelationId) else return Ok command | _ -> if workItemDto.WorkItemId = WorkItemId.Empty then return Error(GraceError.Create (WorkItemError.getErrorMessage WorkItemError.WorkItemDoesNotExist) metadata.CorrelationId) else return Ok command } let processCommand (command: WorkItemCommand) (metadata: EventMetadata) = task { let! workItemEventType = task { match command with | Create (workItemId, workItemNumber, ownerId, organizationId, repositoryId, title, description) -> return Created(workItemId, workItemNumber, ownerId, organizationId, repositoryId, title, description) | SetTitle title -> return TitleSet title | SetDescription description -> return DescriptionSet description | SetStatus status -> return StatusSet status | AddParticipant userId -> return ParticipantAdded userId | RemoveParticipant userId -> return ParticipantRemoved userId | AddTag tag -> return TagAdded tag | RemoveTag tag -> return TagRemoved tag | SetConstraints constraints -> return ConstraintsSet constraints | SetNotes notes -> return NotesSet notes | SetArchitecturalNotes notes -> return ArchitecturalNotesSet notes | SetMigrationNotes notes -> return MigrationNotesSet notes | AddExternalRef reference -> return ExternalRefAdded reference | RemoveExternalRef reference -> return ExternalRefRemoved reference | LinkBranch branchId -> return BranchLinked branchId | UnlinkBranch branchId -> return BranchUnlinked branchId | LinkReference referenceId -> return ReferenceLinked referenceId | UnlinkReference referenceId -> return ReferenceUnlinked referenceId | LinkArtifact artifactId -> return ArtifactLinked artifactId | UnlinkArtifact artifactId -> return ArtifactUnlinked artifactId | LinkPromotionSet promotionSetId -> return PromotionSetLinked promotionSetId | UnlinkPromotionSet promotionSetId -> return PromotionSetUnlinked promotionSetId | LinkReviewNotes reviewNotesId -> return ReviewNotesLinked reviewNotesId | UnlinkReviewNotes reviewNotesId -> return ReviewNotesUnlinked reviewNotesId | LinkReviewCheckpoint reviewCheckpointId -> return ReviewCheckpointLinked reviewCheckpointId | UnlinkReviewCheckpoint reviewCheckpointId -> return ReviewCheckpointUnlinked reviewCheckpointId | LinkValidationResult validationResultId -> return ValidationResultLinked validationResultId | UnlinkValidationResult validationResultId -> return ValidationResultUnlinked validationResultId } let workItemEvent = { Event = workItemEventType; Metadata = metadata } return! this.ApplyEvent workItemEvent } task { currentCommand <- getDiscriminatedUnionCaseName command this.correlationId <- metadata.CorrelationId RequestContext.Set(Constants.CurrentCommandProperty, getDiscriminatedUnionCaseName command) match! isValid command metadata with | Ok command -> return! processCommand command metadata | Error error -> return Error error } ================================================ FILE: src/Grace.Actors/WorkItemNumber.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Types.Types open Grace.Shared.Utilities open Microsoft.Extensions.Logging open Orleans open System open System.Collections.Generic open System.Threading.Tasks module WorkItemNumber = type WorkItemNumberActor() = inherit Grain() static let actorName = ActorName.WorkItemNumber let log = loggerFactory.CreateLogger("WorkItemNumber.Actor") let cachedWorkItemIds = Dictionary() member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () logActorActivation log this.IdentityString activateStartTime "In-memory only" Task.CompletedTask interface IWorkItemNumberActor with member this.GetWorkItemId(workItemNumber: WorkItemNumber) correlationId = this.correlationId <- correlationId match cachedWorkItemIds.TryGetValue workItemNumber with | true, workItemId -> Some workItemId |> returnTask | false, _ -> None |> returnTask member this.SetWorkItemId(workItemNumber: WorkItemNumber) (workItemId: WorkItemId) correlationId = this.correlationId <- correlationId if workItemNumber > 0L && workItemId <> Guid.Empty then cachedWorkItemIds[workItemNumber] <- workItemId Task.CompletedTask ================================================ FILE: src/Grace.Actors/WorkItemNumberCounter.Actor.fs ================================================ namespace Grace.Actors open Grace.Actors.Constants open Grace.Actors.Context open Grace.Actors.Interfaces open Grace.Actors.Services open Grace.Shared.Constants open Grace.Types.Types open Grace.Shared.Utilities open Orleans open Orleans.Runtime open System open System.Threading.Tasks module WorkItemNumberCounter = [] type WorkItemNumberCounterState = { NextWorkItemNumber: WorkItemNumber } type WorkItemNumberCounterActor( [] state: IPersistentState ) = inherit Grain() static let actorName = ActorName.WorkItemNumberCounter let log = loggerFactory.CreateLogger("WorkItemNumberCounter.Actor") member val private correlationId: CorrelationId = String.Empty with get, set override this.OnActivateAsync(ct) = let activateStartTime = getCurrentInstant () logActorActivation log this.IdentityString activateStartTime (getActorActivationMessage state.RecordExists) if not state.RecordExists then state.State <- { NextWorkItemNumber = 1L } Task.CompletedTask interface IWorkItemNumberCounterActor with member this.AllocateNext(correlationId: CorrelationId) = task { this.correlationId <- correlationId if not state.RecordExists && state.State.NextWorkItemNumber <= 0L then state.State <- { NextWorkItemNumber = 1L } let nextWorkItemNumber = if state.State.NextWorkItemNumber > 0L then state.State.NextWorkItemNumber else 1L state.State <- { NextWorkItemNumber = nextWorkItemNumber + 1L } do! state.WriteStateAsync() return nextWorkItemNumber } ================================================ FILE: src/Grace.Aspire.AppHost/AGENTS.md ================================================ # Grace.Aspire.AppHost Agent Notes - AppHost reads the Grace.Server user-secrets ID and forwards selected auth settings to the `grace-server` project. - Auth0/OIDC settings are expected under the raw keys (with `__`) in user secrets or env vars: `grace__auth__oidc__authority`, `grace__auth__oidc__audience`. - When troubleshooting missing auth providers in Aspire, check the `grace-server` environment list in the dashboard and the AppHost startup log line that summarizes forwarded auth keys. ================================================ FILE: src/Grace.Aspire.AppHost/Grace.Aspire.AppHost.csproj ================================================ Exe net10.0 enable enable f1167a88-7f15-49c3-8ea1-30c2608081c9 Shared PreserveNewest ================================================ FILE: src/Grace.Aspire.AppHost/Program.Aspire.AppHost.cs ================================================ extern alias Shared; using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure; using Aspire.Hosting.Redis; using Grace.Shared; using static Grace.Shared.Utilities; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Net; using System.Net.Sockets; using System.Text.Json; using static Grace.Types.Types; using static Shared::Grace.Shared.Constants; public partial class Program { private const string AspireResourceModeEnvVar = "ASPIRE_RESOURCE_MODE"; private const string AspireResourceModeLocal = "Local"; private const string AspireResourceModeAzure = "Azure"; private static void Main(string[] args) { try { var environmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"; var configuration = new ConfigurationBuilder() .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddJsonFile($"appsettings.{environmentName}.json", optional: true, reloadOnChange: true) .AddUserSecrets(typeof(Program).Assembly, optional: true) .AddEnvironmentVariables() .Build(); IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args); // Run-mode switch: // - Local (default): containers/emulators for Azurite, Cosmos emulator, ServiceBus emulator // - Azure: debug locally, but use real Azure resources (connection strings from config/env/user-secrets) var resourceMode = Environment.GetEnvironmentVariable(AspireResourceModeEnvVar) ?? configuration[$"grace:{AspireResourceModeEnvVar}"] ?? AspireResourceModeLocal; var isRunMode = builder.ExecutionContext.IsRunMode; var isPublishMode = builder.ExecutionContext.IsPublishMode; Console.WriteLine($"Aspire execution context: Run={isRunMode}; Publish={isPublishMode}."); var isAzureDebugRun = isRunMode && resourceMode.Equals(AspireResourceModeAzure, StringComparison.OrdinalIgnoreCase); var isTestRun = Environment.GetEnvironmentVariable("GRACE_TESTING") is string testValue && (testValue.Equals("1", StringComparison.OrdinalIgnoreCase) || testValue.Equals("true", StringComparison.OrdinalIgnoreCase)); var runSuffix = isTestRun ? (Environment.GetEnvironmentVariable("GRACE_TEST_RUN_ID") ?? Guid.NewGuid().ToString("N")) : null; static bool IsTruthy(string? value) => !string.IsNullOrWhiteSpace(value) && (value.Equals("1", StringComparison.OrdinalIgnoreCase) || value.Equals("true", StringComparison.OrdinalIgnoreCase) || value.Equals("yes", StringComparison.OrdinalIgnoreCase)); var skipServiceBus = isTestRun && IsTruthy(Environment.GetEnvironmentVariable("GRACE_TEST_SKIP_SERVICEBUS")); var useFixedTestPorts = isTestRun && IsTruthy(Environment.GetEnvironmentVariable("GRACE_TEST_FIXED_PORTS")); var pubSubSystem = skipServiceBus ? "UnknownPubSubProvider" : "AzureServiceBus"; if (isTestRun) { CleanupDockerContainers(new[] { "servicebus-sql", "servicebus-emulator" }); } // Redis: keep local container for both run modes (Local + Azure debug), and even in publish mode if you like. var redisContainerName = runSuffix is null ? "redis" : $"redis-{runSuffix}"; var redis = builder.AddContainer("redis", "redis", "latest") .WithContainerName(redisContainerName) //.WithLifetime(ContainerLifetime.Session) .WithEnvironment("ACCEPT_EULA", "Y") .WithEndpoint(targetPort: 6379, port: 6379); if (isTestRun) { redis.WithLifetime(ContainerLifetime.Session); } if (!isPublishMode) { // ========================= // RUN MODE (debug / local) // ========================= // Common settings for local debugging var otlpEndpoint = configuration["grace:otlp_endpoint"] ?? "http://localhost:18889"; var stateRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".grace", "aspire"); var logDirectory = Path.Combine(stateRoot, "logs"); Directory.CreateDirectory(stateRoot); Directory.CreateDirectory(logDirectory); // These get set in both Local and Azure-debug runs. var orleansClusterId = configuration[getConfigKey(EnvironmentVariables.OrleansClusterId)] ?? "local"; var orleansServiceId = configuration[getConfigKey(EnvironmentVariables.OrleansServiceId)] ?? "grace-dev"; if (isTestRun && runSuffix is not null) { orleansClusterId = $"{orleansClusterId}-test-{runSuffix}"; orleansServiceId = $"{orleansServiceId}-test-{runSuffix}"; } Console.WriteLine($"Using Orleans ClusterId='{orleansClusterId}' and ServiceId='{orleansServiceId}'."); var graceServer = builder.AddProject("grace-server", "..\\Grace.Server\\Grace.Server.fsproj") .WithParentRelationship(redis) .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") .WithEnvironment("DOTNET_ENVIRONMENT", "Development") .WithEnvironment("OTLP_ENDPOINT_URL", otlpEndpoint) .WithEnvironment(EnvironmentVariables.ApplicationInsightsConnectionString, configuration[getConfigKey(EnvironmentVariables.ApplicationInsightsConnectionString)] ?? string.Empty) .WithEnvironment(EnvironmentVariables.DirectoryVersionContainerName, "directoryversions") .WithEnvironment(EnvironmentVariables.DiffContainerName, "diffs") .WithEnvironment(EnvironmentVariables.ZipFileContainerName, "zipfiles") .WithEnvironment(EnvironmentVariables.RedisHost, "127.0.0.1") .WithEnvironment(EnvironmentVariables.RedisPort, "6379") .WithEnvironment(EnvironmentVariables.OrleansClusterId, orleansClusterId) .WithEnvironment(EnvironmentVariables.OrleansServiceId, orleansServiceId) .WithEnvironment(EnvironmentVariables.GracePubSubSystem, pubSubSystem) .AsHttp2Service() .WithOtlpExporter(); var forwardedAuthKeys = new List(); AddOptionalEnvironment(graceServer, configuration, EnvironmentVariables.GraceAuthOidcAuthority, forwardedAuthKeys); AddOptionalEnvironment(graceServer, configuration, EnvironmentVariables.GraceAuthOidcAudience, forwardedAuthKeys); LogForwardedSettings("Grace.Server auth settings", forwardedAuthKeys); if (isTestRun && !useFixedTestPorts) { var graceTargetPort = GetAvailableTcpPort(); graceServer .WithHttpEndpoint(targetPort: graceTargetPort, name: "http") .WithEnvironment("ASPNETCORE_URLS", "http://127.0.0.1:" + graceTargetPort); } else { graceServer .WithEnvironment("ASPNETCORE_URLS", "https://+:5001;http://+:5000") .WithEnvironment(EnvironmentVariables.GraceServerUri, "http://localhost:5000") .WithHttpEndpoint(targetPort: 5000, name: "http") .WithHttpsEndpoint(targetPort: 5001, name: "https"); } if (!isAzureDebugRun) { // ------------------------- // DebugLocal (default): containers/emulators // ------------------------- Console.WriteLine("Configuring Grace.Server for DebugLocal with local emulators."); var azuriteDataPath = Path.Combine(stateRoot, "azurite"); var cosmosCertPath = Path.Combine(stateRoot, "cosmos-cert"); var serviceBusConfigPath = Path.Combine(stateRoot, "servicebus"); Directory.CreateDirectory(azuriteDataPath); Directory.CreateDirectory(cosmosCertPath); Directory.CreateDirectory(serviceBusConfigPath); // Create Service Bus emulator config (when enabled for tests) string? serviceBusConfigFile = null; if (!skipServiceBus) { serviceBusConfigFile = Path.Combine( serviceBusConfigPath, $"config_{Process.GetCurrentProcess().Id}_{Guid.NewGuid():N}.json"); CreateServiceBusConfiguration(serviceBusConfigFile, configuration); } var azuriteContainerName = runSuffix is null ? "azurite" : $"azurite-{runSuffix}"; var azurite = builder.AddContainer("azurite", "mcr.microsoft.com/azure-storage/azurite", "latest") .WithContainerName(azuriteContainerName) .WithBindMount(azuriteDataPath, "/data") //.WithLifetime(ContainerLifetime.Session) .WithEnvironment("AZURITE_ACCOUNTS", "gracevcsdevelopment:Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==") .WithEndpoint(targetPort: 10000, port: 10000, name: "blob", scheme: "http") .WithEndpoint(targetPort: 10001, port: 10001, name: "queue", scheme: "http") .WithEndpoint(targetPort: 10002, port: 10002, name: "table", scheme: "http"); if (isTestRun) { azurite.WithLifetime(ContainerLifetime.Session); } var azuriteBlobEndpoint = azurite.GetEndpoint("blob"); var azuriteQueueEndpoint = azurite.GetEndpoint("queue"); var azuriteTableEndpoint = azurite.GetEndpoint("table"); var azuriteBlobHostAndPort = azuriteBlobEndpoint.Property(EndpointProperty.HostAndPort); var azuriteQueueHostAndPort = azuriteQueueEndpoint.Property(EndpointProperty.HostAndPort); var azuriteTableHostAndPort = azuriteTableEndpoint.Property(EndpointProperty.HostAndPort); // Cosmos emulator (your existing approach) const string cosmosKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; var cosmosDatabaseName = configuration[getConfigKey(EnvironmentVariables.AzureCosmosDBDatabaseName)] ?? "grace-dev"; var cosmosDbContainerName = configuration[getConfigKey(EnvironmentVariables.AzureCosmosDBContainerName)] ?? "grace-events"; const int cosmosGatewayHostPort = 8081; #pragma warning disable ASPIRECOSMOSDB001 var cosmosEmulatorContainerName = runSuffix is null ? "cosmosdb-emulator" : $"cosmosdb-emulator-{runSuffix}"; var cosmos = builder.AddAzureCosmosDB("cosmos") .RunAsPreviewEmulator(emulator => { emulator .WithContainerName(cosmosEmulatorContainerName) //.WithLifetime(ContainerLifetime.Session) .WithEnvironment("ACCEPT_EULA", "Y") .WithEnvironment("AZURE_COSMOS_EMULATOR_PARTITION_COUNT", "10") .WithEnvironment("AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE", "false") .WithEnvironment("AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE", "127.0.0.1") .WithEnvironment("ENABLE_OTLP_EXPORTER", "true") .WithEnvironment("LOG_LEVEL", "info") .WithDataExplorer(1234) .WithGatewayPort(cosmosGatewayHostPort); if (isTestRun) { emulator.WithLifetime(ContainerLifetime.Session); } }); #pragma warning restore ASPIRECOSMOSDB001 _ = cosmos.AddCosmosDatabase(cosmosDatabaseName) .AddContainer(cosmosDbContainerName, "/PartitionKey"); var cosmosConnStr = BuildCosmosEmulatorConnectionString(cosmos, cosmosKey); graceServer .WithParentRelationship(azurite) .WithParentRelationship(cosmos) .WithEnvironment( EnvironmentVariables.AzureStorageConnectionString, ReferenceExpression.Create( $"DefaultEndpointsProtocol=http;AccountName=gracevcsdevelopment;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://{azuriteBlobHostAndPort}/gracevcsdevelopment;QueueEndpoint=http://{azuriteQueueHostAndPort}/gracevcsdevelopment;TableEndpoint=http://{azuriteTableHostAndPort}/gracevcsdevelopment;") ) .WithEnvironment(EnvironmentVariables.AzureStorageKey, "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==") .WithEnvironment(EnvironmentVariables.AzureCosmosDBConnectionString, cosmosConnStr) .WithEnvironment(EnvironmentVariables.AzureCosmosDBDatabaseName, cosmosDatabaseName) .WithEnvironment(EnvironmentVariables.AzureCosmosDBContainerName, cosmosDbContainerName) .WithEnvironment(EnvironmentVariables.GraceLogDirectory, logDirectory) .WithEnvironment(EnvironmentVariables.DebugEnvironment, "Local"); if (!skipServiceBus) { // Service Bus emulator var serviceBusSqlPassword = configuration["grace:azure_service_bus:sqlpassword"] ?? "SqlIsAwesome1!"; var serviceBusConfigFilePath = serviceBusConfigFile ?? throw new InvalidOperationException("Service Bus config file was not created."); var serviceBusSqlResourceName = runSuffix is null ? "servicebus-sql" : $"servicebus-sql-{runSuffix}"; var serviceBusSql = builder.AddContainer(serviceBusSqlResourceName, "mcr.microsoft.com/mssql/server", "2022-latest") .WithContainerName(serviceBusSqlResourceName) .WithEnvironment("ACCEPT_EULA", "Y") .WithEnvironment("MSSQL_SA_PASSWORD", serviceBusSqlPassword) //.WithLifetime(ContainerLifetime.Session) .WithEndpoint(targetPort: 1433, port: 21433, name: "sql", scheme: "tcp"); if (isTestRun) { serviceBusSql.WithEnvironment("MSSQL_MEMORY_LIMIT_MB", "1024"); } else { var memoryLimit = configuration["grace:azure_service_bus:sqlmemory"]; if (!string.IsNullOrWhiteSpace(memoryLimit)) { serviceBusSql.WithEnvironment("MSSQL_MEMORY_LIMIT_MB", memoryLimit); } } if (isTestRun) { serviceBusSql.WithLifetime(ContainerLifetime.Session); } var serviceBusEmulatorResourceName = runSuffix is null ? "servicebus-emulator" : $"servicebus-emulator-{runSuffix}"; var serviceBusEmulator = builder.AddContainer(serviceBusEmulatorResourceName, "mcr.microsoft.com/azure-messaging/servicebus-emulator", "latest") .WithContainerName(serviceBusEmulatorResourceName) .WithParentRelationship(serviceBusSql) .WithEnvironment("ACCEPT_EULA", "Y") .WithEnvironment("MSSQL_SA_PASSWORD", serviceBusSqlPassword) .WithEnvironment("SQL_SERVER", serviceBusSqlResourceName) .WithEnvironment("SQL_WAIT_INTERVAL", "10") //.WithLifetime(ContainerLifetime.Session) .WithBindMount(serviceBusConfigFilePath, "/ServiceBus_Emulator/ConfigFiles/Config.json") .WithEndpoint(targetPort: 5672, port: 5672, name: "amqp", scheme: "amqp") .WithEndpoint(targetPort: 5300, port: 5300, name: "management", scheme: "http"); if (isTestRun) { serviceBusEmulator.WithLifetime(ContainerLifetime.Session); } var serviceBusAmqpEndpoint = serviceBusEmulator.GetEndpoint("amqp"); var serviceBusHostAndPort = serviceBusAmqpEndpoint.Property(EndpointProperty.HostAndPort); graceServer .WithParentRelationship(serviceBusEmulator) .WithEnvironment( EnvironmentVariables.AzureServiceBusConnectionString, ReferenceExpression.Create( $"Endpoint=sb://{serviceBusHostAndPort};SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;" ) ) .WithEnvironment(EnvironmentVariables.AzureServiceBusNamespace, "sbemulatorns") .WithEnvironment(EnvironmentVariables.AzureServiceBusTopic, "graceeventstream") .WithEnvironment(EnvironmentVariables.AzureServiceBusSubscription, "grace-server"); } else { Console.WriteLine("Skipping Service Bus emulator for this test run (GRACE_TEST_SKIP_SERVICEBUS=1)."); } Console.WriteLine("Grace.Server DebugLocal environment configured:"); Console.WriteLine(" - Azurite at http://localhost:10000-10002"); Console.WriteLine($" - Azurite data at {azuriteDataPath}"); Console.WriteLine(" - Cosmos emulator at http://localhost:8081"); if (!skipServiceBus) { Console.WriteLine(" - Service Bus emulator at amqp://localhost:5672"); Console.WriteLine($" - Service Bus config at {serviceBusConfigFile}"); } Console.WriteLine(" - Aspire dashboard at http://localhost:18888"); Console.WriteLine($" - OTLP endpoint {otlpEndpoint}"); } else { // ------------------------- // DebugAzure: still run locally under debugger, but use REAL Azure resources // ------------------------- Console.WriteLine("Configuring Grace.Server for DebugAzure with real Azure resources."); var azureStorageConnectionString = ResolveSetting(configuration, EnvironmentVariables.AzureStorageConnectionString); var azureStorageAccountName = ResolveSetting(configuration, EnvironmentVariables.AzureStorageAccountName); if (string.IsNullOrWhiteSpace(azureStorageConnectionString)) { azureStorageAccountName = GetRequiredSetting(configuration, EnvironmentVariables.AzureStorageAccountName); Console.WriteLine($"Using Azure Storage account: {azureStorageAccountName}."); } else { Console.WriteLine("Using Azure Storage connection string from configuration."); } var cosmosdbEndpoint = GetRequiredSetting(configuration, EnvironmentVariables.AzureCosmosDBEndpoint); Console.WriteLine($"Using Cosmos DB endpoint: {cosmosdbEndpoint}."); var cosmosDatabaseName = GetRequiredSetting(configuration, EnvironmentVariables.AzureCosmosDBDatabaseName); var cosmosContainerName = GetRequiredSetting(configuration, EnvironmentVariables.AzureCosmosDBContainerName); var serviceBusNamespace = ResolveSetting(configuration, EnvironmentVariables.AzureServiceBusNamespace); var serviceBusTopic = ResolveSetting(configuration, EnvironmentVariables.AzureServiceBusTopic); var serviceBusSubscription = ResolveSetting(configuration, EnvironmentVariables.AzureServiceBusSubscription); graceServer .WithEnvironment(EnvironmentVariables.AzureStorageAccountName, azureStorageAccountName) .WithEnvironment(EnvironmentVariables.AzureStorageConnectionString, azureStorageConnectionString) .WithEnvironment(EnvironmentVariables.AzureCosmosDBEndpoint, cosmosdbEndpoint) .WithEnvironment(EnvironmentVariables.AzureCosmosDBDatabaseName, cosmosDatabaseName) .WithEnvironment(EnvironmentVariables.AzureCosmosDBContainerName, cosmosContainerName) .WithEnvironment(EnvironmentVariables.AzureServiceBusNamespace, serviceBusNamespace) .WithEnvironment(EnvironmentVariables.AzureServiceBusTopic, serviceBusTopic) .WithEnvironment(EnvironmentVariables.AzureServiceBusSubscription, serviceBusSubscription) .WithEnvironment(EnvironmentVariables.GraceLogDirectory, logDirectory) .WithEnvironment(EnvironmentVariables.DebugEnvironment, "Azure"); Console.WriteLine("Grace.Server DebugAzure environment configured (no emulators started):"); Console.WriteLine(" - Azure Storage: using DefaultAzureCredential."); Console.WriteLine(" - Azure Cosmos: using DefaultAzureCredential."); Console.WriteLine(" - Azure Service Bus: using DefaultAzureCredential."); Console.WriteLine(" - Aspire dashboard at http://localhost:18888"); Console.WriteLine($" - OTLP endpoint {otlpEndpoint}"); } } else { // ========================= // PUBLISH MODE (deployment model) // ========================= var cosmos = builder.AddAzureCosmosDB("cosmos"); var cosmosDatabase = cosmos.AddCosmosDatabase(configuration[getConfigKey(EnvironmentVariables.AzureCosmosDBDatabaseName)] ?? "grace-dev"); _ = cosmosDatabase.AddContainer(configuration[getConfigKey(EnvironmentVariables.AzureCosmosDBContainerName)] ?? "grace-events", "/PartitionKey"); var storage = builder.AddAzureStorage("storage"); var blobStorage = storage.AddBlobContainer("directoryversions"); var diffStorage = storage.AddBlobContainer("diffs"); var zipStorage = storage.AddBlobContainer("zipfiles"); var serviceBus = builder.AddAzureServiceBus("servicebus"); _ = serviceBus.AddServiceBusTopic(configuration[getConfigKey(EnvironmentVariables.AzureServiceBusTopic)] ?? "graceeventstream") .AddServiceBusSubscription(configuration[getConfigKey(EnvironmentVariables.AzureServiceBusSubscription)] ?? "grace-server"); var otlpEndpoint = configuration["grace:otlp_endpoint"] ?? "http://localhost:18889"; var publishLogDirectory = configuration["grace:log_directory"] ?? "/tmp/grace-logs"; var graceServer = builder.AddProject("grace-server", "..\\Grace.Server\\Grace.Server.fsproj") .WithReference(cosmosDatabase) .WithReference(blobStorage) .WithReference(diffStorage) .WithReference(zipStorage) .WithReference(serviceBus) .WithParentRelationship(redis) .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Production") .WithEnvironment("DOTNET_ENVIRONMENT", "Production") .WithEnvironment("OTLP_ENDPOINT_URL", otlpEndpoint) .WithEnvironment(EnvironmentVariables.ApplicationInsightsConnectionString, configuration[getConfigKey(EnvironmentVariables.ApplicationInsightsConnectionString)] ?? string.Empty) .WithEnvironment(EnvironmentVariables.GraceServerUri, configuration[getConfigKey(EnvironmentVariables.GraceServerUri)] ?? "https://localhost:5001") .WithEnvironment(EnvironmentVariables.AzureCosmosDBDatabaseName, configuration[getConfigKey(EnvironmentVariables.AzureCosmosDBDatabaseName)] ?? "grace-dev") .WithEnvironment(EnvironmentVariables.AzureCosmosDBContainerName, configuration[getConfigKey(EnvironmentVariables.AzureCosmosDBContainerName)] ?? "grace-events") .WithEnvironment(EnvironmentVariables.DirectoryVersionContainerName, "directoryversions") .WithEnvironment(EnvironmentVariables.DiffContainerName, "diffs") .WithEnvironment(EnvironmentVariables.ZipFileContainerName, "zipfiles") .WithEnvironment(EnvironmentVariables.RedisHost, "localhost") .WithEnvironment(EnvironmentVariables.RedisPort, "6379") .WithEnvironment(EnvironmentVariables.OrleansClusterId, configuration[getConfigKey(EnvironmentVariables.OrleansClusterId)] ?? "production") .WithEnvironment(EnvironmentVariables.OrleansServiceId, configuration[getConfigKey(EnvironmentVariables.OrleansServiceId)] ?? "grace-prod") .WithEnvironment(EnvironmentVariables.GracePubSubSystem, pubSubSystem) .WithEnvironment(EnvironmentVariables.AzureServiceBusTopic, configuration[getConfigKey(EnvironmentVariables.AzureServiceBusTopic)] ?? "graceeventstream") .WithEnvironment(EnvironmentVariables.AzureServiceBusSubscription, configuration[getConfigKey(EnvironmentVariables.AzureServiceBusSubscription)] ?? "grace-server") .WithEnvironment(EnvironmentVariables.GraceLogDirectory, publishLogDirectory) .WithEnvironment(EnvironmentVariables.GraceAuthOidcAuthority, configuration[EnvironmentVariables.GraceAuthOidcAuthority]) .WithEnvironment(EnvironmentVariables.GraceAuthOidcAudience, configuration[EnvironmentVariables.GraceAuthOidcAudience]) .WithEnvironment(EnvironmentVariables.GraceAuthOidcCliClientId, configuration[EnvironmentVariables.GraceAuthOidcCliClientId]) .WithHttpEndpoint(targetPort: 5000, name: "http") .WithHttpsEndpoint(targetPort: 5001, name: "https") .AsHttp2Service() .WithOtlpExporter(); var forwardedAuthKeys = new List(); AddOptionalEnvironment(graceServer, configuration, EnvironmentVariables.GraceAuthOidcAuthority, forwardedAuthKeys); AddOptionalEnvironment(graceServer, configuration, EnvironmentVariables.GraceAuthOidcAudience, forwardedAuthKeys); LogForwardedSettings("Grace.Server auth settings", forwardedAuthKeys); Console.WriteLine("Grace.Server publish/production environment configured (Azure resources with MI by default)."); Console.WriteLine(" - Redis remains local container"); Console.WriteLine($" - OTLP endpoint {otlpEndpoint}"); } // Build + run with exit logging (normal + error) and elapsed time. using var appHost = builder.Build(); var loggerFactory = appHost.Services.GetService(typeof(ILoggerFactory)) as ILoggerFactory ?? LoggerFactory.Create(lb => lb.AddSimpleConsole()); var logger = loggerFactory.CreateLogger("Grace.Aspire.AppHost"); var sw = Stopwatch.StartNew(); try { appHost.Run(); sw.Stop(); logger.LogInformation("Aspire host exited normally. elapsedMs={Elapsed}", sw.ElapsedMilliseconds); } catch (Exception ex) { sw.Stop(); logger.LogError(ex, "Aspire host terminated with error. elapsedMs={Elapsed}", sw.ElapsedMilliseconds); Environment.Exit(1); } } catch (Exception ex) { Console.WriteLine($"Error starting Aspire host: {ex.Message}"); Console.WriteLine(ex.StackTrace); Environment.Exit(1); } } private static string? ResolveSetting(IConfiguration configuration, string name) { var value = Environment.GetEnvironmentVariable(name); if (string.IsNullOrWhiteSpace(value)) { value = Environment.GetEnvironmentVariable(name, EnvironmentVariableTarget.User); } if (string.IsNullOrWhiteSpace(value)) { value = Environment.GetEnvironmentVariable(name, EnvironmentVariableTarget.Machine); } if (string.IsNullOrWhiteSpace(value)) { value = configuration[name]; } if (string.IsNullOrWhiteSpace(value)) { var key = Shared::Grace.Shared.Utilities.getConfigKey(name); value = configuration[key]; } return string.IsNullOrWhiteSpace(value) ? null : value; } private static string GetRequiredSetting(IConfiguration configuration, string name) { var value = ResolveSetting(configuration, name); if (!string.IsNullOrWhiteSpace(value)) { return value; } var key = Shared::Grace.Shared.Utilities.getConfigKey(name); throw new InvalidOperationException( $"Missing required setting '{name}' (or '{key}') for DebugAzure."); } private static void AddOptionalEnvironment( IResourceBuilder resource, IConfiguration configuration, string name, IList forwardedKeys) { var value = ResolveSetting(configuration, name); if (!string.IsNullOrWhiteSpace(value)) { resource.WithEnvironment(name, value); forwardedKeys?.Add(name); } } private static void LogForwardedSettings(string label, IList forwardedKeys) { if (forwardedKeys is { Count: > 0 }) { Console.WriteLine($"{label}: {string.Join(", ", forwardedKeys)}."); } else { Console.WriteLine($"{label}: none detected."); } } private static void CleanupDockerContainers(IEnumerable containerNames) { foreach (var name in containerNames) { try { var startInfo = new ProcessStartInfo("docker", $"rm -f {name}") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; using var proc = Process.Start(startInfo); if (proc is null) { continue; } if (!proc.WaitForExit(5000)) { try { proc.Kill(true); } catch { // Ignore cleanup errors to avoid failing test runs. } } } catch { // Ignore cleanup errors to avoid failing test runs. } } } private static int GetAvailableTcpPort() { var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } private static ReferenceExpression BuildCosmosEmulatorConnectionString( IResourceBuilder cosmos, string accountKey) { if (cosmos.Resource is IResourceWithEndpoints cosmosWithEndpoints) { EndpointReference? selected = null; foreach (var endpoint in cosmosWithEndpoints.GetEndpoints()) { if (endpoint.EndpointAnnotation.TargetPort == 8081) { selected = endpoint; break; } if (endpoint.IsHttps) { selected = endpoint; break; } selected ??= endpoint; } if (selected is not null) { var scheme = selected.EndpointAnnotation.UriScheme; if (string.IsNullOrWhiteSpace(scheme)) { scheme = selected.IsHttps ? "https" : "http"; } var hostAndPort = selected.Property(EndpointProperty.HostAndPort); return ReferenceExpression.Create($"AccountEndpoint={scheme}://{hostAndPort}/;AccountKey={accountKey};"); } } return cosmos.Resource.ConnectionStringExpression; } private static readonly JsonSerializerOptions jsonOptions = new() { WriteIndented = true, PropertyNamingPolicy = null }; /// /// Creates the Service Bus Emulator configuration file with namespace, topics, and subscriptions. /// private static void CreateServiceBusConfiguration(string configFilePath, IConfiguration configuration) { var topicName = Environment.GetEnvironmentVariable(Constants.EnvironmentVariables.AzureServiceBusTopic) ?? "graceeventstream"; var subscriptionName = Environment.GetEnvironmentVariable(Constants.EnvironmentVariables.AzureServiceBusSubscription) ?? "grace-server"; var testSubscriptionName = $"{subscriptionName}-tests"; var config = new { UserConfig = new { Namespaces = new[] { new { Name = "sbemulatorns", Queues = Array.Empty(), Topics = new[] { new { Name = topicName, Properties = new { DefaultMessageTimeToLive = "PT1H", DuplicateDetectionHistoryTimeWindow = "PT20S", RequiresDuplicateDetection = false }, Subscriptions = new[] { new { Name = subscriptionName, Properties = new { DeadLetteringOnMessageExpiration = false, DefaultMessageTimeToLive = "PT1H", LockDuration = "PT1M", MaxDeliveryCount = 10, ForwardDeadLetteredMessagesTo = "", ForwardTo = "", RequiresSession = false }, Rules = Array.Empty() }, new { Name = testSubscriptionName, Properties = new { DeadLetteringOnMessageExpiration = false, DefaultMessageTimeToLive = "PT1H", LockDuration = "PT1M", MaxDeliveryCount = 10, ForwardDeadLetteredMessagesTo = "", ForwardTo = "", RequiresSession = false }, Rules = Array.Empty() } } } } } }, Logging = new { Type = "Console" } } }; var json = JsonSerializer.Serialize(config, jsonOptions); var existingJson = File.Exists(configFilePath) ? File.ReadAllText(configFilePath) : null; if (existingJson != json) { Console.WriteLine($"Creating Service Bus Emulator config at {configFilePath}:\n{json}"); File.WriteAllText(configFilePath, json); } } } ================================================ FILE: src/Grace.Aspire.AppHost/Properties/launchSettings.json ================================================ { "profiles": { "http": { "commandName": "Project", "launchBrowser": true, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16196", "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" }, "dotnetRunMessages": true, "applicationUrl": "http://localhost:15009" }, "https": { "commandName": "Project", "launchBrowser": true, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16196", "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" }, "dotnetRunMessages": true, "applicationUrl": "https://localhost:15010" }, "DebugLocal": { "commandName": "Project", "launchBrowser": true, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_RESOURCE_MODE": "Local", "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16196", "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true", "GRACE_TESTING": "1", "GRACE_TEST_FIXED_PORTS": "1", "grace__authz__bootstrap__system_admin_users": "test-admin" }, "dotnetRunMessages": true, "applicationUrl": "https://localhost:15010" }, "DebugAzure": { "commandName": "Project", "launchBrowser": true, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_RESOURCE_MODE": "Azure", "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16196", "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" }, "dotnetRunMessages": true, "applicationUrl": "https://localhost:15010" } }, "$schema": "http://json.schemastore.org/launchsettings.json" } ================================================ FILE: src/Grace.Aspire.AppHost/appsettings.json ================================================ { "Logging": { "LogLevel": { "Default": "Information", "Microsoft": "Warning" } }, "Aspire": { "Dashboard": { "Enabled": true, "Port": 18888, "OtlpGrpcEndpointUrl": "http://localhost:18889" } } } ================================================ FILE: src/Grace.Aspire.ServiceDefaults/Extensions.cs ================================================ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; using OpenTelemetry.Logs; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; namespace Microsoft.Extensions.Hosting; public static class Extensions { public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) { builder.ConfigureOpenTelemetry(); builder.AddDefaultHealthChecks(); builder.Services.AddServiceDiscovery(); builder.Services.ConfigureHttpClientDefaults(http => { // Turn on resilience by default http.AddStandardResilienceHandler(); // Turn on service discovery by default http.AddServiceDiscovery(); }); return builder; } public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) { builder.Logging.AddOpenTelemetry(logging => { logging.IncludeFormattedMessage = true; logging.IncludeScopes = true; }); builder.Services.AddOpenTelemetry() .WithMetrics(metrics => { metrics.AddRuntimeInstrumentation() .AddBuiltInMeters(); }) .WithTracing(tracing => { if (builder.Environment.IsDevelopment()) { // We want to view all traces in development tracing.SetSampler(new AlwaysOnSampler()); } tracing.AddAspNetCoreInstrumentation() .AddGrpcClientInstrumentation() .AddHttpClientInstrumentation(); }); builder.AddOpenTelemetryExporters(); return builder; } private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) { var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); if (useOtlpExporter) { builder.Services.Configure(logging => logging.AddOtlpExporter()); builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()); builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); } // Uncomment the following lines to enable the Prometheus exporter (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) // builder.Services.AddOpenTelemetry() // .WithMetrics(metrics => metrics.AddPrometheusExporter()); // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) // builder.Services.AddOpenTelemetry() // .UseAzureMonitor(); return builder; } public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) { builder.Services.AddHealthChecks() // Add a default liveness check to ensure app is responsive .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); return builder; } public static WebApplication MapDefaultEndpoints(this WebApplication app) { // Uncomment the following line to enable the Prometheus endpoint (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package) // app.MapPrometheusScrapingEndpoint(); // All health checks must pass for app to be considered ready to accept traffic after starting app.MapHealthChecks("/health"); // Only health checks tagged with the "live" tag must pass for app to be considered alive app.MapHealthChecks("/alive", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }); return app; } private static MeterProviderBuilder AddBuiltInMeters(this MeterProviderBuilder meterProviderBuilder) => meterProviderBuilder.AddMeter( "Microsoft.AspNetCore.Hosting", "Microsoft.AspNetCore.Server.Kestrel", "System.Net.Http"); } ================================================ FILE: src/Grace.Aspire.ServiceDefaults/Grace.Aspire.ServiceDefaults.csproj ================================================ Library net10.0 preview enable enable true true ================================================ FILE: src/Grace.Authorization.Tests/AuthorizationSemantics.Tests.fs ================================================ namespace Grace.Authorization.Tests open FsCheck open Grace.Shared.Authorization open Grace.Shared.Utilities open Grace.Types.Authorization open Grace.Types.Types open NUnit.Framework open System open System.Reflection open Microsoft.FSharp.Reflection [] type AuthorizationSemanticsTests() = let roleCatalog = RoleCatalog.getAll () let principal = { PrincipalType = PrincipalType.User; PrincipalId = "user-1" } let otherPrincipal = { PrincipalType = PrincipalType.User; PrincipalId = "user-2" } let ownerId = Guid.Parse("11111111-1111-1111-1111-111111111111") let organizationId = Guid.Parse("22222222-2222-2222-2222-222222222222") let repositoryId = Guid.Parse("33333333-3333-3333-3333-333333333333") let branchId = Guid.Parse("44444444-4444-4444-4444-444444444444") let resources = [ Resource.System Resource.Owner ownerId Resource.Organization(ownerId, organizationId) Resource.Repository(ownerId, organizationId, repositoryId) Resource.Branch(ownerId, organizationId, repositoryId, branchId) Resource.Path(ownerId, organizationId, repositoryId, "/docs/readme.md") ] let allOperations = FSharpType.GetUnionCases typeof |> Array.map (fun caseInfo -> FSharpValue.MakeUnion(caseInfo, [||]) :?> Operation) |> Array.toList let createAssignment scope roleId = { Principal = principal Scope = scope RoleId = roleId Source = "test" SourceDetail = None CreatedAt = getCurrentInstant () } let scopeKind scope = match scope with | Scope.System -> "system" | Scope.Owner _ -> "owner" | Scope.Organization _ -> "organization" | Scope.Repository _ -> "repository" | Scope.Branch _ -> "branch" let assertAllowed result = match result with | Allowed _ -> () | Denied reason -> Assert.Fail($"Expected Allowed but got Denied: {reason}") let assertDenied result = match result with | Denied _ -> () | Allowed reason -> Assert.Fail($"Expected Denied but got Allowed: {reason}") [] member _.RoleCatalogMatrixMatchesPermissionChecks() = for role in roleCatalog do for resource in resources do let scopes = scopesForResource resource for scope in scopes do for operation in allOperations do let assignments = [ createAssignment scope role.RoleId ] let result = checkPermission roleCatalog assignments [] [ principal ] Set.empty operation resource let expectedAllowed = role.AllowedOperations.Contains operation && role.AppliesTo.Contains(scopeKind scope) if expectedAllowed then assertAllowed result else assertDenied result [] member _.RepoAdminIncludesBranchAdmin() = let repoAdmin = roleCatalog |> List.find (fun role -> role.RoleId.Equals("RepoAdmin", StringComparison.OrdinalIgnoreCase)) Assert.That(repoAdmin.AllowedOperations.Contains BranchAdmin, Is.True) [] member _.IrrelevantAssignmentsDoNotAffectDecision() = let property (operation: Operation) = let scope = Scope.Repository(ownerId, organizationId, repositoryId) let resource = Resource.Repository(ownerId, organizationId, repositoryId) let assignment = createAssignment scope "RepoAdmin" let baseResult = checkPermission roleCatalog [ assignment ] [] [ principal ] Set.empty operation resource let extraAssignment = { assignment with Principal = otherPrincipal } let withExtra = checkPermission roleCatalog [ extraAssignment; assignment ] [] [ principal ] Set.empty operation resource baseResult = withExtra Check.QuickThrowOnFailure property [] member _.ScopeIrrelevanceDoesNotAffectDecision() = let property (operation: Operation) = let scope = Scope.Repository(ownerId, organizationId, repositoryId) let resource = Resource.Repository(ownerId, organizationId, repositoryId) let assignment = createAssignment scope "RepoAdmin" let baseResult = checkPermission roleCatalog [ assignment ] [] [ principal ] Set.empty operation resource let unrelatedAssignment = createAssignment (Scope.Branch(ownerId, organizationId, repositoryId, branchId)) "RepoAdmin" let withUnrelated = checkPermission roleCatalog [ unrelatedAssignment; assignment ] [] [ principal ] Set.empty operation resource baseResult = withUnrelated Check.QuickThrowOnFailure property [] member _.RoleIdCaseInsensitive() = let property (operation: Operation) = let scope = Scope.Repository(ownerId, organizationId, repositoryId) let resource = Resource.Repository(ownerId, organizationId, repositoryId) let assignment = createAssignment scope "RepoReader" let baseResult = checkPermission roleCatalog [ assignment ] [] [ principal ] Set.empty operation resource let mixedCaseAssignment = createAssignment scope "rEpOrEaDeR" let withMixedCase = checkPermission roleCatalog [ mixedCaseAssignment ] [] [ principal ] Set.empty operation resource baseResult = withMixedCase Check.QuickThrowOnFailure property [] member _.RbacMonotonicityForNonPathOps() = let property (operation: Operation) = match operation with | PathRead | PathWrite -> true | _ -> let scope = Scope.Repository(ownerId, organizationId, repositoryId) let resource = Resource.Repository(ownerId, organizationId, repositoryId) let baseAssignment = createAssignment scope "RepoReader" let extraAssignment = createAssignment scope "RepoAdmin" let baseResult = checkPermission roleCatalog [ baseAssignment ] [] [ principal ] Set.empty operation resource let withExtra = checkPermission roleCatalog [ extraAssignment; baseAssignment ] [] [ principal ] Set.empty operation resource match baseResult with | Allowed _ -> match withExtra with | Allowed _ -> true | Denied _ -> false | Denied _ -> true Check.QuickThrowOnFailure property ================================================ FILE: src/Grace.Authorization.Tests/ClaimMapping.Tests.fs ================================================ namespace Grace.Authorization.Tests open Grace.Server.Security open Microsoft.AspNetCore.Authentication open Microsoft.Extensions.Logging.Abstractions open NUnit.Framework open System open System.Security.Claims [] type ClaimMappingTests() = let createPrincipal (claims: Claim list) = let identity = ClaimsIdentity(claims, "Bearer") ClaimsPrincipal(identity) let findValues (claimType: string) (claims: Claim list) = claims |> List.filter (fun claim -> String.Equals(claim.Type, claimType, StringComparison.Ordinal)) |> List.map (fun claim -> claim.Value) [] member _.ClaimsTransformationDoesNotDuplicateGraceUserId() = let principal = createPrincipal [ Claim(PrincipalMapper.GraceUserIdClaim, "existing-user") Claim("sub", "subject-1") ] let transformer = GraceClaimsTransformation(NullLogger.Instance) let transformed = (transformer :> IClaimsTransformation).TransformAsync(principal).Result let graceUserIds = transformed.Claims |> Seq.filter (fun claim -> claim.Type = PrincipalMapper.GraceUserIdClaim) |> Seq.map (fun claim -> claim.Value) |> Seq.toList Assert.That(graceUserIds, Is.EquivalentTo([ "existing-user" ])) [] member _.ClaimMappingIsIdempotent() = let principal = createPrincipal [ Claim("roles", "Admin") Claim("scp", "repo.write repo.read") Claim("groups", "group-1") ] let first = ClaimMapping.mapClaims principal let augmented = ClaimsPrincipal(ClaimsIdentity(principal.Claims |> Seq.append first, "Bearer")) let second = ClaimMapping.mapClaims augmented Assert.That(second, Is.Empty) [] member _.DedupesGraceClaimsAndGroups() = let principal = createPrincipal [ Claim(PrincipalMapper.GraceClaim, "repo.read") Claim(PrincipalMapper.GraceClaim, "repo.read") Claim(PrincipalMapper.GraceGroupIdClaim, "group-1") Claim(PrincipalMapper.GraceGroupIdClaim, "group-1") Claim("roles", "repo.read") Claim("groups", "group-1") ] let mapped = ClaimMapping.mapClaims principal let graceClaims = findValues PrincipalMapper.GraceClaim mapped |> List.sort let graceGroups = findValues PrincipalMapper.GraceGroupIdClaim mapped |> List.sort Assert.That(graceClaims, Is.EquivalentTo([])) Assert.That(graceGroups, Is.EquivalentTo([])) [] member _.SplitsScopesOnSpacesWithoutEmptyEntries() = let principal = createPrincipal [ Claim("scp", "repo.read repo.write ") Claim("scope", " repo.list") ] let mapped = ClaimMapping.mapClaims principal let graceClaims = findValues PrincipalMapper.GraceClaim mapped |> List.sort Assert.That(graceClaims, Is.EquivalentTo([ "repo.list"; "repo.read"; "repo.write" ])) ================================================ FILE: src/Grace.Authorization.Tests/EndpointAuthorizationManifest.Tests.fs ================================================ namespace Grace.Authorization.Tests open Grace.Server.Security.EndpointAuthorizationManifest open NUnit.Framework open System open System.IO open System.Text.RegularExpressions [] type EndpointAuthorizationManifestTests() = let startupPath = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "Grace.Server", "Startup.Server.fs")) let tokenRegex = Regex( "(?\\bGET\\b|\\bPOST\\b|\\bPUT\\b)\\s*\\[|(?subRoute|routef|route)\\s+\"(?[^\"]+)\"", RegexOptions.Multiline ) let parseStartupRoutes () = let text = File.ReadAllText(startupPath) let matches = tokenRegex.Matches(text) let mutable currentPrefix = String.Empty let mutable currentMethod = String.Empty let routes = ResizeArray() for matchItem in matches do if matchItem.Groups["method"].Success then currentMethod <- matchItem.Groups["method"].Value elif matchItem.Groups["kind"].Success then let kind = matchItem.Groups["kind"].Value let path = matchItem.Groups["path"].Value if kind = "subRoute" then currentPrefix <- path else if String.IsNullOrWhiteSpace currentMethod then invalidOp $"Missing HTTP method before route '{path}'." else let fullPath = if String.IsNullOrWhiteSpace currentPrefix then path else $"{currentPrefix}{path}" routes.Add(currentMethod, fullPath) routes |> Seq.toList |> List.append [ ("GET", "/metrics"); ("GET", "/notifications") ] [] member _.ManifestCoversAllRoutes() = let startupRoutes = parseStartupRoutes () |> Set.ofList let manifestRoutes = definitions |> List.map (fun definition -> definition.Method, definition.Path) |> Set.ofList let missing = startupRoutes - manifestRoutes if missing.Count > 0 then let missingText = missing |> Seq.sort |> Seq.map (fun (method, path) -> $"{method} {path}") |> String.concat Environment.NewLine Assert.Fail($"EndpointAuthorizationManifest is missing routes:{Environment.NewLine}{missingText}") let extra = manifestRoutes - startupRoutes if extra.Count > 0 then let extraText = extra |> Seq.sort |> Seq.map (fun (method, path) -> $"{method} {path}") |> String.concat Environment.NewLine Assert.Fail($"EndpointAuthorizationManifest includes routes not in Startup.Server.fs:{Environment.NewLine}{extraText}") [] member _.ManifestDoesNotContainDuplicates() = let duplicates = definitions |> List.groupBy (fun definition -> definition.Method, definition.Path) |> List.choose (fun (key, entries) -> if entries.Length > 1 then Some key else None) if duplicates.Length > 0 then let message = duplicates |> List.map (fun (method, path) -> $"{method} {path}") |> String.concat Environment.NewLine Assert.Fail($"EndpointAuthorizationManifest contains duplicate entries:{Environment.NewLine}{message}") ================================================ FILE: src/Grace.Authorization.Tests/Grace.Authorization.Tests.fsproj ================================================  net10.0 preview false true false true true --test:GraphBasedChecking --test:ParallelOptimization --test:ParallelIlxGen all runtime; build; native; contentfiles; analyzers; buildtransitive ================================================ FILE: src/Grace.Authorization.Tests/PathPermissions.Tests.fs ================================================ namespace Grace.Authorization.Tests open Grace.Shared.Authorization open Grace.Shared.Utilities open Grace.Types.Authorization open Grace.Types.Types open NUnit.Framework open System open System.Collections.Generic [] type PathPermissionsTests() = let ownerId = Guid.Parse("11111111-1111-1111-1111-111111111111") let organizationId = Guid.Parse("22222222-2222-2222-2222-222222222222") let repositoryId = Guid.Parse("33333333-3333-3333-3333-333333333333") let principal = { PrincipalType = PrincipalType.User; PrincipalId = "user-1" } let createAssignment scope roleId = { Principal = principal Scope = scope RoleId = roleId Source = "test" SourceDetail = None CreatedAt = getCurrentInstant () } [] member _.NormalizesPathSeparators() = let permissions = List() permissions.Add({ Claim = "engineering"; DirectoryPermission = DirectoryPermission.Modify }) let pathPermissions = [ { Path = "/images"; Permissions = permissions } ] let claims = Set.ofList [ "engineering" ] let result = checkPathPermission pathPermissions claims "\\images" PathWrite match result with | Some(Allowed _) -> () | Some(Denied reason) -> Assert.Fail($"Expected Allowed but got Denied: {reason}") | None -> Assert.Fail("Expected path permission to match normalized path.") [] member _.WeirdPathsDoNotThrowAndDenyByDefault() = let weirdPaths = [ "" " " "." ".." "../x" "./x" "//" "///" "/../x" "/./x" ] for path in weirdPaths do let resource = Resource.Path(ownerId, organizationId, repositoryId, path) let result = checkPermission (RoleCatalog.getAll ()) [] [] [ principal ] Set.empty Operation.PathRead resource match result with | Denied _ -> () | Allowed reason -> Assert.Fail($"Expected Denied but got Allowed for '{path}': {reason}") ================================================ FILE: src/Grace.Authorization.Tests/PermissionEvaluator.Tests.fs ================================================ namespace Grace.Authorization.Tests open Grace.Server.Security open Grace.Shared.Authorization open Grace.Shared.Utilities open Grace.Types.Authorization open Grace.Types.Types open NUnit.Framework open System open System.Threading.Tasks [] type PermissionEvaluatorTests() = let principal = { PrincipalType = PrincipalType.User; PrincipalId = "user-1" } let ownerId = Guid.Parse("11111111-1111-1111-1111-111111111111") let organizationId = Guid.Parse("22222222-2222-2222-2222-222222222222") let repositoryId = Guid.Parse("33333333-3333-3333-3333-333333333333") let branchId = Guid.Parse("44444444-4444-4444-4444-444444444444") let createAssignment scope roleId = { Principal = principal Scope = scope RoleId = roleId Source = "test" SourceDetail = None CreatedAt = getCurrentInstant () } let emptyPathPermissions (_repositoryId: RepositoryId, _correlationId: CorrelationId) = Task.FromResult([ ]) [] member _.QueriesScopesForResource() = task { let capturedScopes = ResizeArray() let getAssignmentsForScope (scope: Scope, _correlationId: CorrelationId) = capturedScopes.Add scope Task.FromResult([ ]) let evaluator = GracePermissionEvaluator(getAssignmentsForScope, emptyPathPermissions) let resource = Resource.Repository(ownerId, organizationId, repositoryId) let! _ = (evaluator :> IGracePermissionEvaluator).CheckAsync([ principal ], Set.empty, Operation.RepoRead, resource) let expected = scopesForResource resource Assert.That(capturedScopes, Is.EquivalentTo(expected)) } [] member _.UnionsAssignmentsAcrossScopes() = task { let getAssignmentsForScope (scope: Scope, _correlationId: CorrelationId) = match scope with | Scope.Organization _ -> Task.FromResult([ createAssignment scope "OrgAdmin" ]) | _ -> Task.FromResult([ ]) let evaluator = GracePermissionEvaluator(getAssignmentsForScope, emptyPathPermissions) let resource = Resource.Repository(ownerId, organizationId, repositoryId) let! result = (evaluator :> IGracePermissionEvaluator).CheckAsync([ principal ], Set.empty, Operation.RepoWrite, resource) match result with | Allowed _ -> () | Denied reason -> Assert.Fail($"Expected Allowed but got Denied: {reason}") } [] member _.FetchesPathPermissionsOnlyForPathResources() = task { let mutable pathPermissionCalls = 0 let getPathPermissions (_repositoryId: RepositoryId, _correlationId: CorrelationId) = pathPermissionCalls <- pathPermissionCalls + 1 Task.FromResult([ ]) let evaluator = GracePermissionEvaluator( (fun _ -> Task.FromResult([ ])), getPathPermissions ) let repositoryResource = Resource.Repository(ownerId, organizationId, repositoryId) let! _ = (evaluator :> IGracePermissionEvaluator).CheckAsync([ principal ], Set.empty, Operation.RepoRead, repositoryResource) Assert.That(pathPermissionCalls, Is.EqualTo(0)) let pathResource = Resource.Path(ownerId, organizationId, repositoryId, "/docs/readme.md") let! _ = (evaluator :> IGracePermissionEvaluator).CheckAsync([ principal ], Set.empty, Operation.PathRead, pathResource) Assert.That(pathPermissionCalls, Is.EqualTo(1)) } [] member _.FailsClosedWhenRoleMissing() = task { let getAssignmentsForScope (scope: Scope, _correlationId: CorrelationId) = Task.FromResult([ createAssignment scope "MissingRole" ]) let evaluator = GracePermissionEvaluator(getAssignmentsForScope, emptyPathPermissions) let resource = Resource.Repository(ownerId, organizationId, repositoryId) let! result = (evaluator :> IGracePermissionEvaluator).CheckAsync([ principal ], Set.empty, Operation.RepoRead, resource) match result with | Denied _ -> () | Allowed reason -> Assert.Fail($"Expected Denied but got Allowed: {reason}") } ================================================ FILE: src/Grace.Authorization.Tests/PersonalAccessToken.Tests.fs ================================================ namespace Grace.Authorization.Tests open FsCheck open Grace.Types.PersonalAccessToken open NUnit.Framework open System [] type PersonalAccessTokenTests() = [] member _.RoundTripParsesFormattedToken() = let userId = "user-123" let tokenId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") let secret = Array.init 32 (fun index -> byte (index + 1)) let token = formatToken userId tokenId secret match tryParseToken token with | None -> Assert.Fail("Expected token to parse.") | Some(parsedUserId, parsedTokenId, parsedSecret) -> Assert.That(parsedUserId, Is.EqualTo(userId)) Assert.That(parsedTokenId, Is.EqualTo(tokenId)) Assert.That(parsedSecret, Is.EquivalentTo(secret)) [] member _.TryParseNeverThrows() = let property (input: string) = try tryParseToken input |> ignore true with | _ -> false Check.QuickThrowOnFailure property [] member _.RejectsMalformedTokens() = let tokenId = Guid.NewGuid().ToString("N") let goodUser = "user-123" let goodSecret = Convert.ToBase64String(Array.init 32 (fun i -> byte (i + 1))).TrimEnd('=').Replace('+', '-').Replace('/', '_') let goodUserB64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(goodUser)).TrimEnd('=').Replace('+', '-').Replace('/', '_') let malformed = [ "" " " "not-a-token" $"{TokenPrefix}{goodUserB64}.{tokenId}" // missing segment $"{TokenPrefix}{goodUserB64}.{Guid.NewGuid()}.{goodSecret}" // wrong guid format $"{TokenPrefix}@@@.{tokenId}.{goodSecret}" // invalid user b64 $"{TokenPrefix}{goodUserB64}.{tokenId}.@@@" // invalid secret b64 $"{TokenPrefix}{goodUserB64}.{tokenId}.abcd" // wrong secret length ] for value in malformed do Assert.That(tryParseToken value, Is.EqualTo(None), $"Expected malformed token to be rejected: {value}") ================================================ FILE: src/Grace.Authorization.Tests/Program.fs ================================================ module Program [] let main _ = 0 ================================================ FILE: src/Grace.CLI/AGENTS.md ================================================ # Grace.CLI Agents Guide Read `../AGENTS.md` for global expectations before updating CLI code. ## Purpose - Provide developer tooling entry points that wrap Grace services via System.CommandLine and Spectre.Console. - Surface Grace workflows through friendly commands while keeping logic reusable for tests and automation. ## Key Patterns - Define command arguments and options in dedicated `Options` modules that produce `Option<'T>` values. - Create handlers with `CommandHandler.Create(...)` that delegate to reusable services (often via `Grace.SDK`). - Use Spectre.Console for user interaction, but keep core logic testable and separate from console presentation. - Maintain alignment with DTOs and contracts defined in `Grace.Types`. - Auth commands support PATs via `GRACE_TOKEN` (PAT-only), Auth0 M2M, and Auth0 interactive login with secure token storage. Local token files are disabled. Keep token values out of output except on creation. - Avoid positional parameters; prefer named options for clarity. ## Project Rules 1. Keep handlers thin; move heavier logic into services or helpers that are straightforward to unit test. 2. Preserve existing option names and switches. Introduce new aliases when expanding behavior instead of breaking existing scripts. 3. Capture new command patterns or usage tips in this document to guide future agents. 4. Root and selected subcommand help grouping lives in `src/Grace.CLI/Program.CLI.fs` under `rootHelpSections` and the related `*HelpSections` lists; update those lists when adding or renaming commands so new entries do not silently drift into "Other". ## Local State DB - Local status and object cache are stored in `.grace/grace-local.db`. - SQLite side files (`.db-wal`, `.db-shm`, and optional `.db-journal`) are internal; ignore them in repo scans and watch change detection except for status-change coordination. ## Recent Patterns - `grace history` commands operate without requiring a repo `graceconfig.json`. Avoid `Configuration.Current()` in history-related flows. - `grace connect` accepts a positional shortcut in the form `owner/organization/repository`; do not combine it with explicit owner, organization, or repository options. ## Continuous Review Commands - `grace workitem` (aliases: `work`, `work-item`, `wi`) covers create/show/status, linking references or promotion sets, and attach flows (`summary`, `prompt`, `notes`). - `grace review` covers inbox/open/checkpoint/delta/resolve/deepen. Inbox and delta remain CLI stubs until server endpoints land. - `grace queue` covers status/enqueue/pause/resume/dequeue; prefer `--branch` but `--branch-id`/`--branch-name` still work. ## Validation - Add option parsing tests and handler unit tests for new functionality. - Manually exercise impacted commands when practical and ensure `dotnet build --configuration Release` stays green. ## Command Modules (`Grace.CLI.Command`) - Parameter classes usually derive from `ParameterBase()`. Keep them lightweight and validated at construction. - Organize command-specific helpers under `Options` modules and wrap invocation with `CommandHandler.Create`. - Enforce that command parsing remains thin. Push complex behavior into services so tests can cover edge cases. - Add parsing and handler behavior tests in tandem with any new command implementation. ================================================ FILE: src/Grace.CLI/Command/Access.CLI.fs ================================================ namespace Grace.CLI.Command open FSharpPlus open Grace.CLI.Common open Grace.CLI.Common.Validations open Grace.CLI.Services open Grace.CLI.Text open Grace.SDK open Grace.Shared open Grace.Shared.Client.Configuration open Grace.Shared.Parameters.Access open Grace.Shared.Parameters.Common open Grace.Shared.Utilities open Grace.Shared.Validation.Errors open Grace.Types.Authorization open Grace.Types.Types open Spectre.Console open Spectre.Console.Json open System open System.Collections.Generic open System.CommandLine open System.CommandLine.Invocation open System.CommandLine.Parsing open System.Threading open System.Threading.Tasks module Access = module private Options = let ownerId = new Option( OptionName.OwnerId, Required = false, Description = "The owner ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OwnerId.Empty) ) let organizationId = new Option( OptionName.OrganizationId, Required = false, Description = "The organization ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OrganizationId.Empty) ) let repositoryId = new Option( OptionName.RepositoryId, [| "-r" |], Required = false, Description = "The repository ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> RepositoryId.Empty) ) let branchId = new Option( OptionName.BranchId, Required = false, Description = "The branch ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> BranchId.Empty) ) let principalTypeRequired = (new Option( OptionName.PrincipalType, Required = true, Description = "The principal type (User, Group, Service).", Arity = ArgumentArity.ExactlyOne )) .AcceptOnlyFromAmong(listCases ()) let principalIdRequired = new Option(OptionName.PrincipalId, Required = true, Description = "The principal identifier.", Arity = ArgumentArity.ExactlyOne) let principalTypeOptional = (new Option( OptionName.PrincipalType, Required = false, Description = "Optional principal type filter (User, Group, Service).", Arity = ArgumentArity.ZeroOrOne )) .AcceptOnlyFromAmong(listCases ()) let principalIdOptional = new Option(OptionName.PrincipalId, Required = false, Description = "Optional principal identifier filter.", Arity = ArgumentArity.ZeroOrOne) let scopeKindRequired = (new Option( OptionName.ScopeKind, Required = true, Description = "Scope kind (system, owner, org, repo, branch).", Arity = ArgumentArity.ExactlyOne )) .AcceptOnlyFromAmong( [| "system" "owner" "org" "organization" "repo" "repository" "branch" |] ) let roleId = new Option(OptionName.RoleId, Required = true, Description = "Role identifier.", Arity = ArgumentArity.ExactlyOne) let source = new Option(OptionName.Source, Required = false, Description = "Optional role assignment source.", Arity = ArgumentArity.ZeroOrOne) let sourceDetail = new Option( OptionName.SourceDetail, Required = false, Description = "Optional role assignment source detail.", Arity = ArgumentArity.ZeroOrOne ) let pathRequired = new Option(OptionName.Path, Required = true, Description = "Repository relative path.", Arity = ArgumentArity.ExactlyOne) let pathOptional = new Option(OptionName.Path, Required = false, Description = "Optional repository relative path filter.", Arity = ArgumentArity.ZeroOrOne) let claim = new Option( OptionName.Claim, Required = true, Description = "Claim to grant permissions for (repeatable).", Arity = ArgumentArity.OneOrMore ) let directoryPermission = new Option( OptionName.DirectoryPermission, Required = true, Description = "Directory permission to apply (repeatable; match --claim order).", Arity = ArgumentArity.OneOrMore ) let operationRequired = (new Option(OptionName.Operation, Required = true, Description = "Operation to check.", Arity = ArgumentArity.ExactlyOne)) .AcceptOnlyFromAmong(listCases ()) let resourceKindRequired = (new Option( OptionName.ResourceKind, Required = true, Description = "Resource kind (system, owner, org, repo, branch, path).", Arity = ArgumentArity.ExactlyOne )) .AcceptOnlyFromAmong( [| "system" "owner" "org" "organization" "repo" "repository" "branch" "path" |] ) let private formatScope (scope: Scope) = match scope with | Scope.System -> "System" | Scope.Owner ownerId -> $"Owner:{ownerId}" | Scope.Organization (ownerId, organizationId) -> $"Org:{ownerId}/{organizationId}" | Scope.Repository (ownerId, organizationId, repositoryId) -> $"Repo:{ownerId}/{organizationId}/{repositoryId}" | Scope.Branch (ownerId, organizationId, repositoryId, branchId) -> $"Branch:{ownerId}/{organizationId}/{repositoryId}/{branchId}" let private formatClaimPermissions (permissions: IEnumerable) = permissions |> Seq.map (fun permission -> $"{permission.Claim}:{permission.DirectoryPermission}") |> String.concat ", " let private renderAssignments (parseResult: ParseResult) (assignments: RoleAssignment list) = if parseResult |> hasOutput then if Seq.isEmpty assignments then logToAnsiConsole Colors.Highlighted "No role assignments found." else let table = Table(Border = TableBorder.DoubleEdge) table.AddColumns( [| TableColumn($"[{Colors.Important}]Principal[/]") TableColumn($"[{Colors.Important}]Scope[/]") TableColumn($"[{Colors.Important}]Role[/]") TableColumn($"[{Colors.Important}]Source[/]") TableColumn($"[{Colors.Important}]Created[/]") |] ) |> ignore for assignment in assignments do let principalText = $"{assignment.Principal.PrincipalType}:{assignment.Principal.PrincipalId}" let sourceDetail = assignment.SourceDetail |> Option.defaultValue "" let sourceText = if String.IsNullOrWhiteSpace sourceDetail then assignment.Source else $"{assignment.Source} ({sourceDetail})" table.AddRow( $"[{Colors.Deemphasized}]{principalText}[/]", formatScope assignment.Scope, assignment.RoleId, sourceText, formatInstantExtended assignment.CreatedAt ) |> ignore AnsiConsole.Write(table) let private renderRoles (parseResult: ParseResult) (roles: RoleDefinition list) = if parseResult |> hasOutput then if Seq.isEmpty roles then logToAnsiConsole Colors.Highlighted "No roles found." else let table = Table(Border = TableBorder.DoubleEdge) table.AddColumns( [| TableColumn($"[{Colors.Important}]Role[/]") TableColumn($"[{Colors.Important}]Applies To[/]") TableColumn($"[{Colors.Important}]Operations[/]") |] ) |> ignore for role in roles do let appliesTo = role.AppliesTo |> Seq.sort |> String.concat ", " let operations = role.AllowedOperations |> Seq.map string |> Seq.sort |> String.concat ", " table.AddRow($"[{Colors.Deemphasized}]{role.RoleId}[/]", appliesTo, operations) |> ignore AnsiConsole.Write(table) let private renderPathPermissions (parseResult: ParseResult) (pathPermissions: PathPermission list) = if parseResult |> hasOutput then if Seq.isEmpty pathPermissions then logToAnsiConsole Colors.Highlighted "No path permissions found." else let table = Table(Border = TableBorder.DoubleEdge) table.AddColumns( [| TableColumn($"[{Colors.Important}]Path[/]") TableColumn($"[{Colors.Important}]Claims[/]") |] ) |> ignore for pathPermission in pathPermissions do let claims = formatClaimPermissions pathPermission.Permissions table.AddRow($"[{Colors.Deemphasized}]{pathPermission.Path}[/]", claims) |> ignore AnsiConsole.Write(table) let private renderPermissionCheck (parseResult: ParseResult) (result: PermissionCheckResult) = if parseResult |> hasOutput then match result with | Allowed reason -> AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Allowed[/]: {Markup.Escape(reason)}") | Denied reason -> AnsiConsole.MarkupLine($"[{Colors.Error}]Denied[/]: {Markup.Escape(reason)}") let private validateClaimPermissions (parseResult: ParseResult) = let correlationId = getCorrelationId parseResult let claims = parseResult.GetValue(Options.claim) let permissions = parseResult.GetValue(Options.directoryPermission) if isNull claims || claims.Length = 0 then Error(GraceError.Create "At least one --claim value is required." correlationId) elif isNull permissions || permissions.Length = 0 then Error(GraceError.Create "At least one --dir-perm value is required." correlationId) elif claims.Length <> permissions.Length then Error(GraceError.Create "--claim and --dir-perm counts must match." correlationId) else let invalid = permissions |> Array.tryFind (fun value -> discriminatedUnionFromString value |> Option.isNone) match invalid with | Some value -> Error(GraceError.Create $"Invalid DirectoryPermission '{value}'." correlationId) | None -> Ok parseResult let private validatePrincipalFilter (parseResult: ParseResult) (principalType: string option) (principalId: string option) = let correlationId = getCorrelationId parseResult let principalTypeValue = principalType |> Option.defaultValue String.Empty let principalIdValue = principalId |> Option.defaultValue String.Empty if String.IsNullOrWhiteSpace principalTypeValue && String.IsNullOrWhiteSpace principalIdValue then Ok parseResult elif String.IsNullOrWhiteSpace principalTypeValue || String.IsNullOrWhiteSpace principalIdValue then Error(GraceError.Create "PrincipalType and PrincipalId must be provided together." correlationId) else Ok parseResult let private validatePathResource (parseResult: ParseResult) (resourceKind: string) (pathValue: string) = if resourceKind.Equals("path", StringComparison.InvariantCultureIgnoreCase) && String.IsNullOrWhiteSpace pathValue then Error(GraceError.Create "Path is required for Path resources." (getCorrelationId parseResult)) else Ok parseResult type GrantRole() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = GrantRoleParameters( OwnerId = graceIds.OwnerIdString, OrganizationId = graceIds.OrganizationIdString, RepositoryId = graceIds.RepositoryIdString, BranchId = graceIds.BranchIdString, PrincipalType = parseResult.GetValue(Options.principalTypeRequired), PrincipalId = parseResult.GetValue(Options.principalIdRequired), ScopeKind = parseResult.GetValue(Options.scopeKindRequired), RoleId = parseResult.GetValue(Options.roleId), Source = parseResult.GetValue(Options.source), SourceDetail = parseResult.GetValue(Options.sourceDetail), CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Access.GrantRole(parameters) t0.Increment(100.0) return response }) else Access.GrantRole(parameters) match result with | Ok graceReturnValue -> renderAssignments parseResult graceReturnValue.ReturnValue return result |> renderOutput parseResult | Error error -> logToAnsiConsole Colors.Error (Markup.Escape($"{error}")) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } type RevokeRole() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = RevokeRoleParameters( OwnerId = graceIds.OwnerIdString, OrganizationId = graceIds.OrganizationIdString, RepositoryId = graceIds.RepositoryIdString, BranchId = graceIds.BranchIdString, PrincipalType = parseResult.GetValue(Options.principalTypeRequired), PrincipalId = parseResult.GetValue(Options.principalIdRequired), ScopeKind = parseResult.GetValue(Options.scopeKindRequired), RoleId = parseResult.GetValue(Options.roleId), CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Access.RevokeRole(parameters) t0.Increment(100.0) return response }) else Access.RevokeRole(parameters) match result with | Ok graceReturnValue -> renderAssignments parseResult graceReturnValue.ReturnValue return result |> renderOutput parseResult | Error error -> logToAnsiConsole Colors.Error (Markup.Escape($"{error}")) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } type ListRoleAssignments() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let principalType = parseResult.GetValue(Options.principalTypeOptional) |> Option.ofObj let principalId = parseResult.GetValue(Options.principalIdOptional) |> Option.ofObj let validateIncomingParameters = parseResult |> CommonValidations >>= (fun _ -> validatePrincipalFilter parseResult principalType principalId) match validateIncomingParameters with | Ok _ -> let parameters = ListRoleAssignmentsParameters( OwnerId = graceIds.OwnerIdString, OrganizationId = graceIds.OrganizationIdString, RepositoryId = graceIds.RepositoryIdString, BranchId = graceIds.BranchIdString, PrincipalType = (principalType |> Option.defaultValue ""), PrincipalId = (principalId |> Option.defaultValue ""), ScopeKind = parseResult.GetValue(Options.scopeKindRequired), CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Access.ListRoleAssignments(parameters) t0.Increment(100.0) return response }) else Access.ListRoleAssignments(parameters) match result with | Ok graceReturnValue -> renderAssignments parseResult graceReturnValue.ReturnValue return result |> renderOutput parseResult | Error error -> logToAnsiConsole Colors.Error (Markup.Escape($"{error}")) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } type UpsertPathPermission() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> CommonValidations >>= validateClaimPermissions match validateIncomingParameters with | Ok _ -> let claims = parseResult.GetValue(Options.claim) let permissions = parseResult.GetValue(Options.directoryPermission) let claimPermissions = List() for index in 0 .. claims.Length - 1 do claimPermissions.Add(ClaimPermissionParameters(Claim = claims[index], DirectoryPermission = permissions[index])) let parameters = UpsertPathPermissionParameters( OwnerId = graceIds.OwnerIdString, OrganizationId = graceIds.OrganizationIdString, RepositoryId = graceIds.RepositoryIdString, Path = parseResult.GetValue(Options.pathRequired), ClaimPermissions = claimPermissions, CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Access.UpsertPathPermission(parameters) t0.Increment(100.0) return response }) else Access.UpsertPathPermission(parameters) match result with | Ok graceReturnValue -> renderPathPermissions parseResult graceReturnValue.ReturnValue return result |> renderOutput parseResult | Error error -> logToAnsiConsole Colors.Error (Markup.Escape($"{error}")) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } type RemovePathPermission() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = RemovePathPermissionParameters( OwnerId = graceIds.OwnerIdString, OrganizationId = graceIds.OrganizationIdString, RepositoryId = graceIds.RepositoryIdString, Path = parseResult.GetValue(Options.pathRequired), CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Access.RemovePathPermission(parameters) t0.Increment(100.0) return response }) else Access.RemovePathPermission(parameters) match result with | Ok graceReturnValue -> renderPathPermissions parseResult graceReturnValue.ReturnValue return result |> renderOutput parseResult | Error error -> logToAnsiConsole Colors.Error (Markup.Escape($"{error}")) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } type ListPathPermissions() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = ListPathPermissionsParameters( OwnerId = graceIds.OwnerIdString, OrganizationId = graceIds.OrganizationIdString, RepositoryId = graceIds.RepositoryIdString, Path = (parseResult.GetValue(Options.pathOptional) |> Option.ofObj |> Option.defaultValue ""), CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Access.ListPathPermissions(parameters) t0.Increment(100.0) return response }) else Access.ListPathPermissions(parameters) match result with | Ok graceReturnValue -> renderPathPermissions parseResult graceReturnValue.ReturnValue return result |> renderOutput parseResult | Error error -> logToAnsiConsole Colors.Error (Markup.Escape($"{error}")) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } type CheckPermission() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let resourceKind = parseResult.GetValue(Options.resourceKindRequired) let pathValue = parseResult.GetValue(Options.pathOptional) |> Option.ofObj |> Option.defaultValue "" let principalType = parseResult.GetValue(Options.principalTypeOptional) |> Option.ofObj let principalId = parseResult.GetValue(Options.principalIdOptional) |> Option.ofObj let validateIncomingParameters = parseResult |> CommonValidations >>= (fun _ -> validatePrincipalFilter parseResult principalType principalId) >>= (fun _ -> validatePathResource parseResult resourceKind pathValue) match validateIncomingParameters with | Ok _ -> let parameters = CheckPermissionParameters( OwnerId = graceIds.OwnerIdString, OrganizationId = graceIds.OrganizationIdString, RepositoryId = graceIds.RepositoryIdString, BranchId = graceIds.BranchIdString, Operation = parseResult.GetValue(Options.operationRequired), ResourceKind = resourceKind, Path = pathValue, PrincipalType = (principalType |> Option.defaultValue ""), PrincipalId = (principalId |> Option.defaultValue ""), CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Checking permission.[/]") let! response = Access.CheckPermission(parameters) t0.Increment(100.0) return response }) else Access.CheckPermission(parameters) match result with | Ok graceReturnValue -> renderPermissionCheck parseResult graceReturnValue.ReturnValue return result |> renderOutput parseResult | Error error -> logToAnsiConsole Colors.Error (Markup.Escape($"{error}")) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } type ListRoles() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { try if parseResult |> verbose then printParseResult parseResult let parameters = CommonParameters(CorrelationId = getCorrelationId parseResult) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Access.ListRoles(parameters) t0.Increment(100.0) return response }) else Access.ListRoles(parameters) match result with | Ok graceReturnValue -> renderRoles parseResult graceReturnValue.ReturnValue return result |> renderOutput parseResult | Error error -> logToAnsiConsole Colors.Error (Markup.Escape($"{error}")) return result |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } let Build = let addScopeOptions (command: Command) = command |> addOption Options.ownerId |> addOption Options.organizationId |> addOption Options.repositoryId |> addOption Options.branchId let accessCommand = new Command("access", Description = "Manages access control and permissions.") let grantRoleCommand = new Command("grant-role", Description = "Grants a role to a principal at a scope.") |> addScopeOptions |> addOption Options.scopeKindRequired |> addOption Options.principalTypeRequired |> addOption Options.principalIdRequired |> addOption Options.roleId |> addOption Options.source |> addOption Options.sourceDetail grantRoleCommand.Action <- new GrantRole() accessCommand.Subcommands.Add(grantRoleCommand) let revokeRoleCommand = new Command("revoke-role", Description = "Revokes a role from a principal at a scope.") |> addScopeOptions |> addOption Options.scopeKindRequired |> addOption Options.principalTypeRequired |> addOption Options.principalIdRequired |> addOption Options.roleId revokeRoleCommand.Action <- new RevokeRole() accessCommand.Subcommands.Add(revokeRoleCommand) let listRoleAssignmentsCommand = new Command("list-role-assignments", Description = "Lists role assignments at a scope.") |> addScopeOptions |> addOption Options.scopeKindRequired |> addOption Options.principalTypeOptional |> addOption Options.principalIdOptional listRoleAssignmentsCommand.Action <- new ListRoleAssignments() accessCommand.Subcommands.Add(listRoleAssignmentsCommand) let upsertPathPermissionCommand = new Command("upsert-path-permission", Description = "Upserts repository path permissions.") |> addScopeOptions |> addOption Options.pathRequired |> addOption Options.claim |> addOption Options.directoryPermission upsertPathPermissionCommand.Action <- new UpsertPathPermission() accessCommand.Subcommands.Add(upsertPathPermissionCommand) let removePathPermissionCommand = new Command("remove-path-permission", Description = "Removes repository path permissions.") |> addScopeOptions |> addOption Options.pathRequired removePathPermissionCommand.Action <- new RemovePathPermission() accessCommand.Subcommands.Add(removePathPermissionCommand) let listPathPermissionsCommand = new Command("list-path-permissions", Description = "Lists repository path permissions.") |> addScopeOptions |> addOption Options.pathOptional listPathPermissionsCommand.Action <- new ListPathPermissions() accessCommand.Subcommands.Add(listPathPermissionsCommand) let checkPermissionCommand = new Command("check", Description = "Checks a permission for the current or specified principal.") |> addScopeOptions |> addOption Options.operationRequired |> addOption Options.resourceKindRequired |> addOption Options.pathOptional |> addOption Options.principalTypeOptional |> addOption Options.principalIdOptional checkPermissionCommand.Action <- new CheckPermission() accessCommand.Subcommands.Add(checkPermissionCommand) let listRolesCommand = new Command("list-roles", Description = "Lists available roles.") listRolesCommand.Action <- new ListRoles() accessCommand.Subcommands.Add(listRolesCommand) accessCommand ================================================ FILE: src/Grace.CLI/Command/Admin.CLI.fs ================================================ namespace Grace.CLI.Command open FSharpPlus open Grace.CLI.Common open Grace.CLI.Common.Validations open Grace.CLI.Services open Grace.CLI.Text open Grace.SDK.Admin open Grace.Shared open Grace.Shared.Parameters open Grace.Shared.Parameters.Reminder open Grace.Types.Reminder open Grace.Types.Types open Grace.Shared.Utilities open Grace.Shared.Client.Configuration open Grace.Shared.Validation.Errors open NodaTime open Spectre.Console open Spectre.Console.Json open System open System.Collections.Generic open System.CommandLine open System.CommandLine.Invocation open System.CommandLine.Parsing open System.Linq open System.Threading open System.Threading.Tasks module Admin = module Reminder = module private Options = let ownerId = new Option( OptionName.OwnerId, Required = false, Description = "The repository's owner ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OwnerId.Empty) ) let ownerName = new Option( OptionName.OwnerName, Required = false, Description = "The repository's owner name. [default: current owner]", Arity = ArgumentArity.ExactlyOne ) let organizationId = new Option( OptionName.OrganizationId, Required = false, Description = "The repository's organization ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OrganizationId.Empty) ) let organizationName = new Option( OptionName.OrganizationName, Required = false, Description = "The repository's organization name. [default: current organization]", Arity = ArgumentArity.ZeroOrOne ) let repositoryId = new Option( OptionName.RepositoryId, [| "-r" |], Required = false, Description = "The repository's ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> RepositoryId.Empty) ) let repositoryName = new Option( OptionName.RepositoryName, [| "-n" |], Required = false, Description = "The name of the repository. [default: current repository]", Arity = ArgumentArity.ExactlyOne ) let reminderId = new Option("--reminder-id", Required = true, Description = "The ID of the reminder .", Arity = ArgumentArity.ExactlyOne) let maxCount = new Option( "--max-count", Required = false, Description = $"Maximum number of reminders to return. [default: {Constants.DefaultReminderMaxCount}]", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> Constants.DefaultReminderMaxCount) ) let reminderType = (new Option( "--reminder-type", Required = false, Description = "Filter by reminder type (Maintenance, PhysicalDeletion, DeleteCachedState, DeleteZipFile).", Arity = ArgumentArity.ZeroOrOne )) .AcceptOnlyFromAmong(listCases ()) let actorName = new Option( "--actor-name", Required = false, Description = "Filter by target actor name (e.g., Branch, Repository, Owner).", Arity = ArgumentArity.ZeroOrOne ) let actorId = new Option("--actor-id", Required = true, Description = "The target actor ID.", Arity = ArgumentArity.ExactlyOne) let dueAfter = new Option( "--due-after", Required = false, Description = "Filter by reminders due after this time (ISO8601).", Arity = ArgumentArity.ZeroOrOne ) let dueBefore = new Option( "--due-before", Required = false, Description = "Filter by reminders due before this time (ISO8601).", Arity = ArgumentArity.ZeroOrOne ) let fireAt = new Option( "--fire-at", Required = true, Description = "When the reminder should fire (ISO8601 format).", Arity = ArgumentArity.ExactlyOne ) let afterDuration = new Option( "--after", Required = true, Description = "Duration to add relative to now (e.g., +15m, +1h, +1d).", Arity = ArgumentArity.ExactlyOne ) let stateJson = new Option( "--state-json", Required = false, Description = "Optional JSON payload for the reminder state.", Arity = ArgumentArity.ZeroOrOne ) let private listRemindersWithProgress (parameters: ListRemindersParameters) = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Reminder.List(parameters) t0.Increment(100.0) return response }) // List subcommand type List() = inherit AsynchronousCommandLineAction() let listRemindersImpl (parseResult: ParseResult) : Tasks.Task = if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Error error -> Task.FromResult( GraceResult.Error error |> renderOutput parseResult ) | Ok _ -> let graceIds = parseResult |> getNormalizedIdsAndNames let parameters = ListRemindersParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, MaxCount = parseResult.GetValue(Options.maxCount), ReminderType = (parseResult.GetValue(Options.reminderType) |> Option.ofObj |> Option.defaultValue ""), ActorName = (parseResult.GetValue(Options.actorName) |> Option.ofObj |> Option.defaultValue ""), DueAfter = (parseResult.GetValue(Options.dueAfter) |> Option.ofObj |> Option.defaultValue ""), DueBefore = (parseResult.GetValue(Options.dueBefore) |> Option.ofObj |> Option.defaultValue ""), CorrelationId = getCorrelationId parseResult ) task { let! result = if parseResult |> hasOutput then listRemindersWithProgress parameters else Reminder.List(parameters) match result with | Ok graceReturnValue -> if parseResult |> hasOutput then let reminders = graceReturnValue.ReturnValue if Seq.isEmpty reminders then logToAnsiConsole Colors.Highlighted "No reminders found." else let table = Table(Border = TableBorder.DoubleEdge) table.AddColumns( [| TableColumn($"[{Colors.Important}]Reminder ID[/]") TableColumn($"[{Colors.Important}]Type[/]") TableColumn($"[{Colors.Important}]Actor[/]") TableColumn($"[{Colors.Important}]Fire Time[/]") TableColumn($"[{Colors.Important}]Created At[/]") |] ) |> ignore reminders |> Seq.iter (fun reminder -> let actorId = if reminder.ActorId.Length > 8 then reminder.ActorId.Substring(0, 8) else reminder.ActorId table.AddRow( $"{reminder.ReminderId}", $"{reminder.ReminderType}", $"{actorId}", $"{reminder.ReminderTime}", $"{reminder.CreatedAt}" ) |> ignore) AnsiConsole.Write(table) | Error _ -> () return result |> renderOutput parseResult } override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try return! listRemindersImpl parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } // Get subcommand type Get() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let graceIds = parseResult |> getNormalizedIdsAndNames let parameters = GetReminderParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, ReminderId = parseResult.GetValue(Options.reminderId), CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Reminder.Get(parameters) t0.Increment(100.0) return response }) else Reminder.Get(parameters) match result with | Ok graceReturnValue -> let jsonText = JsonText(serialize graceReturnValue.ReturnValue) AnsiConsole.Write(jsonText) AnsiConsole.WriteLine() return Ok graceReturnValue |> renderOutput parseResult | Error graceError -> logToAnsiConsole Colors.Error (Markup.Escape($"{graceError}")) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } // Delete subcommand type Delete() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let graceIds = parseResult |> getNormalizedIdsAndNames let parameters = DeleteReminderParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, ReminderId = parseResult.GetValue(Options.reminderId), CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Reminder.Delete(parameters) t0.Increment(100.0) return response }) else Reminder.Delete(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } // UpdateTime subcommand type UpdateTime() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let graceIds = parseResult |> getNormalizedIdsAndNames let parameters = UpdateReminderTimeParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, ReminderId = parseResult.GetValue(Options.reminderId), FireAt = parseResult.GetValue(Options.fireAt), CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Reminder.UpdateTime(parameters) t0.Increment(100.0) return response }) else Reminder.UpdateTime(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } // Reschedule subcommand type Reschedule() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let graceIds = parseResult |> getNormalizedIdsAndNames let parameters = RescheduleReminderParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, ReminderId = parseResult.GetValue(Options.reminderId), After = parseResult.GetValue(Options.afterDuration), CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Reminder.Reschedule(parameters) t0.Increment(100.0) return response }) else Reminder.Reschedule(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } // Create subcommand type Create() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let graceIds = parseResult |> getNormalizedIdsAndNames let parameters = CreateReminderParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, ActorName = parseResult.GetValue(Options.actorName), ActorId = parseResult.GetValue(Options.actorId), ReminderType = parseResult.GetValue(Options.reminderType), FireAt = parseResult.GetValue(Options.fireAt), StateJson = (parseResult.GetValue(Options.stateJson) |> Option.ofObj |> Option.defaultValue ""), CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Reminder.Create(parameters) t0.Increment(100.0) return response }) else Reminder.Create(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } /// Builds the Reminder subcommand. let Build = let addCommonOptions (command: Command) = command |> addOption Options.ownerName |> addOption Options.ownerId |> addOption Options.organizationName |> addOption Options.organizationId |> addOption Options.repositoryId |> addOption Options.repositoryName // Create main command and aliases let reminderCommand = new Command("reminder", Description = "Administrative commands for managing Grace reminders.") // List subcommand let listCommand = new Command("list", Description = "List reminders for the repository.") |> addCommonOptions |> addOption Options.maxCount |> addOption Options.reminderType |> addOption Options.actorName |> addOption Options.dueAfter |> addOption Options.dueBefore listCommand.Aliases.Add("ls") listCommand.Action <- new List() reminderCommand.Subcommands.Add(listCommand) // Get subcommand let getCommand = new Command("get", Description = "Get details of a specific reminder.") |> addCommonOptions |> addOption Options.reminderId getCommand.Action <- new Get() reminderCommand.Subcommands.Add(getCommand) // Delete subcommand let deleteCommand = new Command("delete", Description = "Delete a reminder.") |> addCommonOptions |> addOption Options.reminderId deleteCommand.Action <- new Delete() reminderCommand.Subcommands.Add(deleteCommand) // UpdateTime subcommand let updateTimeCommand = new Command("update-time", Description = "Update the fire time for a reminder.") |> addCommonOptions |> addOption Options.reminderId |> addOption Options.fireAt updateTimeCommand.Action <- new UpdateTime() reminderCommand.Subcommands.Add(updateTimeCommand) // Reschedule subcommand let rescheduleCommand = new Command("reschedule", Description = "Reschedule a reminder relative to now.") |> addCommonOptions |> addOption Options.reminderId |> addOption Options.afterDuration rescheduleCommand.Action <- new Reschedule() reminderCommand.Subcommands.Add(rescheduleCommand) // Create subcommand let createActorNameOption = new Option( "--actor-name", Required = true, Description = "The target actor name (e.g., Branch, Repository, Owner).", Arity = ArgumentArity.ExactlyOne ) let createReminderTypeOption = (new Option( "--reminder-type", Required = true, Description = "The type of reminder (Maintenance, PhysicalDeletion, DeleteCachedState, DeleteZipFile).", Arity = ArgumentArity.ExactlyOne )) .AcceptOnlyFromAmong(listCases ()) let createCommand = new Command("create", Description = "Create a new manual reminder.") |> addCommonOptions |> addOption createActorNameOption |> addOption Options.actorId |> addOption createReminderTypeOption |> addOption Options.fireAt |> addOption Options.stateJson createCommand.Action <- new Create() reminderCommand.Subcommands.Add(createCommand) reminderCommand /// Builds the Admin subcommand. let Build = // Create main command and aliases let adminCommand = new Command("admin", Description = "Administrative commands for managing Grace.") adminCommand.Add(Reminder.Build) |> ignore adminCommand ================================================ FILE: src/Grace.CLI/Command/Agent.CLI.fs ================================================ namespace Grace.CLI.Command open Grace.CLI.Common open Grace.CLI.Services open Grace.CLI.Text open Grace.SDK open Grace.Shared open Grace.Shared.Client.Configuration open Grace.Shared.Parameters.Common open Grace.Shared.Utilities open Grace.Shared.Validation.Errors open Grace.Types.Automation open Grace.Types.Types open Spectre.Console open System open System.CommandLine open System.CommandLine.Invocation open System.CommandLine.Parsing open System.IO open System.Security.Cryptography open System.Threading open System.Threading.Tasks module AgentCommand = module private Options = let addSummaryWorkItemId = new Option( "--work-item-id", [| "--workItemId"; "-w" |], Required = true, Description = "The work item ID or work item number .", Arity = ArgumentArity.ExactlyOne ) let startWorkItemId = new Option( "--work-item-id", [| "--workItemId"; "-w" |], Required = true, Description = "The work item ID or work item number .", Arity = ArgumentArity.ExactlyOne ) let optionalWorkItemId = new Option( "--work-item-id", [| "--workItemId"; "-w" |], Required = false, Description = "Optional work item ID or work item number .", Arity = ArgumentArity.ExactlyOne ) let summaryFile = new Option( "--summary-file", [| "--summaryFile" |], Required = true, Description = "Path to the summary file to upload.", Arity = ArgumentArity.ExactlyOne ) let promptFile = new Option( "--prompt-file", [| "--promptFile" |], Required = false, Description = "Optional path to the prompt file to upload.", Arity = ArgumentArity.ExactlyOne ) let promptOrigin = new Option( "--prompt-origin", [| "--promptOrigin" |], Required = false, Description = "Optional prompt origin metadata.", Arity = ArgumentArity.ExactlyOne ) let addSummaryPromotionSetId = new Option( "--promotion-set-id", [| "--promotion-set" |], Required = false, Description = "Optional promotion set ID to link as part of add-summary.", Arity = ArgumentArity.ExactlyOne ) let agentId = new Option("--agent-id", [| "--agentId" |], Required = true, Description = "The agent ID .", Arity = ArgumentArity.ExactlyOne) let displayName = new Option( "--display-name", [| "--displayName" |], Required = true, Description = "The agent display name.", Arity = ArgumentArity.ExactlyOne ) let source = new Option( OptionName.Source, Required = false, Description = "The source identifier for this agent session. [default: cli]", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> "cli") ) let promotionSetId = new Option( "--promotion-set-id", [| "--promotion-set" |], Required = false, Description = "Optional promotion set ID .", Arity = ArgumentArity.ExactlyOne ) let sessionId = new Option( "--session-id", [| "--sessionId" |], Required = false, Description = "Optional session ID to stop or inspect.", Arity = ArgumentArity.ExactlyOne ) let stopReason = new Option( "--reason", [| "--stop-reason"; "--stopReason" |], Required = false, Description = "Optional reason to record when stopping work.", Arity = ArgumentArity.ExactlyOne ) let operationId = new Option( "--operation-id", [| "--operationId" |], Required = false, Description = "Optional idempotency token for deterministic replay.", Arity = ArgumentArity.ExactlyOne ) let ownerId = new Option( OptionName.OwnerId, Required = false, Description = "The repository's owner ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OwnerId.Empty) ) let ownerName = new Option( OptionName.OwnerName, Required = false, Description = "The repository's owner name. [default: current owner]", Arity = ArgumentArity.ExactlyOne ) let organizationId = new Option( OptionName.OrganizationId, Required = false, Description = "The organization's ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OrganizationId.Empty) ) let organizationName = new Option( OptionName.OrganizationName, Required = false, Description = "The organization's name. [default: current organization]", Arity = ArgumentArity.ExactlyOne ) let repositoryId = new Option( OptionName.RepositoryId, Required = false, Description = "The repository's ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> RepositoryId.Empty) ) let repositoryName = new Option( OptionName.RepositoryName, Required = false, Description = "The repository's name. [default: current repository]", Arity = ArgumentArity.ExactlyOne ) type private AddSummaryResult = { WorkItem: string; SummaryArtifactId: string; PromptArtifactId: string option; PromotionSetId: string option } type private LocalAgentSessionState = { AgentId: string AgentDisplayName: string Source: string ActiveSessionId: string ActiveWorkItemIdOrNumber: string ActivePromotionSetId: string LastOperationId: string LastCorrelationId: string LastUpdatedAtUtc: DateTime } let private localAgentSessionStateDefault = { AgentId = String.Empty AgentDisplayName = String.Empty Source = "cli" ActiveSessionId = String.Empty ActiveWorkItemIdOrNumber = String.Empty ActivePromotionSetId = String.Empty LastOperationId = String.Empty LastCorrelationId = String.Empty LastUpdatedAtUtc = DateTime.MinValue } let private localStateFileName = "agent-session-state.json" let private tryParseGuid (value: string) (errorMessage: string) (parseResult: ParseResult) = let mutable parsed = Guid.Empty if String.IsNullOrWhiteSpace(value) || Guid.TryParse(value, &parsed) = false || parsed = Guid.Empty then Error(GraceError.Create errorMessage (getCorrelationId parseResult)) else Ok parsed let private tryNormalizeWorkItemIdentifier (value: string) (parseResult: ParseResult) = let mutable parsedGuid = Guid.Empty if String.IsNullOrWhiteSpace(value) then Error(GraceError.Create (WorkItemError.getErrorMessage WorkItemError.InvalidWorkItemId) (getCorrelationId parseResult)) elif Guid.TryParse(value, &parsedGuid) && parsedGuid <> Guid.Empty then Ok(parsedGuid.ToString()) else let mutable parsedNumber = 0L if Int64.TryParse(value, &parsedNumber) then if parsedNumber > 0L then Ok(parsedNumber.ToString()) else Error(GraceError.Create (WorkItemError.getErrorMessage WorkItemError.InvalidWorkItemNumber) (getCorrelationId parseResult)) else Error(GraceError.Create (WorkItemError.getErrorMessage WorkItemError.InvalidWorkItemId) (getCorrelationId parseResult)) let private tryGetOptionString (parseResult: ParseResult) (option: Option) = parseResult.GetValue(option) |> Option.ofObj |> Option.map (fun value -> value.Trim()) |> Option.filter (fun value -> not <| String.IsNullOrWhiteSpace(value)) let private hasRepositoryContextOption (parseResult: ParseResult) = [ OptionName.OwnerId OptionName.OwnerName OptionName.OrganizationId OptionName.OrganizationName OptionName.RepositoryId OptionName.RepositoryName ] |> List.exists (isOptionPresent parseResult) let private ensureRepositoryContextIsAvailable (parseResult: ParseResult) = if configurationFileExists () || hasRepositoryContextOption parseResult then Ok() else Error( GraceError.Create "No Grace repository configuration was found. Run `grace config write` first, or provide `--owner-id`, `--organization-id`, and `--repository-id`." (getCorrelationId parseResult) ) let private ensureRepositoryContextIsComplete (graceIds: GraceIds) (parseResult: ParseResult) = if graceIds.OwnerId = Guid.Empty && String.IsNullOrWhiteSpace(graceIds.OwnerName) then Error(GraceError.Create (getErrorMessage OwnerError.EitherOwnerIdOrOwnerNameRequired) (getCorrelationId parseResult)) elif graceIds.OrganizationId = Guid.Empty && String.IsNullOrWhiteSpace(graceIds.OrganizationName) then Error(GraceError.Create (getErrorMessage OrganizationError.EitherOrganizationIdOrOrganizationNameRequired) (getCorrelationId parseResult)) elif graceIds.RepositoryId = Guid.Empty && String.IsNullOrWhiteSpace(graceIds.RepositoryName) then Error(GraceError.Create (getErrorMessage RepositoryError.EitherRepositoryIdOrRepositoryNameRequired) (getCorrelationId parseResult)) else Ok() let private getLocalStateFilePath () = let graceDirectory = if configurationFileExists () then Current().GraceDirectory else Path.Combine(Environment.CurrentDirectory, Constants.GraceConfigDirectory) Path.GetFullPath(Path.Combine(graceDirectory, localStateFileName)) let private tryReadLocalState (parseResult: ParseResult) = let stateFilePath = getLocalStateFilePath () let correlationId = getCorrelationId parseResult try if not <| File.Exists(stateFilePath) then Ok(stateFilePath, localAgentSessionStateDefault) else let fileContent = File.ReadAllText(stateFilePath) if String.IsNullOrWhiteSpace(fileContent) then Ok(stateFilePath, localAgentSessionStateDefault) else let state = deserialize fileContent let normalizedState = if isNull (box state) then localAgentSessionStateDefault else state Ok(stateFilePath, normalizedState) with | ex -> Error( GraceError.Create ($"Unable to read local agent session state from {stateFilePath}. Run `grace agent bootstrap --agent-id --display-name ` to reset local state. Details: {ex.Message}") correlationId ) let private tryWriteLocalState (parseResult: ParseResult) (stateFilePath: string) (state: LocalAgentSessionState) = let correlationId = getCorrelationId parseResult try Directory.CreateDirectory(Path.GetDirectoryName(stateFilePath)) |> ignore File.WriteAllText(stateFilePath, serialize state) Ok() with | ex -> Error(GraceError.Create ($"Unable to write local agent session state to {stateFilePath}: {ex.Message}") correlationId) let private hasBootstrappedIdentity (state: LocalAgentSessionState) = not <| String.IsNullOrWhiteSpace(state.AgentId) && not <| String.IsNullOrWhiteSpace(state.AgentDisplayName) let private hasActiveSession (state: LocalAgentSessionState) = not <| String.IsNullOrWhiteSpace(state.ActiveSessionId) || not <| String.IsNullOrWhiteSpace(state.ActiveWorkItemIdOrNumber) let private createDefaultOperationId (prefix: string) (correlationId: string) = $"{prefix}:{correlationId}" let private normalizeOperationId (explicitOperationId: string option) (fallbackPrefix: string) (correlationId: string) = explicitOperationId |> Option.defaultValue (createDefaultOperationId fallbackPrefix correlationId) let private staleStateError (parseResult: ParseResult) (details: string) = GraceError.Create $"{details} Run `grace agent work status` and then `grace agent work stop` to reconcile local state." (getCorrelationId parseResult) let private ensureBootstrapped (parseResult: ParseResult) (state: LocalAgentSessionState) = if hasBootstrappedIdentity state then Ok() else Error( GraceError.Create "Agent identity is not bootstrapped. Run `grace agent bootstrap --agent-id --display-name ` first." (getCorrelationId parseResult) ) let private toSessionInfo (state: LocalAgentSessionState) (lifecycleState: AgentSessionLifecycleState) = { AgentSessionInfo.Default with SessionId = state.ActiveSessionId AgentId = state.AgentId AgentDisplayName = state.AgentDisplayName WorkItemIdOrNumber = state.ActiveWorkItemIdOrNumber PromotionSetId = state.ActivePromotionSetId Source = state.Source LifecycleState = lifecycleState } let private createLocalOperationResult (state: LocalAgentSessionState) (lifecycleState: AgentSessionLifecycleState) (message: string) (operationId: string) (wasReplay: bool) = { AgentSessionOperationResult.Default with Session = toSessionInfo state lifecycleState Message = message OperationId = operationId WasIdempotentReplay = wasReplay } let private writeOperationSummary (parseResult: ParseResult) (result: AgentSessionOperationResult) = if not (parseResult |> json) && not (parseResult |> silent) then let lifecycleState = getDiscriminatedUnionCaseName result.Session.LifecycleState AnsiConsole.MarkupLine($"[green]{Markup.Escape(result.Message)}[/]") if not <| String.IsNullOrWhiteSpace(result.Session.SessionId) then AnsiConsole.MarkupLine($"[bold]Session:[/] {Markup.Escape(result.Session.SessionId)}") if not <| String.IsNullOrWhiteSpace(result.Session.WorkItemIdOrNumber) then AnsiConsole.MarkupLine($"[bold]Work Item:[/] {Markup.Escape(result.Session.WorkItemIdOrNumber)}") if not <| String.IsNullOrWhiteSpace(result.OperationId) then AnsiConsole.MarkupLine($"[bold]Operation:[/] {Markup.Escape(result.OperationId)}") AnsiConsole.MarkupLine($"[bold]State:[/] {Markup.Escape(lifecycleState)}") if result.WasIdempotentReplay then AnsiConsole.MarkupLine("[yellow]Operation was handled as an idempotent replay.[/]") let private clearActiveSession (state: LocalAgentSessionState) (operationId: string) (correlationId: string) = { state with ActiveSessionId = String.Empty ActiveWorkItemIdOrNumber = String.Empty ActivePromotionSetId = String.Empty LastOperationId = operationId LastCorrelationId = correlationId LastUpdatedAtUtc = DateTime.UtcNow } let private tryNormalizePromotionSetId (value: string option) (parseResult: ParseResult) = match value with | None -> Ok String.Empty | Some promotionSetId -> match tryParseGuid promotionSetId "Promotion set ID must be a valid non-empty Guid." parseResult with | Error error -> Error error | Ok parsed -> Ok(parsed.ToString()) let private inferMimeType (filePath: string) = match Path.GetExtension(filePath).ToLowerInvariant() with | ".md" -> "text/markdown" | ".txt" -> "text/plain" | ".json" -> "application/json" | _ -> "application/octet-stream" let private tryReadFileContent (filePath: string) (displayName: string) (parseResult: ParseResult) = try Ok(File.ReadAllText(filePath)) with | ex -> Error(GraceError.Create ($"Failed to read {displayName} file '{filePath}': {ex.Message}") (getCorrelationId parseResult)) let private addSummaryHandler (parseResult: ParseResult) = async { if verbose parseResult then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let workItemIdRaw = parseResult.GetValue(Options.addSummaryWorkItemId) let summaryFilePath = parseResult.GetValue(Options.summaryFile) let promptOrigin = tryGetOptionString parseResult Options.promptOrigin |> Option.defaultValue String.Empty let promotionSetIdOption = tryGetOptionString parseResult Options.addSummaryPromotionSetId let promptFilePath = parseResult.GetValue(Options.promptFile) |> Option.ofObj |> Option.defaultValue String.Empty match ensureRepositoryContextIsComplete graceIds parseResult with | Error error -> return Error error | Ok _ -> match tryNormalizeWorkItemIdentifier workItemIdRaw parseResult with | Error error -> return Error error | Ok workItem -> if not <| File.Exists(summaryFilePath) then return Error(GraceError.Create $"Summary file does not exist: {summaryFilePath}" (getCorrelationId parseResult)) elif not (String.IsNullOrWhiteSpace(promptFilePath)) && not <| File.Exists(promptFilePath) then return Error(GraceError.Create $"Prompt file does not exist: {promptFilePath}" (getCorrelationId parseResult)) elif not (String.IsNullOrWhiteSpace(promptOrigin)) && String.IsNullOrWhiteSpace(promptFilePath) then return Error(GraceError.Create "Prompt origin can only be provided when --prompt-file is provided." (getCorrelationId parseResult)) else match tryNormalizePromotionSetId promotionSetIdOption parseResult with | Error error -> return Error error | Ok promotionSetId -> match tryReadFileContent summaryFilePath "summary" parseResult with | Error error -> return Error error | Ok summaryContent -> let promptContentResult = if String.IsNullOrWhiteSpace(promptFilePath) then Ok String.Empty else tryReadFileContent promptFilePath "prompt" parseResult match promptContentResult with | Error error -> return Error error | Ok promptContent -> let parameters = Parameters.WorkItem.AddSummaryParameters( WorkItemId = workItem, SummaryContent = summaryContent, SummaryMimeType = inferMimeType summaryFilePath, PromptContent = promptContent, PromptMimeType = (if String.IsNullOrWhiteSpace(promptFilePath) then String.Empty else inferMimeType promptFilePath), PromptOrigin = promptOrigin, PromotionSetId = promotionSetId, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let! addSummaryResponse = WorkItem.AddSummary(parameters) |> Async.AwaitTask match addSummaryResponse with | Error error -> return Error error | Ok addSummaryResult -> let response = addSummaryResult.ReturnValue let promptArtifactId = response.PromptArtifactId |> Option.ofObj |> Option.map (fun value -> value.Trim()) |> Option.filter (fun value -> not <| String.IsNullOrWhiteSpace(value)) let promotionSetIdResult = response.PromotionSetId |> Option.ofObj |> Option.map (fun value -> value.Trim()) |> Option.filter (fun value -> not <| String.IsNullOrWhiteSpace(value)) let summaryArtifactId = response.SummaryArtifactId |> Option.ofObj |> Option.map (fun value -> value.Trim()) |> Option.filter (fun value -> not <| String.IsNullOrWhiteSpace(value)) |> Option.defaultValue String.Empty let result = { WorkItem = workItem SummaryArtifactId = summaryArtifactId PromptArtifactId = promptArtifactId PromotionSetId = promotionSetIdResult } if not (parseResult |> json) && not (parseResult |> silent) then AnsiConsole.MarkupLine( $"[green]Linked AgentSummary artifact[/] {Markup.Escape(result.SummaryArtifactId)} [green]to work item[/] {Markup.Escape(workItem)}" ) match result.PromptArtifactId with | Some artifactId -> AnsiConsole.MarkupLine( $"[green]Linked Prompt artifact[/] {Markup.Escape(artifactId)} [green]to work item[/] {Markup.Escape(workItem)}" ) | None -> () match result.PromotionSetId with | Some linkedPromotionSetId -> AnsiConsole.MarkupLine( $"[green]Linked promotion set[/] {Markup.Escape(linkedPromotionSetId)} [green]to work item[/] {Markup.Escape(workItem)}" ) | None -> () return Ok(GraceReturnValue.Create result graceIds.CorrelationId) } |> Async.StartAsTask let private bootstrapHandler (parseResult: ParseResult) = async { let correlationId = getCorrelationId parseResult let stateFilePath = getLocalStateFilePath () let existingState = match tryReadLocalState parseResult with | Ok (_, state) -> state | Error _ -> localAgentSessionStateDefault let agentIdRaw = parseResult.GetValue(Options.agentId) |> Option.ofObj |> Option.defaultValue String.Empty let displayName = tryGetOptionString parseResult Options.displayName |> Option.defaultValue String.Empty let source = tryGetOptionString parseResult Options.source |> Option.defaultValue "cli" match tryParseGuid agentIdRaw "Agent ID must be a valid non-empty Guid." parseResult with | Error error -> return Error error | Ok parsedAgentId -> if String.IsNullOrWhiteSpace(displayName) then return Error(GraceError.Create "Display name is required." correlationId) elif hasActiveSession existingState && hasBootstrappedIdentity existingState && not (existingState.AgentId.Equals(parsedAgentId.ToString(), StringComparison.OrdinalIgnoreCase)) then return Error( staleStateError parseResult ($"Local state contains an active session for agent '{existingState.AgentId}'. Stop that session before bootstrapping a different agent.") ) else let normalizedSource = if String.IsNullOrWhiteSpace(source) then "cli" else source let unchanged = existingState.AgentId.Equals(parsedAgentId.ToString(), StringComparison.OrdinalIgnoreCase) && existingState.AgentDisplayName.Equals(displayName, StringComparison.Ordinal) && existingState.Source.Equals(normalizedSource, StringComparison.Ordinal) let operationId = createDefaultOperationId "bootstrap" correlationId let updatedState = { existingState with AgentId = parsedAgentId.ToString() AgentDisplayName = displayName Source = normalizedSource LastOperationId = operationId LastCorrelationId = correlationId LastUpdatedAtUtc = DateTime.UtcNow } match tryWriteLocalState parseResult stateFilePath updatedState with | Error error -> return Error error | Ok _ -> let lifecycleState = if hasActiveSession updatedState then AgentSessionLifecycleState.Active else AgentSessionLifecycleState.Inactive let message = if unchanged then "Agent identity already bootstrapped. Reusing existing local state." else "Agent identity bootstrapped successfully." let operationResult = createLocalOperationResult updatedState lifecycleState message operationId unchanged writeOperationSummary parseResult operationResult return Ok(GraceReturnValue.Create operationResult correlationId) } |> Async.StartAsTask let internal workStartWith (startSession: StartAgentSessionParameters -> Task>) (parseResult: ParseResult) = async { match ensureRepositoryContextIsAvailable parseResult with | Error error -> return Error error | Ok _ -> let graceIds = parseResult |> getNormalizedIdsAndNames match ensureRepositoryContextIsComplete graceIds parseResult with | Error error -> return Error error | Ok _ -> let workItemIdRaw = parseResult.GetValue(Options.startWorkItemId) match tryNormalizeWorkItemIdentifier workItemIdRaw parseResult with | Error error -> return Error error | Ok workItemId -> let promotionSetIdOption = tryGetOptionString parseResult Options.promotionSetId match tryNormalizePromotionSetId promotionSetIdOption parseResult with | Error error -> return Error error | Ok promotionSetId -> match tryReadLocalState parseResult with | Error error -> return Error error | Ok (stateFilePath, state) -> match ensureBootstrapped parseResult state with | Error error -> return Error error | Ok _ -> if hasActiveSession state then if state.ActiveWorkItemIdOrNumber = workItemId then let operationId = if String.IsNullOrWhiteSpace(state.LastOperationId) then createDefaultOperationId "start" graceIds.CorrelationId else state.LastOperationId let operationResult = createLocalOperationResult state AgentSessionLifecycleState.Active "Work session is already active for this work item." operationId true writeOperationSummary parseResult operationResult return Ok(GraceReturnValue.Create operationResult graceIds.CorrelationId) else return Error( staleStateError parseResult ($"Local state indicates an active session for work item '{state.ActiveWorkItemIdOrNumber}', but start requested '{workItemId}'.") ) else let operationId = normalizeOperationId (tryGetOptionString parseResult Options.operationId) "start" graceIds.CorrelationId let sourceFromState = if String.IsNullOrWhiteSpace(state.Source) then "cli" else state.Source let source = tryGetOptionString parseResult Options.source |> Option.defaultValue sourceFromState let startParameters = StartAgentSessionParameters( WorkItemIdOrNumber = workItemId, PromotionSetId = promotionSetId, Source = source, OperationId = operationId, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, AgentId = state.AgentId, AgentDisplayName = state.AgentDisplayName, CorrelationId = graceIds.CorrelationId ) let! startResult = startSession startParameters |> Async.AwaitTask match startResult with | Error error -> return Error error | Ok returnValue -> let normalizedResult = if String.IsNullOrWhiteSpace(returnValue.ReturnValue.OperationId) then { returnValue.ReturnValue with OperationId = operationId } else returnValue.ReturnValue let session = normalizedResult.Session let updatedState = { state with AgentId = if String.IsNullOrWhiteSpace(session.AgentId) then state.AgentId else session.AgentId AgentDisplayName = if String.IsNullOrWhiteSpace(session.AgentDisplayName) then state.AgentDisplayName else session.AgentDisplayName Source = if String.IsNullOrWhiteSpace(session.Source) then source else session.Source ActiveSessionId = if String.IsNullOrWhiteSpace(session.SessionId) then operationId else session.SessionId ActiveWorkItemIdOrNumber = if String.IsNullOrWhiteSpace(session.WorkItemIdOrNumber) then workItemId else session.WorkItemIdOrNumber ActivePromotionSetId = if String.IsNullOrWhiteSpace(session.PromotionSetId) then promotionSetId else session.PromotionSetId LastOperationId = normalizedResult.OperationId LastCorrelationId = graceIds.CorrelationId LastUpdatedAtUtc = DateTime.UtcNow } match tryWriteLocalState parseResult stateFilePath updatedState with | Error error -> return Error error | Ok _ -> writeOperationSummary parseResult normalizedResult return Ok({ returnValue with ReturnValue = normalizedResult }) } |> Async.StartAsTask let internal workStopWith (stopSession: StopAgentSessionParameters -> Task>) (parseResult: ParseResult) = async { match ensureRepositoryContextIsAvailable parseResult with | Error error -> return Error error | Ok _ -> let graceIds = parseResult |> getNormalizedIdsAndNames match ensureRepositoryContextIsComplete graceIds parseResult with | Error error -> return Error error | Ok _ -> match tryReadLocalState parseResult with | Error error -> return Error error | Ok (stateFilePath, state) -> match ensureBootstrapped parseResult state with | Error error -> return Error error | Ok _ -> let requestedSessionId = tryGetOptionString parseResult Options.sessionId |> Option.defaultValue String.Empty let requestedWorkItemRaw = tryGetOptionString parseResult Options.optionalWorkItemId let normalizedRequestedWorkItemResult = match requestedWorkItemRaw with | None -> Ok String.Empty | Some workItemRaw -> tryNormalizeWorkItemIdentifier workItemRaw parseResult match normalizedRequestedWorkItemResult with | Error error -> return Error error | Ok requestedWorkItemId -> if hasActiveSession state && not <| String.IsNullOrWhiteSpace(requestedSessionId) && not (state.ActiveSessionId.Equals(requestedSessionId, StringComparison.OrdinalIgnoreCase)) then return Error( staleStateError parseResult ($"Local state indicates session '{state.ActiveSessionId}', but stop requested session '{requestedSessionId}'.") ) elif hasActiveSession state && not <| String.IsNullOrWhiteSpace(requestedWorkItemId) && not (state.ActiveWorkItemIdOrNumber.Equals(requestedWorkItemId, StringComparison.OrdinalIgnoreCase)) then return Error( staleStateError parseResult ($"Local state indicates work item '{state.ActiveWorkItemIdOrNumber}', but stop requested work item '{requestedWorkItemId}'.") ) elif not (hasActiveSession state) && String.IsNullOrWhiteSpace(requestedSessionId) && String.IsNullOrWhiteSpace(requestedWorkItemId) then let operationId = createDefaultOperationId "stop" graceIds.CorrelationId let updatedState = clearActiveSession state operationId graceIds.CorrelationId match tryWriteLocalState parseResult stateFilePath updatedState with | Error error -> return Error error | Ok _ -> let operationResult = createLocalOperationResult updatedState AgentSessionLifecycleState.Inactive "No active local work session was found. Nothing to stop." operationId true writeOperationSummary parseResult operationResult return Ok(GraceReturnValue.Create operationResult graceIds.CorrelationId) else let operationId = normalizeOperationId (tryGetOptionString parseResult Options.operationId) "stop" graceIds.CorrelationId let sessionId = if String.IsNullOrWhiteSpace(requestedSessionId) then state.ActiveSessionId else requestedSessionId let workItemId = if String.IsNullOrWhiteSpace(requestedWorkItemId) then state.ActiveWorkItemIdOrNumber else requestedWorkItemId let stopParameters = StopAgentSessionParameters( SessionId = sessionId, WorkItemIdOrNumber = workItemId, StopReason = (tryGetOptionString parseResult Options.stopReason |> Option.defaultValue String.Empty), OperationId = operationId, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, AgentId = state.AgentId, AgentDisplayName = state.AgentDisplayName, CorrelationId = graceIds.CorrelationId ) let! stopResult = stopSession stopParameters |> Async.AwaitTask match stopResult with | Error error -> return Error error | Ok returnValue -> let normalizedResult = if String.IsNullOrWhiteSpace(returnValue.ReturnValue.OperationId) then { returnValue.ReturnValue with OperationId = operationId } else returnValue.ReturnValue let clearedState = clearActiveSession state normalizedResult.OperationId graceIds.CorrelationId match tryWriteLocalState parseResult stateFilePath clearedState with | Error error -> return Error error | Ok _ -> writeOperationSummary parseResult normalizedResult return Ok({ returnValue with ReturnValue = normalizedResult }) } |> Async.StartAsTask let internal workStatusWith (getStatus: GetAgentSessionStatusParameters -> Task>) (parseResult: ParseResult) = async { match ensureRepositoryContextIsAvailable parseResult with | Error error -> return Error error | Ok _ -> let graceIds = parseResult |> getNormalizedIdsAndNames match ensureRepositoryContextIsComplete graceIds parseResult with | Error error -> return Error error | Ok _ -> match tryReadLocalState parseResult with | Error error -> return Error error | Ok (stateFilePath, state) -> match ensureBootstrapped parseResult state with | Error error -> return Error error | Ok _ -> let requestedSessionId = tryGetOptionString parseResult Options.sessionId |> Option.defaultValue String.Empty let requestedWorkItemRaw = tryGetOptionString parseResult Options.optionalWorkItemId let normalizedRequestedWorkItemResult = match requestedWorkItemRaw with | None -> Ok String.Empty | Some workItemRaw -> tryNormalizeWorkItemIdentifier workItemRaw parseResult match normalizedRequestedWorkItemResult with | Error error -> return Error error | Ok requestedWorkItemId -> if hasActiveSession state && not <| String.IsNullOrWhiteSpace(requestedSessionId) && not (state.ActiveSessionId.Equals(requestedSessionId, StringComparison.OrdinalIgnoreCase)) then return Error( staleStateError parseResult ($"Local state indicates session '{state.ActiveSessionId}', but status requested session '{requestedSessionId}'.") ) elif hasActiveSession state && not <| String.IsNullOrWhiteSpace(requestedWorkItemId) && not (state.ActiveWorkItemIdOrNumber.Equals(requestedWorkItemId, StringComparison.OrdinalIgnoreCase)) then return Error( staleStateError parseResult ($"Local state indicates work item '{state.ActiveWorkItemIdOrNumber}', but status requested work item '{requestedWorkItemId}'.") ) else let sessionId = if String.IsNullOrWhiteSpace(requestedSessionId) then state.ActiveSessionId else requestedSessionId let workItemId = if String.IsNullOrWhiteSpace(requestedWorkItemId) then state.ActiveWorkItemIdOrNumber else requestedWorkItemId if String.IsNullOrWhiteSpace(sessionId) && String.IsNullOrWhiteSpace(workItemId) then return Error( GraceError.Create "No active local work session is available. Run `grace agent work start --work-item-id ` first, or provide `--session-id` or `--work-item-id`." (getCorrelationId parseResult) ) else let statusParameters = GetAgentSessionStatusParameters( SessionId = sessionId, WorkItemIdOrNumber = workItemId, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, AgentId = state.AgentId, AgentDisplayName = state.AgentDisplayName, CorrelationId = graceIds.CorrelationId ) let! statusResult = getStatus statusParameters |> Async.AwaitTask match statusResult with | Error error -> return Error error | Ok returnValue -> let session = returnValue.ReturnValue.Session let updatedState = match session.LifecycleState with | AgentSessionLifecycleState.Active | AgentSessionLifecycleState.Stopping -> { state with ActiveSessionId = session.SessionId ActiveWorkItemIdOrNumber = session.WorkItemIdOrNumber ActivePromotionSetId = session.PromotionSetId LastOperationId = returnValue.ReturnValue.OperationId LastCorrelationId = graceIds.CorrelationId LastUpdatedAtUtc = DateTime.UtcNow } | AgentSessionLifecycleState.Inactive | AgentSessionLifecycleState.Stopped -> clearActiveSession state returnValue.ReturnValue.OperationId graceIds.CorrelationId match tryWriteLocalState parseResult stateFilePath updatedState with | Error error -> return Error error | Ok _ -> writeOperationSummary parseResult returnValue.ReturnValue return Ok returnValue } |> Async.StartAsTask let private workStartHandler (parseResult: ParseResult) = workStartWith AgentSession.Start parseResult let private workStopHandler (parseResult: ParseResult) = workStopWith AgentSession.Stop parseResult let private workStatusHandler (parseResult: ParseResult) = workStatusWith AgentSession.Status parseResult type AddSummary() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { let! result = addSummaryHandler parseResult return result |> renderOutput parseResult } type Bootstrap() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = bootstrapHandler parseResult return result |> renderOutput parseResult } type WorkStart() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = workStartHandler parseResult return result |> renderOutput parseResult } type WorkStop() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = workStopHandler parseResult return result |> renderOutput parseResult } type WorkStatus() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = workStatusHandler parseResult return result |> renderOutput parseResult } let Build = let addCommonOptions (command: Command) = command |> addOption Options.ownerName |> addOption Options.ownerId |> addOption Options.organizationName |> addOption Options.organizationId |> addOption Options.repositoryName |> addOption Options.repositoryId let agentCommand = new Command("agent", Description = "Agent workflow commands.") let bootstrapCommand = new Command("bootstrap", Description = "Bootstrap local agent identity for deterministic session workflows.") |> addOption Options.agentId |> addOption Options.displayName |> addOption Options.source bootstrapCommand.Action <- new Bootstrap() let workCommand = new Command("work", Description = "Manage agent work sessions.") let workStartCommand = new Command("start", Description = "Start work on a work item.") |> addOption Options.startWorkItemId |> addOption Options.promotionSetId |> addOption Options.source |> addOption Options.operationId |> addCommonOptions workStartCommand.Action <- new WorkStart() let workStopCommand = new Command("stop", Description = "Stop the current or specified work session.") |> addOption Options.sessionId |> addOption Options.optionalWorkItemId |> addOption Options.stopReason |> addOption Options.operationId |> addCommonOptions workStopCommand.Action <- new WorkStop() let workStatusCommand = new Command("status", Description = "Get status for the current or specified work session.") |> addOption Options.sessionId |> addOption Options.optionalWorkItemId |> addCommonOptions workStatusCommand.Action <- new WorkStatus() workCommand.Subcommands.Add(workStartCommand) workCommand.Subcommands.Add(workStopCommand) workCommand.Subcommands.Add(workStatusCommand) let addSummaryCommand = new Command("add-summary", Description = "Submit summary content (and optional prompt content) and link canonical artifacts to a work item.") |> addOption Options.addSummaryWorkItemId |> addOption Options.summaryFile |> addOption Options.promptFile |> addOption Options.promptOrigin |> addOption Options.addSummaryPromotionSetId |> addCommonOptions addSummaryCommand.Action <- new AddSummary() agentCommand.Subcommands.Add(bootstrapCommand) agentCommand.Subcommands.Add(workCommand) agentCommand.Subcommands.Add(addSummaryCommand) agentCommand ================================================ FILE: src/Grace.CLI/Command/Auth.CLI.fs ================================================ namespace Grace.CLI.Command open Grace.CLI.Common open Grace.CLI.Services open Grace.CLI.Text open Grace.SDK open Grace.Shared open Grace.Shared.Client open Grace.Shared.Client.Configuration open Grace.Shared.Parameters.Common open Grace.Shared.Utilities open Grace.Types.Types open Microsoft.Identity.Client.Extensions.Msal open Spectre.Console open NodaTime open System open System.Collections.Generic open System.Diagnostics open System.Net open System.Net.Http open System.Security.Cryptography open System.Text open System.Text.Json open System.CommandLine open System.CommandLine.Invocation open System.CommandLine.Parsing open System.IO open System.Threading open System.Threading.Tasks module Auth = type LoginMode = | Pkce | Device type OidcCliConfig = { Authority: string; Audience: string; ClientId: string; RedirectPort: int; Scopes: string list } type OidcM2mConfig = { Authority: string; Audience: string; ClientId: string; ClientSecret: string; Scopes: string list } type AuthInfo = { GraceUserId: string; Claims: string list } type TokenBundle = { RefreshToken: string AccessToken: string AccessTokenExpiresAt: Instant Issuer: string Audience: string Scopes: string Subject: string option ClientId: string CreatedAt: Instant UpdatedAt: Instant } type TokenResponse = { AccessToken: string; RefreshToken: string option; ExpiresIn: int option; Scope: string option; TokenType: string option } type DeviceCodeResponse = { DeviceCode: string UserCode: string VerificationUri: string VerificationUriComplete: string option ExpiresIn: int IntervalSeconds: int } type TokenStore = { Helper: MsalCacheHelper; StorageProperties: StorageCreationProperties; LockFilePath: string; InProcessLock: SemaphoreSlim } let private tryGetEnv name = let value = Environment.GetEnvironmentVariable(name) if String.IsNullOrWhiteSpace value then None else Some value let private normalizeBearerToken (token: string) = if token.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) then token.Substring("Bearer ".Length).Trim() else token.Trim() let private normalizeAuthority (authority: string) = let trimmed = authority.Trim() if trimmed.EndsWith("/", StringComparison.Ordinal) then trimmed else $"{trimmed}/" let private parseScopes (value: string) = value.Split([| ' ' |], StringSplitOptions.RemoveEmptyEntries) |> Seq.map (fun scopeValue -> scopeValue.Trim()) |> Seq.filter (fun scopeValue -> not (String.IsNullOrWhiteSpace scopeValue)) |> Seq.toList let private defaultCliScopes () = [ "openid" "profile" "email" "offline_access" ] let private buildOidcCliConfig (authority: string) (audience: string) (clientId: string) = let redirectPort = match tryGetEnv Constants.EnvironmentVariables.GraceAuthOidcCliRedirectPort with | Some raw -> match Int32.TryParse raw with | true, parsed when parsed > 0 -> parsed | _ -> 8391 | None -> 8391 let scopes = match tryGetEnv Constants.EnvironmentVariables.GraceAuthOidcCliScopes with | Some raw when not (String.IsNullOrWhiteSpace raw) -> parseScopes raw | _ -> defaultCliScopes () { Authority = normalizeAuthority authority; Audience = audience.Trim(); ClientId = clientId.Trim(); RedirectPort = redirectPort; Scopes = scopes } let private tryGetOidcCliConfigFromEnv () = match tryGetEnv Constants.EnvironmentVariables.GraceAuthOidcAuthority, tryGetEnv Constants.EnvironmentVariables.GraceAuthOidcAudience, tryGetEnv Constants.EnvironmentVariables.GraceAuthOidcCliClientId with | Some authority, Some audience, Some clientId -> Some(buildOidcCliConfig authority audience clientId) | _ -> None let private tryGetOidcCliConfigFromServer (correlationId: string) = task { match tryGetEnv Constants.EnvironmentVariables.GraceServerUri with | None -> return Ok None | Some _ -> let parameters = CommonParameters(CorrelationId = correlationId) let! result = Grace.SDK.Auth.getOidcClientConfig parameters match result with | Ok graceReturnValue -> let config = graceReturnValue.ReturnValue return Ok(Some(buildOidcCliConfig config.Authority config.Audience config.CliClientId)) | Error error -> return Error error } let private tryGetOidcCliConfig (correlationId: string) = task { match tryGetOidcCliConfigFromEnv () with | Some config -> return Ok(Some config) | None -> return! tryGetOidcCliConfigFromServer correlationId } let private tryGetOidcM2mConfig () = match tryGetEnv Constants.EnvironmentVariables.GraceAuthOidcAuthority, tryGetEnv Constants.EnvironmentVariables.GraceAuthOidcAudience, tryGetEnv Constants.EnvironmentVariables.GraceAuthOidcM2mClientId, tryGetEnv Constants.EnvironmentVariables.GraceAuthOidcM2mClientSecret with | Some authority, Some audience, Some clientId, Some clientSecret -> let scopes = match tryGetEnv Constants.EnvironmentVariables.GraceAuthOidcM2mScopes with | Some raw when not (String.IsNullOrWhiteSpace raw) -> parseScopes raw | _ -> [] Some { Authority = normalizeAuthority authority Audience = audience.Trim() ClientId = clientId.Trim() ClientSecret = clientSecret Scopes = scopes } | _ -> None let private tryGetGraceTokenFromEnv () = match tryGetEnv Constants.EnvironmentVariables.GraceToken with | None -> Ok None | Some value -> let normalized = normalizeBearerToken value if String.IsNullOrWhiteSpace normalized then Error $"GRACE_TOKEN is set but empty. Provide a Grace PAT or unset {Constants.EnvironmentVariables.GraceToken}." else match Grace.Types.PersonalAccessToken.tryParseToken normalized with | Some _ -> Ok(Some normalized) | None -> Error $"GRACE_TOKEN accepts Grace PATs only (prefix {Grace.Types.PersonalAccessToken.TokenPrefix}). Auth0 access tokens are not valid here." let private getTokenStoreNamespace (config: OidcCliConfig) = let serverUri = tryGetEnv Constants.EnvironmentVariables.GraceServerUri |> Option.defaultValue String.Empty $"{config.Authority}|{config.Audience}|{config.ClientId}|{serverUri}" .Trim() let private hashNamespace (value: string) = use sha = SHA256.Create() let bytes = Encoding.UTF8.GetBytes(value) let hash = sha.ComputeHash(bytes) Convert.ToHexString(hash).ToLowerInvariant() let private tokenStoreCache = System.Collections.Concurrent.ConcurrentDictionary>() let private createTokenStoreAsync (config: OidcCliConfig) = task { let cacheRoot = UserConfiguration.getUserGraceDirectory () let cacheDirectory = Path.Combine(cacheRoot, "auth") Directory.CreateDirectory(cacheDirectory) |> ignore let key = getTokenStoreNamespace config |> hashNamespace let fileName = $"grace_auth_{key}.bin" let builder = StorageCreationPropertiesBuilder(fileName, cacheDirectory) if OperatingSystem.IsMacOS() then builder.WithMacKeyChain("Grace", "Grace.CLI.Auth") |> ignore elif OperatingSystem.IsLinux() then let attribute1 = KeyValuePair("application", "grace") let attribute2 = KeyValuePair("scope", "auth") builder.WithLinuxKeyring("com.grace.auth", MsalCacheHelper.LinuxKeyRingDefaultCollection, "Grace CLI Auth", attribute1, attribute2) |> ignore let storageProperties = builder.Build() let! helper = MsalCacheHelper.CreateAsync(storageProperties, null) return { Helper = helper StorageProperties = storageProperties LockFilePath = $"{storageProperties.CacheFilePath}.lock" InProcessLock = new SemaphoreSlim(1, 1) } } let private getTokenStoreAsync (config: OidcCliConfig) = let key = getTokenStoreNamespace config tokenStoreCache.GetOrAdd(key, (fun _ -> createTokenStoreAsync config)) let private verifySecureStoreAsync (config: OidcCliConfig) = task { try let! store = getTokenStoreAsync config store.Helper.VerifyPersistence() return Ok store with | ex -> return Error $"Secure token storage is unavailable: {ex.Message}" } let private withTokenLock (store: TokenStore) (action: unit -> Task<'T>) = task { do! store.InProcessLock.WaitAsync() try use _lock = new CrossPlatLock(store.LockFilePath, 100, 100) return! action () finally store.InProcessLock.Release() |> ignore } let private tryLoadTokenBundle (store: TokenStore) = try let data = store.Helper.LoadUnencryptedTokenCache() if isNull data || data.Length = 0 then None else let json = Encoding.UTF8.GetString(data) let bundle = JsonSerializer.Deserialize(json, Constants.JsonSerializerOptions) if obj.ReferenceEquals(bundle, null) then None else Some bundle with | _ -> None let private saveTokenBundle (store: TokenStore) (bundle: TokenBundle) = let json = JsonSerializer.Serialize(bundle, Constants.JsonSerializerOptions) let data = Encoding.UTF8.GetBytes(json) store.Helper.SaveUnencryptedTokenCache(data) let private clearTokenBundle (store: TokenStore) = store.Helper.SaveUnencryptedTokenCache(Array.Empty()) let private tryReadString (root: JsonElement) (name: string) = match root.TryGetProperty(name) with | true, value when value.ValueKind = JsonValueKind.String -> let strValue = value.GetString() if String.IsNullOrWhiteSpace strValue then None else Some strValue | _ -> None let private tryReadInt (root: JsonElement) (name: string) = match root.TryGetProperty(name) with | true, value when value.ValueKind = JsonValueKind.Number -> match value.TryGetInt32() with | true, parsed -> Some parsed | _ -> None | _ -> None let private parseTokenResponse (json: string) = use document = JsonDocument.Parse(json) let root = document.RootElement match tryReadString root "access_token" with | None -> Error "Token response missing access_token." | Some accessToken -> Ok { AccessToken = accessToken RefreshToken = tryReadString root "refresh_token" ExpiresIn = tryReadInt root "expires_in" Scope = tryReadString root "scope" TokenType = tryReadString root "token_type" } let private parseDeviceCodeResponse (json: string) = use document = JsonDocument.Parse(json) let root = document.RootElement match tryReadString root "device_code", tryReadString root "user_code", tryReadString root "verification_uri", tryReadInt root "expires_in" with | Some deviceCode, Some userCode, Some verificationUri, Some expiresIn -> let verificationUriComplete = tryReadString root "verification_uri_complete" let interval = tryReadInt root "interval" |> Option.defaultValue 5 Ok { DeviceCode = deviceCode UserCode = userCode VerificationUri = verificationUri VerificationUriComplete = verificationUriComplete ExpiresIn = expiresIn IntervalSeconds = max 1 interval } | _ -> Error "Device code response missing required fields." let private tryReadOAuthError (json: string) = try use document = JsonDocument.Parse(json) let root = document.RootElement let error = tryReadString root "error" let description = tryReadString root "error_description" match error, description with | Some e, Some d -> Some $"{e}: {d}" | Some e, None -> Some e | None, Some d -> Some d | None, None -> None with | _ -> None let private buildEndpoint (authority: string) (path: string) = $"{authority.TrimEnd('/')}/{path.TrimStart('/')}" let private httpClient = new HttpClient() let private tryCreateAbsoluteUri (url: string) = match Uri.TryCreate(url, UriKind.Absolute) with | true, uri when uri.Scheme = Uri.UriSchemeHttps || uri.Scheme = Uri.UriSchemeHttp -> Ok uri | _ -> Error $"Invalid OIDC endpoint URL: {url}. Check {Constants.EnvironmentVariables.GraceAuthOidcAuthority}." let private postFormAsync (url: string) (formValues: (string * string) list) = task { let contentValues = formValues |> Seq.map (fun (key, value) -> KeyValuePair(key, value)) use content = new FormUrlEncodedContent(contentValues) match tryCreateAbsoluteUri url with | Error message -> return Error message | Ok uri -> let! response = httpClient.PostAsync(uri, content) let! body = response.Content.ReadAsStringAsync() if response.IsSuccessStatusCode then return Ok body else let message = tryReadOAuthError body |> Option.defaultValue body return Error message } let private tryLaunchBrowser (url: string) = try let psi = ProcessStartInfo() psi.FileName <- url psi.UseShellExecute <- true Process.Start(psi) |> ignore Ok() with | ex -> Error ex.Message let private generateBase64Url (bytes: int) = let data = RandomNumberGenerator.GetBytes(bytes) Convert .ToBase64String(data) .TrimEnd('=') .Replace('+', '-') .Replace('/', '_') let private computeCodeChallenge (verifier: string) = use sha = SHA256.Create() let bytes = Encoding.ASCII.GetBytes(verifier) let hash = sha.ComputeHash(bytes) Convert .ToBase64String(hash) .TrimEnd('=') .Replace('+', '-') .Replace('/', '_') let private tryGetJwtClaim (token: string) (claimType: string) = try let parts = token.Split('.') if parts.Length < 2 then None else let payload = parts[ 1 ].Replace('-', '+').Replace('_', '/') let padded = payload + String.replicate ((4 - payload.Length % 4) % 4) "=" let json = Encoding.UTF8.GetString(Convert.FromBase64String(padded)) use document = JsonDocument.Parse(json) tryReadString document.RootElement claimType with | _ -> None let private buildTokenBundle (config: OidcCliConfig) (tokenResponse: TokenResponse) = let now = getCurrentInstant () let expiresIn = tokenResponse.ExpiresIn |> Option.defaultValue 3600 let expiresAt = now.Plus(Duration.FromSeconds(float expiresIn)) let issuer = tryGetJwtClaim tokenResponse.AccessToken "iss" |> Option.defaultValue config.Authority let subject = tryGetJwtClaim tokenResponse.AccessToken "sub" let scopes = tokenResponse.Scope |> Option.defaultValue (String.Join(" ", config.Scopes)) { RefreshToken = tokenResponse.RefreshToken |> Option.defaultValue String.Empty AccessToken = tokenResponse.AccessToken AccessTokenExpiresAt = expiresAt Issuer = issuer Audience = config.Audience Scopes = scopes Subject = subject ClientId = config.ClientId CreatedAt = now UpdatedAt = now } let private requestTokenWithAuthorizationCodeAsync (config: OidcCliConfig) (redirectUri: string) (code: string) (codeVerifier: string) = task { let tokenEndpoint = buildEndpoint config.Authority "oauth/token" let formValues = [ "grant_type", "authorization_code" "client_id", config.ClientId "code", code "code_verifier", codeVerifier "redirect_uri", redirectUri ] let! response = postFormAsync tokenEndpoint formValues match response with | Ok json -> return parseTokenResponse json | Error message -> return Error message } let private requestDeviceCodeAsync (config: OidcCliConfig) = task { let endpoint = buildEndpoint config.Authority "oauth/device/code" let formValues = [ "client_id", config.ClientId "audience", config.Audience "scope", String.Join(" ", config.Scopes) ] let! response = postFormAsync endpoint formValues match response with | Ok json -> return parseDeviceCodeResponse json | Error message -> return Error message } let private pollDeviceCodeAsync (config: OidcCliConfig) (deviceCode: DeviceCodeResponse) = task { let tokenEndpoint = buildEndpoint config.Authority "oauth/token" let expiresAt = getCurrentInstant() .Plus(Duration.FromSeconds(float deviceCode.ExpiresIn)) let mutable delaySeconds = deviceCode.IntervalSeconds let mutable finished = false let mutable finalResult = Error "Device code expired. Please try again." while not finished do if getCurrentInstant () >= expiresAt then finished <- true finalResult <- Error "Device code expired. Please try again." else let formValues = [ "grant_type", "urn:ietf:params:oauth:grant-type:device_code" "device_code", deviceCode.DeviceCode "client_id", config.ClientId ] let! response = postFormAsync tokenEndpoint formValues match response with | Ok json -> finished <- true finalResult <- parseTokenResponse json | Error message -> if message.StartsWith("authorization_pending", StringComparison.OrdinalIgnoreCase) then do! Task.Delay(TimeSpan.FromSeconds(float delaySeconds)) elif message.StartsWith("slow_down", StringComparison.OrdinalIgnoreCase) then delaySeconds <- delaySeconds + 5 do! Task.Delay(TimeSpan.FromSeconds(float delaySeconds)) else finished <- true finalResult <- Error message return finalResult } let private tryAcquireTokenWithPkceAsync (config: OidcCliConfig) (parseResult: ParseResult) = task { let redirectUri = $"http://127.0.0.1:{config.RedirectPort}/callback" let listener = new HttpListener() let startResult = try listener.Prefixes.Add($"http://127.0.0.1:{config.RedirectPort}/") listener.Start() Ok() with | ex -> Error $"Failed to listen on {redirectUri}: {ex.Message}" match startResult with | Error message -> return Error message | Ok () -> try let state = generateBase64Url 16 let codeVerifier = generateBase64Url 32 let codeChallenge = computeCodeChallenge codeVerifier let authorizeEndpoint = buildEndpoint config.Authority "authorize" let query = [ "response_type", "code" "client_id", config.ClientId "redirect_uri", redirectUri "audience", config.Audience "scope", String.Join(" ", config.Scopes) "code_challenge", codeChallenge "code_challenge_method", "S256" "state", state ] |> List.map (fun (k, v) -> $"{Uri.EscapeDataString(k)}={Uri.EscapeDataString(v)}") |> String.concat "&" let url = $"{authorizeEndpoint}?{query}" match tryCreateAbsoluteUri url with | Error message -> return Error message | Ok _ -> match tryLaunchBrowser url with | Ok () -> () | Error message -> AnsiConsole.MarkupLine($"[{Colors.Important}]Open this URL in your browser to continue:[/] {Markup.Escape(url)}") AnsiConsole.MarkupLine($"[{Colors.Deemphasized}]Automatic launch failed: {Markup.Escape(message)}[/]") let! context = listener.GetContextAsync() let request = context.Request let response = context.Response let writeResponse (message: string) = task { use writer = new StreamWriter(response.OutputStream) do! writer.WriteAsync(message) do! writer.FlushAsync() response.Close() } if request.Url.AbsolutePath.TrimEnd('/') <> "/callback" then do! writeResponse "Invalid callback path. You may close this window." return Error "Unexpected callback path." else let queryValues = request.QueryString let errorValue = queryValues["error"] if not (String.IsNullOrWhiteSpace errorValue) then do! writeResponse "Authentication failed. You may close this window." let description = queryValues["error_description"] return Error $"Authorization error: {errorValue} {description}" else let code = queryValues["code"] let returnedState = queryValues["state"] if String.IsNullOrWhiteSpace code then do! writeResponse "Authentication failed. You may close this window." return Error "Authorization code missing from callback." elif not (String.Equals(state, returnedState, StringComparison.Ordinal)) then do! writeResponse "Authentication failed. You may close this window." return Error "Authorization state mismatch." else do! writeResponse "Authentication complete. You may close this window." let! tokenResponse = requestTokenWithAuthorizationCodeAsync config redirectUri code codeVerifier return tokenResponse finally listener.Stop() } let private tryAcquireTokenWithDeviceFlowAsync (config: OidcCliConfig) (parseResult: ParseResult) = task { let! deviceResponse = requestDeviceCodeAsync config match deviceResponse with | Error message -> return Error message | Ok deviceCode -> if parseResult |> hasOutput then match deviceCode.VerificationUriComplete with | Some completeUrl -> AnsiConsole.MarkupLine($"[{Colors.Important}]Complete sign-in:[/] {Markup.Escape(completeUrl)}") | None -> AnsiConsole.MarkupLine($"[{Colors.Important}]Open:[/] {Markup.Escape(deviceCode.VerificationUri)}") AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Code:[/] {Markup.Escape(deviceCode.UserCode)}") return! pollDeviceCodeAsync config deviceCode } let private applyRefreshToken (bundle: TokenBundle) (refreshed: TokenResponse) (now: Instant) : TokenBundle = let expiresIn = refreshed.ExpiresIn |> Option.defaultValue 3600 let expiresAt = now.Plus(Duration.FromSeconds(float expiresIn)) let refreshToken = refreshed.RefreshToken |> Option.defaultValue bundle.RefreshToken let scopes = refreshed.Scope |> Option.defaultValue bundle.Scopes let issuer = tryGetJwtClaim refreshed.AccessToken "iss" |> Option.defaultValue bundle.Issuer let subject = tryGetJwtClaim refreshed.AccessToken "sub" |> Option.orElse bundle.Subject { bundle with RefreshToken = refreshToken AccessToken = refreshed.AccessToken AccessTokenExpiresAt = expiresAt Issuer = issuer Scopes = scopes Subject = subject UpdatedAt = now } let private tryRefreshTokenAsync (config: OidcCliConfig) (bundle: TokenBundle) = task { if String.IsNullOrWhiteSpace bundle.RefreshToken then return Error "Refresh token missing. Run 'grace auth login' again." else let endpoint = buildEndpoint config.Authority "oauth/token" let formValues = [ "grant_type", "refresh_token" "client_id", config.ClientId "refresh_token", bundle.RefreshToken "audience", config.Audience ] let! response = postFormAsync endpoint formValues match response with | Error message -> return Error message | Ok json -> match parseTokenResponse json with | Error message -> return Error message | Ok refreshed -> let now = getCurrentInstant () let updated = applyRefreshToken bundle refreshed now return Ok updated } let private safetyWindow = Duration.FromSeconds(90.0) let private tryGetInteractiveTokenAsync (config: OidcCliConfig) = task { let! storeResult = verifySecureStoreAsync config match storeResult with | Error message -> return Error message | Ok store -> return! withTokenLock store (fun () -> task { match tryLoadTokenBundle store with | None -> return Ok None | Some bundle -> let now = getCurrentInstant () if bundle.AccessTokenExpiresAt > now.Plus(safetyWindow) then return Ok(Some bundle.AccessToken) else let! refreshResult = tryRefreshTokenAsync config bundle match refreshResult with | Ok updated -> saveTokenBundle store updated return Ok(Some updated.AccessToken) | Error message -> clearTokenBundle store return Error message }) } let tryGetAccessToken () = task { match tryGetGraceTokenFromEnv () with | Error message -> return Error message | Ok (Some token) -> return Ok(Some token) | Ok None -> match tryGetEnv Constants.EnvironmentVariables.GraceTokenFile with | Some _ -> return Error $"Local token files are no longer supported. Remove {Constants.EnvironmentVariables.GraceTokenFile} and set {Constants.EnvironmentVariables.GraceToken} instead." | None -> match tryGetOidcM2mConfig () with | Some m2mConfig -> let endpoint = buildEndpoint m2mConfig.Authority "oauth/token" let formValues = [ "grant_type", "client_credentials" "client_id", m2mConfig.ClientId "client_secret", m2mConfig.ClientSecret "audience", m2mConfig.Audience ] |> fun values -> if List.isEmpty m2mConfig.Scopes then values else values @ [ "scope", String.Join(" ", m2mConfig.Scopes) ] let! response = postFormAsync endpoint formValues match response with | Error message -> return Error message | Ok json -> match parseTokenResponse json with | Error message -> return Error message | Ok token -> return Ok(Some token.AccessToken) | None -> let correlationId = ensureNonEmptyCorrelationId String.Empty let! cliConfigResult = tryGetOidcCliConfig correlationId match cliConfigResult with | Ok None -> return Error $"Authentication is not configured. Set {Constants.EnvironmentVariables.GraceAuthOidcAuthority}, {Constants.EnvironmentVariables.GraceAuthOidcAudience}, and {Constants.EnvironmentVariables.GraceAuthOidcCliClientId} (or provide GRACE_TOKEN / M2M credentials)." | Ok (Some cliConfig) -> let! tokenResult = tryGetInteractiveTokenAsync cliConfig return tokenResult | Error error -> return Error error.Error } let private tryGetAccessTokenForSdk () = task { let! result = tryGetAccessToken () match result with | Ok token -> return token | Error _ -> return None } let configureSdkAuth () = Grace.SDK.Auth.setTokenProvider (fun () -> tryGetAccessTokenForSdk ()) let private parseDurationSeconds (value: string) = if String.IsNullOrWhiteSpace value then Error "Expires-in value is required." else let trimmed = value.Trim() if trimmed.Length < 2 then Error "Expires-in must include a unit suffix: s, m, h, or d." else let unitChar = Char.ToLowerInvariant(trimmed[trimmed.Length - 1]) let amountPart = trimmed.Substring(0, trimmed.Length - 1) match Int64.TryParse(amountPart) with | true, amount when amount > 0L -> let seconds = match unitChar with | 's' -> Some amount | 'm' -> Some(amount * 60L) | 'h' -> Some(amount * 3600L) | 'd' -> Some(amount * 86400L) | _ -> None match seconds with | Some value -> Ok value | None -> Error "Expires-in must end with s, m, h, or d." | _ -> Error "Expires-in must start with a positive integer." let private formatInstantOption (instant: NodaTime.Instant option) = match instant with | None -> "Never" | Some value -> instantToLocalTime value module private LoginOptions = let auth = (new Option("--auth", Required = false, Description = "Authentication flow: pkce (browser) or device.", Arity = ArgumentArity.ExactlyOne)) .AcceptOnlyFromAmong([| "pkce"; "device" |]) module private TokenOptions = let name = new Option("--name", Required = true, Description = "A friendly name for the personal access token.", Arity = ArgumentArity.ExactlyOne) let expiresIn = new Option( "--expires-in", Required = false, Description = "Token lifetime with unit suffix: 30d, 12h, 60m, or 3600s.", Arity = ArgumentArity.ExactlyOne ) let noExpiry = new Option( "--no-expiry", Required = false, Description = "Create a token with no expiry (if server policy allows).", Arity = ArgumentArity.Zero ) let store = new Option( "--store", Required = false, Description = "Deprecated: local token storage is disabled. Use GRACE_TOKEN instead.", Arity = ArgumentArity.Zero ) let includeRevoked = new Option("--include-revoked", Required = false, Description = "Include revoked tokens in the list.", Arity = ArgumentArity.Zero) let includeExpired = new Option("--include-expired", Required = false, Description = "Include expired tokens in the list.", Arity = ArgumentArity.Zero) let all = new Option("--all", Required = false, Description = "Include revoked and expired tokens in the list.", Arity = ArgumentArity.Zero) let tokenId = new Argument("token-id", Description = "Token id (GUID).") let token = new Option( "--token", Required = false, Description = "Personal access token (local storage is disabled).", Arity = ArgumentArity.ExactlyOne ) let stdin = new Option( "--stdin", Required = false, Description = "Read the token value from standard input (local storage is disabled).", Arity = ArgumentArity.Zero ) let private authDevelopmentGuidance = "During development, TestAuth may be available. See docs/Authentication.md for details." let private authenticationRequiredMessage = $"Authentication required. Run 'grace auth login' and try again. {authDevelopmentGuidance}" let private addAuthDevelopmentGuidance (message: string) = if String.IsNullOrWhiteSpace message then authDevelopmentGuidance else $"{message} {authDevelopmentGuidance}" let ensureAccessToken (parseResult: ParseResult) = task { let correlationId = parseResult |> getCorrelationId let! tokenResult = tryGetAccessToken () match tokenResult with | Ok (Some _) -> return () | Ok None -> Error(GraceError.Create authenticationRequiredMessage correlationId) |> renderOutput parseResult |> ignore raise (OperationCanceledException()) | Error message -> Error(GraceError.Create (addAuthDevelopmentGuidance message) correlationId) |> renderOutput parseResult |> ignore raise (OperationCanceledException()) } type Login() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: Threading.CancellationToken) : Task = task { let correlationId = parseResult |> getCorrelationId let! cliConfigResult = tryGetOidcCliConfig correlationId match cliConfigResult with | Ok None -> return Error( GraceError.Create $"Authentication is not configured. Set {Constants.EnvironmentVariables.GraceAuthOidcAuthority}, {Constants.EnvironmentVariables.GraceAuthOidcAudience}, and {Constants.EnvironmentVariables.GraceAuthOidcCliClientId}." correlationId ) |> renderOutput parseResult | Ok (Some config) -> let desiredAuth = let raw = parseResult.GetValue(LoginOptions.auth) if String.IsNullOrWhiteSpace raw then None elif raw.Equals("device", StringComparison.OrdinalIgnoreCase) then Some LoginMode.Device else Some LoginMode.Pkce let! storeResult = verifySecureStoreAsync config match storeResult with | Error message -> return Error(GraceError.Create $"{message} Use GRACE_TOKEN or configure Auth0 client credentials (M2M)." correlationId) |> renderOutput parseResult | Ok store -> let! tokenResult = match desiredAuth with | Some LoginMode.Device -> tryAcquireTokenWithDeviceFlowAsync config parseResult | Some LoginMode.Pkce -> tryAcquireTokenWithPkceAsync config parseResult | None -> task { let! pkceResult = tryAcquireTokenWithPkceAsync config parseResult match pkceResult with | Ok _ -> return pkceResult | Error _ -> return! tryAcquireTokenWithDeviceFlowAsync config parseResult } match tokenResult with | Error message -> return Error(GraceError.Create message correlationId) |> renderOutput parseResult | Ok response -> let refreshToken = response.RefreshToken |> Option.defaultValue String.Empty if String.IsNullOrWhiteSpace refreshToken then return Error( GraceError.Create "Refresh token missing. Ensure offline_access scope and refresh token rotation are enabled." correlationId ) |> renderOutput parseResult else let bundle = { buildTokenBundle config response with RefreshToken = refreshToken } do! withTokenLock store (fun () -> task { saveTokenBundle store bundle }) if parseResult |> hasOutput then let subject = bundle.Subject |> Option.defaultValue "unknown" AnsiConsole.MarkupLine($"[{Colors.Important}]Signed in.[/] {Markup.Escape(subject)}") return Ok(GraceReturnValue.Create "Authenticated." correlationId) |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult } type Status() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: Threading.CancellationToken) : Task = task { let correlationId = parseResult |> getCorrelationId let graceTokenResult = tryGetGraceTokenFromEnv () let graceTokenPresent = match graceTokenResult with | Ok (Some _) -> true | Ok None -> false | Error _ -> true let graceTokenValid = match graceTokenResult with | Ok (Some _) -> true | _ -> false let graceTokenError = match graceTokenResult with | Error message -> Some message | _ -> None let m2mConfigured = tryGetOidcM2mConfig () |> Option.isSome let! cliConfigResult = tryGetOidcCliConfig correlationId let mutable configError: string option = None let cliConfig = match cliConfigResult with | Ok value -> value | Error error -> configError <- Some error.Error None let mutable interactiveBundle: TokenBundle option = None let mutable secureStoreError: string option = None match cliConfig with | None -> () | Some config -> let! storeResult = verifySecureStoreAsync config match storeResult with | Error message -> secureStoreError <- Some message | Ok store -> let! bundleOpt = withTokenLock store (fun () -> task { return tryLoadTokenBundle store }) interactiveBundle <- bundleOpt let interactiveConfigured = cliConfig |> Option.isSome let interactiveTokenPresent = interactiveBundle |> Option.isSome let interactiveExpiresAt = interactiveBundle |> Option.map (fun bundle -> bundle.AccessTokenExpiresAt) let interactiveSubject = interactiveBundle |> Option.bind (fun bundle -> bundle.Subject) let activeSource = if graceTokenValid then "Environment (GRACE_TOKEN)" elif graceTokenError.IsSome then "Environment (GRACE_TOKEN invalid)" elif m2mConfigured then "M2M (client credentials)" elif interactiveConfigured then if secureStoreError.IsSome then "Interactive (secure storage unavailable)" elif interactiveTokenPresent then "Interactive (cached token)" else "Interactive (no cached token)" else "None" if parseResult |> hasOutput then AnsiConsole.MarkupLine($"[{Colors.Highlighted}]GRACE_TOKEN:[/] {graceTokenPresent}") if graceTokenError.IsSome then AnsiConsole.MarkupLine($"[{Colors.Important}]GRACE_TOKEN error:[/] {Markup.Escape(graceTokenError.Value)}") AnsiConsole.MarkupLine($"[{Colors.Highlighted}]M2M configured:[/] {m2mConfigured}") AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Interactive configured:[/] {interactiveConfigured}") AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Interactive token:[/] {interactiveTokenPresent}") match interactiveExpiresAt with | Some expiresAt -> AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Access token expires:[/] {Markup.Escape(formatInstantOption (Some expiresAt))}") | None -> () match interactiveSubject with | Some subject -> AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Subject:[/] {Markup.Escape(subject)}") | None -> () match secureStoreError with | Some message -> AnsiConsole.MarkupLine($"[{Colors.Important}]Secure storage:[/] {Markup.Escape(message)}") | None -> () match configError with | Some message -> AnsiConsole.MarkupLine($"[{Colors.Important}]Auth config:[/] {Markup.Escape(message)}") | None -> () AnsiConsole.MarkupLine($"[{Colors.Important}]Active source:[/] {Markup.Escape(activeSource)}") return Ok(GraceReturnValue.Create activeSource correlationId) |> renderOutput parseResult } type Logout() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: Threading.CancellationToken) : Task = task { let correlationId = parseResult |> getCorrelationId let! cliConfigResult = tryGetOidcCliConfig correlationId match cliConfigResult with | Ok None -> return Error(GraceError.Create "Interactive authentication is not configured." correlationId) |> renderOutput parseResult | Ok (Some config) -> let! storeResult = verifySecureStoreAsync config match storeResult with | Error message -> return Error(GraceError.Create message correlationId) |> renderOutput parseResult | Ok store -> do! withTokenLock store (fun () -> task { clearTokenBundle store }) if parseResult |> hasOutput then AnsiConsole.MarkupLine($"[{Colors.Important}]Signed out.[/]") return Ok(GraceReturnValue.Create "Signed out." correlationId) |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult } type WhoAmI() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: Threading.CancellationToken) : Task = task { let correlationId = parseResult |> getCorrelationId let parameters = CommonParameters(CorrelationId = correlationId) let! result = Grace.SDK.Common.getServer (parameters, "auth/me") match result with | Ok graceReturnValue -> if parseResult |> hasOutput then AnsiConsole.MarkupLine($"[{Colors.Important}]Grace user id: {Markup.Escape(graceReturnValue.ReturnValue.GraceUserId)}[/]") if not <| List.isEmpty graceReturnValue.ReturnValue.Claims then let claimList = String.Join(", ", graceReturnValue.ReturnValue.Claims) AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Claims:[/] {Markup.Escape(claimList)}") return Ok graceReturnValue |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult } type TokenCreate() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: Threading.CancellationToken) : Task = task { let correlationId = parseResult |> getCorrelationId do! ensureAccessToken parseResult let tokenName = parseResult.GetValue(TokenOptions.name) let expiresInRaw = parseResult.GetValue(TokenOptions.expiresIn) let noExpiry = parseResult.GetValue(TokenOptions.noExpiry) let store = parseResult.GetValue(TokenOptions.store) if store then return Error(GraceError.Create $"Local token storage is disabled. Set {Constants.EnvironmentVariables.GraceToken} instead." correlationId) |> renderOutput parseResult else let expiresInResult = if String.IsNullOrWhiteSpace expiresInRaw then Ok 0L else parseDurationSeconds expiresInRaw match expiresInResult with | Error message -> return Error(GraceError.Create message correlationId) |> renderOutput parseResult | Ok expiresInSeconds -> let parameters = Grace.Shared.Parameters.Auth.CreatePersonalAccessTokenParameters() parameters.CorrelationId <- correlationId parameters.TokenName <- tokenName parameters.ExpiresInSeconds <- expiresInSeconds parameters.NoExpiry <- noExpiry let! result = Grace.SDK.PersonalAccessToken.Create parameters match result with | Ok graceReturnValue -> let created = graceReturnValue.ReturnValue let storedPath: string option = None if parseResult |> hasOutput then let summary = created.Summary let expiresText = formatInstantOption summary.ExpiresAt AnsiConsole.MarkupLine($"[{Colors.Important}]Token created.[/]") AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Name:[/] {Markup.Escape(summary.Name)}") AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Token Id:[/] {summary.TokenId}") AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Expires:[/] {Markup.Escape(expiresText)}") AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Token:[/] {Markup.Escape(created.Token)}") AnsiConsole.MarkupLine($"[{Colors.Deemphasized}]This token will not be shown again.[/]") match storedPath with | Some path -> AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Stored token at:[/] {Markup.Escape(path)}") | None -> () return Ok graceReturnValue |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult } let private renderTokenList (_parseResult: ParseResult) (tokens: Grace.Types.PersonalAccessToken.PersonalAccessTokenSummary list) : unit = let table = Table(Border = TableBorder.Rounded) table.AddColumn("Name") |> ignore table.AddColumn("TokenId") |> ignore table.AddColumn("Created") |> ignore table.AddColumn("Expires") |> ignore table.AddColumn("Last Used") |> ignore table.AddColumn("Revoked") |> ignore tokens |> List.iter (fun token -> let created = instantToLocalTime token.CreatedAt let expiresText = formatInstantOption token.ExpiresAt let lastUsed = formatInstantOption token.LastUsedAt let revoked = formatInstantOption token.RevokedAt table.AddRow( Markup.Escape(token.Name), token.TokenId.ToString(), Markup.Escape(created), Markup.Escape(expiresText), Markup.Escape(lastUsed), Markup.Escape(revoked) ) |> ignore) AnsiConsole.Write(table) type TokenList() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: Threading.CancellationToken) : Task = task { let correlationId = parseResult |> getCorrelationId do! ensureAccessToken parseResult let includeRevoked = parseResult.GetValue(TokenOptions.includeRevoked) let includeExpired = parseResult.GetValue(TokenOptions.includeExpired) let includeAll = parseResult.GetValue(TokenOptions.all) let parameters = Grace.Shared.Parameters.Auth.ListPersonalAccessTokensParameters() parameters.CorrelationId <- correlationId parameters.IncludeRevoked <- includeRevoked || includeAll parameters.IncludeExpired <- includeExpired || includeAll let! result = Grace.SDK.PersonalAccessToken.List parameters match result with | Ok graceReturnValue -> if parseResult |> hasOutput then renderTokenList parseResult graceReturnValue.ReturnValue return Ok graceReturnValue |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult } type TokenRevoke() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: Threading.CancellationToken) : Task = task { let correlationId = parseResult |> getCorrelationId do! ensureAccessToken parseResult let tokenIdRaw = parseResult.GetValue(TokenOptions.tokenId) match Guid.TryParse(tokenIdRaw) with | false, _ -> return Error(GraceError.Create "Token id must be a valid GUID." correlationId) |> renderOutput parseResult | true, tokenId -> let parameters = Grace.Shared.Parameters.Auth.RevokePersonalAccessTokenParameters() parameters.CorrelationId <- correlationId parameters.TokenId <- tokenId let! result = Grace.SDK.PersonalAccessToken.Revoke parameters match result with | Ok graceReturnValue -> if parseResult |> hasOutput then AnsiConsole.MarkupLine($"[{Colors.Important}]Token revoked.[/]") return Ok graceReturnValue |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult } type TokenSet() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: Threading.CancellationToken) : Task = task { let correlationId = parseResult |> getCorrelationId return Error(GraceError.Create $"Local token storage is disabled. Set {Constants.EnvironmentVariables.GraceToken} for a PAT." correlationId) |> renderOutput parseResult } type TokenClear() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: Threading.CancellationToken) : Task = task { let correlationId = parseResult |> getCorrelationId return Error(GraceError.Create $"Local token storage is disabled. Set {Constants.EnvironmentVariables.GraceToken} for a PAT." correlationId) |> renderOutput parseResult } type TokenStatus() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: Threading.CancellationToken) : Task = task { let correlationId = parseResult |> getCorrelationId let graceTokenResult = tryGetGraceTokenFromEnv () let graceTokenPresent = match graceTokenResult with | Ok (Some _) -> true | Ok None -> false | Error _ -> true let graceTokenValid = match graceTokenResult with | Ok (Some _) -> true | _ -> false let graceTokenError = match graceTokenResult with | Error message -> Some message | _ -> None if parseResult |> hasOutput then AnsiConsole.MarkupLine($"[{Colors.Highlighted}]GRACE_TOKEN:[/] {graceTokenPresent}") AnsiConsole.MarkupLine($"[{Colors.Highlighted}]GRACE_TOKEN valid:[/] {graceTokenValid}") match graceTokenError with | Some message -> AnsiConsole.MarkupLine($"[{Colors.Important}]GRACE_TOKEN error:[/] {Markup.Escape(message)}") | None -> () AnsiConsole.MarkupLine($"[{Colors.Deemphasized}]Local token storage is disabled.[/]") return Ok(GraceReturnValue.Create "Token status." correlationId) |> renderOutput parseResult } let Build = let authCommand = new Command("auth", Description = "Authenticate with Grace.") let loginCommand = new Command("login", Description = "Sign in with Auth0 (PKCE or device flow).") loginCommand.Options.Add(LoginOptions.auth) loginCommand.Action <- new Login() authCommand.Subcommands.Add(loginCommand) let statusCommand = new Command("status", Description = "Show cached login status.") statusCommand.Action <- new Status() authCommand.Subcommands.Add(statusCommand) let logoutCommand = new Command("logout", Description = "Sign out and clear cached credentials.") logoutCommand.Action <- new Logout() authCommand.Subcommands.Add(logoutCommand) let whoamiCommand = new Command("whoami", Description = "Show the authenticated Grace principal.") whoamiCommand.Action <- new WhoAmI() authCommand.Subcommands.Add(whoamiCommand) let tokenCommand = new Command("token", Description = "Manage personal access tokens.") let tokenCreateCommand = new Command("create", Description = "Create a personal access token.") tokenCreateCommand.Options.Add(TokenOptions.name) tokenCreateCommand.Options.Add(TokenOptions.expiresIn) tokenCreateCommand.Options.Add(TokenOptions.noExpiry) tokenCreateCommand.Options.Add(TokenOptions.store) tokenCreateCommand.Action <- new TokenCreate() tokenCommand.Subcommands.Add(tokenCreateCommand) let tokenListCommand = new Command("list", Description = "List personal access tokens.") tokenListCommand.Options.Add(TokenOptions.includeRevoked) tokenListCommand.Options.Add(TokenOptions.includeExpired) tokenListCommand.Options.Add(TokenOptions.all) tokenListCommand.Action <- new TokenList() tokenCommand.Subcommands.Add(tokenListCommand) let tokenRevokeCommand = new Command("revoke", Description = "Revoke a personal access token.") tokenRevokeCommand.Arguments.Add(TokenOptions.tokenId) tokenRevokeCommand.Action <- new TokenRevoke() tokenCommand.Subcommands.Add(tokenRevokeCommand) let tokenSetCommand = new Command("set", Description = "Store a personal access token locally (disabled).") tokenSetCommand.Options.Add(TokenOptions.token) tokenSetCommand.Options.Add(TokenOptions.stdin) tokenSetCommand.Action <- new TokenSet() tokenCommand.Subcommands.Add(tokenSetCommand) let tokenClearCommand = new Command("clear", Description = "Clear the local personal access token (disabled).") tokenClearCommand.Action <- new TokenClear() tokenCommand.Subcommands.Add(tokenClearCommand) let tokenStatusCommand = new Command("status", Description = "Show personal access token status.") tokenStatusCommand.Action <- new TokenStatus() tokenCommand.Subcommands.Add(tokenStatusCommand) authCommand.Subcommands.Add(tokenCommand) authCommand ================================================ FILE: src/Grace.CLI/Command/Branch.CLI.fs ================================================ namespace Grace.CLI.Command open FSharpPlus open Grace.CLI.Common open Grace.CLI.Services open Grace.CLI.Text open Grace.SDK open Grace.Shared open Grace.Shared.Client.Configuration open Grace.Shared.Client.Theme open Grace.Shared.Parameters.Branch open Grace.Shared.Parameters.DirectoryVersion open Grace.Shared.Services open Grace.Shared.Resources open Grace.Shared.Utilities open Grace.Shared.Validation open Grace.Shared.Validation.Errors open Grace.Types.Branch open Grace.Types.DirectoryVersion open Grace.Types.Reference open Grace.Types.Types open NodaTime open NodaTime.TimeZones open Spectre.Console open Spectre.Console.Json open Spectre.Console.Rendering open System open System.Collections.Concurrent open System.Collections.Generic open System.CommandLine open System.CommandLine.Invocation open System.CommandLine.Parsing open System.Globalization open System.IO open System.IO.Enumeration open System.Linq open System.Threading open System.Security.Cryptography open System.Threading.Tasks open System.Text open System.Text.Json open Grace.Shared.Parameters open Grace.Shared.Client open System.Text.RegularExpressions open System.CommandLine.Completions module Branch = type CommonParameters() = inherit ParameterBase() member val public BranchId: string = String.Empty with get, set member val public BranchName: string = String.Empty with get, set member val public OwnerId: string = String.Empty with get, set member val public OwnerName: string = String.Empty with get, set member val public OrganizationId: string = String.Empty with get, set member val public OrganizationName: string = String.Empty with get, set member val public RepositoryId: string = String.Empty with get, set member val public RepositoryName: string = String.Empty with get, set module private Options = let ownerId = new Option( OptionName.OwnerId, Required = false, Description = "The repository's owner ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OwnerId.Empty) ) let ownerName = new Option( OptionName.OwnerName, Required = false, Description = "The repository's owner name. [default: current owner]", Arity = ArgumentArity.ExactlyOne ) let organizationId = new Option( OptionName.OrganizationId, Required = false, Description = "The repository's organization ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> OrganizationId.Empty) ) let organizationName = new Option( OptionName.OrganizationName, Required = false, Description = "The repository's organization name. [default: current organization]", Arity = ArgumentArity.ZeroOrOne ) let repositoryId = new Option( OptionName.RepositoryId, [| "-r" |], Required = false, Description = "The repository's ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> RepositoryId.Empty) ) let repositoryName = new Option( OptionName.RepositoryName, [| "-n" |], Required = false, Description = "The name of the repository. [default: current repository]", Arity = ArgumentArity.ExactlyOne ) let branchId = new Option( OptionName.BranchId, [| "-i" |], Required = false, Description = "The branch's ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> BranchId.Empty) ) let branchName = new Option( OptionName.BranchName, [| "-b" |], Required = false, Description = "The name of the branch. [default: current branch]", Arity = ArgumentArity.ExactlyOne ) let branchNameRequired = new Option(OptionName.BranchName, [| "-b" |], Required = true, Description = "The name of the branch.", Arity = ArgumentArity.ExactlyOne) let parentBranchId = new Option( OptionName.ParentBranchId, [||], Required = false, Description = "The parent branch's ID .", Arity = ArgumentArity.ExactlyOne ) let parentBranchName = new Option( OptionName.ParentBranchName, [||], Required = false, Description = "The name of the parent branch. [default: current branch]", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> String.Empty) ) let newName = new Option(OptionName.NewName, Required = true, Description = "The new name of the branch.", Arity = ArgumentArity.ExactlyOne) let message = new Option( OptionName.Message, [| "-m" |], Required = false, Description = "The text to store with this reference.", Arity = ArgumentArity.ExactlyOne ) let messageRequired = new Option( OptionName.Message, [| "-m" |], Required = true, Description = "The text to store with this reference.", Arity = ArgumentArity.ExactlyOne ) let referenceType = (new Option(OptionName.ReferenceType, Required = false, Description = "The type of reference.", Arity = ArgumentArity.ExactlyOne)) .AcceptOnlyFromAmong(listCases ()) let doNotSwitch = new Option( OptionName.DoNotSwitch, Required = false, Description = "Do not switch your current branch to the new branch after it is created. By default, the new branch becomes the current branch.", Arity = ArgumentArity.ZeroOrOne ) let fullSha = new Option( OptionName.FullSha, Required = false, Description = "Show the full SHA-256 value in output.", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> false) ) let maxCount = new Option( OptionName.MaxCount, Required = false, Description = "The maximum number of results to return.", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> 30) ) let referenceId = new Option(OptionName.ReferenceId, [||], Required = false, Description = "The reference ID .", Arity = ArgumentArity.ExactlyOne) let sha256Hash = new Option( OptionName.Sha256Hash, [||], Required = false, Description = "The full or partial SHA-256 hash value of the version.", Arity = ArgumentArity.ExactlyOne ) let enabled = new Option( OptionName.Enabled, [||], Required = false, Description = "True to enable the feature; false to disable it.", DefaultValueFactory = (fun _ -> false) ) let includeDeleted = new Option(OptionName.IncludeDeleted, [| "-d" |], Required = false, Description = "Include deleted branches in the result. [default: false]") let showEvents = new Option(OptionName.ShowEvents, [| "-e" |], Required = false, Description = "Include actor events in the result. [default: false]") let initialPermissions = new Option( OptionName.InitialPermissions, Required = false, Description = "A list of reference types allowed in this branch.", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> [| Commit Checkpoint Save Tag External |]) ) let reassignChildBranches = new Option( OptionName.ReassignChildBranches, [| "--reassign-child-branches" |], Required = false, Description = "Reassign child branches to a new parent when deleting a branch.", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> false) ) let newParentBranchId = new Option( OptionName.NewParentBranchId, [| "--new-parent-branch-id" |], Required = false, Description = "The new parent branch's ID for reassigning children.", Arity = ArgumentArity.ExactlyOne ) let newParentBranchName = new Option( OptionName.NewParentBranchName, [| "--new-parent-branch-name" |], Required = false, Description = "The name of the new parent branch for reassigning children.", Arity = ArgumentArity.ExactlyOne ) let force = new Option( OptionName.Force, [| "-f"; "--force" |], Required = false, Description = "Force delete all child branches before deleting this branch.", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> false) ) let individual = new Option( OptionName.Individual, [| "--individual" |], Required = false, Description = "Force an individual promotion, bypassing any promotion group on a Hybrid branch.", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> false) ) let promotionMode = (new Option( "--promotion-mode", [| "-pm" |], Required = true, Description = "The promotion mode for the branch: IndividualOnly, GroupOnly, or Hybrid.", Arity = ArgumentArity.ExactlyOne )) .AcceptOnlyFromAmong(listCases ()) let toBranchId = new Option( OptionName.ToBranchId, [| "-d" |], Required = false, Description = "The ID of the branch to switch to .", Arity = ArgumentArity.ExactlyOne ) let toBranchName = new Option( OptionName.ToBranchName, [| "-c" |], Required = false, Description = "The name of the branch to switch to.", Arity = ArgumentArity.ExactlyOne ) let forceRecompute = new Option( OptionName.ForceRecompute, Required = false, Description = "Force the re-computation of the recursive directory contents. [default: false]", Arity = ArgumentArity.ZeroOrOne ) let directoryVersionId = new Option( OptionName.DirectoryVersionId, [| "-v" |], Required = false, Description = "The directory version ID to assign to the promotion .", Arity = ArgumentArity.ExactlyOne ) //let listDirectories = new Option("--listDirectories", Required = false, Description = "Show directories when listing contents. [default: false]") //let listFiles = new Option("--listFiles", Required = false, Description = "Show files when listing contents. Implies --listDirectories. [default: false]") let mustBeAValidGuid (parseResult: ParseResult) (parameters: CommonParameters) (option: Option) (value: string) (error: BranchError) = let mutable guid = Guid.Empty if parseResult.GetResult(option) <> null && not <| String.IsNullOrEmpty(value) && (Guid.TryParse(value, &guid) = false || guid = Guid.Empty) then Error(GraceError.Create (getErrorMessage error) (getCorrelationId parseResult)) else Ok(parseResult, parameters) let mustBeAValidGraceName (parseResult: ParseResult) (parameters: CommonParameters) (option: Option) (value: string) (error: BranchError) = if parseResult.GetResult(option) <> null && not <| Constants.GraceNameRegex.IsMatch(value) then Error(GraceError.Create (getErrorMessage error) (getCorrelationId parseResult)) else Ok(parseResult, parameters) let oneOfTheseOptionsMustBeProvided (parseResult: ParseResult) (options: Option array) (error: BranchError) = match options |> Array.tryFind (fun opt -> not <| isNull (parseResult.GetResult(opt))) with | Some opt -> Ok(parseResult) | None -> Error(GraceError.Create (getErrorMessage error) (getCorrelationId parseResult)) /// Adjusts parameters to account for whether Id's or Name's were specified by the user, or should be taken from default values. let normalizeIdsAndNames<'T when 'T :> CommonParameters> (parseResult: ParseResult) (parameters: 'T) = if parseResult.GetResult(Options.ownerId).Implicit && not <| isNull (parseResult.GetResult(Options.ownerName)) && not <| parseResult.GetResult(Options.ownerName).Implicit then parameters.OwnerId <- String.Empty if parseResult .GetResult( Options.organizationId ) .Implicit && not <| isNull (parseResult.GetResult(Options.organizationName)) && not <| parseResult .GetResult( Options.organizationName ) .Implicit then parameters.OrganizationId <- String.Empty if parseResult .GetResult( Options.repositoryId ) .Implicit && not <| isNull (parseResult.GetResult(Options.repositoryName)) && not <| parseResult .GetResult( Options.repositoryName ) .Implicit then parameters.RepositoryId <- String.Empty parameters let private CommonValidations parseResult = let ``Message must not be empty`` (parseResult: ParseResult) = if parseResult.CommandResult.Command.Options.FirstOrDefault(fun option -> option.Name = OptionName.Message) <> null then let message = parseResult .GetValue(OptionName.Message) .Trim() if not <| String.IsNullOrEmpty(message) then Ok(parseResult) else Error(GraceError.Create (getErrorMessage BranchError.MessageIsRequired) (getCorrelationId parseResult)) else Ok(parseResult) let ``Message must be less than 2048 characters`` (parseResult: ParseResult) = if parseResult.CommandResult.Command.Options.FirstOrDefault(fun option -> option.Name = OptionName.Message) <> null then let message = parseResult .GetValue(OptionName.Message) .Trim() if message.Length <= 2048 then Ok(parseResult) else Error(GraceError.Create (getErrorMessage BranchError.StringIsTooLong) (getCorrelationId parseResult)) else Ok(parseResult) (parseResult) |> ``Message must not be empty`` >>= ``Message must be less than 2048 characters`` let private ``BranchName must not be empty`` (parseResult: ParseResult) = let graceIds = getNormalizedIdsAndNames parseResult if (parseResult.CommandResult.Command.Options.Contains(Options.branchNameRequired) || parseResult.CommandResult.Command.Options.Contains(Options.branchName)) && not <| String.IsNullOrEmpty(graceIds.BranchName) then Ok parseResult else Error(GraceError.Create (getErrorMessage BranchError.BranchNameIsRequired) (getCorrelationId parseResult)) let private valueOrEmpty (value: string) = if String.IsNullOrWhiteSpace(value) then String.Empty else value let private guidToString (value: Guid) = if value = Guid.Empty then String.Empty else $"{value}" let private fallbackString hasValue supplied fallbackValue = if hasValue then supplied |> valueOrEmpty else fallbackValue |> valueOrEmpty let private fallbackGuidString hasValue supplied fallbackValue = if hasValue then supplied |> valueOrEmpty else fallbackValue |> guidToString // Create subcommand. type Create() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = parseResult |> CommonValidations >>= ``BranchName must not be empty`` match validateIncomingParameters with | Ok _ -> // In a Create() command, if --branch-id is implicit, that's the current branch taken from graceconfig.json, and the // current branch, by default, is the parent branch of the new one. Therefore, we need to set BranchId to a new Guid. let mutable graceIds = parseResult |> getNormalizedIdsAndNames if parseResult.GetResult(Options.branchId).Implicit then let branchId = Guid.NewGuid() graceIds <- { graceIds with BranchId = branchId; BranchIdString = $"{branchId}" } let parentBranchId = parseResult.GetValue(Options.parentBranchId) let parentBranchNameResult = parseResult.GetResult(Options.parentBranchName) let parentBranchIdResult = parseResult.GetResult(Options.parentBranchId) let parentBranchNameExplicit = not <| isNull parentBranchNameResult && not parentBranchNameResult.Implicit let parentBranchIdExplicit = not <| isNull parentBranchIdResult && not parentBranchIdResult.Implicit let parentBranchName = let suppliedParentBranchName = parseResult.GetValue(Options.parentBranchName) |> valueOrEmpty if not parentBranchNameExplicit && not parentBranchIdExplicit && suppliedParentBranchName = String.Empty && parentBranchId = Guid.Empty then Current().BranchName else suppliedParentBranchName let! parentBranchIdString = task { match parentBranchId, parentBranchName with | parentBranchId, parentBranchName when parentBranchId <> Guid.Empty -> return parentBranchId.ToString() | parentBranchId, parentBranchName when parentBranchName <> String.Empty -> return String.Empty | _ -> // No parent specified, determine based on current branch's promotion support if parseResult.GetResult(Options.branchId).Implicit then // Get the current branch (before we changed graceIds.BranchId to the new branch) let currentBranchId = Current().BranchId if currentBranchId <> Guid.Empty then // Get current branch details to check if it supports promotions let currentBranchParameters = GetBranchParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, BranchId = $"{currentBranchId}", BranchName = String.Empty, CorrelationId = graceIds.CorrelationId ) match! Branch.Get(currentBranchParameters) with | Ok returnValue -> let currentBranch = returnValue.ReturnValue // If current branch supports promotions, use it as parent if currentBranch.PromotionEnabled then return $"{currentBranchId}" // If current branch doesn't support promotions, use its parent (if valid) elif currentBranch.ParentBranchId <> Guid.Empty then return $"{currentBranch.ParentBranchId}" else // Current branch has no valid parent, let server handle the error return String.Empty | Error _ -> // If we can't get current branch info, let server handle validation return String.Empty else return String.Empty else return String.Empty } let initialPermissions = match parseResult.GetValue(Options.initialPermissions) with | null -> Array.empty | permissions -> permissions let parameters = CreateBranchParameters( RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, ParentBranchId = parentBranchIdString, ParentBranchName = parentBranchName, InitialPermissions = initialPermissions, CorrelationId = graceIds.CorrelationId ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Branch.Create(parameters) t0.Increment(100.0) return response }) else Branch.Create(parameters) match result with | Ok returnValue -> if not <| parseResult.GetValue(Options.doNotSwitch) then let newConfig = Current() newConfig.BranchId <- Guid.Parse($"{returnValue.Properties[nameof BranchId]}") newConfig.BranchName <- $"{returnValue.Properties[nameof BranchName]}" updateConfiguration newConfig return result |> renderOutput parseResult | Error _ -> return result |> renderOutput parseResult | Error error -> return GraceResult.Error error |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } let private getRecursiveSizeImpl (parseResult: ParseResult) : Tasks.Task = if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> CommonValidations let correlationId = getCorrelationId parseResult match validateIncomingParameters with | Error error -> Task.FromResult( GraceResult.Error error |> renderOutput parseResult ) | Ok _ -> let referenceId = if isNull (parseResult.GetResult(Options.referenceId)) then String.Empty else parseResult .GetValue(Options.referenceId) .ToString() let sha256Hash = if isNull (parseResult.GetResult(Options.sha256Hash)) then String.Empty else parseResult.GetValue(Options.sha256Hash) let sdkParameters = Parameters.Branch.ListContentsParameters( RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, Sha256Hash = sha256Hash, ReferenceId = referenceId, Pattern = String.Empty, ShowDirectories = true, ShowFiles = true, ForceRecompute = false, CorrelationId = correlationId ) task { let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Branch.GetRecursiveSize(sdkParameters) t0.Increment(100.0) return response }) else Branch.GetRecursiveSize(sdkParameters) match result with | Ok returnValue -> AnsiConsole.MarkupLine $"[{Colors.Highlighted}]Total file size: {returnValue.ReturnValue:N0}[/]" | Error error -> AnsiConsole.MarkupLine $"[{Colors.Error}]{error}[/]" return result |> renderOutput parseResult } type GetRecursiveSize() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try return! getRecursiveSizeImpl parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } let private getShortHash (sha256Hash: Sha256Hash) = if String.IsNullOrWhiteSpace(sha256Hash) then String.Empty elif sha256Hash.Length <= 8 then sha256Hash else sha256Hash.Substring(0, 8) let private tryGetRootSha256Hash (directoryVersions: IEnumerable) = directoryVersions |> Seq.tryFind (fun directoryVersion -> directoryVersion.RelativePath = Constants.RootDirectoryPath) |> Option.map (fun directoryVersion -> directoryVersion.Sha256Hash) let printContents (parseResult: ParseResult) (directoryVersions: IEnumerable) = let directoryVersionArray = directoryVersions |> Seq.toArray if directoryVersionArray.Length > 0 then let longestRelativePath = getLongestRelativePath ( directoryVersionArray |> Seq.map (fun directoryVersion -> directoryVersion.ToLocalDirectoryVersion(DateTime.UtcNow)) ) //logToAnsiConsole Colors.Verbose $"In printContents: getLongestRelativePath: {longestRelativePath}" let additionalSpaces = String.replicate (longestRelativePath - 2) " " let additionalImportantDashes = String.replicate (longestRelativePath + 3) "-" let additionalDeemphasizedDashes = String.replicate (38) "-" directoryVersionArray |> Seq.iteri (fun i directoryVersion -> AnsiConsole.WriteLine() if i = 0 then AnsiConsole.MarkupLine( $"[{Colors.Important}]Created At SHA-256 Size Path{additionalSpaces}[/][{Colors.Deemphasized}] (DirectoryVersionId)[/]" ) AnsiConsole.MarkupLine( $"[{Colors.Important}]-----------------------------------------------------{additionalImportantDashes}[/][{Colors.Deemphasized}] {additionalDeemphasizedDashes}[/]" ) //logToAnsiConsole Colors.Verbose $"In printContents: directoryVersion.RelativePath: {directoryVersion.RelativePath}" let rightAlignedDirectoryVersionId = (String.replicate (longestRelativePath - directoryVersion.RelativePath.Length) " ") + $"({directoryVersion.DirectoryVersionId})" AnsiConsole.MarkupLine( $"[{Colors.Highlighted}]{formatInstantAligned directoryVersion.CreatedAt} {getShortSha256Hash directoryVersion.Sha256Hash} {directoryVersion.Size, 13:N0} /{directoryVersion.RelativePath}[/] [{Colors.Deemphasized}] {rightAlignedDirectoryVersionId}[/]" ) //if parseResult.CommandResult.Command.Options.Contains(Options.listFiles) then let sortedFiles = directoryVersion.Files.OrderBy(fun f -> f.RelativePath) for file in sortedFiles do AnsiConsole.MarkupLine( $"[{Colors.Verbose}]{formatInstantAligned file.CreatedAt} {getShortSha256Hash file.Sha256Hash} {file.Size, 13:N0} |- {file.RelativePath.Split('/').LastOrDefault()}[/]" )) let private listContentsImpl (parseResult: ParseResult) : Tasks.Task = if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> CommonValidations let correlationId = getCorrelationId parseResult match validateIncomingParameters with | Error error -> Task.FromResult( GraceResult.Error error |> renderOutput parseResult ) | Ok _ -> let referenceId = if isNull (parseResult.GetResult Options.referenceId) then String.Empty else (parseResult.GetValue Options.referenceId) .ToString() let sha256Hash = if isNull (parseResult.GetResult Options.sha256Hash) then String.Empty else parseResult.GetValue Options.sha256Hash let forceRecompute = parseResult.GetValue Options.forceRecompute let sdkParameters = ListContentsParameters( RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, Sha256Hash = sha256Hash, ReferenceId = referenceId, Pattern = String.Empty, ShowDirectories = true, ShowFiles = true, ForceRecompute = forceRecompute, CorrelationId = correlationId ) task { let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Branch.ListContents(sdkParameters) t0.Value <- 100.0 return response }) else Branch.ListContents(sdkParameters) match result with | Ok returnValue -> let directoryVersions = returnValue .ReturnValue .Select(fun directoryVersionDto -> directoryVersionDto.DirectoryVersion) .OrderBy(fun dv -> dv.RelativePath) .ToArray() let directoryCount = directoryVersions.Length let fileCount = directoryVersions.Sum(fun directoryVersion -> directoryVersion.Files.Count) let totalFileSize = directoryVersions.Sum(fun directoryVersion -> directoryVersion.Files.Sum(fun f -> f.Size)) AnsiConsole.MarkupLine($"[{Colors.Important}]All values taken from the selected version of this branch from the server.[/]") AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Number of directories: {directoryCount}.[/]") AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Number of files: {fileCount}; total file size: {totalFileSize:N0}.[/]") match tryGetRootSha256Hash directoryVersions with | Some rootSha256Hash -> AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Root SHA-256 hash: {getShortHash rootSha256Hash}[/]") | None -> AnsiConsole.MarkupLine($"[{Colors.Error}]Root SHA-256 hash: unavailable (root directory entry missing from server response).[/]") if directoryCount > 0 then printContents parseResult directoryVersions else AnsiConsole.MarkupLine($"[{Colors.Verbose}]No directory entries were returned.[/]") | Error _ -> () return result |> renderOutput parseResult } type ListContents() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) = task { try return! listContentsImpl parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type SetName() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> CommonValidations let correlationId = getCorrelationId parseResult let newName = parseResult.GetValue(Options.newName) match validateIncomingParameters with | Ok _ -> let parameters = Parameters.Branch.SetBranchNameParameters( BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, NewName = newName, CorrelationId = correlationId ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Branch.SetName(parameters) t0.Increment(100.0) return response }) else Branch.SetName(parameters) return result |> renderOutput parseResult | Error error -> return GraceResult.Error error |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } let private assignImpl (parseResult: ParseResult) : Tasks.Task = if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let correlationId = getCorrelationId parseResult let validateIncomingParameters = parseResult |> CommonValidations let requiredInputs = oneOfTheseOptionsMustBeProvided parseResult [| Options.directoryVersionId Options.sha256Hash |] BranchError.EitherDirectoryVersionIdOrSha256HashRequired match validateIncomingParameters, requiredInputs with | Error error, _ | _, Error error -> Task.FromResult( GraceResult.Error error |> renderOutput parseResult ) | Ok _, Ok _ -> let directoryVersionId = if isNull (parseResult.GetResult(Options.directoryVersionId)) then Guid.Empty else parseResult.GetValue(Options.directoryVersionId) let sha256Hash = if isNull (parseResult.GetResult(Options.sha256Hash)) then String.Empty else parseResult.GetValue(Options.sha256Hash) let parameters = Parameters.Branch.AssignParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, DirectoryVersionId = directoryVersionId, Sha256Hash = sha256Hash, CorrelationId = correlationId ) task { let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Branch.Assign(parameters) t0.Increment(100.0) return response }) else Branch.Assign(parameters) return result |> renderOutput parseResult } type Assign() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try return! assignImpl parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } let private validateAndCleanMessage (message: string) (correlationId: CorrelationId) : Result = // Helpers local to this function to keep things simple. let fail msg = Error(GraceError.Create msg correlationId) // Regexes: created per-call for simplicity; // you can hoist them to module-level if you want them compiled once. let disallowedControlChars = Regex("[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]", RegexOptions.Compiled) let bidiControls = Regex("[\u202A-\u202E\u2066-\u2069]", RegexOptions.Compiled) let nonWhitespace = Regex(@"\S", RegexOptions.Compiled) // 1. Normalize newlines to '\n' let step1 = message .Trim() .Replace("\r\n", "\n") .Replace("\r", "\n") // 2. Trim trailing whitespace per line let step2 = let lines = step1.Split('\n') let trimmedLines = lines |> Array.map (fun line -> line.TrimEnd()) String.concat "\n" trimmedLines // 3. Remove leading / trailing *blank* lines (whitespace-only) let step3 = let lines = step2.Split('\n') let mutable start = 0 let mutable finish = lines.Length - 1 while start <= finish && String.IsNullOrWhiteSpace lines[start] do start <- start + 1 while finish >= start && String.IsNullOrWhiteSpace lines[finish] do finish <- finish - 1 if start > finish then "" else String.concat "\n" lines[start..finish] // 4. Unicode normalization to NFC let cleaned = step3.Normalize(NormalizationForm.FormC) // 5. Actual validations // 5a. Must contain at least one non-whitespace character if String.IsNullOrEmpty cleaned || not (nonWhitespace.IsMatch cleaned) then fail "Message must contain at least one non-whitespace character." // 5b. Disallow unwanted control characters elif disallowedControlChars.IsMatch cleaned then fail "Message contains disallowed control characters." // 5c. Disallow bidi control characters elif bidiControls.IsMatch cleaned then fail "Message contains disallowed Unicode bidi control characters." else Ok cleaned type CreateReferenceCommand = CreateReferenceParameters -> Task> let private createReferenceHandler (parseResult: ParseResult) (message: string) (command: CreateReferenceCommand) (commandType: string) = task { try if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = parseResult |> CommonValidations let referenceMessage = validateAndCleanMessage message (getCorrelationId parseResult) match (validateIncomingParameters, referenceMessage) with | Ok _, Ok referenceMessage -> let graceIds = parseResult |> getNormalizedIdsAndNames //let sha256Bytes = SHA256.HashData(Encoding.ASCII.GetBytes(rnd.NextInt64().ToString("x8"))) //let sha256Hash = Seq.fold (fun (sb: StringBuilder) currentByte -> // sb.Append(sprintf $"{currentByte:X2}")) (StringBuilder(sha256Bytes.Length)) sha256Bytes if parseResult |> hasOutput then return! progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Reading Grace status file.[/]") let t1 = progressContext.AddTask($"[{Color.DodgerBlue1}]Scanning working directory for changes.[/]", autoStart = false) let t2 = progressContext.AddTask($"[{Color.DodgerBlue1}]Creating new directory verions.[/]", autoStart = false) let t3 = progressContext.AddTask($"[{Color.DodgerBlue1}]Uploading changed files to object storage.[/]", autoStart = false) let t4 = progressContext.AddTask($"[{Color.DodgerBlue1}]Uploading new directory versions.[/]", autoStart = false) let t5 = progressContext.AddTask($"[{Color.DodgerBlue1}]Creating new {commandType}.[/]", autoStart = false) //let mutable rootDirectoryId = DirectoryId.Empty //let mutable rootDirectorySha256Hash = Sha256Hash String.Empty let rootDirectoryVersion = ref (DirectoryVersionId.Empty, Sha256Hash String.Empty) match! getGraceWatchStatus () with | Some graceWatchStatus -> t0.Value <- 100.0 t1.Value <- 100.0 t2.Value <- 100.0 t3.Value <- 100.0 t4.Value <- 100.0 rootDirectoryVersion.Value <- (graceWatchStatus.RootDirectoryId, graceWatchStatus.RootDirectorySha256Hash) | None -> t0.StartTask() // Read Grace status file. let! previousGraceStatus = readGraceStatusFile () let mutable newGraceStatus = previousGraceStatus t0.Value <- 100.0 t1.StartTask() // Scan for differences. let! differences = scanForDifferences previousGraceStatus //logToAnsiConsole Colors.Verbose $"differences: {serialize differences}" let! newFileVersions = copyUpdatedFilesToObjectCache t1 differences //logToAnsiConsole Colors.Verbose $"newFileVersions: {serialize newFileVersions}" t1.Value <- 100.0 t2.StartTask() // Create new directory versions. let! (updatedGraceStatus, newDirectoryVersions) = getNewGraceStatusAndDirectoryVersions previousGraceStatus differences newGraceStatus <- updatedGraceStatus rootDirectoryVersion.Value <- (newGraceStatus.RootDirectoryId, newGraceStatus.RootDirectorySha256Hash) t2.Value <- 100.0 t3.StartTask() // Upload to object storage. let updatedRelativePaths = differences .Select(fun difference -> match difference.DifferenceType with | Add -> match difference.FileSystemEntryType with | FileSystemEntryType.File -> Some difference.RelativePath | FileSystemEntryType.Directory -> None | Change -> match difference.FileSystemEntryType with | FileSystemEntryType.File -> Some difference.RelativePath | FileSystemEntryType.Directory -> None | Delete -> None) .Where(fun relativePathOption -> relativePathOption.IsSome) .Select(fun relativePath -> relativePath.Value) // let newFileVersions = updatedRelativePaths.Select(fun relativePath -> // newDirectoryVersions.First(fun dv -> dv.Files.Exists(fun file -> file.RelativePath = relativePath)).Files.First(fun file -> file.RelativePath = relativePath)) let mutable lastFileUploadInstant = newGraceStatus.LastSuccessfulFileUpload if newFileVersions.Count() > 0 then let getUploadMetadataForFilesParameters = Storage.GetUploadMetadataForFilesParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = getCorrelationId parseResult, FileVersions = (newFileVersions |> Seq.map (fun localFileVersion -> localFileVersion.ToFileVersion) |> Seq.toArray) ) match! uploadFilesToObjectStorage getUploadMetadataForFilesParameters with | Ok returnValue -> () //logToAnsiConsole Colors.Verbose $"Uploaded all files to object storage." | Error error -> logToAnsiConsole Colors.Error $"Error uploading files to object storage: {error.Error}" lastFileUploadInstant <- getCurrentInstant () t3.Value <- 100.0 t4.StartTask() // Upload directory versions. let mutable lastDirectoryVersionUpload = newGraceStatus.LastSuccessfulDirectoryVersionUpload if newDirectoryVersions.Count > 0 then let saveParameters = SaveDirectoryVersionsParameters() saveParameters.OwnerId <- graceIds.OwnerIdString saveParameters.OwnerName <- graceIds.OwnerName saveParameters.OrganizationId <- graceIds.OrganizationIdString saveParameters.OrganizationName <- graceIds.OrganizationName saveParameters.RepositoryId <- graceIds.RepositoryIdString saveParameters.RepositoryName <- graceIds.RepositoryName saveParameters.DirectoryVersionId <- $"{newGraceStatus.RootDirectoryId}" saveParameters.DirectoryVersions <- newDirectoryVersions .Select(fun dv -> dv.ToDirectoryVersion) .ToList() saveParameters.CorrelationId <- getCorrelationId parseResult let! uploadDirectoryVersions = DirectoryVersion.SaveDirectoryVersions saveParameters lastDirectoryVersionUpload <- getCurrentInstant () t4.Value <- 100.0 newGraceStatus <- { newGraceStatus with LastSuccessfulFileUpload = lastFileUploadInstant LastSuccessfulDirectoryVersionUpload = lastDirectoryVersionUpload } do! applyGraceStatusIncremental newGraceStatus newDirectoryVersions differences t5.StartTask() // Create new reference. let (rootDirectoryId, rootDirectorySha256Hash) = rootDirectoryVersion.Value let sdkParameters = Parameters.Branch.CreateReferenceParameters( BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, DirectoryVersionId = rootDirectoryId, Sha256Hash = rootDirectorySha256Hash, Message = referenceMessage, CorrelationId = graceIds.CorrelationId ) let! result = command sdkParameters t5.Value <- 100.0 return result }) else let! previousGraceStatus = readGraceStatusFile () let! differences = scanForDifferences previousGraceStatus let! (newGraceIndex, newDirectoryVersions) = getNewGraceStatusAndDirectoryVersions previousGraceStatus differences let updatedRelativePaths = differences .Select(fun difference -> match difference.DifferenceType with | Add -> match difference.FileSystemEntryType with | FileSystemEntryType.File -> Some difference.RelativePath | FileSystemEntryType.Directory -> None | Change -> match difference.FileSystemEntryType with | FileSystemEntryType.File -> Some difference.RelativePath | FileSystemEntryType.Directory -> None | Delete -> None) .Where(fun relativePathOption -> relativePathOption.IsSome) .Select(fun relativePath -> relativePath.Value) let newFileVersions = updatedRelativePaths.Select (fun relativePath -> newDirectoryVersions .First(fun dv -> dv.Files.Exists(fun file -> file.RelativePath = relativePath)) .Files.First(fun file -> file.RelativePath = relativePath)) let getUploadMetadataForFilesParameters = Storage.GetUploadMetadataForFilesParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = getCorrelationId parseResult, FileVersions = (newFileVersions |> Seq.map (fun localFileVersion -> localFileVersion.ToFileVersion) |> Seq.toArray) ) let! uploadResult = uploadFilesToObjectStorage getUploadMetadataForFilesParameters let saveParameters = SaveDirectoryVersionsParameters() saveParameters.OwnerId <- graceIds.OwnerIdString saveParameters.OwnerName <- graceIds.OwnerName saveParameters.OrganizationId <- graceIds.OrganizationIdString saveParameters.OrganizationName <- graceIds.OrganizationName saveParameters.RepositoryId <- graceIds.RepositoryIdString saveParameters.RepositoryName <- graceIds.RepositoryName saveParameters.CorrelationId <- getCorrelationId parseResult saveParameters.DirectoryVersions <- newDirectoryVersions .Select(fun dv -> dv.ToDirectoryVersion) .ToList() let! uploadDirectoryVersions = DirectoryVersion.SaveDirectoryVersions saveParameters let rootDirectoryVersion = getRootDirectoryVersion previousGraceStatus let sdkParameters = Parameters.Branch.CreateReferenceParameters( BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, DirectoryVersionId = rootDirectoryVersion.DirectoryVersionId, Sha256Hash = rootDirectoryVersion.Sha256Hash, Message = referenceMessage, CorrelationId = graceIds.CorrelationId ) let! result = command sdkParameters return result | Error error, _ -> return Error error | _, Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId)) } let private promotionHandler (parseResult: ParseResult) (message: string) = task { try if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = parseResult |> CommonValidations let sanitizedMessage = message.Trim() match validateIncomingParameters with | Ok _ -> let graceIds = parseResult |> getNormalizedIdsAndNames if parseResult |> hasOutput then return! progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Reading Grace status file.[/]") let t1 = progressContext.AddTask($"[{Color.DodgerBlue1}]Checking if the promotion is valid.[/]", autoStart = false) let t2 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]", autoStart = false) // Read Grace status file. let! graceStatus = readGraceStatusFile () let rootDirectoryId = graceStatus.RootDirectoryId let rootDirectorySha256Hash = graceStatus.RootDirectorySha256Hash t0.Value <- 100.0 // Check if the promotion is valid; i.e. it's allowed by the ReferenceTypes enabled in the repository. t1.StartTask() // For single-step promotion, the current branch's latest commit will become the parent branch's next promotion. // If our current state is not the latest commit, print a warning message. // Get the Dto for the current branch. That will have its latest commit. let branchGetParameters = GetBranchParameters( BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) //logToAnsiConsole // Colors.Verbose // $"In promotionHandler: branchGetParameters:{Environment.NewLine}{serialize branchGetParameters}" let! branchResult = Branch.Get(branchGetParameters) match branchResult with | Ok branchReturnValue -> // If we succeeded, get the parent branch Dto. That will have its latest promotion. let! parentBranchResult = Branch.GetParentBranch(branchGetParameters) match parentBranchResult with | Ok parentBranchReturnValue -> // Yay, we have both Dto's. let branchDto = branchReturnValue.ReturnValue //logToAnsiConsole Colors.Verbose $"In promotionHandler: branchDto:{Environment.NewLine}{serialize branchDto}" let parentBranchDto = parentBranchReturnValue.ReturnValue let referenceIds = List() if branchDto.LatestCommit <> ReferenceDto.Default then referenceIds.Add(branchDto.LatestCommit.ReferenceId) if branchDto.LatestPromotion <> ReferenceDto.Default then referenceIds.Add(branchDto.LatestPromotion.ReferenceId) if referenceIds.Count > 0 then let getReferencesByReferenceIdParameters = Parameters.Repository.GetReferencesByReferenceIdParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, ReferenceIds = referenceIds, CorrelationId = graceIds.CorrelationId ) //logToAnsiConsole // Colors.Verbose // $"In promotionHandler: getReferencesByReferenceIdParameters:{Environment.NewLine}{serialize getReferencesByReferenceIdParameters}" match! Repository.GetReferencesByReferenceId(getReferencesByReferenceIdParameters) with | Ok returnValue -> let references = returnValue.ReturnValue let latestPromotableReference = references .OrderByDescending(fun reference -> reference.CreatedAt) .First() // If the current branch's latest reference is not the latest commit - i.e. they've done more work in the branch // after the commit they're expecting to promote - print a warning. //match getReferencesByReferenceIdResult with //| Ok returnValue -> // let references = returnValue.ReturnValue // if referenceDto.DirectoryId <> graceStatus.RootDirectoryId then // logToAnsiConsole Colors.Important $"Note: the branch has been updated since the latest commit." //| Error error -> () // I don't really care if this call fails, it's just a warning message. t1.Value <- 100.0 // If the current branch is based on the parent's latest promotion, then we can proceed with the promotion. if branchDto.BasedOn.ReferenceId = parentBranchDto.LatestPromotion.ReferenceId then t2.StartTask() let promotionParameters = Parameters.Branch.CreateReferenceParameters( BranchId = $"{parentBranchDto.BranchId}", OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, DirectoryVersionId = latestPromotableReference.DirectoryId, Sha256Hash = latestPromotableReference.Sha256Hash, Message = sanitizedMessage, CorrelationId = graceIds.CorrelationId ) let! promotionResult = Branch.Promote(promotionParameters) match promotionResult with | Ok returnValue -> //logToAnsiConsole Colors.Verbose $"Succeeded doing promotion." //logToAnsiConsole // Colors.Verbose // $"{serialize (returnValue.Properties.OrderBy(fun kvp -> kvp.Key))}" let promotionReferenceId = returnValue.Properties[ "ReferenceId" ].ToString() //let promotionReferenceId = returnValue.Properties.Item(nameof ReferenceId) :?> string let rebaseParameters = Parameters.Branch.RebaseParameters( BranchId = $"{branchDto.BranchId}", RepositoryId = $"{branchDto.RepositoryId}", OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, BasedOn = Guid.Parse(promotionReferenceId) ) let! rebaseResult = Branch.Rebase(rebaseParameters) t2.Value <- 100.0 match rebaseResult with | Ok returnValue -> //logToAnsiConsole Colors.Verbose $"Succeeded doing rebase." return promotionResult | Error error -> return Error error | Error error -> t2.Value <- 100.0 return Error error else return Error( GraceError.Create (getErrorMessage BranchError.BranchIsNotBasedOnLatestPromotion) (parseResult |> getCorrelationId) ) | Error error -> t2.Value <- 100.0 return Error error else return Error( GraceError.Create (getErrorMessage BranchError.PromotionNotAvailableBecauseThereAreNoPromotableReferences) (parseResult |> getCorrelationId) ) | Error error -> t1.Value <- 100.0 return Error error | Error error -> t1.Value <- 100.0 return Error error }) else // Same result, with no output. return Error(GraceError.Create "Need to implement the else clause." (parseResult |> getCorrelationId)) | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId)) } type Promote() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let message = parseResult.GetValue(Options.message) |> valueOrEmpty let! result = promotionHandler parseResult message return result |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type Commit() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let message = parseResult.GetValue(Options.messageRequired) |> valueOrEmpty let command (parameters: CreateReferenceParameters) = task { return! Branch.Commit(parameters) } let! result = createReferenceHandler parseResult message command (nameof(Commit).ToLowerInvariant()) return result |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type Checkpoint() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let message = parseResult.GetValue(Options.message) |> valueOrEmpty let command (parameters: CreateReferenceParameters) = task { return! Branch.Checkpoint(parameters) } let! result = createReferenceHandler parseResult message command (nameof(Checkpoint).ToLowerInvariant()) return result |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type Save() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let message = parseResult.GetValue(Options.message) |> valueOrEmpty let command (parameters: CreateReferenceParameters) = task { return! Branch.Save(parameters) } let! result = createReferenceHandler parseResult message command (nameof(Save).ToLowerInvariant()) return result |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type Tag() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let message = parseResult.GetValue(Options.messageRequired) |> valueOrEmpty let command (parameters: CreateReferenceParameters) = task { return! Branch.Tag(parameters) } let! result = createReferenceHandler parseResult message command (nameof(Tag).ToLowerInvariant()) return result |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type CreateExternal() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let message = parseResult.GetValue(Options.messageRequired) |> valueOrEmpty let command (parameters: CreateReferenceParameters) = task { return! Branch.CreateExternal(parameters) } let! result = createReferenceHandler parseResult message command ("External".ToLowerInvariant()) return result |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type EnableFeatureCommand = EnableFeatureParameters -> Task> let private enableFeatureHandler (parseResult: ParseResult) (enabled: bool) (command: EnableFeatureCommand) = task { try if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = parseResult |> CommonValidations match validateIncomingParameters with | Ok _ -> let graceIds = parseResult |> getNormalizedIdsAndNames let parameters = Parameters.Branch.EnableFeatureParameters( BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, Enabled = enabled, CorrelationId = graceIds.CorrelationId ) let! result = command parameters match result with | Ok returnValue -> return Ok(GraceReturnValue.Create (returnValue.ReturnValue) graceIds.CorrelationId) | Error error -> return Error error | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId)) } type EnableAssign() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try let graceIds = parseResult |> getNormalizedIdsAndNames let enabled = parseResult.GetValue(Options.enabled) let command (parameters: EnableFeatureParameters) = task { return! Branch.EnableAssign(parameters) } let! result = enableFeatureHandler parseResult enabled command return result |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type EnablePromotion() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try let graceIds = parseResult |> getNormalizedIdsAndNames let enabled = parseResult.GetValue(Options.enabled) let command (parameters: EnableFeatureParameters) = task { return! Branch.EnablePromotion(parameters) } let! result = enableFeatureHandler parseResult enabled command return result |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type EnableCommit() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try let graceIds = parseResult |> getNormalizedIdsAndNames let enabled = parseResult.GetValue(Options.enabled) let command (parameters: EnableFeatureParameters) = task { return! Branch.EnableCommit(parameters) } let! result = enableFeatureHandler parseResult enabled command return result |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type EnableCheckpoint() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try let graceIds = parseResult |> getNormalizedIdsAndNames let enabled = parseResult.GetValue(Options.enabled) let command (parameters: EnableFeatureParameters) = task { return! Branch.EnableCheckpoint(parameters) } let! result = enableFeatureHandler parseResult enabled command return result |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type EnableSave() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try let graceIds = parseResult |> getNormalizedIdsAndNames let enabled = parseResult.GetValue(Options.enabled) let command (parameters: EnableFeatureParameters) = task { return! Branch.EnableSave(parameters) } let! result = enableFeatureHandler parseResult enabled command return result |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type EnableTag() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try let graceIds = parseResult |> getNormalizedIdsAndNames let enabled = parseResult.GetValue(Options.enabled) let command (parameters: EnableFeatureParameters) = task { return! Branch.EnableTag(parameters) } let! result = enableFeatureHandler parseResult enabled command return result |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type EnableExternal() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try let graceIds = parseResult |> getNormalizedIdsAndNames let enabled = parseResult.GetValue(Options.enabled) let command (parameters: EnableFeatureParameters) = task { return! Branch.EnableExternal(parameters) } let! result = enableFeatureHandler parseResult enabled command return result |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type EnableAutoRebase() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try let graceIds = parseResult |> getNormalizedIdsAndNames let enabled = parseResult.GetValue(Options.enabled) let command (parameters: EnableFeatureParameters) = task { return! Branch.EnableAutoRebase(parameters) } let! result = enableFeatureHandler parseResult enabled command return result |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type SetPromotionMode() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = parseResult |> CommonValidations match validateIncomingParameters with | Ok _ -> let graceIds = parseResult |> getNormalizedIdsAndNames let promotionMode = parseResult.GetValue(Options.promotionMode) let parameters = Parameters.Branch.SetPromotionModeParameters( BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, PromotionMode = promotionMode, CorrelationId = graceIds.CorrelationId ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Branch.SetPromotionMode(parameters) t0.Increment(100.0) return response }) else Branch.SetPromotionMode(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } // Get subcommand type Get() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let includeDeleted = parseResult.GetValue(Options.includeDeleted) let showEvents = parseResult.GetValue(Options.showEvents) let validateIncomingParameters = parseResult |> CommonValidations match validateIncomingParameters with | Ok _ -> let graceIds = parseResult |> getNormalizedIdsAndNames let branchParameters = GetBranchParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, IncludeDeleted = includeDeleted, CorrelationId = graceIds.CorrelationId ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Branch.Get(branchParameters) t0.Increment(100.0) return response }) else Branch.Get(branchParameters) match result with | Ok graceReturnValue -> let jsonText = JsonText(serialize graceReturnValue.ReturnValue) AnsiConsole.Write(jsonText) AnsiConsole.WriteLine() if showEvents then let eventsParameters = GetBranchVersionParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, IncludeDeleted = includeDeleted, CorrelationId = graceIds.CorrelationId ) let! eventsResult = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Branch.GetEvents(eventsParameters) t0.Increment(100.0) return response }) else Branch.GetEvents(eventsParameters) match eventsResult with | Ok eventsValue -> let sb = StringBuilder() for line in eventsValue.ReturnValue do sb.AppendLine($"{Markup.Escape(line)},") |> ignore AnsiConsole.MarkupLine $"[{Colors.Verbose}]{Markup.Escape(line)}[/]" if sb.Length > 0 then sb.Remove(sb.Length - 1, 1) |> ignore AnsiConsole.WriteLine() return 0 | Error graceError -> return renderOutput parseResult (GraceResult.Error graceError) else return 0 | Error graceError -> return renderOutput parseResult (GraceResult.Error graceError) | Error graceError -> return renderOutput parseResult (GraceResult.Error graceError) with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type GetReferenceQuery = GetReferencesParameters -> Task> let private fetchReferences (getBranchParameters: GetBranchParameters) (getReferencesParameters: GetReferencesParameters) (query: GetReferenceQuery) (graceIds: GraceIds) = task { let! branchResult = Branch.Get(getBranchParameters) let! referencesResult = query getReferencesParameters match (branchResult, referencesResult) with | (Ok branchValue, Ok referencesValue) -> let graceReturnValue = GraceReturnValue.Create (branchValue.ReturnValue, referencesValue.ReturnValue) graceIds.CorrelationId referencesValue.Properties |> Seq.iter (fun kvp -> graceReturnValue.Properties.Add(kvp.Key, kvp.Value)) return Ok graceReturnValue | (_, Error error) | (Error error, _) -> return Error error } let private getReferenceHandlerImpl (parseResult: ParseResult) (maxCount: int) (query: GetReferenceQuery) = if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = parseResult |> CommonValidations let graceIds = parseResult |> getNormalizedIdsAndNames match validateIncomingParameters with | Error error -> Task.FromResult(Error error) | Ok _ -> let getBranchParameters = GetBranchParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, CorrelationId = graceIds.CorrelationId ) let getReferencesParameters = GetReferencesParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, MaxCount = maxCount, CorrelationId = graceIds.CorrelationId ) let fetch () = fetchReferences getBranchParameters getReferencesParameters query graceIds if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = fetch () t0.Increment(100.0) return response }) else fetch () let getReferenceHandler (parseResult: ParseResult) (maxCount: int) (query: GetReferenceQuery) = task { try return! getReferenceHandlerImpl parseResult maxCount query with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId)) } let private createReferenceTable (parseResult: ParseResult) (references: ReferenceDto array) = let sortedResults = references |> Array.sortByDescending (fun row -> row.CreatedAt) let table = Table(Border = TableBorder.Rounded, ShowHeaders = true) table.AddColumns( [| TableColumn($"[{Colors.Important}]Type[/]") TableColumn($"[{Colors.Important}]Message[/]") TableColumn($"[{Colors.Important}]SHA-256[/]") TableColumn($"[{Colors.Important}]When[/]", Alignment = Justify.Right) TableColumn($"[{Colors.Important}][/]") |] ) |> ignore if parseResult |> verbose then table .AddColumns( [| TableColumn($"[{Colors.Deemphasized}]ReferenceId[/]") |] ) .AddColumns( [| TableColumn($"[{Colors.Deemphasized}]Root DirectoryVersionId[/]") |] ) |> ignore for row in sortedResults do //logToAnsiConsole Colors.Verbose $"{serialize row}" let sha256Hash = if parseResult.GetValue(Options.fullSha) then $"{row.Sha256Hash}" else $"{getShortSha256Hash row.Sha256Hash}" let localCreatedAtTime = row.CreatedAt.ToDateTimeUtc().ToLocalTime() let referenceTime = $"""{localCreatedAtTime.ToString("g", CultureInfo.CurrentUICulture)}""" if parseResult |> verbose then table.AddRow( [| $"{getDiscriminatedUnionCaseName (row.ReferenceType)}" $"{row.ReferenceText}" sha256Hash ago row.CreatedAt $"[{Colors.Deemphasized}]{referenceTime}[/]" $"[{Colors.Deemphasized}]{row.ReferenceId}[/]" $"[{Colors.Deemphasized}]{row.DirectoryId}[/]" |] ) else table.AddRow( [| $"{getDiscriminatedUnionCaseName (row.ReferenceType)}" $"{row.ReferenceText}" sha256Hash ago row.CreatedAt $"[{Colors.Deemphasized}]{referenceTime}[/]" |] ) |> ignore table let printReferenceTable (table: Table) (references: ReferenceDto array) branchName referenceName = AnsiConsole.MarkupLine($"[{Colors.Important}]{referenceName} in branch {branchName}:[/]") AnsiConsole.Write(table) AnsiConsole.MarkupLine($"[{Colors.Important}]Returned {references.Length} rows.[/]") let private renderReferencesOutput (parseResult: ParseResult) (label: string) (result: GraceResult) = match result with | Ok graceReturnValue -> let (branchDto, references) = graceReturnValue.ReturnValue let rendered = result |> renderOutput parseResult if parseResult |> hasOutput then let referenceTable = createReferenceTable parseResult references printReferenceTable referenceTable references branchDto.BranchName label rendered | Error _ -> result |> renderOutput parseResult type GetReferences() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try let maxCount = parseResult.GetValue(Options.maxCount) let query (parameters: GetReferencesParameters) = Branch.GetReferences parameters let! result = getReferenceHandler parseResult maxCount query return renderReferencesOutput parseResult "References" result with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type GetPromotions() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try let maxCount = parseResult.GetValue(Options.maxCount) let query (parameters: GetReferencesParameters) = task { return! Branch.GetPromotions(parameters) } let! result = getReferenceHandler parseResult maxCount query return renderReferencesOutput parseResult "Promotions" result with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type GetCommits() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try let maxCount = parseResult.GetValue(Options.maxCount) let query (parameters: GetReferencesParameters) = task { return! Branch.GetCommits(parameters) } let! result = getReferenceHandler parseResult maxCount query return renderReferencesOutput parseResult "Commits" result with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type GetCheckpoints() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try let maxCount = parseResult.GetValue(Options.maxCount) let query (parameters: GetReferencesParameters) = task { let! checkpointsResult = Branch.GetCheckpoints(parameters) match checkpointsResult with | Ok checkpointsValue -> let! commitsResult = Branch.GetCommits(parameters) match commitsResult with | Ok commitsValue -> let combined = Seq.append checkpointsValue.ReturnValue commitsValue.ReturnValue |> Seq.sortByDescending (fun reference -> reference.CreatedAt) |> Seq.take maxCount |> Seq.toArray return Ok(GraceReturnValue.Create combined (getCorrelationId parseResult)) | Error error -> return Error error | Error error -> return Error error } let! result = getReferenceHandler parseResult maxCount query return renderReferencesOutput parseResult "Checkpoints" result with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type GetSaves() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try let maxCount = parseResult.GetValue(Options.maxCount) let query (parameters: GetReferencesParameters) = task { return! Branch.GetSaves(parameters) } let! result = getReferenceHandler parseResult maxCount query return renderReferencesOutput parseResult "Saves" result with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type GetTags() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try let maxCount = parseResult.GetValue(Options.maxCount) let query (parameters: GetReferencesParameters) = task { return! Branch.GetTags(parameters) } let! result = getReferenceHandler parseResult maxCount query return renderReferencesOutput parseResult "Tags" result with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type GetExternals() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try let maxCount = parseResult.GetValue(Options.maxCount) let query (parameters: GetReferencesParameters) = task { return! Branch.GetExternals(parameters) } let! result = getReferenceHandler parseResult maxCount query return renderReferencesOutput parseResult "Externals" result with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type SwitchParameters() = member val ToBranchId: string = String.Empty with get, set member val ToBranchName: string = String.Empty with get, set member val Sha256Hash: string = String.Empty with get, set member val ReferenceId: string = String.Empty with get, set type Switch() = inherit AsynchronousCommandLineAction() let switchHandler (parseResult: ParseResult) (switchParameters: SwitchParameters) = task { try let graceIds = getNormalizedIdsAndNames parseResult /// The GraceStatus at the beginning of running this command. let mutable previousGraceStatus = GraceStatus.Default /// The GraceStatus after the current version is saved. let mutable newGraceStatus = GraceStatus.Default /// The DirectoryId of the root directory version. let mutable rootDirectoryId = DirectoryVersionId.Empty /// The SHA-256 hash of the root directory version. let mutable rootDirectorySha256Hash = Sha256Hash String.Empty /// The set of DirectoryIds in the working directory after the current version is saved. let mutable directoryIdsInNewGraceStatus: HashSet = null let showOutput = parseResult |> hasOutput if parseResult |> verbose then printParseResult parseResult // Validate the incoming parameters. let validateIncomingParameters (showOutput, parseResult: ParseResult, parameters: SwitchParameters) = let ``Either ToBranchId or ToBranchName must be provided if no Sha256Hash or ReferenceId`` (parseResult: ParseResult) = oneOfTheseOptionsMustBeProvided parseResult [| Options.toBranchId Options.toBranchName Options.sha256Hash Options.referenceId |] BranchError.EitherToBranchIdOrToBranchNameIsRequired match parseResult |> CommonValidations >>= ``Either ToBranchId or ToBranchName must be provided if no Sha256Hash or ReferenceId`` with | Ok result -> Ok(showOutput, parseResult, parameters) |> returnTask | Error error -> Error error |> returnTask // 0. Get the branchDto for the current branch. let getCurrentBranch (t: ProgressTask) (showOutput, parseResult: ParseResult, parameters: SwitchParameters) = task { t |> startProgressTask showOutput let getParameters = GetBranchParameters( OwnerId = $"{Current().OwnerId}", OrganizationId = $"{Current().OrganizationId}", RepositoryId = $"{Current().RepositoryId}", BranchId = $"{Current().BranchId}", CorrelationId = getCorrelationId parseResult ) match! Branch.Get(getParameters) with | Ok returnValue -> t |> setProgressTaskValue showOutput 100.0 let branchDto = returnValue.ReturnValue return Ok(showOutput, parseResult, parameters, branchDto) | Error error -> t |> setProgressTaskValue showOutput 50.0 return Error error } // 1. Read the Grace status file. let readGraceStatusFile (t: ProgressTask) (showOutput, parseResult: ParseResult, parameters: SwitchParameters, currentBranch: BranchDto) = task { t |> startProgressTask showOutput let! existingGraceStaus = readGraceStatusFile () previousGraceStatus <- existingGraceStaus newGraceStatus <- existingGraceStaus t |> setProgressTaskValue showOutput 100.0 return Ok(showOutput, parseResult, parameters, currentBranch) } // 2. Scan the working directory for differences. let scanForDifferences (t: ProgressTask) (showOutput, parseResult: ParseResult, parameters: SwitchParameters, currentBranch: BranchDto) = task { t |> startProgressTask showOutput let! differences = if currentBranch.SaveEnabled then scanForDifferences newGraceStatus else List() |> returnTask t |> setProgressTaskValue showOutput 100.0 return Ok(showOutput, parseResult, parameters, currentBranch, differences) } // 3. Create new directory versions. let getNewGraceStatusAndDirectoryVersions (t: ProgressTask) (showOutput, parseResult: ParseResult, parameters: SwitchParameters, currentBranch: BranchDto, differences: List) = task { t |> startProgressTask showOutput let mutable newDirectoryVersions = List() if currentBranch.SaveEnabled then let! (updatedGraceStatus, newVersions) = getNewGraceStatusAndDirectoryVersions previousGraceStatus differences newGraceStatus <- updatedGraceStatus newDirectoryVersions <- newVersions rootDirectoryId <- newGraceStatus.RootDirectoryId rootDirectorySha256Hash <- newGraceStatus.RootDirectorySha256Hash directoryIdsInNewGraceStatus <- newGraceStatus.Index.Keys.ToHashSet() t |> setProgressTaskValue showOutput 100.0 return Ok(showOutput, parseResult, parameters, currentBranch, differences, newDirectoryVersions) } // 4. Upload changed files to object storage. let uploadChangedFilesToObjectStorage (t: ProgressTask) (showOutput, parseResult: ParseResult, parameters: SwitchParameters, currentBranch: BranchDto, differences: List, newDirectoryVersions: List) = task { t |> startProgressTask showOutput if currentBranch.SaveEnabled && newDirectoryVersions.Any() then let updatedRelativePaths = differences .Select(fun difference -> match difference.DifferenceType with | Add -> match difference.FileSystemEntryType with | FileSystemEntryType.File -> Some difference.RelativePath | FileSystemEntryType.Directory -> None | Change -> match difference.FileSystemEntryType with | FileSystemEntryType.File -> Some difference.RelativePath | FileSystemEntryType.Directory -> None | Delete -> None) .Where(fun relativePathOption -> relativePathOption.IsSome) .Select(fun relativePath -> relativePath.Value) let newFileVersions = updatedRelativePaths.Select (fun relativePath -> newDirectoryVersions .First(fun dv -> dv.Files.Exists(fun file -> file.RelativePath = relativePath)) .Files.First(fun file -> file.RelativePath = relativePath)) logToAnsiConsole Colors.Verbose $"Uploading {newFileVersions.Count()} file(s) from {newDirectoryVersions.Count} new directory version(s) to object storage." let getUploadMetadataForFilesParameters = Storage.GetUploadMetadataForFilesParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = getCorrelationId parseResult, FileVersions = (newFileVersions |> Seq.map (fun localFileVersion -> localFileVersion.ToFileVersion) |> Seq.toArray) ) match! uploadFilesToObjectStorage getUploadMetadataForFilesParameters with | Ok returnValue -> t |> setProgressTaskValue showOutput 100.0 return Ok(showOutput, parseResult, parameters, currentBranch, newDirectoryVersions) | Error error -> t |> setProgressTaskValue showOutput 50.0 return Error error else t |> setProgressTaskValue showOutput 100.0 return Ok(showOutput, parseResult, parameters, currentBranch, newDirectoryVersions) } // 5. Upload new directory versions. let uploadNewDirectoryVersions (t: ProgressTask) (showOutput, parseResult: ParseResult, parameters: SwitchParameters, currentBranch: BranchDto, newDirectoryVersions: List) = task { t |> startProgressTask showOutput if currentBranch.SaveEnabled && newDirectoryVersions.Any() then let saveParameters = SaveDirectoryVersionsParameters() saveParameters.OwnerId <- graceIds.OwnerIdString saveParameters.OwnerName <- graceIds.OwnerName saveParameters.OrganizationId <- graceIds.OrganizationIdString saveParameters.OrganizationName <- graceIds.OrganizationName saveParameters.RepositoryId <- graceIds.RepositoryIdString saveParameters.RepositoryName <- graceIds.RepositoryName saveParameters.CorrelationId <- getCorrelationId parseResult saveParameters.DirectoryVersions <- newDirectoryVersions .Select(fun dv -> dv.ToDirectoryVersion) .ToList() let! uploadDirectoryVersions = DirectoryVersion.SaveDirectoryVersions saveParameters match! DirectoryVersion.SaveDirectoryVersions saveParameters with | Ok returnValue -> t |> setProgressTaskValue showOutput 100.0 return Ok(showOutput, parseResult, parameters, currentBranch, $"Save created prior to branch switch.") | Error error -> t |> setProgressTaskValue showOutput 50.0 return Error error else t |> setProgressTaskValue showOutput 100.0 return Ok(showOutput, parseResult, parameters, currentBranch, $"Save created prior to branch switch.") } // 6. Create a before save reference. let createSaveReference (t: ProgressTask) (showOutput, parseResult: ParseResult, parameters: SwitchParameters, branchDto: BranchDto, message: string) = task { t |> startProgressTask showOutput if parseResult |> verbose then logToAnsiConsole Colors.Verbose $"In createSaveReference: BranchName: {branchDto.BranchName}; message: {message}; SaveEnabled = {branchDto.SaveEnabled}." if branchDto.SaveEnabled then match! createSaveReference newGraceStatus.Index[rootDirectoryId] message (getCorrelationId parseResult) with | Ok returnValue -> if parseResult |> verbose then logToAnsiConsole Colors.Verbose $"In createSaveReference: BranchName: {branchDto.BranchName}; message: {message}; Succeeded." t |> setProgressTaskValue showOutput 100.0 return Ok(showOutput, parseResult, parameters, branchDto) | Error error -> if parseResult |> verbose then logToAnsiConsole Colors.Verbose $"In createSaveReference: BranchName: {branchDto.BranchName}; message: {message}; Error: {error}." t |> setProgressTaskValue showOutput 50.0 return Error error else t |> setProgressTaskValue showOutput 100.0 return Ok(showOutput, parseResult, parameters, branchDto) } /// 7. Get the branch and directory versions for the requested version we're switching to from the server. let getVersionToSwitchToFromBranch (t: ProgressTask) (showOutput, parseResult: ParseResult, parameters: SwitchParameters, currentBranch: BranchDto) = task { let getNewBranchParameters = GetBranchParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, BranchId = switchParameters.ToBranchId, BranchName = switchParameters.ToBranchName, Sha256Hash = switchParameters.Sha256Hash, ReferenceId = switchParameters.ReferenceId, CorrelationId = graceIds.CorrelationId ) if parseResult |> verbose then logToAnsiConsole Colors.Verbose $"In getVersionToSwitchTo: getNewBranchParameters: {serialize getNewBranchParameters}." let! newBranchResult = Branch.Get(getNewBranchParameters) match newBranchResult with | Error error -> return Error error | Ok returnValue -> let newBranch = returnValue.ReturnValue if parseResult |> verbose then logToAnsiConsole Colors.Verbose $"In getVersionToSwitchTo: New branch: {serialize newBranch}." let getBranchVersionParameters = GetBranchVersionParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = $"{newBranch.RepositoryId}", BranchId = $"{newBranch.BranchId}", ReferenceId = switchParameters.ReferenceId, Sha256Hash = switchParameters.Sha256Hash, CorrelationId = graceIds.CorrelationId ) if parseResult |> verbose then logToAnsiConsole Colors.Verbose $"In getVersionToSwitchTo: getBranchVersionParameters: {serialize getBranchVersionParameters}." let! versionResult = Branch.GetVersion getBranchVersionParameters match versionResult with | Error error -> return Error error | Ok returnValue -> let directoryIds = returnValue.ReturnValue if parseResult |> verbose then logToAnsiConsole Colors.Verbose $"Retrieved {directoryIds.Count()} directory version(s) for branch {newBranch.BranchName}." t |> setProgressTaskValue showOutput 100.0 return Ok(showOutput, parseResult, parameters, currentBranch, newBranch, directoryIds) } let getVersionToSwitchToFromReference (t: ProgressTask) (showOutput, parseResult: ParseResult, parameters: SwitchParameters, currentBranch: BranchDto) = task { let getReferenceParameters = GetReferenceParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, ReferenceId = switchParameters.ReferenceId, CorrelationId = graceIds.CorrelationId ) let! referenceResult = Branch.GetReference(getReferenceParameters) match referenceResult with | Error error -> return Error error | Ok returnValue -> // We have the reference, let's get the new branch and the DirectoryVersion from the reference. let reference = returnValue.ReturnValue let getNewBranchParameters = GetBranchParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, BranchId = $"{reference.BranchId}", CorrelationId = graceIds.CorrelationId ) let! getNewBranchResult = Branch.Get(getNewBranchParameters) let getVersionParameters = GetBranchVersionParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, BranchId = $"{reference.BranchId}", ReferenceId = switchParameters.ReferenceId, CorrelationId = graceIds.CorrelationId ) let! getVersionResult = Branch.GetVersion getVersionParameters match (getNewBranchResult, getVersionResult) with | Ok branchReturnValue, Ok versionReturnValue -> let newBranch = branchReturnValue.ReturnValue let directoryIds = versionReturnValue.ReturnValue t |> setProgressTaskValue showOutput 100.0 return Ok(showOutput, parseResult, parameters, currentBranch, newBranch, directoryIds) | Error error, _ -> return Error error | _, Error error -> return Error error } let getVersionToSwitchToFromSha (t: ProgressTask) (showOutput, parseResult: ParseResult, parameters: SwitchParameters, currentBranch: BranchDto) = task { let getVersionParameters = GetBranchVersionParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, BranchId = $"{currentBranch.BranchId}", Sha256Hash = switchParameters.Sha256Hash, CorrelationId = graceIds.CorrelationId ) let! versionResult = Branch.GetVersion getVersionParameters match versionResult with | Ok returnValue -> let directoryIds = returnValue.ReturnValue t |> setProgressTaskValue showOutput 100.0 return Ok(showOutput, parseResult, parameters, currentBranch, currentBranch, directoryIds) | Error error -> return Error error } let getVersionToSwitchTo (t: ProgressTask) (showOutput, parseResult: ParseResult, parameters: SwitchParameters, currentBranch: BranchDto) = t |> startProgressTask showOutput if not <| String.IsNullOrEmpty(switchParameters.ToBranchId) || not <| String.IsNullOrEmpty(switchParameters.ToBranchName) then getVersionToSwitchToFromBranch t (showOutput, parseResult, parameters, currentBranch) elif not <| String.IsNullOrEmpty(switchParameters.ReferenceId) then getVersionToSwitchToFromReference t (showOutput, parseResult, parameters, currentBranch) elif not <| String.IsNullOrEmpty(switchParameters.Sha256Hash) then getVersionToSwitchToFromSha t (showOutput, parseResult, parameters, currentBranch) else Task.FromResult( Error( GraceError.Create (getErrorMessage BranchError.EitherToBranchIdOrToBranchNameIsRequired) (parseResult |> getCorrelationId) ) ) let getMissingDirectoryVersionsWithClosure (missingDirectoryIds: IEnumerable) = task { let knownDirectoryIds = HashSet(directoryIdsInNewGraceStatus) let fetchedDirectoryVersions = Dictionary() let pendingDirectoryIds = Queue() missingDirectoryIds |> Seq.distinct |> Seq.iter (fun directoryId -> pendingDirectoryIds.Enqueue(directoryId)) let mutable resultError: GraceError option = None while pendingDirectoryIds.Count > 0 && resultError.IsNone do let batch = List() while pendingDirectoryIds.Count > 0 do let directoryId = pendingDirectoryIds.Dequeue() if not (knownDirectoryIds.Contains(directoryId)) && not (fetchedDirectoryVersions.ContainsKey(directoryId)) then batch.Add(directoryId) if batch.Count > 0 then let getByDirectoryIdParameters = GetByDirectoryIdsParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, DirectoryVersionId = $"{rootDirectoryId}", DirectoryIds = batch, CorrelationId = graceIds.CorrelationId ) match! DirectoryVersion.GetByDirectoryIds getByDirectoryIdParameters with | Ok returnValue -> let fetchedBatch = returnValue.ReturnValue |> Seq.toArray let fetchedBatchIds = HashSet() fetchedBatch |> Seq.iter (fun directoryVersionDto -> let directoryVersion = directoryVersionDto.DirectoryVersion fetchedBatchIds.Add(directoryVersion.DirectoryVersionId) |> ignore knownDirectoryIds.Add(directoryVersion.DirectoryVersionId) |> ignore fetchedDirectoryVersions[directoryVersion.DirectoryVersionId] <- directoryVersionDto) let unresolvedRequestedIds = batch .Where(fun requestedId -> not (fetchedBatchIds.Contains(requestedId)) && not (knownDirectoryIds.Contains(requestedId))) .ToArray() if unresolvedRequestedIds.Length > 0 then let unresolvedRequestedIdsText = unresolvedRequestedIds |> Seq.map (fun directoryId -> $"{directoryId}") |> String.concat ", " resultError <- Some( GraceError.Create $"Failed switching branches because the server did not return required DirectoryVersionId values: {unresolvedRequestedIdsText}." (parseResult |> getCorrelationId) ) else fetchedBatch |> Seq.collect (fun directoryVersionDto -> directoryVersionDto.DirectoryVersion.Directories) |> Seq.iter (fun childDirectoryId -> if not (knownDirectoryIds.Contains(childDirectoryId)) && not (fetchedDirectoryVersions.ContainsKey(childDirectoryId)) then pendingDirectoryIds.Enqueue(childDirectoryId)) | Error error -> resultError <- Some error match resultError with | Some error -> return Error error | None -> return Ok(fetchedDirectoryVersions.Values |> Seq.toArray :> IEnumerable) } /// 8. Update object cache and working directory. let updateWorkingDirectory (t: ProgressTask) (showOutput, parseResult: ParseResult, parameters: SwitchParameters, currentBranch: BranchDto, newBranch: BranchDto, directoryIds: IEnumerable) = task { t |> startProgressTask showOutput let missingDirectoryIds = directoryIds .Where(fun directoryId -> not <| directoryIdsInNewGraceStatus.Contains(directoryId)) .ToList() if parseResult |> verbose then logToAnsiConsole Colors.Verbose $"In updateWorkingDirectory: missingDirectoryIds.Count: {missingDirectoryIds.Count()}." match! getMissingDirectoryVersionsWithClosure missingDirectoryIds with | Ok newDirectoryVersionDtos -> // Create a new version of GraceStatus that includes the new DirectoryVersions. if parseResult |> verbose then logToAnsiConsole Colors.Verbose $"In updateWorkingDirectory: newDirectoryVersions.Count: {newDirectoryVersionDtos.Count()}." let graceStatusWithNewDirectoryVersionsFromServer = updateGraceStatusWithNewDirectoryVersionsFromServer newGraceStatus newDirectoryVersionDtos let mutable isError = false // Identify files that we don't already have in object cache and download them. let getDownloadUriParameters = Storage.GetDownloadUriParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) for directoryVersion in graceStatusWithNewDirectoryVersionsFromServer.Index.Values do match! (downloadFilesFromObjectStorage getDownloadUriParameters directoryVersion.Files (getCorrelationId parseResult)) with | Ok _ -> try //logToAnsiConsole Colors.Verbose $"Succeeded downloading files from object storage for {directoryVersion.RelativePath}." // Write the UpdatesInProgress file to let grace watch know to ignore these changes. // This file is deleted in the finally clause. do! File.WriteAllTextAsync(updateInProgressFileName (), "`grace switch` is in progress.") // Update working directory based on new GraceStatus.Index do! updateWorkingDirectory newGraceStatus graceStatusWithNewDirectoryVersionsFromServer newDirectoryVersionDtos (getCorrelationId parseResult) //logToAnsiConsole Colors.Verbose $"Succeeded calling updateWorkingDirectory." // Save the new Grace Status. do! writeGraceStatusFile graceStatusWithNewDirectoryVersionsFromServer // Update graceconfig.json. let configuration = Current() configuration.BranchId <- newBranch.BranchId configuration.BranchName <- newBranch.BranchName updateConfiguration configuration t |> setProgressTaskValue showOutput 100.0 finally // Delete the UpdatesInProgress file. File.Delete(updateInProgressFileName ()) | Error error -> if parseResult |> verbose then logToAnsiConsole Colors.Verbose $"Failed downloading files from object storage for {directoryVersion.RelativePath}." logToAnsiConsole Colors.Error $"{error}" isError <- true if not <| isError then newGraceStatus <- graceStatusWithNewDirectoryVersionsFromServer rootDirectoryId <- newGraceStatus.RootDirectoryId rootDirectorySha256Hash <- newGraceStatus.RootDirectorySha256Hash directoryIdsInNewGraceStatus <- newGraceStatus.Index.Keys.ToHashSet() if parseResult |> verbose then logToAnsiConsole Colors.Verbose $"About to exit updateWorkingDirectory." return Ok(showOutput, parseResult, parameters, newBranch, $"Save created after branch switch.") else return Error(GraceError.Create $"Failed downloading files from object storage." (parseResult |> getCorrelationId)) | Error error -> if parseResult |> verbose then logToAnsiConsole Colors.Verbose $"Failed retrieving directory versions for switch." logToAnsiConsole Colors.Error $"{error}" return Error(GraceError.Create $"{error}" (parseResult |> getCorrelationId)) } let writeNewGraceStatus (t: ProgressTask) (showOutput, parseResult: ParseResult, parameters: SwitchParameters, currentBranch: BranchDto) = task { t |> startProgressTask showOutput do! writeGraceStatusFile newGraceStatus do! upsertObjectCache newGraceStatus.Index.Values t |> setProgressTaskValue showOutput 100.0 return Ok(showOutput, parseResult, parameters, currentBranch) } let generateResult (progressTasks: ProgressTask array) = task { let! result = (showOutput, parseResult, switchParameters) |> validateIncomingParameters >>=! getCurrentBranch progressTasks[0] >>=! readGraceStatusFile progressTasks[1] >>=! scanForDifferences progressTasks[2] >>=! getNewGraceStatusAndDirectoryVersions progressTasks[3] >>=! uploadChangedFilesToObjectStorage progressTasks[4] >>=! uploadNewDirectoryVersions progressTasks[5] >>=! createSaveReference progressTasks[6] >>=! getVersionToSwitchTo progressTasks[7] >>=! updateWorkingDirectory progressTasks[8] >>=! createSaveReference progressTasks[9] >>=! writeNewGraceStatus progressTasks[10] match result with | Ok _ -> return 0 | Error error -> if parseResult |> verbose then AnsiConsole.MarkupLine($"[{Colors.Error}]{error}[/]") else AnsiConsole.MarkupLine($"[{Colors.Error}]{error.Error}[/]") return -1 } if showOutput then return! progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]{UIString.getString GettingCurrentBranch}[/]", autoStart = false) let t1 = progressContext.AddTask($"[{Color.DodgerBlue1}]{UIString.getString ReadingGraceStatus}[/]", autoStart = false) let t2 = progressContext.AddTask($"[{Color.DodgerBlue1}]{UIString.getString ScanningWorkingDirectory}[/]", autoStart = false) let t3 = progressContext.AddTask( $"[{Color.DodgerBlue1}]{UIString.getString CreatingNewDirectoryVersions}[/]", autoStart = false ) let t4 = progressContext.AddTask($"[{Color.DodgerBlue1}]{UIString.getString UploadingFiles}[/]", autoStart = false) let t5 = progressContext.AddTask($"[{Color.DodgerBlue1}]{UIString.getString SavingDirectoryVersions}[/]", autoStart = false) let t6 = progressContext.AddTask($"[{Color.DodgerBlue1}]{UIString.getString CreatingSaveReference}[/]", autoStart = false) let t7 = progressContext.AddTask($"[{Color.DodgerBlue1}]{UIString.getString GettingLatestVersion}[/]", autoStart = false) let t8 = progressContext.AddTask($"[{Color.DodgerBlue1}]{UIString.getString UpdatingWorkingDirectory}[/]", autoStart = false) let t9 = progressContext.AddTask($"[{Color.DodgerBlue1}]{UIString.getString CreatingSaveReference}[/]", autoStart = false) let t10 = progressContext.AddTask($"[{Color.DodgerBlue1}]{UIString.getString WritingGraceStatusFile}[/]", autoStart = false) return! generateResult [| t0 t1 t2 t3 t4 t5 t6 t7 t8 t9 t10 |] }) else // If we're not showing output, we don't need to create the progress tasks. return! generateResult [| emptyTask emptyTask emptyTask emptyTask emptyTask emptyTask emptyTask emptyTask emptyTask emptyTask emptyTask |] with | ex -> logToConsole $"{ExceptionResponse.Create ex}" logToAnsiConsole Colors.Error (Markup.Escape($"{ExceptionResponse.Create ex}")) logToAnsiConsole Colors.Important $"CorrelationId: {(parseResult |> getCorrelationId)}" return -1 } override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { Directory.CreateDirectory(Path.GetDirectoryName(updateInProgressFileName ())) |> ignore try if parseResult |> verbose then printParseResult parseResult do! File.WriteAllTextAsync(updateInProgressFileName (), "`grace switch` is in progress.") let graceIds = parseResult |> getNormalizedIdsAndNames let switchParameters = SwitchParameters() let toBranchId = parseResult.GetValue(Options.toBranchId) if toBranchId <> Guid.Empty then switchParameters.ToBranchId <- $"{toBranchId}" let toBranchName = parseResult.GetValue(Options.toBranchName) switchParameters.ToBranchName <- toBranchName let referenceId = parseResult.GetValue(Options.referenceId) if referenceId <> Guid.Empty then switchParameters.ReferenceId <- $"{referenceId}" let sha256Hash = parseResult.GetValue(Options.sha256Hash) switchParameters.Sha256Hash <- sha256Hash let! result = switchHandler parseResult switchParameters return result finally if File.Exists(updateInProgressFileName ()) then File.Delete(updateInProgressFileName ()) } let rebaseHandler (graceIds: GraceIds) (graceStatus: GraceStatus) = task { // -------------------------------------------------------------------------------------------------------------------------------------- // Algorithm: // // Get a diff between the promotion from the parent branch that the current branch is based on, and the latest promotion from the parent branch. // These are the changes that we expect to apply to the current branch. // // Get a diff between the latest reference on this branch and the promotion that it's based on from the parent branch. // This will be what's changed in the current branch since it was last rebased. // // If a file has changed in the first diff, but not in the second diff, cool, we can automatically copy them. // If a file has changed in the second diff, but not in the first diff, cool, we can keep those changes. // If a file has changed in both, we have a promotion conflict, so we'll call an LLM to suggest a resolution. // // Then we call Branch.Rebase() to actually record the update. // -------------------------------------------------------------------------------------------------------------------------------------- logToAnsiConsole Colors.Verbose $"In Branch.CLI.rebaseHandler: GraceIds:{Environment.NewLine}{serialize graceIds}" // First, get the current branchDto so we have the latest promotion that it's based on. let branchGetParameters = GetBranchParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, CorrelationId = graceIds.CorrelationId ) match! Branch.Get(branchGetParameters) with | Ok returnValue -> let branchDto = returnValue.ReturnValue // Now, get the parent branch information so we have its latest promotion. match! Branch.GetParentBranch(branchGetParameters) with | Ok returnValue -> let parentBranchDto = returnValue.ReturnValue if branchDto.BasedOn.ReferenceId = parentBranchDto.LatestPromotion.ReferenceId then AnsiConsole.MarkupLine("The current branch is already based on the latest promotion in the parent branch.") AnsiConsole.MarkupLine("Run `grace status` to see more.") return 0 else // Now, get ReferenceDtos for current.BasedOn and parent.LatestPromotion so we have their DirectoryId's. let latestCommit = branchDto.LatestCommit let parentLatestPromotion = parentBranchDto.LatestPromotion let basedOn = branchDto.BasedOn // Get the latest reference from the current branch. let getReferencesParameters = Parameters.Branch.GetReferencesParameters( OwnerId = graceIds.OwnerIdString, OrganizationId = graceIds.OrganizationIdString, RepositoryId = graceIds.RepositoryIdString, BranchId = graceIds.BranchIdString, MaxCount = 1, CorrelationId = graceIds.CorrelationId ) //logToAnsiConsole Colors.Verbose $"getReferencesParameters: {getReferencesParameters |> serialize)}" match! Branch.GetReferences(getReferencesParameters) with | Ok returnValue -> let latestReference = if returnValue.ReturnValue.Count() > 0 then returnValue.ReturnValue.First() else ReferenceDto.Default //logToAnsiConsole Colors.Verbose $"latestReference: {serialize latestReference}" // Now we have all of the references we need, so we have DirectoryId's to do diffs with. let! (diffs, errors) = task { if basedOn.DirectoryId <> DirectoryVersionId.Empty then // First diff: parent promotion that current branch is based on vs. parent's latest promotion. let diffParameters = Parameters.Diff.GetDiffParameters( OwnerId = graceIds.OwnerIdString, OrganizationId = graceIds.OrganizationIdString, RepositoryId = $"{branchDto.RepositoryId}", DirectoryVersionId1 = basedOn.DirectoryId, DirectoryVersionId2 = parentLatestPromotion.DirectoryId, CorrelationId = graceIds.CorrelationId ) //logToAnsiConsole Colors.Verbose $"First diff: {Markup.Escape(serialize diffParameters)}" let! firstDiff = Diff.GetDiff(diffParameters) // Second diff: latest reference on current branch vs. parent promotion that current branch is based on. let diffParameters = Parameters.Diff.GetDiffParameters( OwnerId = graceIds.OwnerIdString, OrganizationId = graceIds.OrganizationIdString, RepositoryId = $"{branchDto.RepositoryId}", DirectoryVersionId1 = latestReference.DirectoryId, DirectoryVersionId2 = basedOn.DirectoryId, CorrelationId = graceIds.CorrelationId ) //logToAnsiConsole Colors.Verbose $"Second diff: {Markup.Escape(serialize diffParameters)}" let! secondDiff = Diff.GetDiff(diffParameters) let returnValue = Result.partition [ firstDiff secondDiff ] return returnValue else // This should only happen when first creating a repository, when main has no promotions. // Only one diff possible: latest reference on current branch vs. parent's latest promotion. let diffParameters = Parameters.Diff.GetDiffParameters( OwnerId = graceIds.OwnerIdString, OrganizationId = graceIds.OrganizationIdString, RepositoryId = $"{branchDto.RepositoryId}", DirectoryVersionId1 = latestReference.DirectoryId, DirectoryVersionId2 = parentLatestPromotion.DirectoryId, CorrelationId = graceIds.CorrelationId ) //logToAnsiConsole Colors.Verbose $"Initial diff: {Markup.Escape(serialize diffParameters)}" let! diff = Diff.GetDiff(diffParameters) let returnValue = Result.partition [ diff ] return returnValue } // So, right now, if repo just created, and BasedOn is empty, we'll have a single diff. // That fails a few lines below here. // Have to decide what to do in this case. if errors.Count() = 0 then // Yay! We have our two diffs. let diff1 = diffs[0].ReturnValue let diff2 = diffs[1].ReturnValue let filesToDownload = List() // Identify which files have been changed in the first diff, but not in the second diff. // We can just download and copy these files into place in the working directory. for fileDifference in diff1.Differences do if not <| diff2.Differences.Any(fun d -> d.RelativePath = fileDifference.RelativePath) then // Copy different file version into place - similar to how we do it for switch filesToDownload.Add(fileDifference) let getParentLatestPromotionDirectoryParameters = Parameters.DirectoryVersion.GetParameters( OwnerId = $"{branchDto.OwnerId}", OrganizationId = $"{branchDto.OrganizationId}", RepositoryId = $"{branchDto.RepositoryId}", DirectoryVersionId = $"{parentLatestPromotion.DirectoryId}", CorrelationId = graceIds.CorrelationId ) let getLatestReferenceDirectoryParameters = Parameters.DirectoryVersion.GetParameters( OwnerId = $"{branchDto.OwnerId}", OrganizationId = $"{branchDto.OrganizationId}", RepositoryId = $"{branchDto.RepositoryId}", DirectoryVersionId = $"{latestReference.DirectoryId}", CorrelationId = graceIds.CorrelationId ) // Get the directory versions for the parent promotion that we're rebasing on, and the latest reference. let! d1 = DirectoryVersion.GetDirectoryVersionsRecursive(getParentLatestPromotionDirectoryParameters) let! d2 = DirectoryVersion.GetDirectoryVersionsRecursive(getLatestReferenceDirectoryParameters) let createFileVersionLookupDictionary (directoryVersionDtos: IEnumerable) = let lookup = Dictionary(StringComparer.OrdinalIgnoreCase) directoryVersionDtos |> Seq.map (fun dv -> dv.DirectoryVersion) |> Seq.map (fun dv -> dv.ToLocalDirectoryVersion(dv.CreatedAt.ToDateTimeUtc())) |> Seq.map (fun dv -> dv.Files) |> Seq.concat |> Seq.iter (fun file -> //logToConsole $"In Branch.CLI.createFileVersionLookupDictionary: Adding to lookup: {file.RelativePath}." lookup.TryAdd(file.RelativePath, file) |> ignore) //lookup.GetAlternateLookup() lookup let (directories, errors) = Result.partition [ d1; d2 ] if errors.Count() = 0 then let parentLatestPromotionDirectoryVersions = directories[0].ReturnValue let latestReferenceDirectoryVersions = directories[1].ReturnValue let parentLatestPromotionLookup = createFileVersionLookupDictionary parentLatestPromotionDirectoryVersions let latestReferenceLookup = createFileVersionLookupDictionary latestReferenceDirectoryVersions // Get the specific FileVersions for those files from the contents of the parent's latest promotion. let fileVersionsToDownload = filesToDownload |> Seq.where (fun fileToDownload -> parentLatestPromotionLookup.ContainsKey($"{fileToDownload.RelativePath}")) |> Seq.map (fun fileToDownload -> parentLatestPromotionLookup[$"{fileToDownload.RelativePath}"]) //logToAnsiConsole Colors.Verbose $"fileVersionsToDownload: {fileVersionsToDownload.Count()}" //for f in fileVersionsToDownload do // logToAnsiConsole Colors.Verbose $"relativePath: {f.RelativePath}" // Download those FileVersions from object storage, and copy them into the working directory. let getDownloadUriParameters = Storage.GetDownloadUriParameters( OwnerId = graceIds.OwnerIdString, OrganizationId = graceIds.OrganizationIdString, RepositoryId = graceIds.RepositoryIdString, CorrelationId = graceIds.CorrelationId ) match! downloadFilesFromObjectStorage getDownloadUriParameters fileVersionsToDownload graceIds.CorrelationId with | Ok _ -> //logToAnsiConsole Colors.Verbose $"Succeeded in downloadFilesFromObjectStorage." fileVersionsToDownload |> Seq.iter (fun file -> // Delete the existing file in the working directory. File.Delete(file.FullName) // Copy the version from the object cache to the working directory. File.Copy(file.FullObjectPath, file.FullName)) //logToAnsiConsole Colors.Verbose $"Copied files into place." | Error error -> AnsiConsole.WriteLine($"[{Colors.Error}]{Markup.Escape(error)}[/]") // If a file has changed in the second diff, but not in the first diff, cool, we can keep those changes, nothing to be done. // If a file has changed in both, we have to check the two diffs at the line-level to see if there are any conflicts. let mutable potentialPromotionConflicts = false for diff1Difference in diff1.Differences do let diff2DifferenceQuery = diff2.Differences.Where (fun d -> d.RelativePath = diff1Difference.RelativePath && d.FileSystemEntryType = FileSystemEntryType.File && d.DifferenceType = DifferenceType.Change) if diff2DifferenceQuery.Count() = 1 then // We have a file that's changed in both diffs. let diff2Difference = diff2DifferenceQuery.First() // Check the Sha256Hash values; if they're identical, ignore the file. //let fileVersion1 = parentLatestPromotionLookup[$"{diff1Difference.RelativePath}"] let fileVersion1 = parentLatestPromotionLookup.FirstOrDefault(fun kvp -> kvp.Key = $"{diff1Difference.RelativePath}") //let fileVersion2 = latestReferenceLookup[$"{diff2Difference.RelativePath}"] let fileVersion2 = latestReferenceLookup.FirstOrDefault(fun kvp -> kvp.Key = $"{diff2Difference.RelativePath}") //if (not <| isNull(fileVersion1) && not <| isNull(fileVersion2)) && (fileVersion1.Value.Sha256Hash <> fileVersion2.Value.Sha256Hash) then if (fileVersion1.Value.Sha256Hash <> fileVersion2.Value.Sha256Hash) then // Compare them at a line level; if there are no overlapping lines, we can just modify the working-directory version. // ... // For now, we're just going to show a message. AnsiConsole.MarkupLine( $"[{Colors.Important}]Potential promotion conflict: file {diff1Difference.RelativePath} has been changed in both the latest promotion, and in the current branch.[/]" ) AnsiConsole.MarkupLine( $"[{Colors.Important}]fileVersion1.Sha256Hash: {fileVersion1.Value.Sha256Hash}; fileVersion1.LastWriteTimeUTC: {fileVersion1.Value.LastWriteTimeUtc}.[/]" ) AnsiConsole.MarkupLine( $"[{Colors.Important}]fileVersion2.Sha256Hash: {fileVersion2.Value.Sha256Hash}; fileVersion2.LastWriteTimeUTC: {fileVersion2.Value.LastWriteTimeUtc}.[/]" ) potentialPromotionConflicts <- true /// Create new directory versions and updates Grace Status with them. let getNewGraceStatusAndDirectoryVersions ( showOutput, graceStatus, currentBranch: BranchDto, differences: IEnumerable ) = task { if differences.Count() > 0 then let! (updatedGraceStatus, newDirectoryVersions) = getNewGraceStatusAndDirectoryVersions graceStatus differences return Ok(updatedGraceStatus, newDirectoryVersions) else return Ok(graceStatus, List()) } /// Upload new DirectoryVersion records to the server. let uploadNewDirectoryVersions (currentBranch: BranchDto) (newDirectoryVersions: List) = task { if currentBranch.SaveEnabled && newDirectoryVersions.Any() then let saveParameters = SaveDirectoryVersionsParameters() saveParameters.OwnerId <- graceIds.OwnerIdString saveParameters.OrganizationId <- graceIds.OrganizationIdString saveParameters.RepositoryId <- graceIds.RepositoryIdString saveParameters.CorrelationId <- graceIds.CorrelationId saveParameters.DirectoryVersions <- newDirectoryVersions .Select(fun dv -> dv.ToDirectoryVersion) .ToList() match! DirectoryVersion.SaveDirectoryVersions saveParameters with | Ok returnValue -> return Ok() | Error error -> return Error error else return Ok() } if not <| potentialPromotionConflicts then // Yay! No promotion conflicts. let mutable newGraceStatus = graceStatus // Update the GraceStatus file with the new file versions (and therefore new LocalDirectoryVersion's) we just put in place. // filesToDownload is, conveniently, the list of files we're changing in the rebase. match! getNewGraceStatusAndDirectoryVersions (true, graceStatus, branchDto, filesToDownload) with | Ok (updatedGraceStatus, newDirectoryVersions) -> // Ensure that previous DirectoryVersions for a given path are deleted from GraceStatus. newDirectoryVersions |> Seq.iter (fun localDirectoryVersion -> let directoryVersionsWithSameRelativePath = updatedGraceStatus.Index.Values.Where(fun dv -> dv.RelativePath = localDirectoryVersion.RelativePath) if directoryVersionsWithSameRelativePath.Count() > 1 then // Delete all but the most recent DirectoryVersion for this path. directoryVersionsWithSameRelativePath |> Seq.where (fun dv -> dv.DirectoryVersionId <> localDirectoryVersion.DirectoryVersionId) |> Seq.iter (fun dv -> let mutable localDirectoryVersion = LocalDirectoryVersion.Default updatedGraceStatus.Index.Remove(dv.DirectoryVersionId, &localDirectoryVersion) |> ignore)) let! result = uploadNewDirectoryVersions branchDto newDirectoryVersions do! writeGraceStatusFile updatedGraceStatus do! updateGraceWatchInterprocessFile updatedGraceStatus None newGraceStatus <- updatedGraceStatus | Error error -> logToAnsiConsole Colors.Error (Markup.Escape($"{error}")) // Create a save reference to mark the state of the branch after rebase. let rootDirectoryVersion = getRootDirectoryVersion newGraceStatus let saveReferenceParameters = Parameters.Branch.CreateReferenceParameters( OwnerId = graceIds.OwnerIdString, OrganizationId = graceIds.OrganizationIdString, RepositoryId = graceIds.RepositoryIdString, BranchId = graceIds.BranchIdString, Sha256Hash = rootDirectoryVersion.Sha256Hash, DirectoryVersionId = rootDirectoryVersion.DirectoryVersionId, Message = $"Save after rebase from {parentBranchDto.BranchName}; {getShortSha256Hash parentLatestPromotion.Sha256Hash} - {parentLatestPromotion.ReferenceText}." ) match! Branch.Save(saveReferenceParameters) with | Ok returnValue -> // Add a rebase event to the branch. let rebaseParameters = Parameters.Branch.RebaseParameters( OwnerId = graceIds.OwnerIdString, OrganizationId = graceIds.OrganizationIdString, RepositoryId = graceIds.RepositoryIdString, BranchId = graceIds.BranchIdString, BasedOn = parentLatestPromotion.ReferenceId ) match! Branch.Rebase(rebaseParameters) with | Ok returnValue -> AnsiConsole.MarkupLine($"[{Colors.Important}]Rebase succeeded.[/]") return 0 | Error error -> logToAnsiConsole Colors.Error (Markup.Escape($"{error}")) return -1 | Error error -> logToAnsiConsole Colors.Error (Markup.Escape($"{error}")) return -1 else AnsiConsole.MarkupLine($"[{Colors.Highlighted}]A potential promotion conflict was detected. Rebase not successful.[/]") return -1 else logToAnsiConsole Colors.Error (Markup.Escape($"{errors.First()}")) return -1 else logToAnsiConsole Colors.Error (Markup.Escape($"{errors.First()}")) return -1 | Error error -> logToAnsiConsole Colors.Error (Markup.Escape($"{error}")) return -1 | Error error -> logToAnsiConsole Colors.Error (Markup.Escape($"{error}")) return -1 | Error error -> logToAnsiConsole Colors.Error (Markup.Escape($"{error}")) return -1 } type Rebase() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { Directory.CreateDirectory(Path.GetDirectoryName(updateInProgressFileName ())) |> ignore try if parseResult |> verbose then printParseResult parseResult do! File.WriteAllTextAsync(updateInProgressFileName (), "`grace rebase` is in progress.") let graceIds = parseResult |> getNormalizedIdsAndNames let! graceStatus = readGraceStatusFile () let! result = rebaseHandler graceIds graceStatus return result finally if File.Exists(updateInProgressFileName ()) then File.Delete(updateInProgressFileName ()) } type ParentBranchReferencesState = | NoParentBranch | References of ReferenceDto array | FetchError of GraceError let private getParentBranchReferencesState (graceIds: GraceIds) (branchDto: BranchDto) = if branchDto.ParentBranchId <> Constants.DefaultParentBranchId then let getParentBranchReferencesParameters = GetReferencesParameters( BranchId = $"{branchDto.ParentBranchId}", OwnerId = $"{branchDto.OwnerId}", OrganizationId = $"{branchDto.OrganizationId}", RepositoryId = $"{branchDto.RepositoryId}", MaxCount = 5, CorrelationId = graceIds.CorrelationId ) task { let! parentBranchReferencesResult = Branch.GetReferences(getParentBranchReferencesParameters) return match parentBranchReferencesResult with | Ok returnValue -> References returnValue.ReturnValue | Error error -> FetchError error } else Task.FromResult NoParentBranch let private renderBranchStatus (parseResult: ParseResult) (branchDto: BranchDto) (parentBranchDto: BranchDto) (mostRecentReferences: ReferenceDto array) (parentBranchReferencesState: ParentBranchReferencesState) = let horizontalLineChar = "-" let separator = "-" // Now that I have the current and parent branch, I can get the details for the latest // promotion, latest commit, latest checkpoint, and latest save. let latestSave = branchDto.LatestSave let latestCheckpoint = branchDto.LatestCheckpoint let latestCommit = branchDto.LatestCommit let latestParentBranchPromotion = parentBranchDto.LatestPromotion let basedOn = branchDto.BasedOn let longestAgoLength = [ latestSave latestCheckpoint latestCommit latestParentBranchPromotion basedOn ] |> Seq.map (fun b -> (ago b.CreatedAt).Length) |> Seq.max let aligned (s: string) = let space = " " $"{String.replicate (longestAgoLength - s.Length) space}{s}" let permissions (branchDto: BranchDto) = let sb = stringBuilderPool.Get() try if branchDto.PromotionEnabled then sb.Append("Promotion/") |> ignore if branchDto.CommitEnabled then sb.Append("Commit/") |> ignore if branchDto.CheckpointEnabled then sb.Append("Checkpoint/") |> ignore if branchDto.SaveEnabled then sb.Append("Save/") |> ignore if branchDto.TagEnabled then sb.Append("Tag/") |> ignore if branchDto.ExternalEnabled then sb.Append("External/") |> ignore if sb.Length > 0 && sb[sb.Length - 1] = '/' then sb.Remove(sb.Length - 1, 1) |> ignore sb.ToString() finally if not <| isNull sb then stringBuilderPool.Return(sb) let ownerLabel = Utilities.getLocalizedString Text.StringResourceName.Owner let organizationLabel = Utilities.getLocalizedString Text.StringResourceName.Organization let repositoryLabel = Utilities.getLocalizedString Text.StringResourceName.Repository let branchLabel = Utilities.getLocalizedString Text.StringResourceName.Branch let headerLength = ownerLabel.Length + organizationLabel.Length + repositoryLabel.Length + 6 let column1 = TableColumn(String.replicate headerLength horizontalLineChar) let basedOnMessage = if branchDto.BasedOn.ReferenceId = parentBranchDto.LatestPromotion.ReferenceId || branchDto.ParentBranchId = Constants.DefaultParentBranchId then $"[{Colors.Added}]Based on latest promotion[/]" else $"[{Colors.Important}]Not based on latest promotion[/]" let referenceTable = (createReferenceTable parseResult mostRecentReferences) .Expand() let ownerOrgRepoHeader = if parseResult |> verbose then $"[{Colors.Important}]{ownerLabel}:[/] {Current().OwnerName} [{Colors.Deemphasized}]{separator} {Current().OwnerId}[/] {separator} [{Colors.Important}]{organizationLabel}:[/] {Current().OrganizationName} [{Colors.Deemphasized}]{separator} {Current().OrganizationId}[/] {separator} [{Colors.Important}]{repositoryLabel}:[/] {Current().RepositoryName} [{Colors.Deemphasized}]{separator} {Current().RepositoryId}[/]" else $"[{Colors.Important}]{ownerLabel}:[/] {Current().OwnerName} {separator} [{Colors.Important}]{organizationLabel}:[/] {Current().OrganizationName} {separator} [{Colors.Important}]{repositoryLabel}:[/] {Current().RepositoryName}" let branchHeader = if parseResult |> verbose then $"[{Colors.Important}] {branchLabel}:[/] {branchDto.BranchName} [{Colors.Deemphasized}]{separator}[/] {basedOnMessage} [{Colors.Deemphasized}]{separator} Allows {permissions branchDto} {separator} {branchDto.BranchId} [/]" else $"[{Colors.Important}] {branchLabel}:[/] {branchDto.BranchName} [{Colors.Deemphasized}]{separator}[/] {basedOnMessage} [{Colors.Deemphasized}]{separator} Allows {permissions branchDto} [/]" let parentBranchHeader = if parseResult |> verbose then $"[{Colors.Important}] Parent branch:[/] {parentBranchDto.BranchName} [{Colors.Deemphasized}]{separator} Allows {permissions parentBranchDto} {separator} {parentBranchDto.BranchId} [/]" else $"[{Colors.Important}] Parent branch:[/] {parentBranchDto.BranchName} [{Colors.Deemphasized}]{separator} Allows {permissions parentBranchDto} [/]" let commitReferenceTable = let sortedReferences = [| latestCommit; latestCheckpoint |] |> Array.sortByDescending (fun r -> r.CreatedAt) (createReferenceTable parseResult sortedReferences) .Expand() let outerTable = Table(Border = TableBorder.None, ShowHeaders = false) .AddColumns(column1) let branchTable = Table(ShowHeaders = false, Border = TableBorder.None, Expand = true) branchTable .AddColumn(column1) .AddEmptyRow() .AddRow($"[{Colors.Important}] Most recent references:[/]") .AddRow(Padder(referenceTable).Padding(1, 0, 0, 0)) .AddEmptyRow() |> ignore let branchPanel = Panel(branchTable, Expand = true) branchPanel.Header <- PanelHeader(branchHeader, Justify.Left) branchPanel.Border <- BoxBorder.Double outerTable.AddRow(branchPanel).AddEmptyRow() |> ignore match parentBranchReferencesState with | References parentBranchReferences -> let parentBranchReferencesTable = (createReferenceTable parseResult parentBranchReferences) .Expand() let parentBranchTable = Table(ShowHeaders = false, Border = TableBorder.None, Expand = true) parentBranchTable .AddColumn(column1) .AddEmptyRow() .AddRow($"[{Colors.Important}] Most recent references:[/]") .AddRow( Padder(parentBranchReferencesTable) .Padding(1, 0, 0, 0) ) |> ignore let parentBranchPanel = Panel(parentBranchTable, Expand = true) parentBranchPanel.Header <- PanelHeader(parentBranchHeader, Justify.Left) parentBranchPanel.Border <- BoxBorder.Double outerTable.AddRow(parentBranchPanel) |> ignore | FetchError error -> logToAnsiConsole Colors.Error (Markup.Escape($"{error}")) | NoParentBranch -> outerTable.AddRow($"[{Colors.Important}]Parent branch[/]: None") |> ignore outerTable .AddEmptyRow() .AddRow(ownerOrgRepoHeader) |> ignore AnsiConsole.Write(outerTable) 0 let private statusHandlerImpl (parseResult: ParseResult) = task { if parseResult |> verbose then printParseResult parseResult do! Auth.ensureAccessToken parseResult let graceIds = parseResult |> getNormalizedIdsAndNames // Show repo and branch names. let getParameters = GetBranchParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, CorrelationId = graceIds.CorrelationId ) let! branchResult = Branch.Get(getParameters) let! parentBranchResult = Branch.GetParentBranch(getParameters) match branchResult, parentBranchResult with | Ok branchReturnValue, Ok parentBranchReturnValue -> let branchDto = branchReturnValue.ReturnValue let parentBranchDto = parentBranchReturnValue.ReturnValue let getReferencesParameters = Parameters.Branch.GetReferencesParameters( BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, MaxCount = 10, CorrelationId = graceIds.CorrelationId ) let! referencesResult = Branch.GetReferences(getReferencesParameters) let mostRecentReferences = match referencesResult with | Ok returnValue -> returnValue.ReturnValue | Error _ -> Array.Empty() let! parentBranchReferencesState = getParentBranchReferencesState graceIds branchDto return renderBranchStatus parseResult branchDto parentBranchDto mostRecentReferences parentBranchReferencesState | Error error, _ | _, Error error -> logToAnsiConsole Colors.Error (Markup.Escape($"{error}")) return -1 } let private statusHandler (parseResult: ParseResult) = task { try return! statusHandlerImpl parseResult with | :? OperationCanceledException -> return 1 | ex -> logToAnsiConsole Colors.Error (Markup.Escape($"{ExceptionResponse.Create ex}")) return -1 } type Status() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { let graceIds = parseResult |> getNormalizedIdsAndNames return! statusHandler parseResult } let private deleteHandlerImpl (parseResult: ParseResult) = if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> CommonValidations match validateIncomingParameters with | Error error -> Task.FromResult(Error error) | Ok _ -> let force = parseResult.GetValue(Options.force) let reassignChildBranches = parseResult.GetValue(Options.reassignChildBranches) let newParentBranchId = parseResult.GetValue(Options.newParentBranchId) |> valueOrEmpty let newParentBranchName = parseResult.GetValue(Options.newParentBranchName) |> valueOrEmpty // Validate that --force and --reassign-child-branches are not both specified if force && reassignChildBranches then Task.FromResult( Error(GraceError.Create (BranchError.getErrorMessage BranchError.CannotSpecifyBothForceAndReassignChildBranches) graceIds.CorrelationId) ) else let deleteParameters = Parameters.Branch.DeleteBranchParameters( BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, Force = force, ReassignChildBranches = reassignChildBranches, NewParentBranchId = newParentBranchId, NewParentBranchName = newParentBranchName, CorrelationId = graceIds.CorrelationId ) if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = Branch.Delete(deleteParameters) t0.Increment(100.0) return result }) else Branch.Delete(deleteParameters) let private deleteHandler (parseResult: ParseResult) = task { try return! deleteHandlerImpl parseResult with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId)) } type Delete() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try let graceIds = parseResult |> getNormalizedIdsAndNames let! result = deleteHandler parseResult return result |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } let private updateParentBranchHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> CommonValidations match validateIncomingParameters with | Ok _ -> let newParentBranchId = parseResult.GetValue(Options.newParentBranchId) |> valueOrEmpty let newParentBranchName = parseResult.GetValue(Options.newParentBranchName) |> valueOrEmpty let updateParentBranchParameters = Parameters.Branch.UpdateParentBranchParameters( BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, NewParentBranchId = newParentBranchId, NewParentBranchName = newParentBranchName, CorrelationId = graceIds.CorrelationId ) if parseResult |> hasOutput then return! progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = Branch.UpdateParentBranch(updateParentBranchParameters) t0.Increment(100.0) return result }) else return! Branch.UpdateParentBranch(updateParentBranchParameters) | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId)) } type UpdateParentBranch() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try let graceIds = parseResult |> getNormalizedIdsAndNames let! result = updateParentBranchHandler parseResult return result |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } //type UndeleteParameters() = // inherit CommonParameters() //let private undeleteHandler (parseResult: ParseResult) (undeleteParameters: UndeleteParameters) = // task { // try // if parseResult |> verbose then printParseResult parseResult // let validateIncomingParameters = parseResult |> CommonValidations // match validateIncomingParameters with // | Ok _ -> // let parameters = Parameters.Owner.UndeleteParameters(OwnerId = undeletecontext.OwnerId, OwnerName = undeletecontext.OwnerName, CorrelationId = undeletecontext.CorrelationId) // if parseResult |> showOutput then // return! progress.Columns(progressColumns) // .StartAsync(fun progressContext -> // task { // let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") // let! result = Owner.Undelete(parameters) // t0.Increment(100.0) // return result // }) // else // return! Owner.Undelete(parameters) // | Error error -> return Error error // with // | ex -> return Error (GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId)) // } //let private Undelete = // CommandHandler.Create(fun (parseResult: ParseResult) (undeleteParameters: UndeleteParameters) -> // task { // let! result = undeleteHandler parseResult undeleteParameters // return result |> renderOutput parseResult // }) let Build = let addCommonOptions (command: Command) = command |> addOption Options.ownerName |> addOption Options.ownerId |> addOption Options.organizationName |> addOption Options.organizationId |> addOption Options.repositoryName |> addOption Options.repositoryId |> addOption Options.branchName |> addOption Options.branchId // Create main command and aliases, if any.` let branchCommand = new Command("branch", Description = "Create, change, or delete branch-level information.") branchCommand.Aliases.Add("br") // Add subcommands. let branchCreateCommand = new Command("create", Description = "Create a new branch.") |> addOption Options.branchNameRequired |> addOption Options.branchId |> addOption Options.parentBranchName |> addOption Options.parentBranchId |> addOption Options.ownerName |> addOption Options.ownerId |> addOption Options.organizationName |> addOption Options.organizationId |> addOption Options.repositoryName |> addOption Options.repositoryId |> addOption Options.initialPermissions |> addOption Options.doNotSwitch branchCreateCommand.Action <- new Create() branchCommand.Subcommands.Add(branchCreateCommand) let switchCommand = new Command( "switch", Description = "Switches your current branch to another branch, or to a specific reference or Sha256Hash. If a Sha256Hash is provided, the current branch will be set to the version with that hash." ) |> addOption Options.toBranchId |> addOption Options.toBranchName |> addOption Options.sha256Hash |> addOption Options.referenceId |> addCommonOptions switchCommand.Aliases.Add("download") switchCommand.Action <- new Switch() branchCommand.Subcommands.Add(switchCommand) let statusCommand = new Command("status", Description = "Displays status information about the current repository and branch.") |> addCommonOptions statusCommand.Action <- new Status() branchCommand.Subcommands.Add(statusCommand) let promoteCommand = new Command("promote", Description = "Promotes a commit into the parent branch.") |> addOption Options.message |> addOption Options.individual |> addCommonOptions promoteCommand.Action <- new Promote() branchCommand.Subcommands.Add(promoteCommand) let commitCommand = new Command("commit", Description = "Create a commit.") |> addOption Options.messageRequired |> addCommonOptions commitCommand.Action <- new Commit() branchCommand.Subcommands.Add(commitCommand) let checkpointCommand = new Command("checkpoint", Description = "Create a checkpoint.") |> addOption Options.message |> addCommonOptions checkpointCommand.Action <- new Checkpoint() branchCommand.Subcommands.Add(checkpointCommand) let saveCommand = new Command("save", Description = "Create a save.") |> addOption Options.message |> addCommonOptions saveCommand.Action <- new Save() branchCommand.Subcommands.Add(saveCommand) let tagCommand = new Command("tag", Description = "Create a tag.") |> addOption Options.messageRequired |> addCommonOptions tagCommand.Action <- new Tag() branchCommand.Subcommands.Add(tagCommand) let createExternalCommand = new Command("create-external", Description = "Create an external reference.") |> addOption Options.messageRequired |> addCommonOptions createExternalCommand.Action <- new CreateExternal() branchCommand.Subcommands.Add(createExternalCommand) let rebaseCommand = new Command("rebase", Description = "Rebase this branch on a promotion from the parent branch.") |> addCommonOptions rebaseCommand.Action <- new Rebase() branchCommand.Subcommands.Add(rebaseCommand) let listContentsCommand = new Command("list-contents", Description = "List directories and files in the current branch.") |> addOption Options.referenceId |> addOption Options.sha256Hash |> addOption Options.forceRecompute |> addCommonOptions listContentsCommand.Action <- new ListContents() branchCommand.Subcommands.Add(listContentsCommand) let getRecursiveSizeCommand = new Command("get-recursive-size", Description = "Get the recursive size of the current branch.") |> addOption Options.referenceId |> addOption Options.sha256Hash |> addCommonOptions getRecursiveSizeCommand.Action <- new GetRecursiveSize() branchCommand.Subcommands.Add(getRecursiveSizeCommand) let enableAssignCommand = new Command("enable-assign", Description = "Enable or disable assigning promotions on this branch.") |> addOption Options.enabled |> addCommonOptions enableAssignCommand.Action <- new EnableAssign() branchCommand.Subcommands.Add(enableAssignCommand) let enablePromotionCommand = new Command("enable-promotion", Description = "Enable or disable promotions on this branch.") |> addOption Options.enabled |> addCommonOptions enablePromotionCommand.Action <- new EnablePromotion() branchCommand.Subcommands.Add(enablePromotionCommand) let enableCommitCommand = new Command("enable-commit", Description = "Enable or disable commits on this branch.") |> addOption Options.enabled |> addCommonOptions enableCommitCommand.Action <- new EnableCommit() branchCommand.Subcommands.Add(enableCommitCommand) let enableCheckpointsCommand = new Command("enable-checkpoints", Description = "Enable or disable checkpoints on this branch.") |> addOption Options.enabled |> addCommonOptions enableCheckpointsCommand.Action <- new EnableCheckpoint() branchCommand.Subcommands.Add(enableCheckpointsCommand) let enableSaveCommand = new Command("enable-save", Description = "Enable or disable saves on this branch.") |> addOption Options.enabled |> addCommonOptions enableSaveCommand.Action <- new EnableSave() branchCommand.Subcommands.Add(enableSaveCommand) let enableTagCommand = new Command("enable-tag", Description = "Enable or disable tags on this branch.") |> addOption Options.enabled |> addCommonOptions enableTagCommand.Action <- new EnableTag() branchCommand.Subcommands.Add(enableTagCommand) let enableExternalCommand = new Command("enable-external", Description = "Enable or disable external references on this branch.") |> addOption Options.enabled |> addCommonOptions enableExternalCommand.Action <- new EnableExternal() branchCommand.Subcommands.Add(enableExternalCommand) let enableAutoRebaseCommand = new Command("enable-auto-rebase", Description = "Enable or disable auto-rebase on this branch.") |> addOption Options.enabled |> addCommonOptions enableAutoRebaseCommand.Action <- new EnableAutoRebase() branchCommand.Subcommands.Add(enableAutoRebaseCommand) let setPromotionModeCommand = new Command("set-promotion-mode", Description = "Set the promotion mode for the branch (IndividualOnly, GroupOnly, or Hybrid).") |> addOption Options.promotionMode |> addCommonOptions setPromotionModeCommand.Action <- new SetPromotionMode() branchCommand.Subcommands.Add(setPromotionModeCommand) let setNameCommand = new Command("set-name", Description = "Change the name of the branch.") |> addOption Options.newName |> addCommonOptions setNameCommand.Action <- new SetName() branchCommand.Subcommands.Add(setNameCommand) let getCommand = new Command("get", Description = "Gets details for the branch.") |> addOption Options.includeDeleted |> addOption Options.showEvents |> addCommonOptions getCommand.Action <- new Get() branchCommand.Subcommands.Add(getCommand) let getReferencesCommand = new Command("get-references", Description = "Retrieves a list of the most recent references from the branch.") |> addCommonOptions |> addOption Options.maxCount |> addOption Options.fullSha getReferencesCommand.Action <- new GetReferences() branchCommand.Subcommands.Add(getReferencesCommand) let getPromotionsCommand = new Command("get-promotions", Description = "Retrieves a list of the most recent promotions from the branch.") |> addCommonOptions |> addOption Options.maxCount |> addOption Options.fullSha getPromotionsCommand.Action <- new GetPromotions() branchCommand.Subcommands.Add(getPromotionsCommand) let getCommitsCommand = new Command("get-commits", Description = "Retrieves a list of the most recent commits from the branch.") |> addCommonOptions |> addOption Options.maxCount |> addOption Options.fullSha getCommitsCommand.Action <- new GetCommits() branchCommand.Subcommands.Add(getCommitsCommand) let getCheckpointsCommand = new Command("get-checkpoints", Description = "Retrieves a list of the most recent checkpoints from the branch.") |> addCommonOptions |> addOption Options.maxCount |> addOption Options.fullSha getCheckpointsCommand.Action <- new GetCheckpoints() branchCommand.Subcommands.Add(getCheckpointsCommand) let getSavesCommand = new Command("get-saves", Description = "Retrieves a list of the most recent saves from the branch.") |> addCommonOptions |> addOption Options.maxCount |> addOption Options.fullSha getSavesCommand.Action <- new GetSaves() branchCommand.Subcommands.Add(getSavesCommand) let getTagsCommand = new Command("get-tags", Description = "Retrieves a list of the most recent tags from the branch.") |> addCommonOptions |> addOption Options.maxCount |> addOption Options.fullSha getTagsCommand.Action <- new GetTags() branchCommand.Subcommands.Add(getTagsCommand) let getExternalsCommand = new Command("get-externals", Description = "Retrieves a list of the most recent external references from the branch.") |> addCommonOptions |> addOption Options.maxCount |> addOption Options.fullSha getExternalsCommand.Action <- new GetExternals() branchCommand.Subcommands.Add(getExternalsCommand) let deleteCommand = new Command("delete", Description = "Delete the branch.") |> addOption Options.force |> addOption Options.reassignChildBranches |> addOption Options.newParentBranchId |> addOption Options.newParentBranchName |> addCommonOptions deleteCommand.Action <- new Delete() branchCommand.Subcommands.Add(deleteCommand) let updateParentBranchCommand = new Command("update-parent-branch", Description = "Update the parent branch of this branch.") |> addOption Options.newParentBranchId |> addOption Options.newParentBranchName |> addCommonOptions updateParentBranchCommand.Action <- new UpdateParentBranch() branchCommand.Subcommands.Add(updateParentBranchCommand) let assignCommand = new Command("assign", Description = "Assign a promotion to this branch.") |> addOption Options.directoryVersionId |> addOption Options.sha256Hash |> addOption Options.message |> addCommonOptions assignCommand.Action <- new Assign() branchCommand.Subcommands.Add(assignCommand) //let undeleteCommand = new Command("undelete", Description = "Undelete a deleted owner.") |> addCommonOptions //undeleteCommand.Action <- Undelete //branchCommand.Subcommands.Add(undeleteCommand) branchCommand ================================================ FILE: src/Grace.CLI/Command/Candidate.CLI.fs ================================================ namespace Grace.CLI.Command open Grace.CLI.Common open Grace.CLI.Services open Grace.CLI.Text open Grace.SDK open Grace.Shared open Grace.Shared.Utilities open Grace.Types.Types open Spectre.Console open System open System.CommandLine open System.CommandLine.Invocation open System.CommandLine.Parsing open System.Threading open System.Threading.Tasks module CandidateCommand = module private Options = let candidateId = new Option( "--candidate", [| "--candidate-id" |], Required = true, Description = "The candidate ID .", Arity = ArgumentArity.ExactlyOne ) let gate = new Option("--gate", Required = true, Description = "The gate name to rerun.", Arity = ArgumentArity.ExactlyOne) let ownerId = new Option( OptionName.OwnerId, Required = false, Description = "The repository's owner ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OwnerId.Empty) ) let ownerName = new Option( OptionName.OwnerName, Required = false, Description = "The repository's owner name. [default: current owner]", Arity = ArgumentArity.ExactlyOne ) let organizationId = new Option( OptionName.OrganizationId, Required = false, Description = "The organization's ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OrganizationId.Empty) ) let organizationName = new Option( OptionName.OrganizationName, Required = false, Description = "The organization's name. [default: current organization]", Arity = ArgumentArity.ExactlyOne ) let repositoryId = new Option( OptionName.RepositoryId, Required = false, Description = "The repository's ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> RepositoryId.Empty) ) let repositoryName = new Option( OptionName.RepositoryName, Required = false, Description = "The repository's name. [default: current repository]", Arity = ArgumentArity.ExactlyOne ) let internal tryParseCandidateId (candidateId: string) (parseResult: ParseResult) = let mutable parsed = Guid.Empty if String.IsNullOrWhiteSpace(candidateId) || not (Guid.TryParse(candidateId, &parsed)) || parsed = Guid.Empty then Error(GraceError.Create "CandidateId must be a valid non-empty Guid." (getCorrelationId parseResult)) else Ok(parsed.ToString()) let internal buildCandidateProjectionParameters (graceIds: GraceIds) (candidateId: string) = Parameters.Review.CandidateProjectionParameters( CandidateId = candidateId, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let internal buildCandidateGateRerunParameters (graceIds: GraceIds) (candidateId: string) (gate: string) = Parameters.Review.CandidateGateRerunParameters( CandidateId = candidateId, Gate = gate, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let private renderCandidateSnapshot (parseResult: ParseResult) (snapshot: Parameters.Review.CandidateProjectionSnapshotResult) = if not (parseResult |> json) && not (parseResult |> silent) then let table = Table(Border = TableBorder.Rounded) table.AddColumn("Field") |> ignore table.AddColumn("Value") |> ignore table.AddRow("Candidate", Markup.Escape(snapshot.Identity.CandidateId)) |> ignore table.AddRow("PromotionSet", Markup.Escape(snapshot.Identity.PromotionSetId)) |> ignore table.AddRow("PromotionSetStatus", Markup.Escape(snapshot.PromotionSetStatus)) |> ignore table.AddRow("StepsComputationStatus", Markup.Escape(snapshot.StepsComputationStatus)) |> ignore table.AddRow("QueueState", Markup.Escape(snapshot.QueueState)) |> ignore table.AddRow( "RunningPromotionSetId", if String.IsNullOrWhiteSpace(snapshot.RunningPromotionSetId) then "-" else Markup.Escape(snapshot.RunningPromotionSetId) ) |> ignore table.AddRow("UnresolvedFindings", snapshot.UnresolvedFindingCount.ToString()) |> ignore table.AddRow("ValidationSummaryAvailable", snapshot.ValidationSummaryAvailable.ToString()) |> ignore table.AddRow("RequiredActions", Markup.Escape(String.Join(", ", snapshot.RequiredActions))) |> ignore if not snapshot.Diagnostics.IsEmpty then table.AddRow("Diagnostics", Markup.Escape(String.Join(" | ", snapshot.Diagnostics))) |> ignore AnsiConsole.Write(table) let private renderCandidateRequiredActions (parseResult: ParseResult) (result: Parameters.Review.CandidateRequiredActionsResult) = if not (parseResult |> json) && not (parseResult |> silent) then AnsiConsole.MarkupLine($"[bold]Candidate[/] {Markup.Escape(result.Identity.CandidateId)}") let requiredActionsText = String.Join(", ", result.RequiredActions) AnsiConsole.MarkupLine($"[bold]Required actions:[/] {Markup.Escape(requiredActionsText)}") if not result.Diagnostics.IsEmpty then let diagnosticsText = String.Join(" | ", result.Diagnostics) AnsiConsole.MarkupLine($"[yellow]Diagnostics:[/] {Markup.Escape(diagnosticsText)}") let private renderCandidateAttestations (parseResult: ParseResult) (result: Parameters.Review.CandidateAttestationsResult) = if not (parseResult |> json) && not (parseResult |> silent) then let table = Table(Border = TableBorder.Rounded) table.AddColumn("Attestation") |> ignore table.AddColumn("Status") |> ignore table.AddColumn("Detail") |> ignore result.Attestations |> List.iter (fun attestation -> table.AddRow(Markup.Escape(attestation.Name), Markup.Escape(attestation.Status), Markup.Escape(attestation.Detail)) |> ignore) AnsiConsole.Write(table) if not result.Diagnostics.IsEmpty then let diagnosticsText = String.Join(" | ", result.Diagnostics) AnsiConsole.MarkupLine($"[yellow]Diagnostics:[/] {Markup.Escape(diagnosticsText)}") let private renderCandidateActionResult (parseResult: ParseResult) (result: Parameters.Review.CandidateActionResult) = if not (parseResult |> json) && not (parseResult |> silent) then AnsiConsole.MarkupLine( $"[green]Candidate action[/] {Markup.Escape(result.Action)} [green]completed for[/] {Markup.Escape(result.Identity.CandidateId)}" ) if not result.AppliedOperations.IsEmpty then let operationsText = String.Join(" -> ", result.AppliedOperations) AnsiConsole.MarkupLine($"[bold]Operations:[/] {Markup.Escape(operationsText)}") if not result.Diagnostics.IsEmpty then let diagnosticsText = String.Join(" | ", result.Diagnostics) AnsiConsole.MarkupLine($"[yellow]Diagnostics:[/] {Markup.Escape(diagnosticsText)}") let private resolveCandidateFromParseResult (parseResult: ParseResult) = let candidateId = parseResult.GetValue(Options.candidateId) tryParseCandidateId candidateId parseResult let private getHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames match resolveCandidateFromParseResult parseResult with | Error error -> return Error error | Ok candidateId -> let parameters = buildCandidateProjectionParameters graceIds candidateId let! result = Review.GetCandidate(parameters) match result with | Ok returnValue -> renderCandidateSnapshot parseResult returnValue.ReturnValue return Ok returnValue | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type Get() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = getHandler parseResult return result |> renderOutput parseResult } let private requiredActionsHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames match resolveCandidateFromParseResult parseResult with | Error error -> return Error error | Ok candidateId -> let parameters = buildCandidateProjectionParameters graceIds candidateId let! result = Review.GetCandidateRequiredActions(parameters) match result with | Ok returnValue -> renderCandidateRequiredActions parseResult returnValue.ReturnValue return Ok returnValue | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type RequiredActions() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = requiredActionsHandler parseResult return result |> renderOutput parseResult } let private attestationsHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames match resolveCandidateFromParseResult parseResult with | Error error -> return Error error | Ok candidateId -> let parameters = buildCandidateProjectionParameters graceIds candidateId let! result = Review.GetCandidateAttestations(parameters) match result with | Ok returnValue -> renderCandidateAttestations parseResult returnValue.ReturnValue return Ok returnValue | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type Attestations() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = attestationsHandler parseResult return result |> renderOutput parseResult } let private retryHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames match resolveCandidateFromParseResult parseResult with | Error error -> return Error error | Ok candidateId -> let parameters = buildCandidateProjectionParameters graceIds candidateId let! result = Review.RetryCandidate(parameters) match result with | Ok returnValue -> renderCandidateActionResult parseResult returnValue.ReturnValue return Ok returnValue | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type Retry() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = retryHandler parseResult return result |> renderOutput parseResult } let private cancelHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames match resolveCandidateFromParseResult parseResult with | Error error -> return Error error | Ok candidateId -> let parameters = buildCandidateProjectionParameters graceIds candidateId let! result = Review.CancelCandidate(parameters) match result with | Ok returnValue -> renderCandidateActionResult parseResult returnValue.ReturnValue return Ok returnValue | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type Cancel() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = cancelHandler parseResult return result |> renderOutput parseResult } let private gateRerunHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let gate = parseResult.GetValue(Options.gate) |> Option.ofObj |> Option.defaultValue String.Empty if String.IsNullOrWhiteSpace gate then return Error(GraceError.Create "Gate is required for candidate gate rerun." (getCorrelationId parseResult)) else match resolveCandidateFromParseResult parseResult with | Error error -> return Error error | Ok candidateId -> let parameters = buildCandidateGateRerunParameters graceIds candidateId gate let! result = Review.RerunCandidateGate(parameters) match result with | Ok returnValue -> renderCandidateActionResult parseResult returnValue.ReturnValue return Ok returnValue | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type GateRerun() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = gateRerunHandler parseResult return result |> renderOutput parseResult } let Build = let addCommonOptions (command: Command) = command |> addOption Options.ownerName |> addOption Options.ownerId |> addOption Options.organizationName |> addOption Options.organizationId |> addOption Options.repositoryName |> addOption Options.repositoryId let candidateCommand = new Command("candidate", Description = "Candidate-first reviewer operations projected over PromotionSet-backed runtime semantics.") let getCommand = new Command("get", Description = "Get candidate projection details.") |> addOption Options.candidateId |> addCommonOptions getCommand.Action <- new Get() candidateCommand.Subcommands.Add(getCommand) let requiredActionsCommand = new Command("required-actions", Description = "Get deterministic required actions for a candidate.") |> addOption Options.candidateId |> addCommonOptions requiredActionsCommand.Action <- new RequiredActions() candidateCommand.Subcommands.Add(requiredActionsCommand) let attestationsCommand = new Command("attestations", Description = "Get candidate attestation state from policy and review checkpoints.") |> addOption Options.candidateId |> addCommonOptions attestationsCommand.Action <- new Attestations() candidateCommand.Subcommands.Add(attestationsCommand) let retryCommand = new Command("retry", Description = "Retry candidate processing through recompute and queue operations.") |> addOption Options.candidateId |> addCommonOptions retryCommand.Action <- new Retry() candidateCommand.Subcommands.Add(retryCommand) let cancelCommand = new Command("cancel", Description = "Cancel queued candidate processing.") |> addOption Options.candidateId |> addCommonOptions cancelCommand.Action <- new Cancel() candidateCommand.Subcommands.Add(cancelCommand) let gateCommand = new Command("gate", Description = "Gate-related candidate operations.") let gateRerunCommand = new Command("rerun", Description = "Rerun candidate gate evaluation semantics.") |> addOption Options.candidateId |> addOption Options.gate |> addCommonOptions gateRerunCommand.Action <- new GateRerun() gateCommand.Subcommands.Add(gateRerunCommand) candidateCommand.Subcommands.Add(gateCommand) candidateCommand ================================================ FILE: src/Grace.CLI/Command/Common.CLI.fs ================================================ namespace Grace.CLI open FSharpPlus open Grace.CLI.Services open Grace.CLI.Text open Grace.Shared open Grace.Shared.Validation.Errors open Grace.Shared.Client.Configuration open Grace.Shared.Resources.Text open Grace.Types.Types open Grace.Shared.Utilities open Spectre.Console open System open System.CommandLine open System.CommandLine.Parsing open System.Globalization open System.Linq open System.Text.Json open System.Threading.Tasks open Spectre.Console.Rendering open Spectre.Console.Json open System.Text.RegularExpressions module Common = type ParameterBase() = member val public CorrelationId: string = String.Empty with get, set member val public Json: bool = false with get, set member val public OutputFormat: string = String.Empty with get, set /// The output format for the command. type OutputFormat = | Normal | Json | Minimal | Silent | Verbose /// Adds an option (i.e. parameter) to a command, so you can do cool stuff like `|> addOption Options.someOption |> addOption Options.anotherOption`. let addOption (option: Option) (command: Command) = command.Options.Add(option) command let public Language = CultureInfo.CurrentCulture.TwoLetterISOLanguageName /// Gets the "... ago" text. let ago = ago Language module Options = let correlationId = new Option( OptionName.CorrelationId, [| "-c" |], Required = false, Description = "CorrelationId for end-to-end tracking .", Arity = ArgumentArity.ExactlyOne, Recursive = true, DefaultValueFactory = (fun _ -> CorrelationId.Empty) ) let source = new Option( OptionName.Source, Required = false, Description = "Optional invocation source metadata for history attribution.", Arity = ArgumentArity.ExactlyOne, Recursive = true ) let output = (new Option( OptionName.Output, [| "-o" |], Required = false, Description = "The style of output.", Arity = ArgumentArity.ExactlyOne, Recursive = true, DefaultValueFactory = (fun _ -> "Normal") )) .AcceptOnlyFromAmong(listCases ()) /// Gets the correlationId value from the command's ParseResult. let getCorrelationId (parseResult: ParseResult) = Services.resolveCorrelationId parseResult [] let SourceEnvironmentVariableName = "GRACE_SOURCE" let private normalizeSource (value: string) = if String.IsNullOrWhiteSpace(value) then None else Some(value.Trim()) let private tryGetExplicitSourceFromParseResult (parseResult: ParseResult) = if isNull parseResult then None else let result = parseResult.GetResult(OptionName.Source) if isNull result then None else try let optionResult = result :?> OptionResult if optionResult.Implicit then None else parseResult.GetValue(OptionName.Source) |> normalizeSource with | :? InvalidOperationException -> None let resolveInvocationSource (parseResult: ParseResult) = match tryGetExplicitSourceFromParseResult parseResult with | Some source -> Some source | None -> Environment.GetEnvironmentVariable(SourceEnvironmentVariableName) |> normalizeSource module Validations = /// Checks that a given name option is a valid Grace name. If the option is not present, it does not return an error. let mustBeAValidGraceName<'T when 'T :> IErrorDiscriminatedUnion> (parseResult: ParseResult) (optionName: string) (error: 'T) = let result = parseResult.GetResult(optionName) let value = parseResult.GetValue(optionName) if result <> null && not <| Constants.GraceNameRegex.IsMatch(value) then Error(GraceError.Create (getErrorMessage error) (parseResult |> getCorrelationId)) else Ok(parseResult) let ``Option must be present`` (optionName: string) (error: IErrorDiscriminatedUnion) (parseResult: ParseResult) = let result = parseResult.GetResult(optionName) if isNull result then Error(GraceError.Create (getErrorMessage error) (parseResult |> getCorrelationId)) else Ok(parseResult) let ``OwnerName must be a valid Grace name`` (parseResult: ParseResult) = mustBeAValidGraceName parseResult OptionName.OwnerName OwnerError.InvalidOwnerName let ``OrganizationName must be a valid Grace name`` (parseResult: ParseResult) = mustBeAValidGraceName parseResult OptionName.OrganizationName OrganizationError.InvalidOrganizationName let ``RepositoryName must be a valid Grace name`` (parseResult: ParseResult) = mustBeAValidGraceName parseResult OptionName.RepositoryName RepositoryError.InvalidRepositoryName let ``BranchName must be a valid Grace name`` (parseResult: ParseResult) = mustBeAValidGraceName parseResult OptionName.BranchName BranchError.InvalidBranchName let ``NewName must be a valid Grace name`` (parseResult: ParseResult) = mustBeAValidGraceName parseResult OptionName.NewName RepositoryError.InvalidNewName let ``Either OwnerId or OwnerName must be provided`` (parseResult: ParseResult) = // Get the command that was invoked. let command = parseResult.CommandResult.Command // Only perform this validation if the command has an OwnerId option. if command.Options.Any(fun option -> option.Name = OptionName.OwnerId) then let ownerIdResult = parseResult.GetResult(OptionName.OwnerId) :?> OptionResult let ownerId = parseResult.GetValue(OptionName.OwnerId) let ownerName = parseResult.GetValue(OptionName.OwnerName) let isOk = ownerIdResult.Implicit || ownerId <> Guid.Empty || not <| String.IsNullOrWhiteSpace(ownerName) if isOk then Ok(parseResult) else Error(GraceError.Create (getErrorMessage OwnerError.EitherOwnerIdOrOwnerNameRequired) (parseResult |> getCorrelationId)) else Ok(parseResult) let ``Either OrganizationId or OrganizationName must be provided`` (parseResult: ParseResult) = // Get the command that was invoked. let command = parseResult.CommandResult.Command // Only perform this validation if the command has an OrganizationId option. if command.Options.Any(fun option -> option.Name = OptionName.OrganizationId) then let organizationId = parseResult.GetValue(OptionName.OrganizationId) let organizationName = parseResult.GetValue(OptionName.OrganizationName) if organizationId = Guid.Empty && String.IsNullOrWhiteSpace(organizationName) then Error( GraceError.Create (getErrorMessage OrganizationError.EitherOrganizationIdOrOrganizationNameRequired) (parseResult |> getCorrelationId) ) else Ok(parseResult) else Ok(parseResult) let ``Either RepositoryId or RepositoryName must be provided`` (parseResult: ParseResult) = // Get the command that was invoked. let command = parseResult.CommandResult.Command // Only perform this validation if the command has a RepositoryId option. if command.Options.Any(fun option -> option.Name = OptionName.RepositoryId) then let repositoryId = parseResult.GetValue(OptionName.RepositoryId) let repositoryName = parseResult.GetValue(OptionName.RepositoryName) if repositoryId = Guid.Empty && String.IsNullOrWhiteSpace(repositoryName) then Error(GraceError.Create (getErrorMessage RepositoryError.EitherRepositoryIdOrRepositoryNameRequired) (parseResult |> getCorrelationId)) else Ok(parseResult) else Ok(parseResult) let ``Either BranchId or BranchName must be provided`` (parseResult: ParseResult) = // Get the command that was invoked. let command = parseResult.CommandResult.Command // Only perform this validation if the command has a BranchId option. if command.Options.Any(fun option -> option.Name = OptionName.BranchId) then let branchId = parseResult.GetValue(OptionName.BranchId) let branchName = parseResult.GetValue(OptionName.BranchName) if branchId = Guid.Empty && String.IsNullOrWhiteSpace(branchName) then Error(GraceError.Create (getErrorMessage BranchError.EitherBranchIdOrBranchNameRequired) (parseResult |> getCorrelationId)) else Ok(parseResult) else Ok(parseResult) let CommonValidations (parseResult: ParseResult) = parseResult |> ``OwnerName must be a valid Grace name`` >>= ``OrganizationName must be a valid Grace name`` >>= ``RepositoryName must be a valid Grace name`` >>= ``BranchName must be a valid Grace name`` >>= ``NewName must be a valid Grace name`` //>>= ``Either OwnerId or OwnerName must be provided`` //>>= ``Either OrganizationId or OrganizationName must be provided`` //>>= ``Either RepositoryId or RepositoryName must be provided`` //>>= ``Either BranchId or BranchName must be provided`` /// Checks if the output format from the command line is a specific format. let isOutputFormat (outputFormat: OutputFormat) (parseResult: ParseResult) = try let outputOption = parseResult.GetValue(Options.output) match outputOption with | null -> // The command didn't have an output option set, which means it defaults to Normal. if outputFormat = OutputFormat.Normal then true else false | _ -> // The command had an output option set, so we check if it matches the expected output format. let formatFromCommand = parseResult.GetValue(Options.output) if outputFormat = discriminatedUnionFromString( formatFromCommand ) .Value then true else false with | ex -> logToAnsiConsole Colors.Error $"Exception in isOutputFormat: {ExceptionResponse.Create ex}" false /// Checks if the output format from the command line is Json. let json parseResult = parseResult |> isOutputFormat Json /// Checks if the output format from the command line is Minimal. let minimal parseResult = parseResult |> isOutputFormat Minimal /// Checks if the output format from the command line is Normal. let normal parseResult = parseResult |> isOutputFormat Normal /// Checks if the output format from the command line is Silent. let silent parseResult = parseResult |> isOutputFormat Silent /// Checks if the output format from the command line is Verbose. let verbose parseResult = parseResult |> isOutputFormat Verbose /// Checks if the output format from the command line is either Normal or Verbose; i.e. it has output. let hasOutput parseResult = parseResult |> normal || parseResult |> verbose let startProgressTask showOutput (t: ProgressTask) = if showOutput then t.StartTask() let setProgressTaskValue showOutput (value: float) (t: ProgressTask) = if showOutput then t.Value <- value let incrementProgressTaskValue showOutput (value: float) (t: ProgressTask) = if showOutput then t.Increment(value) let emptyTask = ProgressTask(0, "Empty progress task", 0.0, autoStart = false) /// Rewrites "[" to "[[" and "]" to "]]". let escapeBrackets s = s.ToString().Replace("[", "[[").Replace("]", "]]") let private resolvedValueOptionNames = [ OptionName.OwnerId OptionName.OwnerName OptionName.OrganizationId OptionName.OrganizationName OptionName.RepositoryId OptionName.RepositoryName OptionName.BranchId OptionName.BranchName ] let private shouldShowResolvedValues (parseResult: ParseResult) = resolvedValueOptionNames |> List.exists (isOptionPresent parseResult) let private tryBuildResolvedValuesText (parseResult: ParseResult) = if isNull parseResult || not (configurationFileExists ()) || not (shouldShowResolvedValues parseResult) then None else let graceIds = Services.getNormalizedIdsAndNames parseResult let sb = stringBuilderPool.Get() try let appendLine label value = sb.AppendLine($"{label}: {value}") |> ignore let appendName label (value: string) = if not <| String.IsNullOrWhiteSpace(value) then appendLine label value if graceIds.HasOwner then appendLine "OwnerId" graceIds.OwnerId appendName "OwnerName" graceIds.OwnerName if graceIds.HasOrganization then appendLine "OrganizationId" graceIds.OrganizationId appendName "OrganizationName" graceIds.OrganizationName if graceIds.HasRepository then appendLine "RepositoryId" graceIds.RepositoryId appendName "RepositoryName" graceIds.RepositoryName if graceIds.HasBranch then appendLine "BranchId" graceIds.BranchId appendName "BranchName" graceIds.BranchName if sb.Length > 0 then Some(sb.ToString()) else None finally stringBuilderPool.Return sb /// Prints the ParseResult with markup. let printParseResult (parseResult: ParseResult) = if not <| isNull parseResult then let sb = stringBuilderPool.Get() try // Gather all options from the root command and the invoked command. let optionList = parseResult.RootCommandResult.Command.Options |> Seq.append parseResult.CommandResult.Command.Options |> Seq.sortBy (fun option -> option.Name) |> Seq.toIReadOnlyList let tryGetValue (option: Option) = let result = parseResult.GetResult(option.Name) if isNull result then None else try let value = parseResult.GetValue(option.Name) if isNull value then None else Some value with | :? InvalidOperationException -> None for option in optionList do match tryGetValue option with | Some value -> if option.ValueType.IsArray then sb.AppendLine($"{option.Name}: {serialize value}") |> ignore else sb.AppendLine($"{option.Name}: {value}") |> ignore | None -> () AnsiConsole.MarkupLine($"[{Colors.Verbose}]{escapeBrackets (parseResult.ToString())}[/]") AnsiConsole.WriteLine() AnsiConsole.MarkupLine($"[{Colors.Verbose}]Parameter values:[/]") AnsiConsole.MarkupLine($"[{Colors.Verbose}]{escapeBrackets (sb.ToString())}[/]") AnsiConsole.WriteLine() match tryBuildResolvedValuesText parseResult with | Some resolvedValues -> AnsiConsole.MarkupLine($"[{Colors.Verbose}]Resolved values:[/]") AnsiConsole.MarkupLine($"[{Colors.Verbose}]{escapeBrackets resolvedValues}[/]") AnsiConsole.WriteLine() | None -> () finally stringBuilderPool.Return sb /// Prints AnsiConsole markup to the console. let writeMarkup (markup: IRenderable) = AnsiConsole.Write(markup) AnsiConsole.WriteLine() /// Prints output to the console, depending on the output format. let renderOutput (parseResult: ParseResult) (result: GraceResult<'T>) = let outputFormat = discriminatedUnionFromString( parseResult.GetValue(Options.output) ) .Value match result with | Ok graceReturnValue -> match outputFormat with | Json -> AnsiConsole.WriteLine(Markup.Escape($"{graceReturnValue}")) | Minimal -> () //AnsiConsole.MarkupLine($"""[{Colors.Highlighted}]{Markup.Escape($"{graceReturnValue.ReturnValue}")}[/]""") | Silent -> () | Verbose -> AnsiConsole.WriteLine() AnsiConsole.MarkupLine($"""[{Colors.Verbose}]EventTime: {formatInstantExtended graceReturnValue.EventTime}[/]""") AnsiConsole.MarkupLine($"""[{Colors.Verbose}]CorrelationId: "{graceReturnValue.CorrelationId}"[/]""") AnsiConsole.MarkupLine($"""[{Colors.Verbose}]Properties: {Markup.Escape(serialize graceReturnValue.Properties)}[/]""") AnsiConsole.WriteLine() | Normal -> () // Return unit because in the Normal case, we expect to print output within each command. 0 | Error error -> let json = if error.Error.Contains("Stack trace") then Uri.UnescapeDataString(error.Error) else Uri.UnescapeDataString(serialize error) let errorText = if error.Error.Contains("Stack trace") then try let exceptionResponse = deserialize error.Error Uri.UnescapeDataString($"{exceptionResponse}") with | ex -> Uri.UnescapeDataString(error.Error) else Uri.UnescapeDataString(error.Error) match outputFormat with | Json -> AnsiConsole.WriteLine($"{Markup.Escape(json)}") | Minimal -> AnsiConsole.MarkupLine($"[{Colors.Error}]{Markup.Escape(errorText)}[/]") | Silent -> () | Verbose -> AnsiConsole.MarkupLine($"[{Colors.Error}]{Markup.Escape(errorText)}[/]") AnsiConsole.WriteLine() AnsiConsole.MarkupLine($"[{Colors.Verbose}]{Markup.Escape(json)}[/]") AnsiConsole.WriteLine() | Normal -> AnsiConsole.MarkupLine($"[{Colors.Error}]{Markup.Escape(errorText)}[/]") -1 let progressBarColumn = new ProgressBarColumn() progressBarColumn.FinishedStyle <- new Style(foreground = Color.Green) let percentageColumn = new PercentageColumn() percentageColumn.Style <- new Style(foreground = Color.Yellow) percentageColumn.CompletedStyle <- new Style(foreground = Color.Yellow) let spinnerColumn = new SpinnerColumn(Spinner.Known.Dots) let progressColumns: ProgressColumn [] = [| new TaskDescriptionColumn(Alignment = Justify.Right) progressBarColumn percentageColumn spinnerColumn |] let progress = AnsiConsole.Progress(AutoRefresh = true, AutoClear = false, HideCompleted = false) ================================================ FILE: src/Grace.CLI/Command/Config.CLI.fs ================================================ namespace Grace.CLI.Command open DiffPlex open DiffPlex.DiffBuilder.Model open FSharpPlus open Grace.CLI.Common open Grace.CLI.Services open Grace.CLI.Text open Grace.SDK open Grace.Shared open Grace.Shared.Client.Configuration open Grace.Types.Branch open Grace.Types.Reference open Grace.Types.Types open Grace.Shared.Utilities open Grace.Shared.Validation.Errors open Spectre.Console open System open System.Collections.Concurrent open System.Collections.Generic open System.CommandLine open System.CommandLine.Invocation open System.CommandLine.Parsing open System.Linq open System.IO open System.Text.Json open System.Threading.Tasks module Config = type CommonParameters() = inherit ParameterBase() member val public Directory: string = "." with get, set member val public Overwrite: bool = false with get, set module private Options = let directory = new Option( OptionName.Directory, Required = false, Description = "The root path of the repository to initialize Grace in [default: current directory]", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> ".") ) let overwrite = new Option( OptionName.Overwrite, Required = false, Description = "Allows Grace to overwrite an existing graceconfig.json file with default values", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> false) ) let private CommonValidations parseResult = let ``Directory must be a valid path`` (parseResult: ParseResult) = let directory = parseResult.GetValue(Options.directory) if Directory.Exists(directory) then Ok parseResult else Error(GraceError.Create (getErrorMessage ConfigError.InvalidDirectoryPath) (getCorrelationId parseResult)) parseResult |> ``Directory must be a valid path`` let private renderLine (diffLine: DiffPiece) = if not <| diffLine.Position.HasValue then $" {diffLine.Text.EscapeMarkup()}" else $"{diffLine.Position, 6:D}: {diffLine.Text.EscapeMarkup()}" let private getMarkup (diffLine: DiffPiece) = if diffLine.Type = ChangeType.Deleted then Markup($"[{Colors.Deleted}]-{renderLine diffLine}[/]") elif diffLine.Type = ChangeType.Inserted then Markup($"[{Colors.Added}]+{renderLine diffLine}[/]") elif diffLine.Type = ChangeType.Modified then Markup($"[{Colors.Changed}]~{renderLine diffLine}[/]") elif diffLine.Type = ChangeType.Imaginary then Markup($"[{Colors.Deemphasized}] {renderLine diffLine}[/]") elif diffLine.Type = ChangeType.Unchanged then Markup($"[{Colors.Important}] {renderLine diffLine}[/]") else Markup($"[{Colors.Important}] {diffLine.Text}[/]") type WriteParameters() = inherit CommonParameters() let writeHandler (parseResult: ParseResult) (parameters: WriteParameters) = task { if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = parseResult |> CommonValidations match validateIncomingParameters with | Ok _ -> // Search for existing .grace directory and existing graceconfig.json // If I find them, and parameters.Overwrite is true, then I can empty out the .grace directory and write a default graceconfig.json. // If I don't find them, I should create the .grace directory and write a default graceconfig.json. // We should use `GraceConfiguration() |> saveConfigFile parameters.Directory` to write the default config let graceDirPath = Path.Combine(parameters.Directory, ".grace") let graceConfigPath = Path.Combine(graceDirPath, "graceconfig.json") let overwriteExisting = parameters.Overwrite && Directory.Exists(graceDirPath) && File.Exists(graceConfigPath) if overwriteExisting then // Clear out everything in the .grace directory. if parseResult |> hasOutput then printfn "Deleting contents of existing .grace directory." Directory.Delete(graceDirPath, recursive = true) if File.Exists(graceConfigPath) && not parameters.Overwrite then if parseResult |> hasOutput then printfn $"Found existing Grace configuration file at {graceConfigPath}. Specify {OptionName.Overwrite} if you'd like to overwrite it.{Environment.NewLine}" else let directoryInfo = Directory.CreateDirectory(graceDirPath) if parseResult |> hasOutput then printfn $"Writing new Grace configuration file at {graceConfigPath}.{Environment.NewLine}" GraceConfiguration() |> saveConfigFile graceConfigPath return Ok(GraceReturnValue.Create () parameters.CorrelationId) | Error error -> return (Error error) } type Write() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: Threading.CancellationToken) : Task = task { if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = parseResult |> CommonValidations let directory = parseResult.GetValue(Options.directory) let overwrite = parseResult.GetValue(Options.overwrite) match validateIncomingParameters with | Ok _ -> // Search for existing .grace directory and existing graceconfig.json // If I find them, and parameters.Overwrite is true, then I can empty out the .grace directory and write a default graceconfig.json. // If I don't find them, I should create the .grace directory and write a default graceconfig.json. // We should use `GraceConfiguration() |> saveConfigFile parameters.Directory` to write the default config let graceDirPath = Path.Combine(directory, ".grace") let graceConfigPath = Path.Combine(graceDirPath, "graceconfig.json") let overwriteExisting = overwrite && Directory.Exists(graceDirPath) && File.Exists(graceConfigPath) if overwriteExisting then // Clear out everything in the .grace directory. if parseResult |> hasOutput then printfn "Deleting contents of existing .grace directory." Directory.Delete(graceDirPath, recursive = true) if File.Exists(graceConfigPath) && not overwrite then if parseResult |> hasOutput then printfn $"Found existing Grace configuration file at {graceConfigPath}. Specify {OptionName.Overwrite} if you'd like to overwrite it.{Environment.NewLine}" else let directoryInfo = Directory.CreateDirectory(graceDirPath) if parseResult |> hasOutput then printfn $"Writing new Grace configuration file at {graceConfigPath}.{Environment.NewLine}" GraceConfiguration() |> saveConfigFile graceConfigPath return Ok(GraceReturnValue.Create () (getCorrelationId parseResult)) |> renderOutput parseResult | Error error -> return (Error error) |> renderOutput parseResult //let! writeResult = writeHandler parseResult //return writeResult |> renderOutput parseResult } let Build = let addCommonOptions (command: Command) = command |> addOption Options.directory |> addOption Options.overwrite let configCommand = new Command("config", Description = "Initializes a repository with the default Grace configuration.") let writeCommand = new Command("write", Description = "Initializes a repository with a default Grace configuration.") |> addCommonOptions writeCommand.Action <- Write() configCommand.Subcommands.Add(writeCommand) configCommand ================================================ FILE: src/Grace.CLI/Command/Connect.CLI.fs ================================================ namespace Grace.CLI.Command open FSharpPlus open Grace.CLI.Common open Grace.CLI.Services open Grace.CLI.Text open Grace.SDK open Grace.Shared open Grace.Shared.Client.Configuration open Grace.Shared.Utilities open Grace.Types.Owner open Grace.Types.Branch open Grace.Types.Organization open Grace.Types.Reference open Grace.Types.Repository open Grace.Types.Types open Grace.Shared.Validation.Common open Grace.Shared.Validation.Errors open System open System.Collections.Generic open System.CommandLine.Invocation open System.CommandLine.Parsing open System.IO open System.Threading.Tasks open System.CommandLine open Spectre.Console open Azure.Storage.Blobs open Azure.Storage.Blobs.Models open System.IO.Compression open Grace.CLI module Connect = type CommonParameters() = inherit ParameterBase() member val public RepositoryId: string = String.Empty with get, set member val public RepositoryName: string = String.Empty with get, set member val public OwnerId: string = String.Empty with get, set member val public OwnerName: string = String.Empty with get, set member val public OrganizationId: string = String.Empty with get, set member val public OrganizationName: string = String.Empty with get, set member val public RetrieveDefaultBranch: bool = true with get, set module private Options = let repositoryId = new Option( OptionName.RepositoryId, [| "-r" |], Required = false, Description = "The repository's ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> RepositoryId.Empty) ) let repositoryName = new Option( OptionName.RepositoryName, [| "-n" |], Required = false, Description = "The name of the repository.", Arity = ArgumentArity.ExactlyOne ) let ownerId = new Option( OptionName.OwnerId, Required = false, Description = "The repository's owner ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> OwnerId.Empty) ) let ownerName = new Option(OptionName.OwnerName, Required = false, Description = "The repository's owner name.", Arity = ArgumentArity.ExactlyOne) let organizationId = new Option( OptionName.OrganizationId, Required = false, Description = "The repository's organization ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> OrganizationId.Empty) ) let organizationName = new Option( OptionName.OrganizationName, Required = false, Description = "The repository's organization name.", Arity = ArgumentArity.ZeroOrOne ) let correlationId = new Option( OptionName.CorrelationId, [| "-c" |], Required = false, Description = "CorrelationId to track this command throughout Grace. [default: new Guid]", Arity = ArgumentArity.ExactlyOne ) let serverAddress = new Option( OptionName.ServerAddress, [| "-s" |], Required = false, Description = "Address of the Grace server to connect to.", Arity = ArgumentArity.ExactlyOne ) let branchId = new Option( OptionName.BranchId, [| "-i" |], Required = false, Description = "The branch ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> BranchId.Empty) ) let branchName = new Option(OptionName.BranchName, [| "-b" |], Required = false, Description = "The name of the branch.", Arity = ArgumentArity.ExactlyOne) let referenceType = (new Option(OptionName.ReferenceType, Required = false, Description = "The type of reference.", Arity = ArgumentArity.ExactlyOne)) .AcceptOnlyFromAmong(listCases ()) let referenceId = new Option(OptionName.ReferenceId, [||], Required = false, Description = "The reference ID .", Arity = ArgumentArity.ExactlyOne) let directoryVersionId = new Option( OptionName.DirectoryVersionId, [| "-t" |], Required = false, Description = "The directory version ID .", Arity = ArgumentArity.ExactlyOne ) let force = new Option( OptionName.Force, [| "-f"; "--force" |], Required = false, Description = "Overwrite conflicting files when connecting.", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> false) ) let retrieveDefaultBranch = new Option( OptionName.RetrieveDefaultBranch, [||], Required = false, Description = "True to retrieve the default branch after connecting; false to connect but not download any files.", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> true) ) module private Arguments = let repositoryShortcut = new Argument("repository", Description = "Repository shortcut in the form owner/organization/repository.", Arity = ArgumentArity.ZeroOrOne) type DirectoryVersionSelection = | UseDirectoryVersionId of DirectoryVersionId | UseReferenceId of ReferenceId | UseReferenceType of ReferenceType | UseDefault let private tryGetExplicitValue<'T> (parseResult: ParseResult) (option: Option<'T>) = let result = parseResult.GetResult(option) if isNull result || result.Implicit then None else Some(parseResult.GetValue(option)) let private tryGetExplicitNonEmptyString (parseResult: ParseResult) (option: Option) = match tryGetExplicitValue parseResult option with | Some value when not <| String.IsNullOrWhiteSpace(value) -> Some value | _ -> None type private RepositoryShortcut = { OwnerName: OwnerName; OrganizationName: OrganizationName; RepositoryName: RepositoryName } let private validateGraceName (name: string) (error: IErrorDiscriminatedUnion) (parseResult: ParseResult) = if Constants.GraceNameRegex.IsMatch(name) then Ok name else Error(GraceError.Create (getErrorMessage error) (getCorrelationId parseResult)) let private tryGetRepositoryShortcut (parseResult: ParseResult) = let result = parseResult.GetResult(Arguments.repositoryShortcut) if isNull result || result.Implicit then Ok None else let value = parseResult.GetValue(Arguments.repositoryShortcut) if String.IsNullOrWhiteSpace(value) then Error(GraceError.Create "Repository shortcut must be in the form owner/organization/repository." (getCorrelationId parseResult)) else let parts = value .Trim() .Split('/', StringSplitOptions.RemoveEmptyEntries) if parts.Length <> 3 then Error(GraceError.Create "Repository shortcut must be in the form owner/organization/repository." (getCorrelationId parseResult)) else let ownerName = parts[ 0 ].Trim() let organizationName = parts[ 1 ].Trim() let repositoryName = parts[ 2 ].Trim() match validateGraceName ownerName OwnerError.InvalidOwnerName parseResult with | Error error -> Error error | Ok ownerName -> match validateGraceName organizationName OrganizationError.InvalidOrganizationName parseResult with | Error error -> Error error | Ok organizationName -> match validateGraceName repositoryName RepositoryError.InvalidRepositoryName parseResult with | Error error -> Error error | Ok repositoryName -> Ok(Some { OwnerName = ownerName; OrganizationName = organizationName; RepositoryName = repositoryName }) let private hasExplicitOwner (parseResult: ParseResult) = tryGetExplicitValue parseResult Options.ownerId |> Option.exists (fun ownerId -> ownerId <> Guid.Empty) || (tryGetExplicitNonEmptyString parseResult Options.ownerName |> Option.isSome) let private hasExplicitOrganization (parseResult: ParseResult) = tryGetExplicitValue parseResult Options.organizationId |> Option.exists (fun organizationId -> organizationId <> Guid.Empty) || (tryGetExplicitNonEmptyString parseResult Options.organizationName |> Option.isSome) let private hasExplicitRepository (parseResult: ParseResult) = tryGetExplicitValue parseResult Options.repositoryId |> Option.exists (fun repositoryId -> repositoryId <> Guid.Empty) || (tryGetExplicitNonEmptyString parseResult Options.repositoryName |> Option.isSome) let internal applyRepositoryShortcut (parseResult: ParseResult) (graceIds: GraceIds) = match tryGetRepositoryShortcut parseResult with | Error error -> Error error | Ok None -> Ok graceIds | Ok (Some shortcut) -> if hasExplicitOwner parseResult || hasExplicitOrganization parseResult || hasExplicitRepository parseResult then Error( GraceError.Create "Provide either the repository shortcut or the owner/organization/repository options, not both." (getCorrelationId parseResult) ) else Ok { graceIds with OwnerId = Guid.Empty OwnerIdString = String.Empty OwnerName = shortcut.OwnerName OrganizationId = Guid.Empty OrganizationIdString = String.Empty OrganizationName = shortcut.OrganizationName RepositoryId = Guid.Empty RepositoryIdString = String.Empty RepositoryName = shortcut.RepositoryName HasOwner = true HasOrganization = true HasRepository = true } let internal getDirectoryVersionSelection (parseResult: ParseResult) = match tryGetExplicitValue parseResult Options.directoryVersionId with | Some directoryVersionId when directoryVersionId <> Guid.Empty -> UseDirectoryVersionId directoryVersionId | _ -> match tryGetExplicitValue parseResult Options.referenceId with | Some referenceId when referenceId <> Guid.Empty -> UseReferenceId referenceId | _ -> match tryGetExplicitNonEmptyString parseResult Options.referenceType with | Some referenceTypeRaw -> let referenceType = discriminatedUnionFromString( referenceTypeRaw ) .Value UseReferenceType referenceType | None -> UseDefault let internal tryGetDirectoryIdFromBranch (referenceType: ReferenceType) (branchDto: BranchDto) = match referenceType with | ReferenceType.Promotion when branchDto.LatestPromotion.DirectoryId <> Guid.Empty -> Some branchDto.LatestPromotion.DirectoryId | ReferenceType.Commit when branchDto.LatestCommit.DirectoryId <> Guid.Empty -> Some branchDto.LatestCommit.DirectoryId | ReferenceType.Checkpoint when branchDto.LatestCheckpoint.DirectoryId <> Guid.Empty -> Some branchDto.LatestCheckpoint.DirectoryId | ReferenceType.Save when branchDto.LatestSave.DirectoryId <> Guid.Empty -> Some branchDto.LatestSave.DirectoryId | _ -> None let internal resolveDefaultDirectoryVersionId (branchDto: BranchDto) = if branchDto.LatestPromotion.DirectoryId <> Guid.Empty then Some branchDto.LatestPromotion.DirectoryId elif branchDto.BasedOn.DirectoryId <> Guid.Empty then Some branchDto.BasedOn.DirectoryId else None let private selectLatestReference (references: ReferenceDto seq) = references |> Seq.sortByDescending (fun reference -> reference.UpdatedAt |> Option.defaultValue reference.CreatedAt) |> Seq.tryHead let private resolveDirectoryVersionIdFromReferenceType (graceIds: GraceIds) (ownerDto: OwnerDto) (organizationDto: OrganizationDto) (repositoryDto: RepositoryDto) (branchDto: BranchDto) (referenceType: ReferenceType) = task { match tryGetDirectoryIdFromBranch referenceType branchDto with | Some directoryId -> return Ok directoryId | None -> let getReferencesParameters = Parameters.Branch.GetReferencesParameters( OwnerId = $"{ownerDto.OwnerId}", OwnerName = ownerDto.OwnerName, OrganizationId = $"{organizationDto.OrganizationId}", OrganizationName = organizationDto.OrganizationName, RepositoryId = $"{repositoryDto.RepositoryId}", RepositoryName = repositoryDto.RepositoryName, BranchId = $"{branchDto.BranchId}", BranchName = branchDto.BranchName, MaxCount = 50, CorrelationId = graceIds.CorrelationId ) let referencesTask = match referenceType with | ReferenceType.Tag -> Branch.GetTags(getReferencesParameters) | ReferenceType.External -> Branch.GetExternals(getReferencesParameters) | ReferenceType.Rebase -> Branch.GetRebases(getReferencesParameters) | _ -> Task.FromResult(Ok(GraceReturnValue.Create [||] graceIds.CorrelationId)) let! referencesResult = referencesTask match referencesResult with | Ok returnValue -> match selectLatestReference returnValue.ReturnValue with | Some reference -> return Ok reference.DirectoryId | None -> return Error(GraceError.Create $"No {referenceType} references were found for branch {branchDto.BranchName}." graceIds.CorrelationId) | Error error -> return Error error } let private resolveTargetDirectoryVersionId (parseResult: ParseResult) (graceIds: GraceIds) (ownerDto: OwnerDto) (organizationDto: OrganizationDto) (repositoryDto: RepositoryDto) (branchDto: BranchDto) = task { match getDirectoryVersionSelection parseResult with | UseDirectoryVersionId directoryVersionId -> return Ok directoryVersionId | UseReferenceId referenceId -> let getReferenceParameters = Parameters.Branch.GetReferenceParameters( OwnerId = $"{ownerDto.OwnerId}", OwnerName = ownerDto.OwnerName, OrganizationId = $"{organizationDto.OrganizationId}", OrganizationName = organizationDto.OrganizationName, RepositoryId = $"{repositoryDto.RepositoryId}", RepositoryName = repositoryDto.RepositoryName, BranchId = $"{branchDto.BranchId}", BranchName = branchDto.BranchName, ReferenceId = $"{referenceId}", CorrelationId = graceIds.CorrelationId ) let! referenceResult = Branch.GetReference(getReferenceParameters) return match referenceResult with | Ok returnValue -> Ok returnValue.ReturnValue.DirectoryId | Error error -> Error error | UseReferenceType referenceType -> return! resolveDirectoryVersionIdFromReferenceType graceIds ownerDto organizationDto repositoryDto branchDto referenceType | UseDefault -> match resolveDefaultDirectoryVersionId branchDto with | Some directoryVersionId -> return Ok directoryVersionId | None -> return Error(GraceError.Create "No downloadable version found for this branch." graceIds.CorrelationId) } let private collectFileConflicts (fileVersions: FileVersion array) (force: bool) = let conflicts = ResizeArray() let filesToSkip = HashSet() let rec loop index = task { if index >= fileVersions.Length then return conflicts, filesToSkip else let fileVersion = fileVersions[index] let filePath = Path.Combine(Current().RootDirectory, fileVersion.RelativePath) if File.Exists(filePath) then try use stream = File.OpenRead(filePath) let! localHash = Grace.Shared.Services.computeSha256ForFile stream fileVersion.RelativePath if localHash = fileVersion.Sha256Hash then filesToSkip.Add(fileVersion.RelativePath) |> ignore elif not force then conflicts.Add(fileVersion.RelativePath) with | _ -> if not force then conflicts.Add(fileVersion.RelativePath) return! loop (index + 1) } loop 0 let private ensureConfigurationFileExists () = if not <| configurationFileExists () then let graceDirPath = Path.Combine(Environment.CurrentDirectory, Constants.GraceConfigDirectory) let graceConfigPath = Path.Combine(graceDirPath, Constants.GraceConfigFileName) Directory.CreateDirectory(graceDirPath) |> ignore if not <| File.Exists(graceConfigPath) then GraceConfiguration() |> saveConfigFile graceConfigPath let private reloadConfiguration () = resetConfiguration () Current() |> ignore let private applyServerAddressOverride (parseResult: ParseResult) = match tryGetExplicitNonEmptyString parseResult Options.serverAddress with | Some serverAddress -> let newConfig = Current() newConfig.ServerUri <- serverAddress updateConfiguration newConfig reloadConfiguration () | None -> () let private validateRequiredIds (parseResult: ParseResult) (graceIds: GraceIds) = let correlationId = getCorrelationId parseResult let ownerValid = graceIds.OwnerId <> Guid.Empty || not <| String.IsNullOrWhiteSpace(graceIds.OwnerName) let organizationValid = graceIds.OrganizationId <> Guid.Empty || not <| String.IsNullOrWhiteSpace(graceIds.OrganizationName) let repositoryValid = graceIds.RepositoryId <> Guid.Empty || not <| String.IsNullOrWhiteSpace(graceIds.RepositoryName) if not ownerValid then Error(GraceError.Create (getErrorMessage OwnerError.EitherOwnerIdOrOwnerNameRequired) correlationId) elif not organizationValid then Error(GraceError.Create (getErrorMessage OrganizationError.EitherOrganizationIdOrOrganizationNameRequired) correlationId) elif not repositoryValid then Error(GraceError.Create (getErrorMessage RepositoryError.EitherRepositoryIdOrRepositoryNameRequired) correlationId) else Ok() let private getOwnerOrganizationRepository (graceIds: GraceIds) = task { let ownerParameters = Parameters.Owner.GetOwnerParameters(OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, CorrelationId = graceIds.CorrelationId) let! ownerResult = Grace.SDK.Owner.Get(ownerParameters) let organizationParameters = Parameters.Organization.GetOrganizationParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, CorrelationId = graceIds.CorrelationId ) let! organizationResult = Organization.Get(organizationParameters) let repositoryParameters = Parameters.Repository.GetRepositoryParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let! repositoryResult = Repository.Get(repositoryParameters) match (ownerResult, organizationResult, repositoryResult) with | (Ok owner, Ok organization, Ok repository) -> return Ok(owner.ReturnValue, organization.ReturnValue, repository.ReturnValue) | (Error error, _, _) -> return Error error | (_, Error error, _) -> return Error error | (_, _, Error error) -> return Error error } let private getBranchForConnect (parseResult: ParseResult) (graceIds: GraceIds) (ownerDto: OwnerDto) (organizationDto: OrganizationDto) (repositoryDto: RepositoryDto) = task { let branchId = tryGetExplicitValue parseResult Options.branchId |> Option.filter (fun value -> value <> Guid.Empty) let branchName = tryGetExplicitNonEmptyString parseResult Options.branchName let branchParameters = match branchId, branchName with | Some id, _ -> Parameters.Branch.GetBranchParameters( OwnerId = $"{ownerDto.OwnerId}", OrganizationId = $"{organizationDto.OrganizationId}", RepositoryId = $"{repositoryDto.RepositoryId}", BranchId = $"{id}", CorrelationId = graceIds.CorrelationId ) | None, Some name -> Parameters.Branch.GetBranchParameters( OwnerId = $"{ownerDto.OwnerId}", OrganizationId = $"{organizationDto.OrganizationId}", RepositoryId = $"{repositoryDto.RepositoryId}", BranchName = name, CorrelationId = graceIds.CorrelationId ) | None, None -> Parameters.Branch.GetBranchParameters( OwnerId = $"{ownerDto.OwnerId}", OrganizationId = $"{organizationDto.OrganizationId}", RepositoryId = $"{repositoryDto.RepositoryId}", BranchName = $"{repositoryDto.DefaultBranchName}", CorrelationId = graceIds.CorrelationId ) let! branchResult = Branch.Get(branchParameters) return match branchResult with | Ok graceReturnValue -> Ok graceReturnValue.ReturnValue | Error error -> Error error } let private buildFileVersionsByRelativePath (fileVersions: FileVersion array) = let lookup = Dictionary(fileVersions.Length, StringComparer.OrdinalIgnoreCase) fileVersions |> Seq.iter (fun fileVersion -> lookup[normalizeFilePath fileVersion.RelativePath] <- fileVersion) lookup let private extractZipEntries (parseResult: ParseResult) (fileVersionsByRelativePath: Dictionary) (filesToSkip: HashSet) (zipFile: Stream) = use zipFile = zipFile use zipArchive = new ZipArchive(zipFile, ZipArchiveMode.Read) AnsiConsole.MarkupLine $"[{Colors.Important}]Streaming contents from .zip file.[/]" AnsiConsole.MarkupLine $"[{Colors.Important}]Starting to write files to disk.[/]" let additionalEntries = ResizeArray() zipArchive.Entries |> Seq.iter (fun entry -> if not <| String.IsNullOrEmpty(entry.Name) then let entryRelativePath = normalizeFilePath entry.FullName match fileVersionsByRelativePath.TryGetValue(entryRelativePath) with | true, fileVersion -> let objectFileName = if String.IsNullOrWhiteSpace(entry.Comment) then fileVersion.GetObjectFileName else entry.Comment let fileInfo = FileInfo(Path.Combine(Current().RootDirectory, fileVersion.RelativePath)) let objectFileInfo = FileInfo(Path.Combine(Current().ObjectDirectory, fileVersion.RelativePath, objectFileName)) Directory.CreateDirectory(fileInfo.DirectoryName) |> ignore Directory.CreateDirectory(objectFileInfo.DirectoryName) |> ignore let writeWorkingFile = not <| filesToSkip.Contains(fileVersion.RelativePath) let writeObjectFile = not objectFileInfo.Exists if fileVersion.IsBinary then if writeWorkingFile then entry.ExtractToFile(fileInfo.FullName, true) if writeObjectFile then entry.ExtractToFile(objectFileInfo.FullName, true) else let uncompressAndWriteToFile (zipEntry: ZipArchiveEntry) (fileInfo: FileInfo) = use entryStream = zipEntry.Open() use fileStream = fileInfo.Create() use gzipStream = new GZipStream(entryStream, CompressionMode.Decompress) gzipStream.CopyTo(fileStream) if writeWorkingFile then uncompressAndWriteToFile entry fileInfo if writeObjectFile then uncompressAndWriteToFile entry objectFileInfo if parseResult |> verbose then AnsiConsole.MarkupLine $"[{Colors.Important}]Wrote {fileVersion.RelativePath}.[/]" | false, _ -> additionalEntries.Add(entry.FullName)) if additionalEntries.Count > 0 && (parseResult |> verbose) then AnsiConsole.MarkupLine $"[{Colors.Deemphasized}]Zip contained {additionalEntries.Count} additional entry(ies). Ignored.[/]" AnsiConsole.MarkupLine $"[{Colors.Important}]Finished writing files to disk.[/]" let private retrieveDefaultBranchAndWrite (parseResult: ParseResult) (graceIds: GraceIds) (ownerDto: OwnerDto) (organizationDto: OrganizationDto) (repositoryDto: RepositoryDto) (branchDto: BranchDto) = task { let! directoryVersionResult = resolveTargetDirectoryVersionId parseResult graceIds ownerDto organizationDto repositoryDto branchDto match directoryVersionResult with | Error error -> return (Error error |> renderOutput parseResult) | Ok directoryVersionId -> let getDirectoryContentsParameters = Parameters.DirectoryVersion.GetParameters( OwnerId = $"{ownerDto.OwnerId}", OrganizationId = $"{organizationDto.OrganizationId}", RepositoryId = $"{repositoryDto.RepositoryId}", DirectoryVersionId = $"{directoryVersionId}", CorrelationId = graceIds.CorrelationId ) AnsiConsole.MarkupLine $"[{Colors.Important}]Retrieving all DirectoryVersions.[/]" let! directoryVersionsResult = DirectoryVersion.GetDirectoryVersionsRecursive(getDirectoryContentsParameters) let getZipFileParameters = Parameters.DirectoryVersion.GetZipFileParameters( OwnerId = $"{ownerDto.OwnerId}", OrganizationId = $"{organizationDto.OrganizationId}", RepositoryId = $"{repositoryDto.RepositoryId}", DirectoryVersionId = $"{directoryVersionId}", CorrelationId = graceIds.CorrelationId ) AnsiConsole.MarkupLine $"[{Colors.Important}]Retrieving zip file download uri.[/]" let! getZipFileResult = DirectoryVersion.GetZipFile(getZipFileParameters) AnsiConsole.MarkupLine $"[{Colors.Important}]Finished getting zip file download uri.[/]" match (directoryVersionsResult, getZipFileResult) with | (Ok directoryVerionsReturnValue, Ok getZipFileReturnValue) -> AnsiConsole.MarkupLine $"[{Colors.Important}]Retrieved all DirectoryVersions.[/]" let directoryVersionDtos = directoryVerionsReturnValue.ReturnValue let fileVersions = directoryVersionDtos |> Seq.map (fun directoryVersionDto -> directoryVersionDto.DirectoryVersion) |> Seq.collect (fun dv -> dv.Files) |> Seq.toArray let force = parseResult.GetValue(Options.force) let! conflicts, filesToSkip = collectFileConflicts fileVersions force if conflicts.Count > 0 then AnsiConsole.MarkupLine $"[{Colors.Error}]Found {conflicts.Count} conflicting file(s). Use --force to overwrite.[/]" if parseResult |> verbose then conflicts |> Seq.sort |> Seq.iter (fun conflict -> AnsiConsole.MarkupLine $"[{Colors.Error}]{conflict}[/]") return (Error(GraceError.Create "Conflicting files exist in the working directory." graceIds.CorrelationId) |> renderOutput parseResult) else let fileVersionsByRelativePath = buildFileVersionsByRelativePath fileVersions let uriWithSharedAccessSignature = getZipFileReturnValue.ReturnValue // Download the .zip file to temp directory. let blobClient = BlobClient(uriWithSharedAccessSignature) let! zipFile = blobClient.OpenReadAsync(bufferSize = 64 * 1024) extractZipEntries parseResult fileVersionsByRelativePath filesToSkip zipFile AnsiConsole.MarkupLine $"[{Colors.Important}]Creating Grace Index file.[/]" let! previousGraceStatus = readGraceStatusFile () let! graceStatus = createNewGraceStatusFile previousGraceStatus parseResult do! writeGraceStatusFile graceStatus AnsiConsole.MarkupLine $"[{Colors.Important}]Creating Grace Object Cache Index file.[/]" do! upsertObjectCache graceStatus.Index.Values return 0 | (Error error, _) -> return (Error error |> renderOutput parseResult) | (_, Error error) -> return (Error error |> renderOutput parseResult) } let private connectImpl (parseResult: ParseResult) : Task = task { if parseResult |> verbose then printParseResult parseResult ensureConfigurationFileExists () reloadConfiguration () applyServerAddressOverride parseResult let validateIncomingParameters = Validations.CommonValidations parseResult match validateIncomingParameters with | Error error -> return (Error error |> renderOutput parseResult) | Ok _ -> let graceIds = getNormalizedIdsAndNames parseResult match applyRepositoryShortcut parseResult graceIds with | Error error -> return (Error error |> renderOutput parseResult) | Ok graceIds -> match validateRequiredIds parseResult graceIds with | Error error -> return (Error error |> renderOutput parseResult) | Ok () -> do! Auth.ensureAccessToken parseResult let! ownerOrgRepoResult = getOwnerOrganizationRepository graceIds match ownerOrgRepoResult with | Ok (ownerDto, organizationDto, repositoryDto) -> AnsiConsole.MarkupLine $"[{Colors.Important}]Found owner, organization, and repository.[/]" let! branchResult = getBranchForConnect parseResult graceIds ownerDto organizationDto repositoryDto match branchResult with | Ok branchDto -> AnsiConsole.MarkupLine $"[{Colors.Important}]Retrieved branch {branchDto.BranchName}.[/]" // Write the new configuration to the config file. let newConfig = Current() newConfig.OwnerId <- ownerDto.OwnerId newConfig.OwnerName <- ownerDto.OwnerName newConfig.OrganizationId <- organizationDto.OrganizationId newConfig.OrganizationName <- organizationDto.OrganizationName newConfig.RepositoryId <- repositoryDto.RepositoryId newConfig.RepositoryName <- repositoryDto.RepositoryName newConfig.BranchId <- branchDto.BranchId newConfig.BranchName <- branchDto.BranchName newConfig.DefaultBranchName <- repositoryDto.DefaultBranchName newConfig.ObjectStorageProvider <- repositoryDto.ObjectStorageProvider updateConfiguration newConfig reloadConfiguration () AnsiConsole.MarkupLine $"[{Colors.Important}]Wrote new Grace configuration file.[/]" let retrieveDefaultBranch = parseResult.GetValue(Options.retrieveDefaultBranch) if retrieveDefaultBranch then return! retrieveDefaultBranchAndWrite parseResult graceIds ownerDto organizationDto repositoryDto branchDto else return 0 | Error error -> return (Error error |> renderOutput parseResult) | Error error -> return (Error error |> renderOutput parseResult) } type Connect() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: Threading.CancellationToken) : Task = task { try return! connectImpl parseResult with | :? OperationCanceledException -> return -1 | ex -> let error = GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult) return (Error error |> renderOutput parseResult) } let Build = // Create main command and aliases, if any. let connectCommand = new Command("connect", Description = "Connect to a Grace repository.") connectCommand.Arguments.Add(Arguments.repositoryShortcut) connectCommand.Options.Add(Options.repositoryId) connectCommand.Options.Add(Options.repositoryName) connectCommand.Options.Add(Options.ownerId) connectCommand.Options.Add(Options.ownerName) connectCommand.Options.Add(Options.organizationId) connectCommand.Options.Add(Options.organizationName) connectCommand.Options.Add(Options.branchId) connectCommand.Options.Add(Options.branchName) connectCommand.Options.Add(Options.referenceType) connectCommand.Options.Add(Options.referenceId) connectCommand.Options.Add(Options.directoryVersionId) connectCommand.Options.Add(Options.correlationId) connectCommand.Options.Add(Options.serverAddress) connectCommand.Options.Add(Options.retrieveDefaultBranch) connectCommand.Options.Add(Options.force) connectCommand.Action <- Connect() connectCommand ================================================ FILE: src/Grace.CLI/Command/Diff.CLI.fs ================================================ namespace Grace.CLI.Command open DiffPlex open DiffPlex.DiffBuilder.Model open FSharpPlus open Grace.CLI.Common open Grace.CLI.Services open Grace.CLI.Text open Grace.SDK open Grace.Shared open Grace.Shared.Client.Configuration open Grace.Shared.Parameters.Branch open Grace.Shared.Parameters.Diff open Grace.Shared.Parameters.DirectoryVersion open Grace.Shared.Utilities open Grace.Types.Branch open Grace.Types.Diff open Grace.Types.Reference open Grace.Types.Types open Grace.Shared.Validation.Errors open Spectre.Console open System open System.Collections.Concurrent open System.Collections.Generic open System.CommandLine open System.CommandLine.Invocation open System.CommandLine.Parsing open System.Linq open System.IO open System.Text.Json open System.Threading open System.Threading.Tasks open Spectre.Console open Spectre.Console open Spectre.Console.Rendering open Grace.Shared.Parameters.Storage module Diff = module private Options = let ownerId = new Option( OptionName.OwnerId, Required = false, Description = "The repository's owner ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OwnerId.Empty) ) let ownerName = new Option( OptionName.OwnerName, Required = false, Description = "The repository's owner name. [default: current owner]", Arity = ArgumentArity.ExactlyOne ) let organizationId = new Option( OptionName.OrganizationId, Required = false, Description = "The repository's organization ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OrganizationId.Empty) ) let organizationName = new Option( OptionName.OrganizationName, Required = false, Description = "The repository's organization name. [default: current organization]", Arity = ArgumentArity.ZeroOrOne ) let repositoryId = new Option( OptionName.RepositoryId, [| "-r" |], Required = false, Description = "The repository's Id .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> RepositoryId.Empty) ) let repositoryName = new Option( OptionName.RepositoryName, [| "-n" |], Required = false, Description = "The name of the repository. [default: current repository]", Arity = ArgumentArity.ExactlyOne ) let branchId = new Option( OptionName.BranchId, [| "-i" |], Required = false, Description = "The branch's ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> BranchId.Empty) ) let branchName = new Option( OptionName.BranchName, [| "-b" |], Required = false, Description = "The name of the branch. [default: current branch]", Arity = ArgumentArity.ExactlyOne ) let directoryVersionId1 = new Option( OptionName.DirectoryVersionId1, [| OptionName.D1 |], Required = true, Description = "The first DirectoryId to compare in the diff.", Arity = ArgumentArity.ExactlyOne ) let directoryVersionId2 = new Option( OptionName.DirectoryVersionId2, [| OptionName.D2 |], Required = false, Description = "The second DirectoryId to compare in the diff.", Arity = ArgumentArity.ExactlyOne ) let sha256Hash1 = new Option( OptionName.Sha256Hash1, [| OptionName.S1 |], Required = true, Description = "The first partial or full SHA-256 hash to compare in the diff.", Arity = ArgumentArity.ExactlyOne ) let sha256Hash2 = new Option( OptionName.Sha256Hash2, [| OptionName.S2 |], Required = false, Description = "The second partial or full SHA-256 hash to compare in the diff.", Arity = ArgumentArity.ExactlyOne ) let tag = new Option(OptionName.Tag, Required = true, Description = "The tag to compare the current version to.", Arity = ArgumentArity.ExactlyOne) let private sha256Validations parseResult = let graceIds = getNormalizedIdsAndNames parseResult let ``Sha256Hash1 must be a valid SHA-256 hash value`` (parseResult: ParseResult) = if parseResult.GetResult(Options.sha256Hash1) <> null && not <| Constants.Sha256Regex.IsMatch(parseResult.GetValue(Options.sha256Hash1)) then let properties = Dictionary() properties.Add("repositoryId", graceIds.RepositoryId) properties.Add("sha256Hash1", parseResult.GetValue(Options.sha256Hash1)) properties.Add("sha256Hash2", parseResult.GetValue(Options.sha256Hash2)) Error(GraceError.CreateWithMetadata null (getErrorMessage DiffError.InvalidSha256Hash) (graceIds.CorrelationId) properties) else Ok parseResult let ``Sha256Hash2 must be a valid SHA-256 hash value`` (parseResult: ParseResult) = if parseResult.GetResult(Options.sha256Hash2) <> null && not <| Constants.Sha256Regex.IsMatch(parseResult.GetValue(Options.sha256Hash2)) then let properties = Dictionary() properties.Add("repositoryId", graceIds.RepositoryId) properties.Add("sha256Hash1", parseResult.GetValue(Options.sha256Hash1)) properties.Add("sha256Hash2", parseResult.GetValue(Options.sha256Hash2)) Error(GraceError.Create (getErrorMessage DiffError.InvalidSha256Hash) (graceIds.CorrelationId)) else Ok parseResult parseResult |> ``Sha256Hash1 must be a valid SHA-256 hash value`` >>= ``Sha256Hash2 must be a valid SHA-256 hash value`` let private renderLine (diffLine: DiffPiece) = if not <| diffLine.Position.HasValue then $" {diffLine.Text.EscapeMarkup()}" else $"{diffLine.Position, 6:D}: {diffLine.Text.EscapeMarkup()}" let private getMarkup (diffLine: DiffPiece) = match diffLine.Type with | ChangeType.Deleted -> Markup($"[{Colors.Deleted}]-{renderLine diffLine}[/]") | ChangeType.Inserted -> Markup($"[{Colors.Added}]+{renderLine diffLine}[/]") | ChangeType.Modified -> Markup($"[{Colors.Changed}]~{renderLine diffLine}[/]") | ChangeType.Imaginary -> Markup($"[{Colors.Deemphasized}] {renderLine diffLine}[/]") | ChangeType.Unchanged -> Markup($"[{Colors.Important}] {renderLine diffLine}[/]") | _ -> Markup($"[{Colors.Important}] {diffLine.Text}[/]") let markupList = List() let addToOutput (markup: IRenderable) = markupList.Add markup let renderInlineDiff (inlineDiff: List) = for i = 0 to inlineDiff.Count - 1 do for diffLine in inlineDiff[i] do addToOutput (getMarkup diffLine) if not <| (i = inlineDiff.Count - 1) then addToOutput (Markup($"[{Colors.Deemphasized}]-------[/]")) else addToOutput (Markup(String.Empty)) let printDiffResults (diffDto: DiffDto) = if diffDto.HasDifferences then addToOutput (Markup($"[{Colors.Important}]Differences found.[/]")) for diff in diffDto.Differences do match diff.FileSystemEntryType with | FileSystemEntryType.File -> addToOutput ( Markup($"[{Colors.Important}]{getDiscriminatedUnionCaseName diff.DifferenceType}[/] [{Colors.Highlighted}]{diff.RelativePath}[/]") ) | FileSystemEntryType.Directory -> if diff.DifferenceType <> DifferenceType.Change then addToOutput ( Markup($"[{Colors.Important}]{getDiscriminatedUnionCaseName diff.DifferenceType}[/] [{Colors.Highlighted}]{diff.RelativePath}[/]") ) for fileDiff in diffDto.FileDiffs.OrderBy(fun fileDiff -> fileDiff.RelativePath) do //addToOutput ((new Rule($"[{Colors.Important}]{fileDiff.RelativePath}[/]")).LeftAligned()) if fileDiff.CreatedAt1 > fileDiff.CreatedAt2 then addToOutput ( (new Rule( $"[{Colors.Important}]{fileDiff.RelativePath} | {getShortSha256Hash fileDiff.FileSha1} - {fileDiff.CreatedAt1 |> ago} | {getShortSha256Hash fileDiff.FileSha2} - {fileDiff.CreatedAt2 |> ago}[/]" )) .LeftJustified() ) else addToOutput ( (new Rule( $"[{Colors.Important}]{fileDiff.RelativePath} | {getShortSha256Hash fileDiff.FileSha2} - {fileDiff.CreatedAt2 |> ago} | {getShortSha256Hash fileDiff.FileSha1} - {fileDiff.CreatedAt1 |> ago}[/]" )) .LeftJustified() ) if fileDiff.IsBinary then addToOutput (Markup($"[{Colors.Important}]Binary file.[/]")) else renderInlineDiff fileDiff.InlineDiff else addToOutput (Markup($"[{Colors.Highlighted}]No differences found.[/]")) /// Creates the text output for a diff to the most recent specific ReferenceType. type GetDiffByReferenceTypeParameters() = member val public BranchId = String.Empty with get, set member val public BranchName = BranchName String.Empty with get, set let private diffToReferenceType (parseResult: ParseResult) (referenceType: ReferenceType) = task { if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = parseResult |> Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let graceIds = getNormalizedIdsAndNames parseResult if parseResult |> hasOutput then do! progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Reading Grace index file.[/]") let t1 = progressContext.AddTask($"[{Color.DodgerBlue1}]Scanning working directory for changes.[/]", autoStart = false) let t2 = progressContext.AddTask($"[{Color.DodgerBlue1}]Creating new directory verions.[/]", autoStart = false) let t3 = progressContext.AddTask($"[{Color.DodgerBlue1}]Uploading changed files to object storage.[/]", autoStart = false) let t4 = progressContext.AddTask($"[{Color.DodgerBlue1}]Uploading new directory versions.[/]", autoStart = false) let t5 = progressContext.AddTask($"[{Color.DodgerBlue1}]Creating a save reference.[/]", autoStart = false) let t6 = progressContext.AddTask( $"[{Color.DodgerBlue1}]Getting {(getDiscriminatedUnionCaseName referenceType) .ToLowerInvariant()}.[/]", autoStart = false ) let t7 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending diff request to server.[/]", autoStart = false) let mutable rootDirectoryId = DirectoryVersionId.Empty let mutable rootDirectorySha256Hash = Sha256Hash String.Empty let mutable previousDirectoryIds: HashSet = null // Check for latest commit and latest root directory version from grace watch. If it's running, we know GraceStatus is up-to-date. match! getGraceWatchStatus () with | Some graceWatchStatus -> t0.Value <- 100.0 t1.Value <- 100.0 t2.Value <- 100.0 t3.Value <- 100.0 t4.Value <- 100.0 t5.Value <- 100.0 rootDirectoryId <- graceWatchStatus.RootDirectoryId rootDirectorySha256Hash <- graceWatchStatus.RootDirectorySha256Hash previousDirectoryIds <- graceWatchStatus.DirectoryIds | None -> let! previousGraceStatus = readGraceStatusFile () t0.Value <- 100.0 t1.StartTask() let! differences = scanForDifferences previousGraceStatus let! newFileVersions = copyUpdatedFilesToObjectCache t1 differences t1.Value <- 100.0 t2.StartTask() let! (updatedGraceStatus, newDirectoryVersions) = getNewGraceStatusAndDirectoryVersions previousGraceStatus differences do! applyGraceStatusIncremental updatedGraceStatus newDirectoryVersions differences rootDirectoryId <- updatedGraceStatus.RootDirectoryId rootDirectorySha256Hash <- updatedGraceStatus.RootDirectorySha256Hash previousDirectoryIds <- updatedGraceStatus.Index.Keys.ToHashSet() t2.Value <- 100.0 t3.StartTask() let getUploadMetadataForFilesParameters = GetUploadMetadataForFilesParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = getCorrelationId parseResult, FileVersions = (newFileVersions |> Seq.map (fun localFileVersion -> localFileVersion.ToFileVersion) |> Seq.toArray) ) match! uploadFilesToObjectStorage getUploadMetadataForFilesParameters with | Ok returnValue -> () | Error error -> logToAnsiConsole Colors.Error $"Failed to upload changed files to object storage. {error}" t3.Value <- 100.0 t4.StartTask() if (newDirectoryVersions.Count > 0) then (task { let saveParameters = SaveDirectoryVersionsParameters() saveParameters.OwnerId <- graceIds.OwnerIdString saveParameters.OwnerName <- graceIds.OwnerName saveParameters.OrganizationId <- graceIds.OrganizationIdString saveParameters.OrganizationName <- graceIds.OrganizationName saveParameters.RepositoryId <- graceIds.RepositoryIdString saveParameters.RepositoryName <- graceIds.RepositoryName saveParameters.CorrelationId <- getCorrelationId parseResult saveParameters.DirectoryVersions <- newDirectoryVersions .Select(fun dv -> dv.ToDirectoryVersion) .ToList() match! DirectoryVersion.SaveDirectoryVersions saveParameters with | Ok returnValue -> () | Error error -> logToAnsiConsole Colors.Error $"Failed to upload new directory versions. {error}" }) .Wait() t4.Value <- 100.0 t5.StartTask() if newDirectoryVersions.Count > 0 then (task { match! createSaveReference (getRootDirectoryVersion updatedGraceStatus) $"Created during `grace diff {(getDiscriminatedUnionCaseName referenceType) .ToLowerInvariant()}` operation." (getCorrelationId parseResult) with | Ok saveReference -> () | Error error -> logToAnsiConsole Colors.Error $"Failed to create a save reference. {error}" }) .Wait() t5.Value <- 100.0 // Check for latest reference of the given type from the server. t6.StartTask() let getReferencesParameters = GetReferencesParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, MaxCount = 1, CorrelationId = graceIds.CorrelationId ) let! getAReferenceResult = task { match referenceType with | Commit -> return! Branch.GetCommits getReferencesParameters | Checkpoint -> return! Branch.GetCheckpoints getReferencesParameters | Save -> return! Branch.GetSaves getReferencesParameters | Tag -> return! Branch.GetTags getReferencesParameters | External -> return! Branch.GetExternals getReferencesParameters | Rebase -> return! Branch.GetRebases getReferencesParameters // Promotions are different, because we actually want the promotion from the parent branch that this branch is based on. | Promotion -> let promotions = List() let branchParameters = Parameters.Branch.GetBranchParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, CorrelationId = graceIds.CorrelationId ) match! Branch.Get(branchParameters) with | Ok returnValue -> let branchDto = returnValue.ReturnValue promotions.Add(branchDto.BasedOn) | Error error -> logToAnsiConsole Colors.Error (Markup.Escape($"Error in Branch.Get: {error}")) if parseResult |> json || parseResult |> verbose then logToAnsiConsole Colors.Verbose (serialize error) return Ok(GraceReturnValue.Create (promotions.ToArray()) graceIds.CorrelationId) } let latestReference = match getAReferenceResult with | Ok returnValue -> // There should only be one reference, because we're using MaxCount = 1. let references = returnValue.ReturnValue if references.Count() > 0 then //logToAnsiConsole Colors.Verbose $"Got latest reference: {references.First().ReferenceText}; {references.First().CreatedAt}; {getShortenedSha256Hash (references.First().Sha256Hash)}; {references.First().DirectoryId}." references.First() else logToAnsiConsole Colors.Error $"Error getting latest reference. No matching references were found." ReferenceDto.Default | Error error -> logToAnsiConsole Colors.Error $"Error getting latest reference: {Markup.Escape(error.Error)}." ReferenceDto.Default t6.Value <- 100.0 // Sending diff request to server. t7.StartTask() //logToAnsiConsole Colors.Verbose $"latestReference.DirectoryId: {latestReference.DirectoryId}; rootDirectoryId: {rootDirectoryId}." let getDiffParameters = GetDiffParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, DirectoryVersionId1 = latestReference.DirectoryId, DirectoryVersionId2 = rootDirectoryId, CorrelationId = graceIds.CorrelationId ) let! getDiffResult = Diff.GetDiff(getDiffParameters) match getDiffResult with | Ok returnValue -> let diffDto = returnValue.ReturnValue printDiffResults diffDto | Error error -> let s = StringExtensions.EscapeMarkup($"{error.Error}") logToAnsiConsole Colors.Error $"Error submitting diff: {s}" if parseResult |> json || parseResult |> verbose then logToAnsiConsole Colors.Verbose (serialize error) t7.Increment(100.0) //AnsiConsole.MarkupLine($"[{Colors.Important}]Differences: {differences.Count}.[/]") //AnsiConsole.MarkupLine($"[{Colors.Error}]{error.Error.EscapeMarkup()}[/]") }) for markup in markupList do writeMarkup markup return 0 else // Do the thing here return 0 | Error error -> return (Error error) |> renderOutput parseResult } type PromotionHandler() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { return! diffToReferenceType parseResult ReferenceType.Promotion } type CommitHandler() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { return! diffToReferenceType parseResult ReferenceType.Commit } type CheckpointHandler() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { return! diffToReferenceType parseResult ReferenceType.Checkpoint } type SaveHandler() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { return! diffToReferenceType parseResult ReferenceType.Save } type TagHandler() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { return! diffToReferenceType parseResult ReferenceType.Tag } type DirectoryIdHandler() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = parseResult |> Validations.CommonValidations match validateIncomingParameters with | Ok _ -> return 0 | Error error -> return (Error error) |> renderOutput parseResult } type ShaHandler() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = parseResult |> Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let graceIds = getNormalizedIdsAndNames parseResult if parseResult |> hasOutput then do! progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Reading Grace index file.[/]") let t1 = progressContext.AddTask($"[{Color.DodgerBlue1}]Scanning working directory for changes.[/]", autoStart = false) let t2 = progressContext.AddTask($"[{Color.DodgerBlue1}]Creating new directory verions.[/]", autoStart = false) let t3 = progressContext.AddTask($"[{Color.DodgerBlue1}]Uploading changed files to object storage.[/]", autoStart = false) let t4 = progressContext.AddTask($"[{Color.DodgerBlue1}]Uploading new directory versions.[/]", autoStart = false) let t5 = progressContext.AddTask($"[{Color.DodgerBlue1}]Creating a save reference.[/]", autoStart = false) let t6 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending diff request to server.[/]", autoStart = false) let mutable rootDirectoryId = DirectoryVersionId.Empty let mutable rootDirectorySha256Hash = Sha256Hash String.Empty let mutable previousDirectoryIds: HashSet = null // Check for latest commit and latest root directory version from grace watch. If it's running, we know GraceStatus is up-to-date. match! getGraceWatchStatus () with | Some graceWatchStatus -> t0.Value <- 100.0 t1.Value <- 100.0 t2.Value <- 100.0 t3.Value <- 100.0 t4.Value <- 100.0 t5.Value <- 100.0 rootDirectoryId <- graceWatchStatus.RootDirectoryId rootDirectorySha256Hash <- graceWatchStatus.RootDirectorySha256Hash previousDirectoryIds <- graceWatchStatus.DirectoryIds | None -> let! previousGraceStatus = readGraceStatusFile () let mutable graceStatus = previousGraceStatus t0.Value <- 100.0 t1.StartTask() let! differences = scanForDifferences previousGraceStatus let! newFileVersions = copyUpdatedFilesToObjectCache t1 differences t1.Value <- 100.0 t2.StartTask() let! (updatedGraceStatus, newDirectoryVersions) = getNewGraceStatusAndDirectoryVersions previousGraceStatus differences do! applyGraceStatusIncremental updatedGraceStatus newDirectoryVersions differences rootDirectoryId <- updatedGraceStatus.RootDirectoryId rootDirectorySha256Hash <- updatedGraceStatus.RootDirectorySha256Hash previousDirectoryIds <- updatedGraceStatus.Index.Keys.ToHashSet() t2.Value <- 100.0 t3.StartTask() let getUploadMetadataForFilesParameters = GetUploadMetadataForFilesParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = getCorrelationId parseResult, FileVersions = (newFileVersions |> Seq.map (fun localFileVersion -> localFileVersion.ToFileVersion) |> Seq.toArray) ) match! uploadFilesToObjectStorage getUploadMetadataForFilesParameters with | Ok returnValue -> () | Error error -> logToAnsiConsole Colors.Error $"Failed to upload changed files to object storage. {error}" t3.Value <- 100.0 t4.StartTask() if (newDirectoryVersions.Count > 0) then (task { let saveParameters = SaveDirectoryVersionsParameters() saveParameters.OwnerId <- graceIds.OwnerIdString saveParameters.OwnerName <- graceIds.OwnerName saveParameters.OrganizationId <- graceIds.OrganizationIdString saveParameters.OrganizationName <- graceIds.OrganizationName saveParameters.RepositoryId <- graceIds.RepositoryIdString saveParameters.RepositoryName <- graceIds.RepositoryName saveParameters.CorrelationId <- getCorrelationId parseResult saveParameters.DirectoryVersions <- newDirectoryVersions .Select(fun dv -> dv.ToDirectoryVersion) .ToList() match! DirectoryVersion.SaveDirectoryVersions saveParameters with | Ok returnValue -> () | Error error -> logToAnsiConsole Colors.Error $"Failed to upload new directory versions. {error}" }) .Wait() t4.Value <- 100.0 t5.StartTask() if newDirectoryVersions.Count > 0 then (task { match! createSaveReference (getRootDirectoryVersion updatedGraceStatus) $"Created during `grace diff sha` operation." (getCorrelationId parseResult) with | Ok saveReference -> () | Error error -> logToAnsiConsole Colors.Error $"Failed to create a save reference. {error}" }) .Wait() t5.Value <- 100.0 // Check for latest reference of the given type from the server. t6.StartTask() let sha256Hash1 = parseResult.GetValue(Options.sha256Hash1) let sha256Hash2 = parseResult.GetValue(Options.sha256Hash2) let getDiffBySha256HashParameters = GetDiffBySha256HashParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, Sha256Hash1 = sha256Hash1, Sha256Hash2 = sha256Hash2, CorrelationId = graceIds.CorrelationId ) match! Diff.GetDiffBySha256Hash(getDiffBySha256HashParameters) with | Ok returnValue -> let diffDto = returnValue.ReturnValue printDiffResults diffDto t6.Value <- 100.0 | Error error -> logToAnsiConsole Colors.Error $"Failed to get diff by sha256 hash. {error}" t6.Value <- 100.0 }) for markup in markupList do writeMarkup markup return 0 else // Do the thing here return 0 | Error error -> return (Error error) |> renderOutput parseResult } let Build = let addCommonOptions (command: Command) = command |> addOption Options.ownerName |> addOption Options.ownerId |> addOption Options.organizationName |> addOption Options.organizationId |> addOption Options.repositoryName |> addOption Options.repositoryId let addBranchOptions (command: Command) = command |> addOption Options.branchName |> addOption Options.branchId let diffCommand = new Command("diff", Description = "Displays the difference between two versions of your repository.") let promotionCommand = new Command("promotion", Description = "Displays the difference between the promotion that this branch is based on and your current version.") |> addCommonOptions |> addBranchOptions promotionCommand.Action <- PromotionHandler() diffCommand.Subcommands.Add(promotionCommand) let commitCommand = new Command("commit", Description = "Displays the difference between the most recent commit and your current version.") |> addCommonOptions |> addBranchOptions commitCommand.Action <- CommitHandler() diffCommand.Subcommands.Add(commitCommand) let checkpointCommand = new Command("checkpoint", Description = "Displays the difference between the most recent checkpoint and your current version.") |> addCommonOptions |> addBranchOptions checkpointCommand.Action <- CheckpointHandler() diffCommand.Subcommands.Add(checkpointCommand) let saveCommand = new Command("save", Description = "Displays the difference between the most recent save and your current version.") |> addCommonOptions |> addBranchOptions saveCommand.Action <- SaveHandler() diffCommand.Subcommands.Add(saveCommand) let tagCommand = new Command("tag", Description = "Displays the difference between the specified tag and your current version.") |> addCommonOptions |> addBranchOptions |> addOption Options.tag tagCommand.Action <- TagHandler() diffCommand.Subcommands.Add(tagCommand) let directoryIdCommand = new Command( "directoryid", Description = "Displays the difference between two versions, specified by DirectoryId. If a second DirectoryId is not supplied, the current branch's root DirectoryId will be used." ) |> addCommonOptions |> addOption Options.directoryVersionId1 |> addOption Options.directoryVersionId2 directoryIdCommand.Action <- DirectoryIdHandler() diffCommand.Subcommands.Add(directoryIdCommand) let shaCommand = new Command( "sha", Description = "Displays the difference between two versions, specified by partial or full SHA-256 hash. If a second SHA-256 value is not supplied, the current branch's root SHA-256 hash will be used." ) |> addCommonOptions |> addOption Options.sha256Hash1 |> addOption Options.sha256Hash2 shaCommand.Action <- ShaHandler() diffCommand.Subcommands.Add(shaCommand) diffCommand ================================================ FILE: src/Grace.CLI/Command/DirectoryVersion.CLI.fs ================================================ namespace Grace.CLI.Command open FSharpPlus open Grace.CLI.Common open Grace.CLI.Common.Validations open Grace.CLI.Services open Grace.CLI.Text open Grace.SDK open Grace.Shared open Grace.Shared.Client.Configuration open Grace.Shared.Client.Theme open Grace.Types.Branch open Grace.Types.Reference open Grace.Shared.Parameters.Branch open Grace.Shared.Parameters.DirectoryVersion open Grace.Shared.Services open Grace.Types.Types open Grace.Shared.Utilities open Grace.Shared.Validation open Grace.Shared.Validation.Errors open NodaTime open NodaTime.TimeZones open Spectre.Console open Spectre.Console.Json open System open System.Collections.Concurrent open System.Collections.Generic open System.CommandLine open System.CommandLine.Invocation open System.CommandLine.Parsing open System.Globalization open System.IO open System.IO.Enumeration open System.Linq open System.Security.Cryptography open System.Threading.Tasks open System.Text open System.Text.Json open Grace.Shared.Parameters.Storage module DirectoryVersion = open Grace.Shared.Validation.Common.Input module private Options = let ownerId = new Option( OptionName.OwnerId, Required = false, Description = "The repository's owner ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OwnerId.Empty) ) let ownerName = new Option( OptionName.OwnerName, Required = false, Description = "The repository's owner name. [default: current owner]", Arity = ArgumentArity.ExactlyOne ) let organizationId = new Option( OptionName.OrganizationId, Required = false, Description = "The repository's organization ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> OrganizationId.Empty) ) let organizationName = new Option( OptionName.OrganizationName, Required = false, Description = "The repository's organization name. [default: current organization]", Arity = ArgumentArity.ZeroOrOne ) let repositoryId = new Option( OptionName.RepositoryId, [| "-r" |], Required = false, Description = "The repository's ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> RepositoryId.Empty) ) let repositoryName = new Option( OptionName.RepositoryName, [| "-n" |], Required = false, Description = "The name of the repository. [default: current repository]", Arity = ArgumentArity.ExactlyOne ) let maxCount = new Option( OptionName.MaxCount, Required = false, Description = "The maximum number of results to return.", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> 30) ) let sha256Hash = new Option( OptionName.Sha256Hash, [||], Required = false, Description = "The full or partial SHA-256 hash value of the version.", Arity = ArgumentArity.ExactlyOne ) let includeDeleted = new Option(OptionName.IncludeDeleted, [| "-d" |], Required = false, Description = "Include deleted branches in the result. [default: false]") let directoryVersionIdRequired = new Option( OptionName.DirectoryVersionId, [| "-v" |], Required = true, Description = "The DirectoryVersionId to act on .", Arity = ArgumentArity.ExactlyOne ) let private DirectoryVersionValidations parseResult = Ok parseResult type GetZipFile() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: Threading.CancellationToken) : Task = task { try if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = parseResult |> CommonValidations >>= DirectoryVersionValidations match validateIncomingParameters with | Ok _ -> let graceIds = getNormalizedIdsAndNames parseResult let directoryVersionId = parseResult.GetValue(Options.directoryVersionIdRequired) let sha256Hash = parseResult.GetValue(Options.sha256Hash) let sdkParameters = Parameters.DirectoryVersion.GetZipFileParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, DirectoryVersionId = $"{directoryVersionId}", Sha256Hash = sha256Hash, CorrelationId = graceIds.CorrelationId ) if parseResult |> hasOutput then let! result = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = DirectoryVersion.GetZipFile(sdkParameters) t0.Increment(100.0) match result with | Ok returnValue -> AnsiConsole.MarkupLine $"[{Colors.Highlighted}]{returnValue.ReturnValue}[/]" | Error error -> logToAnsiConsole Colors.Error $"{error}" return result }) return result |> renderOutput parseResult else let! result = DirectoryVersion.GetZipFile(sdkParameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId)) |> renderOutput parseResult //match result with //| Ok returnValue -> AnsiConsole.MarkupLine $"[{Colors.Highlighted}]{returnValue.ReturnValue}[/]" //| Error error -> logToAnsiConsole Colors.Error $"{error}" //return result |> renderOutput parseResult } let Build = let addCommonOptions (command: Command) = command |> addOption Options.ownerName |> addOption Options.ownerId |> addOption Options.organizationName |> addOption Options.organizationId |> addOption Options.repositoryName |> addOption Options.repositoryId |> addOption Options.directoryVersionIdRequired // Create main command and aliases, if any.` let directoryVersionCommand = new Command("directory-version", Description = "Work with directory versions in a repository.") directoryVersionCommand.Aliases.Add("dv") directoryVersionCommand.Aliases.Add("ver") let getZipFileCommand = new Command("get-zip-file", Description = "Gets the .zip file for a specific directory version.") |> addOption Options.sha256Hash |> addCommonOptions getZipFileCommand.Action <- GetZipFile() directoryVersionCommand.Subcommands.Add(getZipFileCommand) directoryVersionCommand ================================================ FILE: src/Grace.CLI/Command/History.CLI.fs ================================================ namespace Grace.CLI.Command open Grace.CLI open Grace.CLI.Common open Grace.CLI.Text open Grace.Shared open Grace.Shared.Client open Grace.Shared.Utilities open NodaTime open Spectre.Console open System open System.Collections.Generic open System.CommandLine open System.CommandLine.Invocation open System.CommandLine.Parsing open System.Diagnostics open System.IO open System.Linq open System.Threading.Tasks module History = module private Options = let limit = new Option( OptionName.Limit, Required = false, Description = "Maximum number of history entries to show.", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> 50) ) let repo = new Option( OptionName.Repo, Required = false, Description = "Filter to entries whose repoRoot matches the current directory.", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> false) ) let failed = new Option( OptionName.Failed, Required = false, Description = "Show only failed commands.", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> false) ) let success = new Option( OptionName.Success, Required = false, Description = "Show only successful commands.", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> false) ) let since = new Option( OptionName.Since, Required = false, Description = "Only show entries since a duration (e.g. 10m, 24h, 7d).", Arity = ArgumentArity.ExactlyOne ) let contains = new Option( OptionName.Contains, Required = false, Description = "Substring match against the stored command line.", Arity = ArgumentArity.ExactlyOne ) let showId = new Option( OptionName.Id, Required = false, Description = "Show the stable id column.", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> false) ) let runNumber = new Argument("number", Description = "History number (from most recent = 1).", Arity = ArgumentArity.ZeroOrOne) let runId = new Option( OptionName.Id, Required = false, Description = "Run by stable id instead of history number.", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> Guid.Empty) ) let yes = new Option( OptionName.Yes, Required = false, Description = "Skip confirmation prompts.", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> false) ) let useCurrentCwd = new Option( OptionName.UseCurrentCwd, Required = false, Description = "Run from the current working directory instead of the recorded one.", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> false) ) let dryRun = new Option( OptionName.DryRun, Required = false, Description = "Print the resolved command and working directory without executing.", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> false) ) let replace = new Option( OptionName.Replace, Required = false, Description = "Provide replacements for redacted values (name=value or argIndex=value).", Arity = ArgumentArity.OneOrMore ) let private warnOnCorruptConfig (parseResult: ParseResult) (loadResult: UserConfiguration.UserConfigurationLoadResult) = if loadResult.WasCorrupt && not (parseResult |> json) && not (parseResult |> silent) then let message = match loadResult.ErrorMessage with | Some error -> error | None -> "User configuration is invalid; using defaults." AnsiConsole.MarkupLine($"[yellow]{Markup.Escape(message)}[/]") let private formatDuration (durationMs: int64) = $"{(float durationMs) / 1000.0:F3}s" let private abbreviatePath (value: string) = if String.IsNullOrWhiteSpace(value) then String.Empty elif value.Length <= 40 then value else "..." + value.Substring(value.Length - 37) let private formatRepoName (entry: HistoryStorage.HistoryEntry) = match entry.repoName with | Some name -> name | None -> match entry.repoRoot with | Some root -> try let name = DirectoryInfo(root).Name if String.IsNullOrWhiteSpace(name) then String.Empty else name with | _ -> String.Empty | None -> String.Empty let private formatRepoBranch (entry: HistoryStorage.HistoryEntry) = entry.repoBranch |> Option.defaultValue String.Empty let internal filterEntries (entries: HistoryStorage.HistoryEntry list) (limit: int) (filterRepo: bool) (filterFailed: bool) (filterSuccess: bool) (sinceDuration: Duration option) (containsText: string option) (sourceText: string option) = let normalizedContainsText = containsText |> Option.bind (fun text -> if String.IsNullOrWhiteSpace(text) then None else Some(text.Trim())) let normalizedSourceText = sourceText |> Option.bind (fun source -> if String.IsNullOrWhiteSpace(source) then None else Some(source.Trim())) let mutable filtered = entries if filterRepo then match HistoryStorage.tryFindRepoRoot Environment.CurrentDirectory with | Some repoRoot -> let comparer = if runningOnWindows then StringComparer.InvariantCultureIgnoreCase else StringComparer.InvariantCulture filtered <- filtered |> List.filter (fun entry -> match entry.repoRoot with | Some entryRoot -> comparer.Equals(entryRoot, repoRoot) | None -> false) | None -> filtered <- List.empty match sinceDuration with | Some duration -> let cutoff = getCurrentInstant().Minus(duration) filtered <- filtered |> List.filter (fun entry -> entry.timestampUtc >= cutoff) | None -> () match normalizedContainsText with | Some text -> filtered <- filtered |> List.filter (fun entry -> entry.commandLine.IndexOf(text, StringComparison.InvariantCultureIgnoreCase) >= 0) | None -> () match normalizedSourceText with | Some source -> filtered <- filtered |> List.filter (fun entry -> match entry.source with | Some entrySource -> entrySource.Equals(source, StringComparison.OrdinalIgnoreCase) | None -> false) | None -> () if filterFailed && not filterSuccess then filtered <- filtered |> List.filter (fun entry -> entry.exitCode <> 0) elif filterSuccess && not filterFailed then filtered <- filtered |> List.filter (fun entry -> entry.exitCode = 0) let ordered = filtered |> List.sortByDescending (fun entry -> entry.timestampUtc) if limit > 0 then ordered |> List.truncate (min limit ordered.Length) else ordered let private renderTable (entries: HistoryStorage.HistoryEntry list) (showId: bool) = let table = Table(Border = TableBorder.DoubleEdge, ShowHeaders = true) let columns = if showId then [| TableColumn($"[bold]#[/]") TableColumn($"[bold]When[/]") TableColumn($"[bold]Exit[/]") TableColumn($"[bold]Dur[/]") TableColumn($"[bold]Cwd[/]") TableColumn($"[bold]Repo[/]") TableColumn($"[bold]Branch[/]") TableColumn($"[bold]Command[/]") TableColumn($"[bold]Id[/]") |] else [| TableColumn($"[bold]#[/]") TableColumn($"[bold]When[/]") TableColumn($"[bold]Exit[/]") TableColumn($"[bold]Dur[/]") TableColumn($"[bold]Cwd[/]") TableColumn($"[bold]Repo[/]") TableColumn($"[bold]Branch[/]") TableColumn($"[bold]Command[/]") |] table.AddColumns(columns) |> ignore entries |> List.iteri (fun index entry -> let row = if showId then [| $"{index + 1}" Markup.Escape(ago entry.timestampUtc) $"{entry.exitCode}" formatDuration entry.durationMs Markup.Escape(abbreviatePath entry.cwd) Markup.Escape(formatRepoName entry) Markup.Escape(formatRepoBranch entry) Markup.Escape(entry.commandLine) $"{entry.id}" |] else [| $"{index + 1}" Markup.Escape(ago entry.timestampUtc) $"{entry.exitCode}" formatDuration entry.durationMs Markup.Escape(abbreviatePath entry.cwd) Markup.Escape(formatRepoName entry) Markup.Escape(formatRepoBranch entry) Markup.Escape(entry.commandLine) |] table.AddRow(row) |> ignore) AnsiConsole.Write(table) let private outputEntries (parseResult: ParseResult) (entries: HistoryStorage.HistoryEntry list) (showId: bool) (corruptCount: int) = if parseResult |> json then let payload = serialize entries AnsiConsole.WriteLine(Markup.Escape(payload)) elif parseResult |> silent then () else if corruptCount > 0 then AnsiConsole.MarkupLine($"[yellow]Skipped {corruptCount} corrupt history entries.[/]") renderTable entries showId AnsiConsole.WriteLine() type HistoryOn() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: Threading.CancellationToken) : Task = task { let loadResult = UserConfiguration.loadUserConfiguration () warnOnCorruptConfig parseResult loadResult let configuration = if loadResult.WasCorrupt then UserConfiguration.UserConfiguration() else loadResult.Configuration configuration.History.Enabled <- true match UserConfiguration.saveUserConfiguration configuration with | Ok _ -> if parseResult |> json then AnsiConsole.WriteLine(Markup.Escape(serialize {| enabled = true |})) elif parseResult |> silent then () else AnsiConsole.MarkupLine("[green]History recording enabled.[/]") return 0 | Error error -> if not (parseResult |> silent) then AnsiConsole.MarkupLine($"[red]{Markup.Escape(error)}[/]") return -1 } type HistoryOff() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: Threading.CancellationToken) : Task = task { let loadResult = UserConfiguration.loadUserConfiguration () warnOnCorruptConfig parseResult loadResult let configuration = if loadResult.WasCorrupt then UserConfiguration.UserConfiguration() else loadResult.Configuration configuration.History.Enabled <- false match UserConfiguration.saveUserConfiguration configuration with | Ok _ -> if parseResult |> json then AnsiConsole.WriteLine(Markup.Escape(serialize {| enabled = false |})) elif parseResult |> silent then () else AnsiConsole.MarkupLine("[green]History recording disabled.[/]") return 0 | Error error -> if not (parseResult |> silent) then AnsiConsole.MarkupLine($"[red]{Markup.Escape(error)}[/]") return -1 } type HistoryShow() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: Threading.CancellationToken) : Task = task { let limit = parseResult.GetValue(Options.limit) let filterRepo = parseResult.GetValue(Options.repo) let filterFailed = parseResult.GetValue(Options.failed) let filterSuccess = parseResult.GetValue(Options.success) let sinceText = parseResult.GetValue(Options.since) let containsText = parseResult.GetValue(Options.contains) let sourceText = parseResult.GetValue(Common.Options.source) let showId = parseResult.GetValue(Options.showId) if limit < 0 then AnsiConsole.MarkupLine("[red]Limit must be positive.[/]") return -1 else let sinceDuration = if String.IsNullOrWhiteSpace(sinceText) then Ok None else match HistoryStorage.tryParseDuration sinceText with | Ok duration -> Ok(Some duration) | Error error -> Error error match sinceDuration with | Error error -> AnsiConsole.MarkupLine($"[red]{Markup.Escape(error)}[/]") return -1 | Ok since -> let readResult = HistoryStorage.readHistoryEntries () let filtered = filterEntries readResult.Entries limit filterRepo filterFailed filterSuccess since (if String.IsNullOrWhiteSpace(containsText) then None else Some containsText) (if String.IsNullOrWhiteSpace(sourceText) then None else Some sourceText) outputEntries parseResult filtered showId readResult.CorruptCount return 0 } type HistorySearch() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: Threading.CancellationToken) : Task = task { let searchText = parseResult.GetValue("text") let limit = parseResult.GetValue(Options.limit) let filterRepo = parseResult.GetValue(Options.repo) let filterFailed = parseResult.GetValue(Options.failed) let filterSuccess = parseResult.GetValue(Options.success) let sinceText = parseResult.GetValue(Options.since) let sourceText = parseResult.GetValue(Common.Options.source) let showId = parseResult.GetValue(Options.showId) if limit < 0 then AnsiConsole.MarkupLine("[red]Limit must be positive.[/]") return -1 else let sinceDuration = if String.IsNullOrWhiteSpace(sinceText) then Ok None else match HistoryStorage.tryParseDuration sinceText with | Ok duration -> Ok(Some duration) | Error error -> Error error match sinceDuration with | Error error -> AnsiConsole.MarkupLine($"[red]{Markup.Escape(error)}[/]") return -1 | Ok since -> let readResult = HistoryStorage.readHistoryEntries () let filtered = filterEntries readResult.Entries limit filterRepo filterFailed filterSuccess since (if String.IsNullOrWhiteSpace(searchText) then None else Some searchText) (if String.IsNullOrWhiteSpace(sourceText) then None else Some sourceText) outputEntries parseResult filtered showId readResult.CorruptCount return 0 } let private parseReplacements (values: string array) = let replacements = Dictionary(StringComparer.InvariantCultureIgnoreCase) let errors = ResizeArray() if isNull values then Ok replacements else for value in values do let trimmed = if isNull value then String.Empty else value.Trim() let equalsIndex = trimmed.IndexOf('=') if equalsIndex <= 0 then errors.Add($"Invalid replacement '{trimmed}'. Expected name=value.") else let name = trimmed.Substring(0, equalsIndex).Trim() let replacement = trimmed.Substring(equalsIndex + 1) if String.IsNullOrWhiteSpace(name) then errors.Add($"Invalid replacement '{trimmed}'. Name cannot be empty.") else replacements[name] <- replacement if errors.Count > 0 then Error(String.Join(Environment.NewLine, errors)) else Ok replacements let private replaceFirst (text: string) (replacement: string) = let index = text.IndexOf(HistoryStorage.Placeholder, StringComparison.Ordinal) if index < 0 then text else text.Substring(0, index) + replacement + text.Substring(index + HistoryStorage.Placeholder.Length) let private applyReplacements (argv: string array) (redactions: HistoryStorage.Redaction list) (replacements: IDictionary) = let updated = Array.copy argv let missing = ResizeArray() for redaction in redactions do let indexKey = $"{redaction.argIndex}" let replacement = if replacements.ContainsKey(indexKey) then Some replacements[indexKey] elif replacements.ContainsKey(redaction.name) then Some replacements[redaction.name] else None match replacement with | Some value -> let currentArg = updated[redaction.argIndex] updated[redaction.argIndex] <- replaceFirst currentArg value | None -> missing.Add(redaction) updated, missing |> Seq.toList let private promptForReplacements (missing: HistoryStorage.Redaction list) (replacements: IDictionary) = for redaction in missing do let key = $"{redaction.argIndex}" if not <| replacements.ContainsKey(key) then let prompt = TextPrompt($"Replacement for {redaction.name} (arg #{redaction.argIndex + 1}):") .Secret() let value = AnsiConsole.Prompt(prompt) replacements[key] <- value let private resolveWorkingDirectory (entryCwd: string) (useCurrentCwd: bool) (canPrompt: bool) (yes: bool) = if useCurrentCwd then Ok(Environment.CurrentDirectory, false) elif String.IsNullOrWhiteSpace(entryCwd) then Error "History entry did not record a working directory. Use --use-current-cwd to run from the current directory." elif Directory.Exists(entryCwd) then Ok(entryCwd, false) else if canPrompt && not yes then let message = $"Recorded working directory not found: {Markup.Escape(entryCwd)}. Use current directory instead?" let useCurrent = AnsiConsole.Confirm(message, defaultValue = true) if useCurrent then Ok(Environment.CurrentDirectory, true) else Error $"Recorded working directory not found: {entryCwd}." else Error $"Recorded working directory not found: {entryCwd}. Use --use-current-cwd to run from the current directory." type HistoryRun() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: Threading.CancellationToken) : Task = let number = parseResult.GetValue(Options.runNumber) let byId = parseResult.GetValue(Options.runId) let yes = parseResult.GetValue(Options.yes) let useCurrentCwd = parseResult.GetValue(Options.useCurrentCwd) let dryRun = parseResult.GetValue(Options.dryRun) let replacementsInput = parseResult.GetValue(Options.replace) let canPrompt = not Console.IsInputRedirected && not Console.IsOutputRedirected let loadResult = UserConfiguration.loadUserConfiguration () warnOnCorruptConfig parseResult loadResult let historyConfig = loadResult.Configuration.History let readResult = HistoryStorage.readHistoryEntries () let ordered = readResult.Entries |> List.sortByDescending (fun entry -> entry.timestampUtc) let target = if byId <> Guid.Empty then ordered |> List.tryFind (fun entry -> entry.id = byId) else if number <= 0 || number > ordered.Length then None else Some ordered[number - 1] let exitCode = match target with | None -> AnsiConsole.MarkupLine("[red]History entry not found.[/]") -1 | Some entry -> match parseReplacements replacementsInput with | Error error -> AnsiConsole.MarkupLine($"[red]{Markup.Escape(error)}[/]") -1 | Ok replacements -> let mutable argvToRun = entry.argvNormalized let mutable missingRedactions = List.empty if entry.redactions.Length > 0 then let updated, missing = applyReplacements argvToRun entry.redactions replacements argvToRun <- updated missingRedactions <- missing if missingRedactions.Length > 0 && canPrompt && not yes then promptForReplacements missingRedactions replacements let updatedAfterPrompt, missingAfterPrompt = applyReplacements argvToRun entry.redactions replacements argvToRun <- updatedAfterPrompt missingRedactions <- missingAfterPrompt let stillRedacted = argvToRun |> Array.exists (fun arg -> arg.Contains(HistoryStorage.Placeholder)) if missingRedactions.Length > 0 || stillRedacted then let missingKeys = missingRedactions |> List.map (fun redaction -> $"{redaction.name} (arg #{redaction.argIndex + 1})") |> Seq.distinct |> String.concat ", " AnsiConsole.MarkupLine($"[red]Missing replacements for redacted values: {Markup.Escape(missingKeys)}[/]") -1 else match resolveWorkingDirectory entry.cwd useCurrentCwd canPrompt yes with | Error message -> AnsiConsole.MarkupLine($"[red]{Markup.Escape(message)}[/]") -1 | Ok (cwd, usedFallback) -> if usedFallback && not (parseResult |> silent) then AnsiConsole.MarkupLine($"[yellow]Recorded working directory not found; using current directory: {Markup.Escape(cwd)}[/]") let commandLine = HistoryStorage.buildCommandLine argvToRun AnsiConsole.MarkupLine($"[bold]About to run:[/] grace {Markup.Escape(commandLine)}") AnsiConsole.MarkupLine($"[bold]Working directory:[/] {Markup.Escape(cwd)}") let shouldProceed = if HistoryStorage.isDestructive commandLine historyConfig && not yes then AnsiConsole.Confirm("This command looks destructive. Re-run?", defaultValue = false) else true if not shouldProceed then 1 else if dryRun then 0 else let executablePath = Environment.ProcessPath if String.IsNullOrWhiteSpace(executablePath) then AnsiConsole.MarkupLine("[red]Failed to locate Grace executable.[/]") -1 else try let startInfo = ProcessStartInfo() startInfo.FileName <- executablePath startInfo.WorkingDirectory <- cwd startInfo.UseShellExecute <- false startInfo.RedirectStandardInput <- false startInfo.RedirectStandardOutput <- false startInfo.RedirectStandardError <- false argvToRun |> Array.iter startInfo.ArgumentList.Add use proc = new Process() proc.StartInfo <- startInfo if proc.Start() then proc.WaitForExit() proc.ExitCode else AnsiConsole.MarkupLine("[red]Failed to start Grace process.[/]") -1 with | ex -> AnsiConsole.MarkupLine($"[red]Failed to start Grace process: {Markup.Escape(ex.Message)}[/]") -1 Task.FromResult(exitCode) type HistoryDelete() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: Threading.CancellationToken) : Task = task { match HistoryStorage.clearHistory () with | Ok removed -> if parseResult |> json then AnsiConsole.WriteLine(Markup.Escape(serialize {| deleted = true; removed = removed |})) elif parseResult |> silent then () else AnsiConsole.MarkupLine("[green]History cleared.[/]") return 0 | Error error -> if not (parseResult |> silent) then AnsiConsole.MarkupLine($"[red]{Markup.Escape(error)}[/]") return -1 } let Build = let historyCommand = Command("history", Description = "Manage local Grace CLI history.") let onCommand = Command("on", Description = "Enable history recording.") onCommand.Action <- HistoryOn() let offCommand = Command("off", Description = "Disable history recording.") offCommand.Action <- HistoryOff() let showCommand = Command("show", Description = "Show history entries.") showCommand |> addOption Options.limit |> addOption Options.repo |> addOption Options.failed |> addOption Options.success |> addOption Options.since |> addOption Options.contains |> addOption Options.showId |> ignore showCommand.Action <- HistoryShow() let searchCommand = Command("search", Description = "Search history entries.") let searchText = new Argument("text", Description = "Text to search for.") searchCommand.Arguments.Add(searchText) searchCommand |> addOption Options.limit |> addOption Options.repo |> addOption Options.failed |> addOption Options.success |> addOption Options.since |> addOption Options.showId |> ignore searchCommand.Action <- HistorySearch() let runCommand = Command("run", Description = "Re-run a prior command.") runCommand.Arguments.Add(Options.runNumber) runCommand |> addOption Options.runId |> addOption Options.yes |> addOption Options.useCurrentCwd |> addOption Options.dryRun |> addOption Options.replace |> ignore runCommand.Action <- HistoryRun() let deleteCommand = Command("delete", Description = "Clear history.") deleteCommand.Action <- HistoryDelete() historyCommand.Subcommands.Add(onCommand) historyCommand.Subcommands.Add(offCommand) historyCommand.Subcommands.Add(showCommand) historyCommand.Subcommands.Add(searchCommand) historyCommand.Subcommands.Add(runCommand) historyCommand.Subcommands.Add(deleteCommand) historyCommand ================================================ FILE: src/Grace.CLI/Command/Maintenance.CLI.fs ================================================ namespace Grace.CLI.Command open Grace.CLI.Common open Grace.CLI.Services open Grace.CLI.Text open Grace.SDK open Grace.Shared open Grace.Shared.Client.Configuration open Grace.Shared.Parameters.DirectoryVersion open Grace.Shared.Services open Grace.Shared.Parameters.Storage open Grace.Shared.Constants open Grace.Shared.Utilities open Grace.Shared.Validation.Errors open Grace.Types.Types open Spectre.Console open System open System.Collections.Concurrent open System.CommandLine open System.CommandLine.Invocation open System.CommandLine.Parsing open System.Linq open System.IO open System.Text.Json open System.Threading open System.Threading.Tasks open System.Collections.Generic open NodaTime module Maintenance = module private Options = let ownerId = new Option( OptionName.OwnerId, Required = false, Description = "The repository's owner ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OwnerId.Empty) ) let ownerName = new Option( OptionName.OwnerName, Required = false, Description = "The repository's owner name. [default: current owner]", Arity = ArgumentArity.ExactlyOne ) let organizationId = new Option( OptionName.OrganizationId, Required = false, Description = "The repository's organization ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OrganizationId.Empty) ) let organizationName = new Option( OptionName.OrganizationName, Required = false, Description = "The repository's organization name. [default: current organization]", Arity = ArgumentArity.ZeroOrOne ) let repositoryId = new Option( OptionName.RepositoryId, [| "-r" |], Required = false, Description = "The repository's ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> RepositoryId.Empty) ) let repositoryName = new Option( OptionName.RepositoryName, [| "-n" |], Required = false, Description = "The name of the repository. [default: current repository]", Arity = ArgumentArity.ExactlyOne ) let listDirectories = new Option( OptionName.ListDirectories, Required = false, Description = "Show a list of directories in the Grace Index. [default: true]", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> true) ) let listFiles = new Option( OptionName.ListFiles, Required = false, Description = $"Show a list of files in the Grace Index. Implies {OptionName.ListDirectories}. [default: true]", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> true) ) let path = new Option( "path", Required = false, Description = "The relative path to list. Wildcards ? and * are permitted. [default: *.*]", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> "*.*") ) let private tryGetRootSha256Hash (graceStatus: GraceStatus) = let rootHashFromIndex = graceStatus.Index.Values |> Seq.tryFind (fun directoryVersion -> directoryVersion.RelativePath = Constants.RootDirectoryPath) |> Option.map (fun directoryVersion -> directoryVersion.Sha256Hash) let rootHashFromStatusMeta = if String.IsNullOrWhiteSpace(graceStatus.RootDirectorySha256Hash) then None else Some graceStatus.RootDirectorySha256Hash rootHashFromIndex |> Option.orElse rootHashFromStatusMeta let private getShortHash (sha256Hash: Sha256Hash) = if String.IsNullOrWhiteSpace(sha256Hash) then String.Empty elif sha256Hash.Length <= 8 then sha256Hash else sha256Hash.Substring(0, 8) let private writeRootShaSummary (graceStatus: GraceStatus) = match tryGetRootSha256Hash graceStatus with | Some rootSha256Hash -> AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Root SHA-256 hash: {getShortHash rootSha256Hash}[/]") | None -> AnsiConsole.MarkupLine($"[{Colors.Error}]Root SHA-256 hash: unavailable (root directory entry missing).[/]") let private updateIndexHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames if parseResult |> hasOutput then let! graceStatus = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Reading existing Grace index file.[/]") let t1 = progressContext.AddTask($"[{Color.DodgerBlue1}]Computing new Grace index file.[/]", autoStart = false) let t2 = progressContext.AddTask($"[{Color.DodgerBlue1}]Writing new Grace index file.[/]", autoStart = false) let t3 = progressContext.AddTask($"[{Color.DodgerBlue1}]Ensure files are in the object cache.[/]", autoStart = false) let t4 = progressContext.AddTask($"[{Color.DodgerBlue1}]Ensure object cache index is up-to-date.[/]", autoStart = false) let t5 = progressContext.AddTask($"[{Color.DodgerBlue1}]Ensure files are uploaded to object storage.[/]", autoStart = false) let t6 = progressContext.AddTask( $"[{Color.DodgerBlue1}]Ensure directory versions are uploaded to Grace Server.[/]", autoStart = false ) // Read the existing Grace status file. t0.Increment(0.0) let! previousGraceStatus = readGraceStatusFile () t0.Increment(100.0) // Compute the new Grace status file, based on the contents of the working directory. t1.StartTask() let! graceStatus = createNewGraceStatusFile previousGraceStatus parseResult t1.Value <- 100.0 // Write the new Grace status file to disk. t2.StartTask() do! writeGraceStatusFile graceStatus t2.Value <- 100.0 // Ensure all files are in the object cache. t3.StartTask() let fileVersions = ConcurrentDictionary() // Loop through the local directory versions, and populate fileVersions with all of the files in the repo. let plr = Parallel.ForEach( graceStatus.Index.Values, Constants.ParallelOptions, (fun ldv -> for fileVersion in ldv.Files do fileVersions.TryAdd(fileVersion.RelativePath, fileVersion) |> ignore) ) let incrementAmount = 100.0 / double fileVersions.Count // Loop through the files, and copy them to the object cache if they don't already exist. let plr = Parallel.ForEach( fileVersions, Constants.ParallelOptions, (fun kvp _ -> let fileVersion = kvp.Value let fullObjectPath = fileVersion.FullObjectPath if not <| File.Exists(fullObjectPath) then Directory.CreateDirectory(Path.GetDirectoryName(fullObjectPath)) |> ignore // If the directory already exists, this will do nothing. File.Copy(Path.Combine(Current().RootDirectory, fileVersion.RelativePath), fullObjectPath) t3.Increment(incrementAmount)) ) t3.Value <- 100.0 // Ensure the object cache index is up-to-date. t4.StartTask() do! upsertObjectCache graceStatus.Index.Values t4.Value <- 100.0 // Ensure all files are uploaded to object storage. t5.StartTask() let incrementAmount = 100.0 / double fileVersions.Count match Current().ObjectStorageProvider with | ObjectStorageProvider.Unknown -> () | AzureBlobStorage -> if parseResult |> verbose then logToAnsiConsole Colors.Verbose "Uploading files to Azure Blob Storage." // Breaking the uploads into chunks allows us to interleave checking to see if files are already uploaded with actually uploading them when they haven't been. let chunkSize = 32 let fileVersionGroups = fileVersions.Chunk(chunkSize) let succeeded = ConcurrentQueue>() let errors = ConcurrentQueue() // Loop through the groups of file versions, and upload files that aren't already in object storage. do! Parallel.ForEachAsync( fileVersionGroups, Constants.ParallelOptions, (fun fileVersions ct -> ValueTask( task { let getUploadMetadataForFilesParameters = GetUploadMetadataForFilesParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = getCorrelationId parseResult, FileVersions = fileVersions .Select(fun kvp -> kvp.Value.ToFileVersion) .ToArray() ) match! Storage.GetUploadMetadataForFiles getUploadMetadataForFilesParameters with | Ok graceReturnValue -> let uploadMetadata = graceReturnValue.ReturnValue // Increment the counter for the files that we don't have to upload. t5.Increment( incrementAmount * double (fileVersions.Count() - uploadMetadata.Count) ) // Index all of the file versions by their SHA256 hash; we'll look up the files to upload with it. let filesIndexedByRelativePath = Dictionary( fileVersions.Select(fun kvp -> KeyValuePair(kvp.Value.RelativePath, kvp.Value)) ) // Upload the files in this chunk to object storage. do! Parallel.ForEachAsync( uploadMetadata, Constants.ParallelOptions, (fun upload ct -> ValueTask( task { let fileVersion = filesIndexedByRelativePath[upload.RelativePath] .ToFileVersion let! result = Storage.SaveFileToObjectStorage (Current().RepositoryId) fileVersion (upload.BlobUriWithSasToken) (getCorrelationId parseResult) // Increment the counter for each file that we do upload. t5.Increment(incrementAmount) match result with | Ok result -> succeeded.Enqueue(result) | Error error -> errors.Enqueue(error) } )) ) | Error error -> AnsiConsole.Write((new Panel($"{error}")).BorderColor(Color.Red3)) } )) ) if errors |> Seq.isEmpty then if parseResult |> verbose then logToAnsiConsole Colors.Verbose "All files uploaded successfully." () else AnsiConsole.MarkupLine($"{errors.Count} errors occurred while uploading files to object storage.") let mutable error = GraceError.Create String.Empty String.Empty while not <| errors.IsEmpty do if errors.TryDequeue(&error) then AnsiConsole.MarkupLine($"[{Colors.Error}]{error.Error.EscapeMarkup()}[/]") | AWSS3 -> () | GoogleCloudStorage -> () t5.Value <- 100.0 // Ensure all directory versions are uploaded to Grace Server. t6.StartTask() if parseResult |> verbose then logToAnsiConsole Colors.Verbose "Uploading new directory versions to the server." let chunkSize = 16 let succeeded = ConcurrentQueue>() let errors = ConcurrentQueue() let incrementAmount = 100.0 / double graceStatus.Index.Count // We'll segment the uploads by the number of segments in the path, // so we process the deepest paths first, and the new children exist before the parent is created. // Within each segment group, we'll parallelize the processing for performance. let segmentGroups = graceStatus .Index .Values .GroupBy(fun dv -> countSegments dv.RelativePath) .OrderByDescending(fun group -> group.Key) for group in segmentGroups do let directoryVersionGroups = group.Chunk(chunkSize) let parallelOptions = System.Threading.Tasks.ParallelOptions(MaxDegreeOfParallelism = 3) do! Parallel.ForEachAsync( directoryVersionGroups, parallelOptions, (fun (directoryVersionGroup: LocalDirectoryVersion array) (ct: CancellationToken) -> ValueTask( task { let saveDirectoryVersionsParameters = SaveDirectoryVersionsParameters() saveDirectoryVersionsParameters.OwnerId <- graceIds.OwnerIdString saveDirectoryVersionsParameters.OwnerName <- graceIds.OwnerName saveDirectoryVersionsParameters.OrganizationId <- graceIds.OrganizationIdString saveDirectoryVersionsParameters.OrganizationName <- graceIds.OrganizationName saveDirectoryVersionsParameters.RepositoryId <- graceIds.RepositoryIdString saveDirectoryVersionsParameters.RepositoryName <- graceIds.RepositoryName saveDirectoryVersionsParameters.CorrelationId <- getCorrelationId parseResult saveDirectoryVersionsParameters.DirectoryVersions <- directoryVersionGroup .Select(fun dv -> dv.ToDirectoryVersion) .ToList() match! DirectoryVersion.SaveDirectoryVersions saveDirectoryVersionsParameters with | Ok result -> succeeded.Enqueue(result) | Error error -> errors.Enqueue(error) t6.Increment( incrementAmount * double directoryVersionGroup.Length ) } )) ) t6.Value <- 100.0 AnsiConsole.MarkupLine($"[{Colors.Important}]succeeded: {succeeded.Count}; errors: {errors.Count}.[/]") let mutable error = GraceError.Create String.Empty String.Empty while not <| errors.IsEmpty do errors.TryDequeue(&error) |> ignore if error.Error.Contains("TRetval") then logToConsole $"********* {error.Error}" AnsiConsole.MarkupLine($"[{Colors.Error}]{error.Error.EscapeMarkup()}[/]") return graceStatus }) let fileCount = graceStatus .Index .Values .Select(fun directoryVersion -> directoryVersion.Files.Count) .Sum() let totalFileSize = graceStatus.Index.Values.Sum(fun directoryVersion -> directoryVersion.Files.Sum(fun f -> int64 f.Size)) AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Number of directories scanned: {graceStatus.Index.Count}.[/]") AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Number of files scanned: {fileCount}; total file size: {totalFileSize:N0}.[/]") writeRootShaSummary graceStatus return 0 else let! previousGraceStatus = readGraceStatusFile () let! graceStatus = createNewGraceStatusFile previousGraceStatus parseResult do! writeGraceStatusFile graceStatus let fileVersions = ConcurrentDictionary() let plr = Parallel.ForEach( graceStatus.Index.Values, Constants.ParallelOptions, (fun ldv -> for fileVersion in ldv.Files do fileVersions.TryAdd(fileVersion.RelativePath, fileVersion) |> ignore) ) let plr = Parallel.ForEach( fileVersions, Constants.ParallelOptions, (fun kvp _ -> let fileVersion = kvp.Value let fullObjectPath = fileVersion.FullObjectPath if not <| File.Exists(fullObjectPath) then Directory.CreateDirectory(Path.GetDirectoryName(fullObjectPath)) |> ignore File.Copy(Path.Combine(Current().RootDirectory, fileVersion.RelativePath), fullObjectPath)) ) match Current().ObjectStorageProvider with | ObjectStorageProvider.Unknown -> return -1 | AzureBlobStorage -> let chunkSize = 32 let fileVersionGroups = fileVersions.Chunk(chunkSize) let succeeded = ConcurrentQueue>() let errors = ConcurrentQueue() do! Parallel.ForEachAsync( fileVersionGroups, Constants.ParallelOptions, (fun fileVersions ct -> ValueTask( task { let getUploadMetadataForFilesParameters = GetUploadMetadataForFilesParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = getCorrelationId parseResult, FileVersions = (fileVersions |> Seq.map (fun kvp -> kvp.Value.ToFileVersion) |> Seq.toArray) ) match! Storage.GetUploadMetadataForFiles getUploadMetadataForFilesParameters with | Ok graceReturnValue -> let uploadMetadata = graceReturnValue.ReturnValue let filesIndexedBySha256Hash = Dictionary( fileVersions.Select(fun kvp -> KeyValuePair(kvp.Value.Sha256Hash, kvp.Value)) ) do! Parallel.ForEachAsync( uploadMetadata, Constants.ParallelOptions, (fun upload ct -> ValueTask( task { let fileVersion = filesIndexedBySha256Hash[upload.Sha256Hash] .ToFileVersion let! result = Storage.SaveFileToObjectStorage (Current().RepositoryId) fileVersion (upload.BlobUriWithSasToken) (getCorrelationId parseResult) match result with | Ok result -> succeeded.Enqueue(result) | Error error -> errors.Enqueue(error) } )) ) | Error error -> AnsiConsole.MarkupLine($"[{Colors.Error}]{error}[/]") } )) ) if errors |> Seq.isEmpty then return 0 else AnsiConsole.MarkupLine($"{errors.Count} errors occurred.") let mutable error = GraceError.Create String.Empty String.Empty while not <| errors.IsEmpty do if errors.TryDequeue(&error) then AnsiConsole.MarkupLine($"[{Colors.Error}]{error.Error.EscapeMarkup()}[/]") return -1 | AWSS3 -> return 0 | GoogleCloudStorage -> return 0 with | ex -> logToAnsiConsole Colors.Error $"Exception in UpdateIndex: {ExceptionResponse.Create ex}" return -1 } type UpdateIndex() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { return! updateIndexHandler parseResult } type Scan() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { if parseResult |> verbose then printParseResult parseResult if parseResult |> hasOutput then let! (differences, newDirectoryVersions) = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Reading Grace index file.[/]") let t1 = progressContext.AddTask($"[{Color.DodgerBlue1}]Scanning working directory for changes.[/]", autoStart = false) let t2 = progressContext.AddTask($"[{Color.DodgerBlue1}]Computing root directory SHA-256 value.[/]", autoStart = false) t0.Increment(0.0) let! previousGraceStatus = readGraceStatusFile () t0.Increment(100.0) t1.StartTask() t1.Increment(0.0) let! differences = scanForDifferences previousGraceStatus t1.Increment(100.0) t2.StartTask() t2.Increment(0.0) let! (newGraceIndex, newDirectoryVersions) = getNewGraceStatusAndDirectoryVersions previousGraceStatus differences t2.Increment(100.0) return (differences, newDirectoryVersions) }) AnsiConsole.MarkupLine $"[{Colors.Highlighted}]Number of differences: {differences.Count}[/]" for difference in differences do let x = sprintf "%A" difference AnsiConsole.MarkupLine $"[{Colors.Important}]{x}[/]" AnsiConsole.MarkupLine $"[{Colors.Highlighted}]Number of new DirectoryVersions: {newDirectoryVersions.Count}[/]" for ldv in newDirectoryVersions do AnsiConsole.MarkupLine $"[{Colors.Important}]SHA-256: {ldv.Sha256Hash.Substring(0, 8)}; DirectoryId: {ldv.DirectoryVersionId}; RelativePath: {ldv.RelativePath}[/]" //AnsiConsole.MarkupLine $"[{Colors.Highlighted}]Root SHA-256 hash: {rootDirectoryVersion.Sha256Hash.Substring(8)}[/]" else let! previousGraceStatus = readGraceStatusFile () let! differences = scanForDifferences previousGraceStatus let! (newGraceIndex, newDirectoryVersions) = getNewGraceStatusAndDirectoryVersions previousGraceStatus differences AnsiConsole.MarkupLine $"[{Colors.Highlighted}]Number of differences: {differences.Count}[/]" for difference in differences do let x = sprintf "%A" difference AnsiConsole.MarkupLine $"[{Colors.Important}]{x}[/]" AnsiConsole.MarkupLine $"[{Colors.Highlighted}]newDirectoryVersions.Count: {newDirectoryVersions.Count}[/]" for ldv in newDirectoryVersions do AnsiConsole.MarkupLine $"[{Colors.Important}]SHA-256: {ldv.Sha256Hash.Substring(0, 8)}; DirectoryId: {ldv.DirectoryVersionId}; RelativePath: {ldv.RelativePath}[/]" return 0 } type Stats() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { if parseResult |> verbose then printParseResult parseResult let! graceStatus = readGraceStatusFile () let directoryCount = graceStatus.Index.Count let fileCount = graceStatus .Index .Values .Select(fun directoryVersion -> directoryVersion.Files.Count) .Sum() let totalFileSize = graceStatus.Index.Values.Sum(fun directoryVersion -> directoryVersion.Files.Sum(fun f -> int64 f.Size)) AnsiConsole.MarkupLine($"[{Colors.Important}]All values taken from the local Grace status file.[/]") AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Number of directories: {directoryCount}.[/]") AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Number of files: {fileCount}; total file size: {totalFileSize:N0}.[/]") writeRootShaSummary graceStatus return 0 } type ListContents() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { if parseResult |> verbose then printParseResult parseResult let listDirectories = parseResult.GetValue(Options.listDirectories) let listFiles = parseResult.GetValue(Options.listFiles) let! graceStatus = readGraceStatusFile () let directoryCount = graceStatus.Index.Count let fileCount = graceStatus .Index .Values .Select(fun directoryVersion -> directoryVersion.Files.Count) .Sum() let totalFileSize = graceStatus.Index.Values.Sum(fun directoryVersion -> directoryVersion.Files.Sum(fun f -> int64 f.Size)) AnsiConsole.MarkupLine($"[{Colors.Important}]All values taken from the local Grace status file.[/]") AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Number of directories: {directoryCount}.[/]") AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Number of files: {fileCount}; total file size: {totalFileSize:N0}.[/]") writeRootShaSummary graceStatus if listDirectories then if directoryCount = 0 then AnsiConsole.MarkupLine($"[{Colors.Verbose}]No directory entries found in the local Grace status file.[/]") else let longestRelativePath = getLongestRelativePath graceStatus.Index.Values let additionalSpaces = String.replicate (longestRelativePath - 2) " " let additionalImportantDashes = String.replicate (longestRelativePath + 3) "-" let additionalDeemphasizedDashes = String.replicate 38 "-" let sortedDirectoryVersions = graceStatus.Index.Values.OrderBy(fun dv -> dv.RelativePath) sortedDirectoryVersions |> Seq.iteri (fun i directoryVersion -> AnsiConsole.WriteLine() if i = 0 then AnsiConsole.MarkupLine( $"[{Colors.Important}]Last Write Time (UTC) SHA-256 Size Path{additionalSpaces}[/][{Colors.Deemphasized}] (DirectoryVersionId)[/]" ) AnsiConsole.MarkupLine( $"[{Colors.Important}]-----------------------------------------------------{additionalImportantDashes}[/][{Colors.Deemphasized}] {additionalDeemphasizedDashes}[/]" ) let rightAlignedDirectoryVersionId = (String.replicate (longestRelativePath - directoryVersion.RelativePath.Length) " ") + $"({directoryVersion.DirectoryVersionId})" AnsiConsole.MarkupLine( $"[{Colors.Highlighted}]{formatDateTimeAligned directoryVersion.LastWriteTimeUtc} {getShortSha256Hash directoryVersion.Sha256Hash} {directoryVersion.Size, 13:N0} /{directoryVersion.RelativePath}[/] [{Colors.Deemphasized}] {rightAlignedDirectoryVersionId}[/]" ) if listFiles then let sortedFiles = directoryVersion.Files.OrderBy(fun f -> f.RelativePath) for file in sortedFiles do AnsiConsole.MarkupLine( $"[{Colors.Verbose}]{formatDateTimeAligned file.LastWriteTimeUtc} {getShortSha256Hash file.Sha256Hash} {file.Size, 13:N0} |- {file.FileInfo.Name}[/]" )) return 0 } type CheckIgnoreEntries() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { if parseResult |> verbose then printParseResult parseResult AnsiConsole.MarkupLine($"[{Colors.Important}]Directory ignore entries:[/]") for dir in Current().GraceDirectoryIgnoreEntries do AnsiConsole.MarkupLine($"[{Colors.Highlighted}] {dir}[/]") AnsiConsole.WriteLine() AnsiConsole.MarkupLine($"[{Colors.Important}]File ignore entries:[/]") for file in Current().GraceFileIgnoreEntries do AnsiConsole.MarkupLine($"[{Colors.Highlighted}] {file}[/]") return 0 } let Build = let addCommonOptions (command: Command) = command |> addOption Options.ownerName |> addOption Options.ownerId |> addOption Options.organizationName |> addOption Options.organizationId |> addOption Options.repositoryId |> addOption Options.repositoryName let maintenanceCommand = new Command("maintenance", Description = "Performs various maintenance tasks.") maintenanceCommand.Aliases.Add("maint") let updateIndexCommand = new Command("update-index", Description = "Recreates the local Grace index file based on the current working directory contents.") |> addCommonOptions updateIndexCommand.Action <- new UpdateIndex() maintenanceCommand.Subcommands.Add(updateIndexCommand) let scanCommand = new Command("scan", Description = "Scans the working directory contents for changes.") |> addCommonOptions scanCommand.Action <- new Scan() maintenanceCommand.Subcommands.Add(scanCommand) let statsCommand = new Command("stats", Description = "Displays statistics about the current working directory.") |> addCommonOptions statsCommand.Action <- new Stats() maintenanceCommand.Subcommands.Add(statsCommand) let listContentsCommand = new Command("list-contents", Description = "List directories and files from the Grace Status file.") |> addCommonOptions |> addOption Options.listDirectories |> addOption Options.listFiles listContentsCommand.Action <- new ListContents() maintenanceCommand.Subcommands.Add(listContentsCommand) let checkIgnoreEntriesCommand = new Command("check-ignore-entries", Description = "Check the .graceignore entries for validity.") |> addCommonOptions checkIgnoreEntriesCommand.Action <- new CheckIgnoreEntries() maintenanceCommand.Subcommands.Add(checkIgnoreEntriesCommand) maintenanceCommand ================================================ FILE: src/Grace.CLI/Command/Organization.CLI.fs ================================================ namespace Grace.CLI.Command open FSharpPlus open Grace.CLI.Common open Grace.CLI.Common.Validations open Grace.CLI.Services open Grace.CLI.Text open Grace.SDK open Grace.Shared open Grace.Shared.Client.Configuration open Grace.Types.Types open Grace.Shared.Utilities open Grace.Shared.Validation.Errors open NodaTime open Spectre.Console open Spectre.Console.Json open System open System.Collections.Generic open System.CommandLine open System.CommandLine.Invocation open System.Linq open System.Threading open System.Threading.Tasks open Grace.CLI module Organization = type CommonParameters() = inherit ParameterBase() member val public OwnerId: string = String.Empty with get, set member val public OwnerName: string = String.Empty with get, set member val public OrganizationId: string = String.Empty with get, set member val public OrganizationName: string = String.Empty with get, set module private Options = let ownerId = new Option( OptionName.OwnerId, Required = false, Description = "The organization's owner ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> OwnerId.Empty) ) let ownerName = new Option( OptionName.OwnerName, Required = false, Description = "The organization's owner name. [default: current owner]", Arity = ArgumentArity.ExactlyOne ) let organizationId = new Option( OptionName.OrganizationId, Required = false, Description = "The organization ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> OrganizationId.Empty) ) let organizationName = new Option( OptionName.OrganizationName, Required = false, Description = "The name of the organization. [default: current organization]", Arity = ArgumentArity.ExactlyOne ) let organizationNameRequired = new Option(OptionName.OrganizationName, Required = true, Description = "The name of the organization.", Arity = ArgumentArity.ExactlyOne) let organizationType = (new Option( OptionName.OrganizationType, Required = true, Description = "The type of the organization. [default: Public]", Arity = ArgumentArity.ExactlyOne )) .AcceptOnlyFromAmong(listCases ()) let searchVisibility = (new Option( OptionName.SearchVisibility, Required = true, Description = "Enables or disables the organization appearing in searches. [default: Visible]", Arity = ArgumentArity.ExactlyOne )) .AcceptOnlyFromAmong(listCases ()) let description = new Option(OptionName.Description, Required = true, Description = "Description of the organization.", Arity = ArgumentArity.ExactlyOne) let newName = new Option(OptionName.NewName, Required = true, Description = "The new name of the organization.", Arity = ArgumentArity.ExactlyOne) let force = new Option(OptionName.Force, Required = false, Description = "Delete even if there is data under this organization. [default: false]") let includeDeleted = new Option( OptionName.IncludeDeleted, [| "-d" |], Required = false, Description = "Include deleted organizations in the result. [default: false]" ) let deleteReason = new Option( OptionName.DeleteReason, Required = true, Description = "The reason for deleting the organization.", Arity = ArgumentArity.ExactlyOne ) let doNotSwitch = new Option( OptionName.DoNotSwitch, Required = false, Description = "Do not switch your current organization to the new organization after it is created. By default, the new organization becomes the current organization.", Arity = ArgumentArity.ZeroOrOne ) // Create subcommand. type Create() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult // In a Create() command, if --organization-id is implicit, that's actually the old OrganizationId taken from graceconfig.json, // and we need to set OrganizationId to a new Guid. let mutable graceIds = parseResult |> getNormalizedIdsAndNames if parseResult .GetResult( Options.organizationId ) .Implicit then let organizationId = Guid.NewGuid() graceIds <- { graceIds with OrganizationId = organizationId; OrganizationIdString = $"{organizationId}" } let validateIncomingParameters = parseResult |> CommonValidations match validateIncomingParameters with | Ok _ -> let organizationId = if parseResult .GetResult( Options.organizationId ) .Implicit then Guid.NewGuid().ToString() else graceIds.OrganizationIdString let parameters = Parameters.Organization.CreateOrganizationParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = organizationId, OrganizationName = graceIds.OrganizationName, CorrelationId = getCorrelationId parseResult ) if parseResult |> hasOutput then let! result = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = Organization.Create(parameters) t0.Increment(100.0) return result }) match result with | Ok returnValue -> if not <| parseResult.GetValue(Options.doNotSwitch) then let newConfig = Current() newConfig.OrganizationId <- Guid.Parse($"{returnValue.Properties[nameof OrganizationId]}") newConfig.OrganizationName <- $"{returnValue.Properties[nameof OrganizationName]}" updateConfiguration newConfig return result |> renderOutput parseResult | Error _ -> return result |> renderOutput parseResult else let! result = Organization.Create(parameters) match result with | Ok returnValue -> if not <| parseResult.GetValue(Options.doNotSwitch) then let newConfig = Current() newConfig.OrganizationId <- Guid.Parse($"{returnValue.Properties[nameof OrganizationId]}") newConfig.OrganizationName <- $"{returnValue.Properties[nameof OrganizationName]}" updateConfiguration newConfig return result |> renderOutput parseResult | Error _ -> return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } // Get subcommand type Get() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Parameters.Organization.GetOrganizationParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, IncludeDeleted = parseResult.GetValue(Options.includeDeleted), CorrelationId = getCorrelationId parseResult ) if parseResult |> hasOutput then let! result = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = Organization.Get(parameters) t0.Increment(100.0) return result }) match result with | Ok graceReturnValue -> let jsonText = JsonText(serialize graceReturnValue.ReturnValue) AnsiConsole.Write(jsonText) AnsiConsole.WriteLine() return Ok graceReturnValue |> renderOutput parseResult | Error graceError -> return Error graceError |> renderOutput parseResult else let! result = Organization.Get(parameters) match result with | Ok graceReturnValue -> let jsonText = JsonText(serialize graceReturnValue.ReturnValue) AnsiConsole.Write(jsonText) AnsiConsole.WriteLine() return Ok graceReturnValue |> renderOutput parseResult | Error graceError -> return Error graceError |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } // SetName subcommand type SetName() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Parameters.Organization.SetOrganizationNameParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, NewName = parseResult.GetValue(Options.newName), CorrelationId = getCorrelationId parseResult ) if parseResult |> hasOutput then let! result = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = Organization.SetName(parameters) t0.Increment(100.0) return result }) return result |> renderOutput parseResult else let! result = Organization.SetName(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } /// Organization.SetType subcommand definition type SetType() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Parameters.Organization.SetOrganizationTypeParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, OrganizationType = parseResult.GetValue(Options.organizationType), CorrelationId = getCorrelationId parseResult ) if parseResult |> hasOutput then let! result = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = Organization.SetType(parameters) t0.Increment(100.0) return result }) return result |> renderOutput parseResult else let! result = Organization.SetType(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } // SetSearchVisibility subcommand type SetSearchVisibility() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Parameters.Organization.SetOrganizationSearchVisibilityParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, SearchVisibility = parseResult.GetValue(Options.searchVisibility), CorrelationId = getCorrelationId parseResult ) if parseResult |> hasOutput then let! result = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = Organization.SetSearchVisibility(parameters) t0.Increment(100.0) return result }) return result |> renderOutput parseResult else let! result = Organization.SetSearchVisibility(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } // SetDescription subcommand type SetDescription() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Parameters.Organization.SetOrganizationDescriptionParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, Description = parseResult.GetValue(Options.description), CorrelationId = getCorrelationId parseResult ) if parseResult |> hasOutput then let! result = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = Organization.SetDescription(parameters) t0.Increment(100.0) return result }) return result |> renderOutput parseResult else let! result = Organization.SetDescription(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } // Delete subcommand type Delete() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Parameters.Organization.DeleteOrganizationParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, Force = parseResult.GetValue(Options.force), DeleteReason = parseResult.GetValue(Options.deleteReason), CorrelationId = getCorrelationId parseResult ) if parseResult |> hasOutput then let! result = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = Organization.Delete(parameters) t0.Increment(100.0) return result }) return result |> renderOutput parseResult else let! result = Organization.Delete(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } // Undelete subcommand type Undelete() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Parameters.Organization.DeleteOrganizationParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, CorrelationId = getCorrelationId parseResult ) if parseResult |> hasOutput then let! result = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = Organization.Delete(parameters) t0.Increment(100.0) return result }) return result |> renderOutput parseResult else let! result = Organization.Delete(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } let Build = let addCommonOptionsWithoutOrganizationName (command: Command) = command |> addOption Options.organizationId |> addOption Options.ownerName |> addOption Options.ownerId let addCommonOptions (command: Command) = command |> addOption Options.organizationName |> addCommonOptionsWithoutOrganizationName // Create main command and aliases, if any. let organizationCommand = new Command("organization", Description = "Create, change, or delete organization-level information.") organizationCommand.Aliases.Add("org") // Add subcommands. let organizationCreateCommand = new Command("create", Description = "Create a new organization.") |> addOption Options.organizationNameRequired |> addCommonOptionsWithoutOrganizationName |> addOption Options.doNotSwitch organizationCreateCommand.Action <- new Create() organizationCommand.Subcommands.Add(organizationCreateCommand) let getCommand = new Command("get", Description = "Gets details for the organization.") |> addOption Options.includeDeleted |> addCommonOptions getCommand.Action <- new Get() organizationCommand.Subcommands.Add(getCommand) let setNameCommand = new Command("set-name", Description = "Change the name of the organization.") |> addOption Options.newName |> addCommonOptions setNameCommand.Action <- new SetName() organizationCommand.Subcommands.Add(setNameCommand) let setTypeCommand = new Command("set-type", Description = "Change the type of the organization.") |> addOption Options.organizationType |> addCommonOptions setTypeCommand.Action <- new SetType() organizationCommand.Subcommands.Add(setTypeCommand) let setSearchVisibilityCommand = new Command("set-search-visibility", Description = "Change the search visibility of the organization.") |> addOption Options.searchVisibility |> addCommonOptions setSearchVisibilityCommand.Action <- new SetSearchVisibility() organizationCommand.Subcommands.Add(setSearchVisibilityCommand) let setDescriptionCommand = new Command("set-description", Description = "Change the description of the organization.") |> addOption Options.description |> addCommonOptions setDescriptionCommand.Action <- new SetDescription() organizationCommand.Subcommands.Add(setDescriptionCommand) let deleteCommand = new Command("delete", Description = "Delete the organization.") |> addOption Options.force |> addOption Options.deleteReason |> addCommonOptions deleteCommand.Action <- new Delete() organizationCommand.Subcommands.Add(deleteCommand) let undeleteCommand = new Command("undelete", Description = "Undeletes the organization.") |> addCommonOptions undeleteCommand.Action <- new Undelete() organizationCommand.Subcommands.Add(undeleteCommand) organizationCommand ================================================ FILE: src/Grace.CLI/Command/Owner.CLI.fs ================================================ namespace Grace.CLI.Command open FSharpPlus open Grace.CLI.Common open Grace.CLI.Common.Validations open Grace.CLI.Text open Grace.SDK open Grace.Shared open Grace.Shared.Client.Configuration open Grace.Shared.Client.Theme open Grace.Shared.Validation open Grace.Shared.Validation.Errors open Grace.Types.Types open NodaTime open System open System.Collections.Generic open System.CommandLine.Invocation open System.CommandLine.Parsing open System.Linq open System.Threading open System.Threading.Tasks open System.CommandLine open Spectre.Console open Spectre.Console.Json open Grace.Shared.Utilities open Grace.CLI open Grace.CLI.Services module Owner = module private Options = let ownerId = new Option( OptionName.OwnerId, [||], Required = false, Description = "The Id of the owner .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> OwnerId.Empty) ) let ownerName = new Option( OptionName.OwnerName, Required = false, Description = "The name of the owner. [default: current owner]", Arity = ArgumentArity.ExactlyOne ) let ownerNameRequired = new Option(OptionName.OwnerName, Required = true, Description = "The name of the owner.", Arity = ArgumentArity.ExactlyOne) let ownerTypeRequired = (new Option(OptionName.OwnerType, Required = true, Description = "The type of owner. [default: Public]", Arity = ArgumentArity.ExactlyOne)) .AcceptOnlyFromAmong(Utilities.listCases ()) let searchVisibilityRequired = (new Option( OptionName.SearchVisibility, Required = true, Description = "Enables or disables the owner appearing in searches. [default: true]", Arity = ArgumentArity.ExactlyOne )) .AcceptOnlyFromAmong(Utilities.listCases ()) let descriptionRequired = new Option(OptionName.Description, Required = true, Description = "Description of the owner.", Arity = ArgumentArity.ExactlyOne) let newName = new Option(OptionName.NewName, Required = true, Description = "The new name of the organization.", Arity = ArgumentArity.ExactlyOne) let force = new Option(OptionName.Force, Required = false, Description = "Delete even if there is data under this owner. [default: false]") let includeDeleted = new Option(OptionName.IncludeDeleted, [| "-d" |], Required = false, Description = "Include deleted owners in the result. [default: false]") let deleteReason = new Option(OptionName.DeleteReason, Required = true, Description = "The reason for deleting the owner.", Arity = ArgumentArity.ExactlyOne) let doNotSwitch = new Option( OptionName.DoNotSwitch, Required = false, Description = "Do not switch your current owner to the new owner after it is created. By default, the new owner becomes the current owner.", Arity = ArgumentArity.ZeroOrOne ) let ownerCommonValidations = CommonValidations >=> ``Either OwnerId or OwnerName must be provided`` // Create subcommand. type Create() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult // In a Create() command, if --owner-id is implicit, that's actually the old OwnerId taken from graceconfig.json, // and we need to set OwnerId to a new Guid. let mutable graceIds = parseResult |> getNormalizedIdsAndNames if parseResult.GetResult(Options.ownerId).Implicit then let ownerId = Guid.NewGuid() graceIds <- { graceIds with OwnerId = ownerId; OwnerIdString = $"{ownerId}" } let validateIncomingParameters = parseResult |> ownerCommonValidations >>= (``Option must be present`` OptionName.OwnerName OwnerNameIsRequired) match validateIncomingParameters with | Ok _ -> let ownerId = if parseResult.GetResult(Options.ownerId).Implicit then Guid.NewGuid().ToString() else graceIds.OwnerIdString let parameters = Parameters.Owner.CreateOwnerParameters(OwnerId = ownerId, OwnerName = graceIds.OwnerName, CorrelationId = graceIds.CorrelationId) if parseResult |> hasOutput then let! result = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = Owner.Create(parameters) t0.Increment(100.0) return result }) match result with | Ok returnValue -> // Update the Grace configuration file with the newly-created owner. if not <| parseResult.GetValue(Options.doNotSwitch) then let newConfig = Current() newConfig.OwnerId <- Guid.Parse($"{returnValue.Properties[nameof OwnerId]}") newConfig.OwnerName <- $"{returnValue.Properties[nameof OwnerName]}" logToAnsiConsole Colors.Verbose $"newConfig: {serialize newConfig}." updateConfiguration newConfig return result |> renderOutput parseResult | Error _ -> return result |> renderOutput parseResult else let! result = Owner.Create(parameters) match result with | Ok returnValue -> // Update the Grace configuration file with the newly-created owner. if not <| parseResult.GetValue(Options.doNotSwitch) then let newConfig = Current() newConfig.OwnerId <- Guid.Parse($"{returnValue.Properties[nameof OwnerId]}") newConfig.OwnerName <- $"{returnValue.Properties[nameof OwnerName]}" logToAnsiConsole Colors.Verbose $"newConfig: {serialize newConfig}." updateConfiguration newConfig return result |> renderOutput parseResult | Error _ -> return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } // Get subcommand type Get() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> ownerCommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Parameters.Owner.GetOwnerParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, IncludeDeleted = parseResult.GetValue(Options.includeDeleted), CorrelationId = getCorrelationId parseResult ) if parseResult |> hasOutput then let! result = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = Owner.Get(parameters) t0.Increment(100.0) return result }) match result with | Ok graceReturnValue -> let jsonText = JsonText(serialize graceReturnValue.ReturnValue) AnsiConsole.Write(jsonText) AnsiConsole.WriteLine() return Ok graceReturnValue |> renderOutput parseResult | Error graceError -> return Error graceError |> renderOutput parseResult else let! result = Owner.Get(parameters) match result with | Ok graceReturnValue -> let jsonText = JsonText(serialize graceReturnValue.ReturnValue) AnsiConsole.Write(jsonText) AnsiConsole.WriteLine() return Ok graceReturnValue |> renderOutput parseResult | Error graceError -> return Error graceError |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } // SetName subcommand type SetName() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> ownerCommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Parameters.Owner.SetOwnerNameParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, NewName = parseResult.GetValue(Options.newName), CorrelationId = getCorrelationId parseResult ) if parseResult |> hasOutput then let! result = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = Owner.SetName(parameters) match result with | Ok returnValue -> // Update the Grace configuration file with the new Owner name. let newConfig = Current() newConfig.OwnerName <- parseResult.GetValue(Options.newName) updateConfiguration newConfig | Error _ -> () t0.Increment(100.0) return result }) return result |> renderOutput parseResult else let! result = Owner.SetName(parameters) match result with | Ok graceReturnValue -> // Update the Grace configuration file with the new Owner name. let newConfig = Current() newConfig.OwnerName <- parseResult.GetValue(Options.newName) updateConfiguration newConfig | Error _ -> () return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } // SetType subcommand type SetType() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> ownerCommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Parameters.Owner.SetOwnerTypeParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OwnerType = parseResult.GetValue(Options.ownerTypeRequired), CorrelationId = getCorrelationId parseResult ) if parseResult |> hasOutput then let! result = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = Owner.SetType(parameters) t0.Increment(100.0) return result }) return result |> renderOutput parseResult else let! result = Owner.SetType(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } // SetSearchVisibility subcommand type SetSearchVisibility() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> ownerCommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Parameters.Owner.SetOwnerSearchVisibilityParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, SearchVisibility = parseResult.GetValue(Options.searchVisibilityRequired), CorrelationId = getCorrelationId parseResult ) if parseResult |> hasOutput then let! result = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = Owner.SetSearchVisibility(parameters) t0.Increment(100.0) return result }) return result |> renderOutput parseResult else let! result = Owner.SetSearchVisibility(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } // SetDescription subcommand type SetDescription() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> ownerCommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Parameters.Owner.SetOwnerDescriptionParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, Description = parseResult.GetValue(Options.descriptionRequired), CorrelationId = getCorrelationId parseResult ) if parseResult |> hasOutput then let! result = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = Owner.SetDescription(parameters) t0.Increment(100.0) return result }) return result |> renderOutput parseResult else let! result = Owner.SetDescription(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } // Delete subcommand type Delete() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> ownerCommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Parameters.Owner.DeleteOwnerParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, Force = parseResult.GetValue(Options.force), DeleteReason = parseResult.GetValue(Options.deleteReason), CorrelationId = getCorrelationId parseResult ) if parseResult |> hasOutput then let! result = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = Owner.Delete(parameters) t0.Increment(100.0) return result }) return result |> renderOutput parseResult else let! result = Owner.Delete(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } // Undelete subcommand type Undelete() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> ownerCommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Parameters.Owner.UndeleteOwnerParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, CorrelationId = getCorrelationId parseResult ) if parseResult |> hasOutput then let! result = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = Owner.Undelete(parameters) t0.Increment(100.0) return result }) return result |> renderOutput parseResult else let! result = Owner.Undelete(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } let Build = let addCommonOptions (command: Command) = command |> addOption Options.ownerName |> addOption Options.ownerId // Create main command and aliases, if any.` let ownerCommand = new Command("owner", Description = "Create, change, or delete owner-level information.") // Add subcommands. let ownerCreateCommand = new Command("create", Description = "Create a new owner.") |> addOption Options.ownerNameRequired |> addOption Options.ownerId |> addOption Options.doNotSwitch ownerCreateCommand.Action <- new Create() ownerCommand.Subcommands.Add(ownerCreateCommand) let getCommand = new Command("get", Description = "Gets details for the owner.") |> addOption Options.includeDeleted |> addCommonOptions getCommand.Action <- new Get() ownerCommand.Subcommands.Add(getCommand) let setNameCommand = new Command("set-name", Description = "Change the name of the owner.") |> addOption Options.newName |> addCommonOptions setNameCommand.Action <- new SetName() ownerCommand.Subcommands.Add(setNameCommand) let setTypeCommand = new Command("set-type", Description = "Change the type of the owner.") |> addOption Options.ownerTypeRequired |> addCommonOptions setTypeCommand.Action <- new SetType() ownerCommand.Subcommands.Add(setTypeCommand) let setSearchVisibilityCommand = new Command("set-search-visibility", Description = "Change the search visibility of the owner.") |> addOption Options.searchVisibilityRequired |> addCommonOptions setSearchVisibilityCommand.Action <- new SetSearchVisibility() ownerCommand.Subcommands.Add(setSearchVisibilityCommand) let setDescriptionCommand = new Command("set-description", Description = "Change the description of the owner.") |> addOption Options.descriptionRequired |> addCommonOptions setDescriptionCommand.Action <- new SetDescription() ownerCommand.Subcommands.Add(setDescriptionCommand) let deleteCommand = new Command("delete", Description = "Delete the owner.") |> addOption Options.force |> addOption Options.deleteReason |> addCommonOptions deleteCommand.Action <- new Delete() ownerCommand.Subcommands.Add(deleteCommand) let undeleteCommand = new Command("undelete", Description = "Undelete a deleted owner.") |> addCommonOptions undeleteCommand.Action <- new Undelete() ownerCommand.Subcommands.Add(undeleteCommand) ownerCommand ================================================ FILE: src/Grace.CLI/Command/PromotionSet.CLI.fs ================================================ namespace Grace.CLI.Command open Grace.CLI.Common open Grace.CLI.Services open Grace.CLI.Text open Grace.SDK open Grace.Shared open Grace.Shared.Utilities open Grace.Shared.Validation.Errors open Grace.Types.PromotionSet open Grace.Types.Types open Spectre.Console open System open System.CommandLine open System.CommandLine.Invocation open System.CommandLine.Parsing open System.IO open System.Text.Json open System.Threading open System.Threading.Tasks module PromotionSetCommand = module private Options = let promotionSetId = new Option( "--promotion-set", [| "--promotion-set-id" "--promotionSetId" |], Required = true, Description = "The promotion set ID .", Arity = ArgumentArity.ExactlyOne ) let promotionSetIdOptional = new Option( "--promotion-set", [| "--promotion-set-id" "--promotionSetId" |], Required = false, Description = "The promotion set ID . If omitted, a new one is generated.", Arity = ArgumentArity.ExactlyOne ) let targetBranchId = new Option( "--target-branch-id", [| "--target-branch" |], Required = true, Description = "The target branch ID .", Arity = ArgumentArity.ExactlyOne ) let promotionPointersFile = new Option( "--promotion-pointers-file", [| "--pointers-file"; "--input-file" |], Required = true, Description = "Path to a JSON file containing PromotionPointer entries.", Arity = ArgumentArity.ExactlyOne ) let reason = new Option( "--reason", [| "--recompute-reason" |], Required = false, Description = "Optional reason for recomputing the promotion set.", Arity = ArgumentArity.ExactlyOne ) let force = new Option( OptionName.Force, [| "-f"; "--force" |], Required = false, Description = "Force logical deletion of the promotion set.", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> false) ) let deleteReason = new Option( OptionName.DeleteReason, Required = false, Description = "Optional reason for deleting the promotion set.", Arity = ArgumentArity.ExactlyOne ) let stepId = new Option( "--step", [| "--step-id"; "--stepId" |], Required = true, Description = "The promotion set step ID .", Arity = ArgumentArity.ExactlyOne ) let decisionsFile = new Option( "--decisions-file", [| "--decisionsFile" |], Required = true, Description = "Path to a JSON file containing ConflictResolutionDecision entries.", Arity = ArgumentArity.ExactlyOne ) let ownerId = new Option( OptionName.OwnerId, Required = false, Description = "The repository's owner ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OwnerId.Empty) ) let ownerName = new Option( OptionName.OwnerName, Required = false, Description = "The repository's owner name. [default: current owner]", Arity = ArgumentArity.ExactlyOne ) let organizationId = new Option( OptionName.OrganizationId, Required = false, Description = "The organization's ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OrganizationId.Empty) ) let organizationName = new Option( OptionName.OrganizationName, Required = false, Description = "The organization's name. [default: current organization]", Arity = ArgumentArity.ExactlyOne ) let repositoryId = new Option( OptionName.RepositoryId, Required = false, Description = "The repository's ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> RepositoryId.Empty) ) let repositoryName = new Option( OptionName.RepositoryName, Required = false, Description = "The repository's name. [default: current repository]", Arity = ArgumentArity.ExactlyOne ) type private ConflictStepDisplay = { StepId: PromotionSetStepId Order: int ConflictStatus: string ConflictSummaryArtifactId: ArtifactId option DownloadUri: UriWithSharedAccessSignature option DownloadUriError: string option } type private ConflictShowResult = { PromotionSetId: PromotionSetId Status: string StepsComputationStatus: string StepsComputationAttempt: int ConflictedSteps: ConflictStepDisplay list } type private ConflictDecisionsWrapper = { Decisions: ConflictResolutionDecision list } type private PromotionPointersWrapper = { PromotionPointers: PromotionPointer list } let private tryParseGuid (value: string) (errorMessage: string) (parseResult: ParseResult) = let mutable parsed = Guid.Empty if String.IsNullOrWhiteSpace(value) || Guid.TryParse(value, &parsed) = false || parsed = Guid.Empty then Error(GraceError.Create errorMessage (getCorrelationId parseResult)) else Ok parsed let private parseOptionalPromotionSetId (value: string) (parseResult: ParseResult) = if String.IsNullOrWhiteSpace(value) then Ok(Guid.NewGuid()) else tryParseGuid value (QueueError.getErrorMessage QueueError.InvalidPromotionSetId) parseResult let private tryDeserializeDecisionsFromFile (filePath: string) = if not <| File.Exists filePath then Error $"Decisions file does not exist: {filePath}" else try let fileContent = File.ReadAllText filePath let decisionsResult = try Ok(JsonSerializer.Deserialize(fileContent, Constants.JsonSerializerOptions)) with | _ -> try let wrapped = JsonSerializer.Deserialize(fileContent, Constants.JsonSerializerOptions) Ok wrapped.Decisions with | _ -> Error "Decisions file must be valid JSON as an array or { \"decisions\": [...] }." match decisionsResult with | Ok decisions when List.isEmpty decisions -> Error "Decisions file must contain at least one decision." | Ok decisions -> Ok decisions | Error error -> Error error with | ex -> Error($"Unable to read decisions file: {ex.Message}") let private tryDeserializePromotionPointersFromFile (filePath: string) = if not <| File.Exists filePath then Error $"Promotion pointers file does not exist: {filePath}" else try let fileContent = File.ReadAllText filePath let pointersResult = try Ok(JsonSerializer.Deserialize(fileContent, Constants.JsonSerializerOptions)) with | _ -> try let wrapped = JsonSerializer.Deserialize(fileContent, Constants.JsonSerializerOptions) Ok wrapped.PromotionPointers with | _ -> Error "Promotion pointers file must be valid JSON as an array or { \"promotionPointers\": [...] }." match pointersResult with | Ok pointers -> let normalizedPointers = if obj.ReferenceEquals(box pointers, null) then [] else pointers if List.isEmpty normalizedPointers then Error "Promotion pointers file must contain at least one entry." else Ok normalizedPointers | Error error -> Error error with | ex -> Error($"Unable to read promotion pointers file: {ex.Message}") let private getPromotionSet (graceIds: GraceIds) (promotionSetId: PromotionSetId) = let parameters = Parameters.PromotionSet.GetPromotionSetParameters( PromotionSetId = promotionSetId.ToString(), OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) Grace.SDK.PromotionSet.Get(parameters) let private getArtifactDownloadUri (graceIds: GraceIds) (artifactId: ArtifactId) = let parameters = Parameters.Artifact.GetArtifactDownloadUriParameters( ArtifactId = artifactId.ToString(), OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) Artifact.GetDownloadUri(parameters) let private renderPromotionSet (parseResult: ParseResult) (promotionSet: PromotionSetDto) = if not (parseResult |> json) && not (parseResult |> silent) then let table = Table(Border = TableBorder.Rounded) table.AddColumn("Field") |> ignore table.AddColumn("Value") |> ignore table.AddRow("PromotionSetId", Markup.Escape(promotionSet.PromotionSetId.ToString())) |> ignore table.AddRow("TargetBranchId", Markup.Escape(promotionSet.TargetBranchId.ToString())) |> ignore table.AddRow("Status", Markup.Escape(getDiscriminatedUnionCaseName promotionSet.Status)) |> ignore table.AddRow("StepsComputationStatus", Markup.Escape(getDiscriminatedUnionCaseName promotionSet.StepsComputationStatus)) |> ignore table.AddRow("StepsComputationAttempt", promotionSet.StepsComputationAttempt.ToString()) |> ignore table.AddRow("StepCount", promotionSet.Steps.Length.ToString()) |> ignore AnsiConsole.Write(table) let private renderPromotionSetEvents (parseResult: ParseResult) (promotionSetId: PromotionSetId) (events: PromotionSetEvent seq) = if not (parseResult |> json) && not (parseResult |> silent) then let eventsList = events |> Seq.toList if List.isEmpty eventsList then AnsiConsole.MarkupLine($"[yellow]No events found for promotion set[/] {Markup.Escape(promotionSetId.ToString())}.") else let table = Table(Border = TableBorder.Rounded) table.AddColumn("Timestamp") |> ignore table.AddColumn("Event") |> ignore table.AddColumn("Principal") |> ignore eventsList |> List.iter (fun promotionSetEvent -> table.AddRow( Markup.Escape(promotionSetEvent.Metadata.Timestamp.ToString()), Markup.Escape(getDiscriminatedUnionCaseName promotionSetEvent.Event), Markup.Escape(promotionSetEvent.Metadata.Principal) ) |> ignore) AnsiConsole.Write(table) let private buildConflictStepDisplay (graceIds: GraceIds) (step: PromotionSetStep) = task { let baseDisplay = { StepId = step.StepId Order = step.Order ConflictStatus = getDiscriminatedUnionCaseName step.ConflictStatus ConflictSummaryArtifactId = step.ConflictSummaryArtifactId DownloadUri = Option.None DownloadUriError = Option.None } match step.ConflictSummaryArtifactId with | Option.None -> return baseDisplay | Option.Some artifactId -> match! getArtifactDownloadUri graceIds artifactId with | Ok returnValue -> return { baseDisplay with DownloadUri = Option.Some returnValue.ReturnValue.DownloadUri } | Error error -> return { baseDisplay with DownloadUriError = Option.Some error.Error } } let private renderConflictSummary (parseResult: ParseResult) (result: ConflictShowResult) = if not (parseResult |> json) && not (parseResult |> silent) then AnsiConsole.MarkupLine($"[bold]Promotion Set[/] {Markup.Escape(result.PromotionSetId.ToString())}") AnsiConsole.MarkupLine( $"[bold]Status:[/] {Markup.Escape(result.Status)} [bold]Computation:[/] {Markup.Escape(result.StepsComputationStatus)} [bold]Attempt:[/] {result.StepsComputationAttempt}" ) if List.isEmpty result.ConflictedSteps then AnsiConsole.MarkupLine("[yellow]No conflicts found on the current PromotionSet steps.[/]") else let table = Table(Border = TableBorder.Rounded) table.AddColumns( [| "Order" "StepId" "ConflictStatus" "ArtifactId" "DownloadUri" |] ) |> ignore result.ConflictedSteps |> List.iter (fun step -> let artifactIdText = match step.ConflictSummaryArtifactId with | Option.Some artifactId -> Markup.Escape(artifactId.ToString()) | Option.None -> "-" let downloadUriText = match step.DownloadUri, step.DownloadUriError with | Option.Some uri, _ -> Markup.Escape(uri.ToString()) | Option.None, Option.Some error -> Markup.Escape($"Unavailable: {error}") | _ -> "-" table.AddRow( step.Order.ToString(), Markup.Escape(step.StepId.ToString()), Markup.Escape(step.ConflictStatus), artifactIdText, downloadUriText ) |> ignore) AnsiConsole.Write(table) let private showConflictsHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let promotionSetIdRaw = parseResult.GetValue(Options.promotionSetId) match tryParseGuid promotionSetIdRaw (QueueError.getErrorMessage QueueError.InvalidPromotionSetId) parseResult with | Error error -> return Error error | Ok promotionSetId -> match! getPromotionSet graceIds promotionSetId with | Error error -> return Error error | Ok returnValue -> let promotionSet = returnValue.ReturnValue let conflictedSteps = promotionSet.Steps |> List.filter (fun step -> step.ConflictStatus <> StepConflictStatus.NoConflicts || step.ConflictSummaryArtifactId.IsSome) let! displays = conflictedSteps |> List.map (buildConflictStepDisplay graceIds) |> List.toArray |> Task.WhenAll let result = { PromotionSetId = promotionSet.PromotionSetId Status = getDiscriminatedUnionCaseName promotionSet.Status StepsComputationStatus = getDiscriminatedUnionCaseName promotionSet.StepsComputationStatus StepsComputationAttempt = promotionSet.StepsComputationAttempt ConflictedSteps = displays |> Array.toList } renderConflictSummary parseResult result return Ok(GraceReturnValue.Create result graceIds.CorrelationId) with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type ShowConflicts() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = showConflictsHandler parseResult return result |> renderOutput parseResult } let private createPromotionSetHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let promotionSetIdRaw = parseResult.GetValue(Options.promotionSetIdOptional) let targetBranchIdRaw = parseResult.GetValue(Options.targetBranchId) match parseOptionalPromotionSetId promotionSetIdRaw parseResult with | Error error -> return Error error | Ok promotionSetId -> match tryParseGuid targetBranchIdRaw (QueueError.getErrorMessage QueueError.InvalidTargetBranchId) parseResult with | Error error -> return Error error | Ok targetBranchId -> let parameters = Parameters.PromotionSet.CreatePromotionSetParameters( PromotionSetId = promotionSetId.ToString(), TargetBranchId = targetBranchId.ToString(), OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let! result = Grace.SDK.PromotionSet.Create(parameters) match result with | Ok returnValue -> if not (parseResult |> json) && not (parseResult |> silent) then AnsiConsole.MarkupLine( $"[green]Created promotion set[/] {Markup.Escape(promotionSetId.ToString())} [green]for target branch[/] {Markup.Escape(targetBranchId.ToString())}" ) return Ok returnValue | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type CreatePromotionSet() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = createPromotionSetHandler parseResult return result |> renderOutput parseResult } let private getPromotionSetHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let promotionSetIdRaw = parseResult.GetValue(Options.promotionSetId) match tryParseGuid promotionSetIdRaw (QueueError.getErrorMessage QueueError.InvalidPromotionSetId) parseResult with | Error error -> return Error error | Ok promotionSetId -> match! getPromotionSet graceIds promotionSetId with | Ok returnValue -> renderPromotionSet parseResult returnValue.ReturnValue return Ok returnValue | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type GetPromotionSet() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = getPromotionSetHandler parseResult return result |> renderOutput parseResult } let private getPromotionSetEventsHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let promotionSetIdRaw = parseResult.GetValue(Options.promotionSetId) match tryParseGuid promotionSetIdRaw (QueueError.getErrorMessage QueueError.InvalidPromotionSetId) parseResult with | Error error -> return Error error | Ok promotionSetId -> let parameters = Parameters.PromotionSet.GetPromotionSetEventsParameters( PromotionSetId = promotionSetId.ToString(), OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let! result = Grace.SDK.PromotionSet.GetEvents(parameters) match result with | Ok returnValue -> renderPromotionSetEvents parseResult promotionSetId returnValue.ReturnValue return Ok returnValue | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type GetPromotionSetEvents() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = getPromotionSetEventsHandler parseResult return result |> renderOutput parseResult } let private updateInputPromotionsHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let promotionSetIdRaw = parseResult.GetValue(Options.promotionSetId) let promotionPointersFilePath = parseResult.GetValue(Options.promotionPointersFile) match tryParseGuid promotionSetIdRaw (QueueError.getErrorMessage QueueError.InvalidPromotionSetId) parseResult with | Error error -> return Error error | Ok promotionSetId -> match tryDeserializePromotionPointersFromFile promotionPointersFilePath with | Error errorText -> return Error(GraceError.Create errorText (getCorrelationId parseResult)) | Ok promotionPointers -> let parameters = Parameters.PromotionSet.UpdatePromotionSetInputPromotionsParameters( PromotionSetId = promotionSetId.ToString(), PromotionPointers = promotionPointers, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let! result = Grace.SDK.PromotionSet.UpdateInputPromotions(parameters) match result with | Ok returnValue -> if not (parseResult |> json) && not (parseResult |> silent) then AnsiConsole.MarkupLine( $"[green]Updated input promotions for promotion set[/] {Markup.Escape(promotionSetId.ToString())} [green]with[/] {promotionPointers.Length} [green]pointer(s).[/]" ) return Ok returnValue | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type UpdateInputPromotions() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = updateInputPromotionsHandler parseResult return result |> renderOutput parseResult } let private recomputePromotionSetHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let promotionSetIdRaw = parseResult.GetValue(Options.promotionSetId) let reasonText = parseResult.GetValue(Options.reason) |> Option.ofObj |> Option.defaultValue String.Empty match tryParseGuid promotionSetIdRaw (QueueError.getErrorMessage QueueError.InvalidPromotionSetId) parseResult with | Error error -> return Error error | Ok promotionSetId -> let parameters = Parameters.PromotionSet.RecomputePromotionSetParameters( PromotionSetId = promotionSetId.ToString(), Reason = reasonText, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let! result = Grace.SDK.PromotionSet.Recompute(parameters) match result with | Ok returnValue -> if not (parseResult |> json) && not (parseResult |> silent) then AnsiConsole.MarkupLine($"[green]Requested recompute for promotion set[/] {Markup.Escape(promotionSetId.ToString())}") return Ok returnValue | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type RecomputePromotionSet() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = recomputePromotionSetHandler parseResult return result |> renderOutput parseResult } let private applyPromotionSetHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let promotionSetIdRaw = parseResult.GetValue(Options.promotionSetId) match tryParseGuid promotionSetIdRaw (QueueError.getErrorMessage QueueError.InvalidPromotionSetId) parseResult with | Error error -> return Error error | Ok promotionSetId -> let parameters = Parameters.PromotionSet.ApplyPromotionSetParameters( PromotionSetId = promotionSetId.ToString(), OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let! result = Grace.SDK.PromotionSet.Apply(parameters) match result with | Ok returnValue -> if not (parseResult |> json) && not (parseResult |> silent) then AnsiConsole.MarkupLine($"[green]Requested apply for promotion set[/] {Markup.Escape(promotionSetId.ToString())}") return Ok returnValue | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type ApplyPromotionSet() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = applyPromotionSetHandler parseResult return result |> renderOutput parseResult } let private deletePromotionSetHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let promotionSetIdRaw = parseResult.GetValue(Options.promotionSetId) let force = parseResult.GetValue(Options.force) let deleteReason = parseResult.GetValue(Options.deleteReason) |> Option.ofObj |> Option.defaultValue String.Empty match tryParseGuid promotionSetIdRaw (QueueError.getErrorMessage QueueError.InvalidPromotionSetId) parseResult with | Error error -> return Error error | Ok promotionSetId -> let parameters = Parameters.PromotionSet.DeletePromotionSetParameters( PromotionSetId = promotionSetId.ToString(), Force = force, DeleteReason = deleteReason, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let! result = Grace.SDK.PromotionSet.Delete(parameters) match result with | Ok returnValue -> if not (parseResult |> json) && not (parseResult |> silent) then AnsiConsole.MarkupLine($"[green]Deleted promotion set[/] {Markup.Escape(promotionSetId.ToString())}") return Ok returnValue | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type DeletePromotionSet() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = deletePromotionSetHandler parseResult return result |> renderOutput parseResult } let private resolveConflictsHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let promotionSetIdRaw = parseResult.GetValue(Options.promotionSetId) let stepIdRaw = parseResult.GetValue(Options.stepId) let decisionsFilePath = parseResult.GetValue(Options.decisionsFile) match tryParseGuid promotionSetIdRaw (QueueError.getErrorMessage QueueError.InvalidPromotionSetId) parseResult with | Error error -> return Error error | Ok promotionSetId -> match tryParseGuid stepIdRaw (ValidationResultError.getErrorMessage ValidationResultError.InvalidPromotionSetStepId) parseResult with | Error error -> return Error error | Ok stepId -> match tryDeserializeDecisionsFromFile decisionsFilePath with | Error errorText -> return Error(GraceError.Create errorText (getCorrelationId parseResult)) | Ok decisions -> match! getPromotionSet graceIds promotionSetId with | Error error -> return Error error | Ok promotionSetReturnValue -> let promotionSet = promotionSetReturnValue.ReturnValue if promotionSet.StepsComputationAttempt <= 0 then return Error( GraceError.Create (ValidationResultError.getErrorMessage ValidationResultError.InvalidStepsComputationAttempt) (getCorrelationId parseResult) ) else let parameters = Parameters.PromotionSet.ResolvePromotionSetConflictsParameters( PromotionSetId = promotionSetId.ToString(), StepId = stepId.ToString(), Decisions = decisions, StepsComputationAttempt = promotionSet.StepsComputationAttempt, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let! result = Grace.SDK.PromotionSet.ResolveConflicts(parameters) match result with | Ok returnValue -> if not (parseResult |> json) && not (parseResult |> silent) then AnsiConsole.MarkupLine( $"[green]Submitted conflict decisions for promotion set[/] {Markup.Escape(promotionSetId.ToString())} [green]step[/] {Markup.Escape(stepId.ToString())}" ) return Ok returnValue | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type ResolveConflicts() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = resolveConflictsHandler parseResult return result |> renderOutput parseResult } let Build = let addCommonOptions (command: Command) = command |> addOption Options.ownerName |> addOption Options.ownerId |> addOption Options.organizationName |> addOption Options.organizationId |> addOption Options.repositoryName |> addOption Options.repositoryId let promotionSetCommand = new Command("promotion-set", Description = "Manage promotion sets.") promotionSetCommand.Aliases.Add("prset") let conflictsCommand = new Command("conflicts", Description = "Inspect and resolve promotion set conflicts.") let createCommand = new Command("create", Description = "Create a promotion set for a target branch.") |> addOption Options.promotionSetIdOptional |> addOption Options.targetBranchId |> addCommonOptions createCommand.Action <- new CreatePromotionSet() promotionSetCommand.Subcommands.Add(createCommand) let getCommand = new Command("get", Description = "Get promotion set details.") |> addOption Options.promotionSetId |> addCommonOptions getCommand.Action <- new GetPromotionSet() promotionSetCommand.Subcommands.Add(getCommand) let getEventsCommand = new Command("get-events", Description = "Get promotion set events.") |> addOption Options.promotionSetId |> addCommonOptions getEventsCommand.Action <- new GetPromotionSetEvents() promotionSetCommand.Subcommands.Add(getEventsCommand) let updateInputPromotionsCommand = new Command("update-input-promotions", Description = "Update input promotion pointers for a promotion set.") |> addOption Options.promotionSetId |> addOption Options.promotionPointersFile |> addCommonOptions updateInputPromotionsCommand.Action <- new UpdateInputPromotions() promotionSetCommand.Subcommands.Add(updateInputPromotionsCommand) let recomputeCommand = new Command("recompute", Description = "Request recomputation of promotion set steps.") |> addOption Options.promotionSetId |> addOption Options.reason |> addCommonOptions recomputeCommand.Action <- new RecomputePromotionSet() promotionSetCommand.Subcommands.Add(recomputeCommand) let applyCommand = new Command("apply", Description = "Apply a promotion set.") |> addOption Options.promotionSetId |> addCommonOptions applyCommand.Action <- new ApplyPromotionSet() promotionSetCommand.Subcommands.Add(applyCommand) let deleteCommand = new Command("delete", Description = "Logically delete a promotion set.") |> addOption Options.promotionSetId |> addOption Options.force |> addOption Options.deleteReason |> addCommonOptions deleteCommand.Action <- new DeletePromotionSet() promotionSetCommand.Subcommands.Add(deleteCommand) let showConflictsCommand = new Command("show", Description = "Show conflicts for a promotion set and print conflict artifact URIs.") |> addOption Options.promotionSetId |> addCommonOptions showConflictsCommand.Action <- new ShowConflicts() conflictsCommand.Subcommands.Add(showConflictsCommand) let resolveConflictsCommand = new Command("resolve", Description = "Resolve blocked promotion set conflicts from a decisions JSON file.") |> addOption Options.promotionSetId |> addOption Options.stepId |> addOption Options.decisionsFile |> addCommonOptions resolveConflictsCommand.Action <- new ResolveConflicts() conflictsCommand.Subcommands.Add(resolveConflictsCommand) promotionSetCommand.Subcommands.Add(conflictsCommand) promotionSetCommand ================================================ FILE: src/Grace.CLI/Command/Queue.CLI.fs ================================================ namespace Grace.CLI.Command open Grace.CLI.Common open Grace.CLI.Services open Grace.CLI.Text open Grace.SDK open Grace.Shared open Grace.Shared.Utilities open Grace.Shared.Validation.Errors open Grace.Types.Queue open Grace.Types.Types open Spectre.Console open System open System.CommandLine open System.CommandLine.Invocation open System.CommandLine.Parsing open System.Threading open System.Threading.Tasks module QueueCommand = module private Options = let promotionSetId = new Option( "--promotion-set", [| "--promotion-set-id" |], Required = true, Description = "The promotion set ID .", Arity = ArgumentArity.ExactlyOne ) let promotionSetIdOptional = new Option( "--promotion-set", [| "--promotion-set-id" |], Required = false, Description = "The promotion set ID .", Arity = ArgumentArity.ExactlyOne ) let workItemId = new Option( "--work", [| "--work-item-id"; "-w" |], Required = false, Description = "The work item ID or work item number .", Arity = ArgumentArity.ExactlyOne ) let policySnapshotId = new Option( "--policy-snapshot-id", Required = false, Description = "Policy snapshot ID to initialize the queue.", Arity = ArgumentArity.ExactlyOne ) let branch = new Option("--branch", [| "-b" |], Required = false, Description = "Target branch ID or name.", Arity = ArgumentArity.ExactlyOne) let branchId = new Option( OptionName.BranchId, Required = false, Description = "Target branch ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> BranchId.Empty) ) let branchName = new Option( OptionName.BranchName, Required = false, Description = "Target branch name. [default: current branch]", Arity = ArgumentArity.ExactlyOne ) let ownerId = new Option( OptionName.OwnerId, Required = false, Description = "The repository's owner ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OwnerId.Empty) ) let ownerName = new Option( OptionName.OwnerName, Required = false, Description = "The repository's owner name. [default: current owner]", Arity = ArgumentArity.ExactlyOne ) let organizationId = new Option( OptionName.OrganizationId, Required = false, Description = "The organization's ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OrganizationId.Empty) ) let organizationName = new Option( OptionName.OrganizationName, Required = false, Description = "The organization's name. [default: current organization]", Arity = ArgumentArity.ExactlyOne ) let repositoryId = new Option( OptionName.RepositoryId, Required = false, Description = "The repository's ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> RepositoryId.Empty) ) let repositoryName = new Option( OptionName.RepositoryName, Required = false, Description = "The repository's name. [default: current repository]", Arity = ArgumentArity.ExactlyOne ) let private tryParseGuid (value: string) (error: QueueError) (parseResult: ParseResult) = let mutable parsed = Guid.Empty if String.IsNullOrWhiteSpace(value) || Guid.TryParse(value, &parsed) = false || parsed = Guid.Empty then Error(GraceError.Create (QueueError.getErrorMessage error) (getCorrelationId parseResult)) else Ok parsed let private tryParseWorkItemId (value: string) (parseResult: ParseResult) = let mutable parsedGuid = Guid.Empty if String.IsNullOrWhiteSpace(value) then Ok String.Empty elif Guid.TryParse(value, &parsedGuid) && parsedGuid <> Guid.Empty then Ok(parsedGuid.ToString()) else let mutable parsedNumber = 0L if Int64.TryParse(value, &parsedNumber) then if parsedNumber > 0L then Ok(parsedNumber.ToString()) else Error(GraceError.Create (WorkItemError.getErrorMessage WorkItemError.InvalidWorkItemNumber) (getCorrelationId parseResult)) else Error(GraceError.Create (WorkItemError.getErrorMessage WorkItemError.InvalidWorkItemId) (getCorrelationId parseResult)) let private resolveBranchByName (parseResult: ParseResult) (graceIds: GraceIds) (branchName: string) = task { let parameters = Parameters.Branch.GetBranchParameters( BranchName = branchName, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) match! Grace.SDK.Branch.Get(parameters) with | Error error -> return Error error | Ok returnValue -> return Ok returnValue.ReturnValue.BranchId } let private resolveTargetBranchId (parseResult: ParseResult) (graceIds: GraceIds) = task { let branchRaw = parseResult.GetValue(Options.branch) |> Option.ofObj |> Option.defaultValue String.Empty if not (String.IsNullOrWhiteSpace branchRaw) then let mutable parsed = Guid.Empty if Guid.TryParse(branchRaw, &parsed) && parsed <> Guid.Empty then return Ok parsed else return! resolveBranchByName parseResult graceIds branchRaw elif graceIds.BranchId <> Guid.Empty then return Ok graceIds.BranchId elif not (String.IsNullOrWhiteSpace graceIds.BranchName) then return! resolveBranchByName parseResult graceIds graceIds.BranchName else return Error(GraceError.Create (QueueError.getErrorMessage QueueError.InvalidTargetBranchId) (getCorrelationId parseResult)) } let private resolvePolicySnapshotId (parseResult: ParseResult) (graceIds: GraceIds) (targetBranchId: Guid) = task { let rawPolicySnapshotId = parseResult.GetValue(Options.policySnapshotId) |> Option.ofObj |> Option.defaultValue String.Empty if not (String.IsNullOrWhiteSpace rawPolicySnapshotId) then return Ok rawPolicySnapshotId else let parameters = Parameters.Policy.GetPolicyParameters( TargetBranchId = targetBranchId.ToString(), OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) match! Policy.GetCurrent(parameters) with | Error error -> return Error error | Ok returnValue -> match returnValue.ReturnValue with | Some snapshot when not (String.IsNullOrWhiteSpace snapshot.PolicySnapshotId) -> return Ok snapshot.PolicySnapshotId | _ -> return Ok String.Empty } let private writeQueueStatus (parseResult: ParseResult) (queue: PromotionQueue) = if not (parseResult |> json) && not (parseResult |> silent) then let table = Table(Border = TableBorder.Rounded) table.AddColumn(TableColumn("[bold]Field[/]").LeftAligned()) |> ignore table.AddColumn(TableColumn("[bold]Value[/]").LeftAligned()) |> ignore table.AddRow("Target branch", Markup.Escape(queue.TargetBranchId.ToString())) |> ignore table.AddRow("State", Markup.Escape(getDiscriminatedUnionCaseName queue.State)) |> ignore table.AddRow("Promotion set count", queue.PromotionSetIds.Length.ToString()) |> ignore match queue.RunningPromotionSetId with | Some promotionSetId -> table.AddRow("Running promotion set", Markup.Escape(promotionSetId.ToString())) |> ignore | None -> table.AddRow("Running promotion set", "-") |> ignore AnsiConsole.Write(table) let private statusHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let! targetBranchIdResult = resolveTargetBranchId parseResult graceIds match targetBranchIdResult with | Error error -> return Error error | Ok targetBranchId -> let parameters = Parameters.Queue.QueueStatusParameters( TargetBranchId = targetBranchId.ToString(), OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let! result = Queue.Status(parameters) match result with | Ok returnValue -> writeQueueStatus parseResult returnValue.ReturnValue return Ok returnValue | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type Status() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = statusHandler parseResult return result |> renderOutput parseResult } let private pauseHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let! targetBranchIdResult = resolveTargetBranchId parseResult graceIds match targetBranchIdResult with | Error error -> return Error error | Ok targetBranchId -> let parameters = Parameters.Queue.QueueActionParameters( TargetBranchId = targetBranchId.ToString(), OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let! result = Queue.Pause(parameters) if not (parseResult |> json) && not (parseResult |> silent) then AnsiConsole.MarkupLine("[green]Queue paused.[/]") return result with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type Pause() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = pauseHandler parseResult return result |> renderOutput parseResult } let private resumeHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let! targetBranchIdResult = resolveTargetBranchId parseResult graceIds match targetBranchIdResult with | Error error -> return Error error | Ok targetBranchId -> let parameters = Parameters.Queue.QueueActionParameters( TargetBranchId = targetBranchId.ToString(), OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let! result = Queue.Resume(parameters) if not (parseResult |> json) && not (parseResult |> silent) then AnsiConsole.MarkupLine("[green]Queue resumed.[/]") return result with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type Resume() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = resumeHandler parseResult return result |> renderOutput parseResult } let private buildEnqueueParameters (graceIds: GraceIds) (targetBranchId: Guid) (promotionSetId: Guid) (workItemId: string) (policySnapshotId: string) = Parameters.Queue.EnqueueParameters( TargetBranchId = targetBranchId.ToString(), PromotionSetId = promotionSetId.ToString(), WorkItemId = workItemId, PolicySnapshotId = policySnapshotId, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let private enqueueHandlerImpl (parseResult: ParseResult) = task { if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let promotionSetIdRaw = parseResult.GetValue(Options.promotionSetIdOptional) |> Option.ofObj |> Option.defaultValue (Guid.NewGuid().ToString()) match tryParseGuid promotionSetIdRaw QueueError.InvalidPromotionSetId parseResult with | Error error -> return Error error | Ok promotionSetId -> let! targetBranchIdResult = resolveTargetBranchId parseResult graceIds match targetBranchIdResult with | Error error -> return Error error | Ok targetBranchId -> let! policySnapshotIdResult = resolvePolicySnapshotId parseResult graceIds targetBranchId match policySnapshotIdResult with | Error error -> return Error error | Ok policySnapshotId -> let workItemIdRaw = parseResult.GetValue(Options.workItemId) |> Option.ofObj |> Option.defaultValue String.Empty match tryParseWorkItemId workItemIdRaw parseResult with | Error error -> return Error error | Ok workItemId -> let parameters = buildEnqueueParameters graceIds targetBranchId promotionSetId workItemId policySnapshotId let! result = Queue.Enqueue(parameters) if not (parseResult |> json) && not (parseResult |> silent) then AnsiConsole.MarkupLine($"[green]Enqueued promotion set[/] {Markup.Escape(promotionSetId.ToString())}") return result } let private enqueueHandler (parseResult: ParseResult) = task { try return! enqueueHandlerImpl parseResult with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type Enqueue() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = enqueueHandler parseResult return result |> renderOutput parseResult } let private dequeueHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let promotionSetIdRaw = parseResult.GetValue(Options.promotionSetId) match tryParseGuid promotionSetIdRaw QueueError.InvalidPromotionSetId parseResult with | Error error -> return Error error | Ok promotionSetId -> let! targetBranchIdResult = resolveTargetBranchId parseResult graceIds match targetBranchIdResult with | Error error -> return Error error | Ok targetBranchId -> let parameters = Parameters.Queue.PromotionSetActionParameters( TargetBranchId = targetBranchId.ToString(), PromotionSetId = promotionSetId.ToString(), OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let! result = Queue.Dequeue(parameters) if not (parseResult |> json) && not (parseResult |> silent) then AnsiConsole.MarkupLine($"[green]Dequeued promotion set[/] {Markup.Escape(promotionSetId.ToString())}") return result with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type Dequeue() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = dequeueHandler parseResult return result |> renderOutput parseResult } let Build = let addCommonOptions (command: Command) = command |> addOption Options.ownerName |> addOption Options.ownerId |> addOption Options.organizationName |> addOption Options.organizationId |> addOption Options.repositoryName |> addOption Options.repositoryId let addBranchOptions (command: Command) = command |> addOption Options.branch |> addOption Options.branchId |> addOption Options.branchName let queueCommand = new Command("queue", Description = "Manage promotion queues.") let statusCommand = new Command("status", Description = "Get the status of a promotion queue.") |> addBranchOptions |> addCommonOptions statusCommand.Action <- new Status() queueCommand.Subcommands.Add(statusCommand) let enqueueCommand = new Command("enqueue", Description = "Enqueue a promotion set in a promotion queue.") |> addOption Options.promotionSetIdOptional |> addOption Options.workItemId |> addOption Options.policySnapshotId |> addBranchOptions |> addCommonOptions enqueueCommand.Action <- new Enqueue() queueCommand.Subcommands.Add(enqueueCommand) let pauseCommand = new Command("pause", Description = "Pause a promotion queue.") |> addBranchOptions |> addCommonOptions pauseCommand.Action <- new Pause() queueCommand.Subcommands.Add(pauseCommand) let resumeCommand = new Command("resume", Description = "Resume a promotion queue.") |> addBranchOptions |> addCommonOptions resumeCommand.Action <- new Resume() queueCommand.Subcommands.Add(resumeCommand) let dequeueCommand = new Command("dequeue", Description = "Dequeue a promotion set (admin).") |> addOption Options.promotionSetId |> addBranchOptions |> addCommonOptions dequeueCommand.Action <- new Dequeue() queueCommand.Subcommands.Add(dequeueCommand) queueCommand ================================================ FILE: src/Grace.CLI/Command/Reference.CLI.fs ================================================ namespace Grace.CLI.Command open FSharpPlus open Grace.CLI.Common open Grace.CLI.Common.Validations open Grace.CLI.Services open Grace.CLI.Text open Grace.SDK open Grace.Shared open Grace.Shared.Client.Configuration open Grace.Shared.Validation.Errors open Grace.Shared.Parameters.Branch open Grace.Shared.Parameters.DirectoryVersion open Grace.Shared.Parameters.Storage open Grace.Shared.Services open Grace.Types.Branch open Grace.Types.Reference open Grace.Types.Types open Grace.Shared.Utilities open NodaTime open NodaTime.TimeZones open Spectre.Console open Spectre.Console.Json open System open System.Collections.Concurrent open System.Collections.Generic open System.CommandLine open System.CommandLine.Invocation open System.CommandLine.Parsing open System.Globalization open System.IO open System.IO.Enumeration open System.Linq open System.Security.Cryptography open System.Threading open System.Threading.Tasks open System.Text open System.Text.Json module Reference = open Grace.Shared.Validation.Common.Input module private Options = let branchId = new Option( OptionName.BranchId, [| "-i" |], Required = false, Description = "The branch's ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> BranchId.Empty) ) let branchName = new Option( OptionName.BranchName, [| "-b" |], Required = false, Description = "The name of the branch. [default: current branch]", Arity = ArgumentArity.ExactlyOne ) let branchNameRequired = new Option(OptionName.BranchName, [| "-b" |], Required = true, Description = "The name of the branch.", Arity = ArgumentArity.ExactlyOne) let ownerId = new Option( OptionName.OwnerId, Required = false, Description = "The repository's owner ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OwnerId.Empty) ) let ownerName = new Option( OptionName.OwnerName, Required = false, Description = "The repository's owner name. [default: current owner]", Arity = ArgumentArity.ExactlyOne ) let organizationId = new Option( OptionName.OrganizationId, Required = false, Description = "The repository's organization ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> OrganizationId.Empty) ) let organizationName = new Option( OptionName.OrganizationName, Required = false, Description = "The repository's organization name. [default: current organization]", Arity = ArgumentArity.ZeroOrOne ) let repositoryId = new Option( OptionName.RepositoryId, [| "-r" |], Required = false, Description = "The repository's ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> RepositoryId.Empty) ) let repositoryName = new Option( OptionName.RepositoryName, [| "-n" |], Required = false, Description = "The name of the repository. [default: current repository]", Arity = ArgumentArity.ExactlyOne ) let parentBranchId = new Option( OptionName.ParentBranchId, [||], Required = false, Description = "The parent branch's ID .", Arity = ArgumentArity.ExactlyOne ) let parentBranchName = new Option( OptionName.ParentBranchName, [||], Required = false, Description = "The name of the parent branch. [default: current branch]", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> String.Empty) ) let newName = new Option(OptionName.NewName, Required = true, Description = "The new name of the branch.", Arity = ArgumentArity.ExactlyOne) let message = new Option( OptionName.Message, [| "-m" |], Required = false, Description = "The text to store with this reference.", Arity = ArgumentArity.ExactlyOne ) let messageRequired = new Option( OptionName.Message, [| "-m" |], Required = true, Description = "The text to store with this reference.", Arity = ArgumentArity.ExactlyOne ) let referenceType = (new Option(OptionName.ReferenceType, Required = false, Description = "The type of reference.", Arity = ArgumentArity.ExactlyOne)) .AcceptOnlyFromAmong(listCases ()) let fullSha = new Option(OptionName.FullSha, Required = false, Description = "Show the full SHA-256 value in output.", Arity = ArgumentArity.ZeroOrOne) let maxCount = new Option( OptionName.MaxCount, Required = false, Description = "The maximum number of results to return.", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> 30) ) let referenceId = new Option(OptionName.ReferenceId, [||], Required = false, Description = "The reference ID .", Arity = ArgumentArity.ExactlyOne) let sha256Hash = new Option( OptionName.Sha256Hash, [||], Required = false, Description = "The full or partial SHA-256 hash value of the version.", Arity = ArgumentArity.ExactlyOne ) let enabled = new Option( OptionName.Enabled, Required = false, Description = "True to enable the feature; false to disable it.", Arity = ArgumentArity.ZeroOrOne ) let includeDeleted = new Option(OptionName.IncludeDeleted, [| "-d" |], Required = false, Description = "Include deleted branches in the result. [default: false]") let showEvents = new Option(OptionName.ShowEvents, [| "-e" |], Required = false, Description = "Include actor events in the result. [default: false]") let directoryVersionId = new Option( OptionName.DirectoryVersionId, [| "-v" |], Required = false, Description = "The directory version ID to assign to the promotion .", Arity = ArgumentArity.ExactlyOne ) let private valueOrEmpty (value: string) = if String.IsNullOrWhiteSpace(value) then String.Empty else value let private ReferenceValidations (parseResult: ParseResult) = Ok parseResult let printContents (parseResult: ParseResult) (directoryVersions: IEnumerable) = let longestRelativePath = getLongestRelativePath ( directoryVersions |> Seq.map (fun directoryVersion -> directoryVersion.ToLocalDirectoryVersion(DateTime.UtcNow)) ) //logToAnsiConsole Colors.Verbose $"In printContents: getLongestRelativePath: {longestRelativePath}" let additionalSpaces = String.replicate (longestRelativePath - 2) " " let additionalImportantDashes = String.replicate (longestRelativePath + 3) "-" let additionalDeemphasizedDashes = String.replicate (38) "-" directoryVersions |> Seq.iteri (fun i directoryVersion -> AnsiConsole.WriteLine() if i = 0 then AnsiConsole.MarkupLine( $"[{Colors.Important}]Created At SHA-256 Size Path{additionalSpaces}[/][{Colors.Deemphasized}] (DirectoryVersionId)[/]" ) AnsiConsole.MarkupLine( $"[{Colors.Important}]-----------------------------------------------------{additionalImportantDashes}[/][{Colors.Deemphasized}] {additionalDeemphasizedDashes}[/]" ) //logToAnsiConsole Colors.Verbose $"In printContents: directoryVersion.RelativePath: {directoryVersion.RelativePath}" let rightAlignedDirectoryVersionId = (String.replicate (longestRelativePath - directoryVersion.RelativePath.Length) " ") + $"({directoryVersion.DirectoryVersionId})" AnsiConsole.MarkupLine( $"[{Colors.Highlighted}]{formatInstantAligned directoryVersion.CreatedAt} {getShortSha256Hash directoryVersion.Sha256Hash} {directoryVersion.Size, 13:N0} /{directoryVersion.RelativePath}[/] [{Colors.Deemphasized}] {rightAlignedDirectoryVersionId}[/]" ) //if parseResult.CommandResult.Command.Options.Contains(Options.listFiles) then let sortedFiles = directoryVersion.Files.OrderBy(fun f -> f.RelativePath) for file in sortedFiles do AnsiConsole.MarkupLine( $"[{Colors.Verbose}]{formatInstantAligned file.CreatedAt} {getShortSha256Hash file.Sha256Hash} {file.Size, 13:N0} |- {file.RelativePath.Split('/').LastOrDefault()}[/]" )) type GetRecursiveSize() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = parseResult |> CommonValidations >>= ReferenceValidations let graceIds = parseResult |> getNormalizedIdsAndNames match validateIncomingParameters with | Ok _ -> let referenceId = if not <| isNull (parseResult.GetResult(Options.referenceId)) then parseResult .GetValue(Options.referenceId) .ToString() else String.Empty let sha256Hash = parseResult.GetValue(Options.sha256Hash) let sdkParameters = Parameters.Branch.ListContentsParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, Sha256Hash = sha256Hash, ReferenceId = referenceId, Pattern = String.Empty, ShowDirectories = true, ShowFiles = true, ForceRecompute = false, CorrelationId = graceIds.CorrelationId ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Grace.SDK.Branch.GetRecursiveSize(sdkParameters) t0.Increment(100.0) return response }) else Grace.SDK.Branch.GetRecursiveSize(sdkParameters) match result with | Ok returnValue -> AnsiConsole.MarkupLine $"[{Colors.Highlighted}]Total file size: {returnValue.ReturnValue:N0}[/]" | Error error -> AnsiConsole.MarkupLine $"[{Colors.Error}]{error}[/]" return result |> renderOutput parseResult | Error error -> return GraceResult.Error error |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type ListContents() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> ReferenceValidations match validateIncomingParameters with | Ok _ -> let referenceId = if not <| isNull (parseResult.GetResult(Options.referenceId)) then parseResult .GetValue(Options.referenceId) .ToString() else String.Empty let sha256Hash = parseResult.GetValue(Options.sha256Hash) let sdkParameters = Parameters.Branch.ListContentsParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, Sha256Hash = sha256Hash, ReferenceId = referenceId, Pattern = String.Empty, ShowDirectories = true, ShowFiles = true, ForceRecompute = false, CorrelationId = graceIds.CorrelationId ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Grace.SDK.Branch.ListContents(sdkParameters) t0.Increment(100.0) return response }) else Grace.SDK.Branch.ListContents(sdkParameters) match result with | Ok returnValue -> let! graceStatus = readGraceStatusFile () let directoryVersions = returnValue .ReturnValue .Select(fun directoryVersionDto -> directoryVersionDto.DirectoryVersion) .OrderBy(fun dv -> dv.RelativePath) let directoryCount = directoryVersions.Count() let fileCount = directoryVersions .Select(fun directoryVersion -> directoryVersion.Files.Count) .Sum() let totalFileSize = directoryVersions.Sum(fun directoryVersion -> directoryVersion.Files.Sum(fun f -> f.Size)) let rootDirectoryVersion = directoryVersions.First(fun d -> d.RelativePath = Constants.RootDirectoryPath) AnsiConsole.MarkupLine($"[{Colors.Important}]All values taken from the selected version of this branch from the server.[/]") AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Number of directories: {directoryCount}.[/]") AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Number of files: {fileCount}; total file size: {totalFileSize:N0}.[/]") AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Root SHA-256 hash: {rootDirectoryVersion.Sha256Hash.Substring(0, 8)}[/]") printContents parseResult directoryVersions return result |> renderOutput parseResult | Error _ -> return result |> renderOutput parseResult | Error error -> return GraceResult.Error error |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type Assign() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> ReferenceValidations let directoryVersionId = if not <| isNull (parseResult.GetResult(Options.directoryVersionId)) then parseResult.GetValue(Options.directoryVersionId) else Guid.Empty let sha256Hash = parseResult.GetValue(Options.sha256Hash) match validateIncomingParameters with | Ok _ -> match (directoryVersionId, sha256Hash) with | (directoryVersionId, sha256Hash) when directoryVersionId = Guid.Empty && sha256Hash = String.Empty -> let error = GraceError.Create (getErrorMessage ReferenceError.EitherDirectoryVersionIdOrSha256HashRequired) (parseResult |> getCorrelationId) return Error error |> renderOutput parseResult | _ -> let assignParameters = Parameters.Branch.AssignParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, DirectoryVersionId = directoryVersionId, Sha256Hash = sha256Hash, CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Grace.SDK.Branch.Assign(assignParameters) t0.Increment(100.0) return response }) else Grace.SDK.Branch.Assign(assignParameters) return result |> renderOutput parseResult | Error graceError -> return Error graceError |> renderOutput parseResult with | ex -> let error = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return Error error |> renderOutput parseResult } type CreateReferenceCommand = CreateReferenceParameters -> Task> //type ReferenceCommandContext = // { GraceIds: GraceIds // OwnerId: string // OwnerName: string // OrganizationId: string // OrganizationName: string // RepositoryId: string // RepositoryName: string // BranchId: string // BranchName: string // CorrelationId: string } //let buildReferenceContext (parseResult: ParseResult) = // let graceIds = parseResult |> getNormalizedIdsAndNames // let commonParameters = CommonParameters() // commonParameters.OwnerId <- graceIds.OwnerIdString |> valueOrEmpty // commonParameters.OwnerName <- graceIds.OwnerName |> valueOrEmpty // commonParameters.OrganizationId <- graceIds.OrganizationIdString |> valueOrEmpty // commonParameters.OrganizationName <- graceIds.OrganizationName |> valueOrEmpty // commonParameters.RepositoryId <- graceIds.RepositoryIdString |> valueOrEmpty // commonParameters.RepositoryName <- graceIds.RepositoryName |> valueOrEmpty // commonParameters.BranchId <- graceIds.BranchIdString |> valueOrEmpty // commonParameters.BranchName <- graceIds.BranchName |> valueOrEmpty // let (ownerId, organizationId, repositoryId, branchId) = getIds commonParameters // let ownerName = // if String.IsNullOrWhiteSpace(commonParameters.OwnerName) then // $"{Current().OwnerName}" // else // commonParameters.OwnerName // let organizationName = // if String.IsNullOrWhiteSpace(commonParameters.OrganizationName) then // $"{Current().OrganizationName}" // else // commonParameters.OrganizationName // let repositoryName = // if String.IsNullOrWhiteSpace(commonParameters.RepositoryName) then // $"{Current().RepositoryName}" // else // commonParameters.RepositoryName // let branchName = // if String.IsNullOrWhiteSpace(commonParameters.BranchName) then // $"{Current().BranchName}" // else // commonParameters.BranchName // { GraceIds = graceIds // OwnerId = ownerId // OwnerName = ownerName // OrganizationId = organizationId // OrganizationName = organizationName // RepositoryId = repositoryId // RepositoryName = repositoryName // BranchId = branchId // BranchName = branchName // CorrelationId = getCorrelationId parseResult } let createReferenceHandler (parseResult: ParseResult) (message: string) (command: CreateReferenceCommand) (commandType: string) = task { try if parseResult |> verbose then printParseResult parseResult let referenceId = if not <| isNull (parseResult.GetResult(Options.referenceId)) then parseResult .GetValue(Options.referenceId) .ToString() else String.Empty let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> CommonValidations >>= ReferenceValidations match validateIncomingParameters with | Ok _ -> //let sha256Bytes = SHA256.HashData(Encoding.ASCII.GetBytes(rnd.NextInt64().ToString("x8"))) //let sha256Hash = Seq.fold (fun (sb: StringBuilder) currentByte -> // sb.Append(sprintf $"{currentByte:X2}")) (StringBuilder(sha256Bytes.Length)) sha256Bytes if parseResult |> hasOutput then return! progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Reading Grace status file.[/]") let t1 = progressContext.AddTask($"[{Color.DodgerBlue1}]Scanning working directory for changes.[/]", autoStart = false) let t2 = progressContext.AddTask($"[{Color.DodgerBlue1}]Creating new directory verions.[/]", autoStart = false) let t3 = progressContext.AddTask($"[{Color.DodgerBlue1}]Uploading changed files to object storage.[/]", autoStart = false) let t4 = progressContext.AddTask($"[{Color.DodgerBlue1}]Uploading new directory versions.[/]", autoStart = false) let t5 = progressContext.AddTask($"[{Color.DodgerBlue1}]Creating new {commandType}.[/]", autoStart = false) //let mutable rootDirectoryId = DirectoryId.Empty //let mutable rootDirectorySha256Hash = Sha256Hash String.Empty let rootDirectoryVersion = ref (DirectoryVersionId.Empty, Sha256Hash String.Empty) match! getGraceWatchStatus () with | Some graceWatchStatus -> t0.Value <- 100.0 t1.Value <- 100.0 t2.Value <- 100.0 t3.Value <- 100.0 t4.Value <- 100.0 rootDirectoryVersion.Value <- (graceWatchStatus.RootDirectoryId, graceWatchStatus.RootDirectorySha256Hash) | None -> t0.StartTask() // Read Grace status file. let! previousGraceStatus = readGraceStatusFile () let mutable newGraceStatus = previousGraceStatus t0.Value <- 100.0 t1.StartTask() // Scan for differences. let! differences = scanForDifferences previousGraceStatus //logToAnsiConsole Colors.Verbose $"differences: {serialize differences}" let! newFileVersions = copyUpdatedFilesToObjectCache t1 differences //logToAnsiConsole Colors.Verbose $"newFileVersions: {serialize newFileVersions}" t1.Value <- 100.0 t2.StartTask() // Create new directory versions. let! (updatedGraceStatus, newDirectoryVersions) = getNewGraceStatusAndDirectoryVersions previousGraceStatus differences newGraceStatus <- updatedGraceStatus rootDirectoryVersion.Value <- (newGraceStatus.RootDirectoryId, newGraceStatus.RootDirectorySha256Hash) t2.Value <- 100.0 t3.StartTask() // Upload to object storage. let updatedRelativePaths = differences .Select(fun difference -> match difference.DifferenceType with | Add -> match difference.FileSystemEntryType with | FileSystemEntryType.File -> Some difference.RelativePath | FileSystemEntryType.Directory -> None | Change -> match difference.FileSystemEntryType with | FileSystemEntryType.File -> Some difference.RelativePath | FileSystemEntryType.Directory -> None | Delete -> None) .Where(fun relativePathOption -> relativePathOption.IsSome) .Select(fun relativePath -> relativePath.Value) // let newFileVersions = updatedRelativePaths.Select(fun relativePath -> // newDirectoryVersions.First(fun dv -> dv.Files.Exists(fun file -> file.RelativePath = relativePath)).Files.First(fun file -> file.RelativePath = relativePath)) let mutable lastFileUploadInstant = newGraceStatus.LastSuccessfulFileUpload if newFileVersions.Count() > 0 then let getUploadMetadataForFilesParameters = GetUploadMetadataForFilesParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId, FileVersions = (newFileVersions |> Seq.map (fun localFileVersion -> localFileVersion.ToFileVersion) |> Seq.toArray) ) match! uploadFilesToObjectStorage getUploadMetadataForFilesParameters with | Ok returnValue -> () //logToAnsiConsole Colors.Verbose $"Uploaded all files to object storage." | Error error -> logToAnsiConsole Colors.Error $"Error uploading files to object storage: {error.Error}" lastFileUploadInstant <- getCurrentInstant () t3.Value <- 100.0 t4.StartTask() // Upload directory versions. let mutable lastDirectoryVersionUpload = newGraceStatus.LastSuccessfulDirectoryVersionUpload if newDirectoryVersions.Count > 0 then let saveParameters = SaveDirectoryVersionsParameters() saveParameters.OwnerId <- graceIds.OwnerIdString saveParameters.OwnerName <- graceIds.OwnerName saveParameters.OrganizationId <- graceIds.OrganizationIdString saveParameters.OrganizationName <- graceIds.OrganizationName saveParameters.RepositoryId <- graceIds.RepositoryIdString saveParameters.RepositoryName <- graceIds.RepositoryName saveParameters.CorrelationId <- graceIds.CorrelationId saveParameters.DirectoryVersionId <- $"{newGraceStatus.RootDirectoryId}" saveParameters.DirectoryVersions <- newDirectoryVersions .Select(fun dv -> dv.ToDirectoryVersion) .ToList() let! uploadDirectoryVersions = DirectoryVersion.SaveDirectoryVersions saveParameters lastDirectoryVersionUpload <- getCurrentInstant () t4.Value <- 100.0 newGraceStatus <- { newGraceStatus with LastSuccessfulFileUpload = lastFileUploadInstant LastSuccessfulDirectoryVersionUpload = lastDirectoryVersionUpload } do! applyGraceStatusIncremental newGraceStatus newDirectoryVersions differences t5.StartTask() // Create new reference. let (rootDirectoryId, rootDirectorySha256Hash) = rootDirectoryVersion.Value let sdkParameters = Parameters.Branch.CreateReferenceParameters( BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, DirectoryVersionId = rootDirectoryId, Sha256Hash = rootDirectorySha256Hash, Message = message, CorrelationId = graceIds.CorrelationId ) let! result = command sdkParameters t5.Value <- 100.0 return result }) else let! previousGraceStatus = readGraceStatusFile () let! differences = scanForDifferences previousGraceStatus let! (newGraceIndex, newDirectoryVersions) = getNewGraceStatusAndDirectoryVersions previousGraceStatus differences let updatedRelativePaths = differences .Select(fun difference -> match difference.DifferenceType with | Add -> match difference.FileSystemEntryType with | FileSystemEntryType.File -> Some difference.RelativePath | FileSystemEntryType.Directory -> None | Change -> match difference.FileSystemEntryType with | FileSystemEntryType.File -> Some difference.RelativePath | FileSystemEntryType.Directory -> None | Delete -> None) .Where(fun relativePathOption -> relativePathOption.IsSome) .Select(fun relativePath -> relativePath.Value) let newFileVersions = updatedRelativePaths.Select (fun relativePath -> newDirectoryVersions .First(fun dv -> dv.Files.Exists(fun file -> file.RelativePath = relativePath)) .Files.First(fun file -> file.RelativePath = relativePath)) let getUploadMetadataForFilesParameters = GetUploadMetadataForFilesParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId, FileVersions = (newFileVersions |> Seq.map (fun localFileVersion -> localFileVersion.ToFileVersion) |> Seq.toArray) ) let! uploadResult = uploadFilesToObjectStorage getUploadMetadataForFilesParameters let saveParameters = SaveDirectoryVersionsParameters() saveParameters.OwnerId <- graceIds.OwnerIdString saveParameters.OwnerName <- graceIds.OwnerName saveParameters.OrganizationId <- graceIds.OrganizationIdString saveParameters.OrganizationName <- graceIds.OrganizationName saveParameters.RepositoryId <- graceIds.RepositoryIdString saveParameters.RepositoryName <- graceIds.RepositoryName saveParameters.CorrelationId <- graceIds.CorrelationId saveParameters.DirectoryVersions <- newDirectoryVersions .Select(fun dv -> dv.ToDirectoryVersion) .ToList() let! uploadDirectoryVersions = DirectoryVersion.SaveDirectoryVersions saveParameters let rootDirectoryVersion = getRootDirectoryVersion previousGraceStatus let sdkParameters = Parameters.Branch.CreateReferenceParameters( BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, DirectoryVersionId = rootDirectoryVersion.DirectoryVersionId, Sha256Hash = rootDirectoryVersion.Sha256Hash, Message = message, CorrelationId = graceIds.CorrelationId ) let! result = command sdkParameters return result | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId)) } let promotionHandler (parseResult: ParseResult) (message: string) = task { try if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = parseResult |> ReferenceValidations let graceIds = parseResult |> getNormalizedIdsAndNames match validateIncomingParameters with | Ok _ -> if parseResult |> hasOutput then return! progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Reading Grace status file.[/]") let t1 = progressContext.AddTask($"[{Color.DodgerBlue1}]Checking if the promotion is valid.[/]", autoStart = false) let t2 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]", autoStart = false) // Read Grace status file. let! graceStatus = readGraceStatusFile () let rootDirectoryId = graceStatus.RootDirectoryId let rootDirectorySha256Hash = graceStatus.RootDirectorySha256Hash t0.Value <- 100.0 // Check if the promotion is valid; i.e. it's allowed by the ReferenceTypes enabled in the repository. t1.StartTask() // For single-step promotion, the current branch's latest commit will become the parent branch's next promotion. // If our current state is not the latest commit, print a warning message. // Get the Dto for the current branch. That will have its latest commit. let branchGetParameters = GetBranchParameters( BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let! branchResult = Grace.SDK.Branch.Get(branchGetParameters) match branchResult with | Ok branchReturnValue -> // If we succeeded, get the parent branch Dto. That will have its latest promotion. let! parentBranchResult = Branch.GetParentBranch(branchGetParameters) match parentBranchResult with | Ok parentBranchReturnValue -> // Yay, we have both Dto's. let branchDto = branchReturnValue.ReturnValue let parentBranchDto = parentBranchReturnValue.ReturnValue // Get the references for the latest commit and/or promotion on the current branch. //let getReferenceParameters = // Parameters.Branch.GetReferenceParameters(BranchId = parameters.BranchId, BranchName = parameters.BranchName, // OwnerId = parameters.OwnerId, OwnerName = parameters.OwnerName, // OrganizationId = parameters.OrganizationId, OrganizationName = parameters.OrganizationName, // RepositoryId = parameters.RepositoryId, RepositoryName = parameters.RepositoryName, // ReferenceId = $"{branchDto.LatestCommit}", CorrelationId = parameters.CorrelationId) //let! referenceResult = Branch.GetReference(getReferenceParameters) let referenceIds = List() if branchDto.LatestCommit <> ReferenceDto.Default then referenceIds.Add(branchDto.LatestCommit.ReferenceId) if branchDto.LatestPromotion <> ReferenceDto.Default then referenceIds.Add(branchDto.LatestPromotion.ReferenceId) if referenceIds.Count > 0 then let getReferencesByReferenceIdParameters = Parameters.Repository.GetReferencesByReferenceIdParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, ReferenceIds = referenceIds, CorrelationId = graceIds.CorrelationId ) match! Repository.GetReferencesByReferenceId(getReferencesByReferenceIdParameters) with | Ok returnValue -> let references = returnValue.ReturnValue let latestPromotableReference = references .OrderByDescending(fun reference -> reference.CreatedAt) .First() // If the current branch's latest reference is not the latest commit - i.e. they've done more work in the branch // after the commit they're expecting to promote - print a warning. //match getReferencesByReferenceIdResult with //| Ok returnValue -> // let references = returnValue.ReturnValue // if referenceDto.DirectoryId <> graceStatus.RootDirectoryId then // logToAnsiConsole Colors.Important $"Note: the branch has been updated since the latest commit." //| Error error -> () // I don't really care if this call fails, it's just a warning message. t1.Value <- 100.0 // If the current branch is based on the parent's latest promotion, then we can proceed with the promotion. if branchDto.BasedOn.ReferenceId = parentBranchDto.LatestPromotion.ReferenceId then t2.StartTask() let promotionParameters = Parameters.Branch.CreateReferenceParameters( BranchId = $"{parentBranchDto.BranchId}", OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, DirectoryVersionId = latestPromotableReference.DirectoryId, Sha256Hash = latestPromotableReference.Sha256Hash, Message = message, CorrelationId = graceIds.CorrelationId ) let! promotionResult = Grace.SDK.Branch.Promote(promotionParameters) match promotionResult with | Ok returnValue -> logToAnsiConsole Colors.Verbose $"Succeeded doing promotion." let promotionReferenceId = Guid.Parse(returnValue.Properties["ReferenceId"] :?> string) let rebaseParameters = Parameters.Branch.RebaseParameters( BranchId = $"{branchDto.BranchId}", RepositoryId = $"{branchDto.RepositoryId}", OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, BasedOn = promotionReferenceId ) let! rebaseResult = Grace.SDK.Branch.Rebase(rebaseParameters) t2.Value <- 100.0 match rebaseResult with | Ok returnValue -> logToAnsiConsole Colors.Verbose $"Succeeded doing rebase." return promotionResult | Error error -> return Error error | Error error -> t2.Value <- 100.0 return Error error else return Error( GraceError.Create (getErrorMessage BranchError.BranchIsNotBasedOnLatestPromotion) (parseResult |> getCorrelationId) ) | Error error -> t2.Value <- 100.0 return Error error else return Error( GraceError.Create (getErrorMessage ReferenceError.PromotionNotAvailableBecauseThereAreNoPromotableReferences) (parseResult |> getCorrelationId) ) | Error error -> t1.Value <- 100.0 return Error error | Error error -> t1.Value <- 100.0 return Error error }) else // Same result, with no output. return Error(GraceError.Create "Need to implement the else clause." (parseResult |> getCorrelationId)) | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId)) } type Promote() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { let graceIds = parseResult |> getNormalizedIdsAndNames let message = parseResult.GetValue(Options.message) |> valueOrEmpty let! result = promotionHandler parseResult message return result |> renderOutput parseResult } type Commit() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { let graceIds = parseResult |> getNormalizedIdsAndNames let message = parseResult.GetValue(Options.messageRequired) |> valueOrEmpty let command (parameters: CreateReferenceParameters) = task { return! Grace.SDK.Branch.Commit(parameters) } let! result = createReferenceHandler parseResult message command (nameof(Commit).ToLowerInvariant()) return result |> renderOutput parseResult } type Checkpoint() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { let graceIds = parseResult |> getNormalizedIdsAndNames let message = parseResult.GetValue(Options.message) |> valueOrEmpty let command (parameters: CreateReferenceParameters) = task { return! Grace.SDK.Branch.Checkpoint(parameters) } let! result = createReferenceHandler parseResult message command (nameof(Checkpoint).ToLowerInvariant()) return result |> renderOutput parseResult } type Save() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { let graceIds = parseResult |> getNormalizedIdsAndNames let message = parseResult.GetValue(Options.message) |> valueOrEmpty let command (parameters: CreateReferenceParameters) = task { return! Grace.SDK.Branch.Save(parameters) } let! result = createReferenceHandler parseResult message command (nameof(Save).ToLowerInvariant()) return result |> renderOutput parseResult } type Tag() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { let graceIds = parseResult |> getNormalizedIdsAndNames let message = parseResult.GetValue(Options.messageRequired) |> valueOrEmpty let command (parameters: CreateReferenceParameters) = task { return! Grace.SDK.Branch.Tag(parameters) } let! result = createReferenceHandler parseResult message command (nameof(Tag).ToLowerInvariant()) return result |> renderOutput parseResult } type CreateExternal() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { let graceIds = parseResult |> getNormalizedIdsAndNames let message = parseResult.GetValue(Options.messageRequired) |> valueOrEmpty let command (parameters: CreateReferenceParameters) = task { return! Grace.SDK.Branch.CreateExternal(parameters) } let! result = createReferenceHandler parseResult message command ("External".ToLowerInvariant()) return result |> renderOutput parseResult } type Get() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let includeDeleted = parseResult.GetValue(Options.includeDeleted) let showEvents = parseResult.GetValue(Options.showEvents) let validateIncomingParameters = parseResult |> ReferenceValidations match validateIncomingParameters with | Ok _ -> let branchParameters = GetBranchParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, IncludeDeleted = includeDeleted, CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Grace.SDK.Branch.Get(branchParameters) t0.Increment(100.0) return response }) else Grace.SDK.Branch.Get(branchParameters) match result with | Ok graceReturnValue -> let jsonText = JsonText(serialize graceReturnValue.ReturnValue) AnsiConsole.Write(jsonText) AnsiConsole.WriteLine() if showEvents then let eventParameters = GetBranchVersionParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, IncludeDeleted = includeDeleted, CorrelationId = getCorrelationId parseResult ) let! eventsResult = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Branch.GetEvents(eventParameters) t0.Increment(100.0) return response }) else Branch.GetEvents(eventParameters) match eventsResult with | Ok eventsValue -> for line in eventsValue.ReturnValue do AnsiConsole.MarkupLine $"[{Colors.Verbose}]{Markup.Escape(line)}[/]" AnsiConsole.WriteLine() return 0 | Error eventError -> return GraceResult.Error eventError |> renderOutput parseResult else return 0 | Error graceError -> return GraceResult.Error graceError |> renderOutput parseResult | Error graceError -> return GraceResult.Error graceError |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } type Delete() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> ReferenceValidations match validateIncomingParameters with | Ok _ -> let deleteParameters = Parameters.Branch.DeleteBranchParameters( BranchId = graceIds.BranchIdString, BranchName = graceIds.BranchName, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Grace.SDK.Branch.Delete(deleteParameters) t0.Increment(100.0) return response }) else Grace.SDK.Branch.Delete(deleteParameters) return result |> renderOutput parseResult | Error graceError -> return GraceResult.Error graceError |> renderOutput parseResult with | ex -> let graceError = GraceError.Create $"{ExceptionResponse.Create ex}" (parseResult |> getCorrelationId) return renderOutput parseResult (GraceResult.Error graceError) } let Build = let addCommonOptions (command: Command) = command |> addOption Options.ownerName |> addOption Options.ownerId |> addOption Options.organizationName |> addOption Options.organizationId |> addOption Options.repositoryName |> addOption Options.repositoryId |> addOption Options.branchName |> addOption Options.branchId // Create main command and aliases, if any.` let referenceCommand = new Command("reference", Description = "Create or delete references.") referenceCommand.Aliases.Add("ref") let promoteCommand = new Command("promote", Description = "Promotes a commit into the parent branch.") |> addOption Options.message |> addCommonOptions promoteCommand.Action <- new Promote() referenceCommand.Subcommands.Add(promoteCommand) let commitCommand = new Command("commit", Description = "Create a commit.") |> addOption Options.messageRequired |> addCommonOptions commitCommand.Action <- new Commit() referenceCommand.Subcommands.Add(commitCommand) let checkpointCommand = new Command("checkpoint", Description = "Create a checkpoint.") |> addOption Options.message |> addCommonOptions checkpointCommand.Action <- new Checkpoint() referenceCommand.Subcommands.Add(checkpointCommand) let saveCommand = new Command("save", Description = "Create a save.") |> addOption Options.message |> addCommonOptions saveCommand.Action <- new Save() referenceCommand.Subcommands.Add(saveCommand) let tagCommand = new Command("tag", Description = "Create a tag.") |> addOption Options.messageRequired |> addCommonOptions tagCommand.Action <- new Tag() referenceCommand.Subcommands.Add(tagCommand) let createExternalCommand = new Command("create-external", Description = "Create an external reference.") |> addOption Options.messageRequired |> addCommonOptions createExternalCommand.Action <- new CreateExternal() referenceCommand.Subcommands.Add(createExternalCommand) let getCommand = new Command("get", Description = "Gets details for the branch.") |> addOption Options.includeDeleted |> addOption Options.showEvents |> addCommonOptions getCommand.Action <- new Get() referenceCommand.Subcommands.Add(getCommand) let deleteCommand = new Command("delete", Description = "Delete the branch.") |> addCommonOptions deleteCommand.Action <- new Delete() referenceCommand.Subcommands.Add(deleteCommand) let assignCommand = new Command("assign", Description = "Assign a promotion to this branch.") |> addOption Options.directoryVersionId |> addOption Options.sha256Hash |> addOption Options.message |> addCommonOptions assignCommand.Action <- new Assign() referenceCommand.Subcommands.Add(assignCommand) //let undeleteCommand = new Command("undelete", Description = "Undelete a deleted owner.") |> addCommonOptions //undeleteCommand.Action <- Undelete //branchCommand.Subcommands.Add(undeleteCommand) referenceCommand ================================================ FILE: src/Grace.CLI/Command/Repository.CLI.fs ================================================ namespace Grace.CLI.Command open FSharpPlus open Grace.CLI.Common open Grace.CLI.Common.Validations open Grace.CLI.Services open Grace.CLI.Text open Grace.SDK open Grace.Shared open Grace.Shared.Parameters open Grace.Types.Types open Grace.Shared.Utilities open Grace.Shared.Client.Configuration open Grace.Shared.Parameters.DirectoryVersion open Grace.Shared.Parameters.Repository open Grace.Shared.Parameters.Storage open Grace.Shared.Validation.Errors open NodaTime open Spectre.Console open System open System.Collections.Concurrent open System.Collections.Generic open System.CommandLine open System.CommandLine.Invocation open System.CommandLine.Parsing open System.Linq open System.IO open System.Threading open System.Threading.Tasks open Spectre.Console.Json module Repository = module private Options = let ownerId = new Option( OptionName.OwnerId, Required = false, Description = "The repository's owner ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OwnerId.Empty) ) let ownerName = new Option( OptionName.OwnerName, Required = false, Description = "The repository's owner name. [default: current owner]", Arity = ArgumentArity.ExactlyOne ) let organizationId = new Option( OptionName.OrganizationId, Required = false, Description = "The repository's organization ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OrganizationId.Empty) ) let organizationName = new Option( OptionName.OrganizationName, Required = false, Description = "The repository's organization name. [default: current organization]", Arity = ArgumentArity.ZeroOrOne ) let repositoryId = new Option( OptionName.RepositoryId, [| "-r" |], Required = false, Description = "The repository's ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> RepositoryId.Empty) ) let repositoryName = new Option( OptionName.RepositoryName, [| "-n" |], Required = false, Description = "The name of the repository. [default: current repository]", Arity = ArgumentArity.ExactlyOne ) let requiredRepositoryName = new Option( OptionName.RepositoryName, [| "-n" |], Required = true, Description = "The name of the repository.", Arity = ArgumentArity.ExactlyOne ) let description = new Option(OptionName.Description, Required = false, Description = "The description of the repository.", Arity = ArgumentArity.ExactlyOne) let visibility = (new Option( OptionName.Visibility, Required = true, Description = "The visibility of the repository.", Arity = ArgumentArity.ExactlyOne )) .AcceptOnlyFromAmong(listCases ()) let status = (new Option(OptionName.Status, Required = true, Description = "The status of the repository.", Arity = ArgumentArity.ExactlyOne)) .AcceptOnlyFromAmong(listCases ()) let recordSaves = new Option( OptionName.RecordSaves, Required = true, Description = "True to record all saves; false to turn it off.", Arity = ArgumentArity.ExactlyOne ) let defaultServerApiVersion = (new Option( OptionName.DefaultServerApiVersion, Required = true, Description = "The default version of the server API that clients should use when accessing this repository.", Arity = ArgumentArity.ExactlyOne )) .AcceptOnlyFromAmong(listCases ()) let saveDays = new Option( OptionName.SaveDays, Required = true, Description = "How many days to keep saves. [default: 7.0]", Arity = ArgumentArity.ExactlyOne ) let checkpointDays = new Option( OptionName.CheckpointDays, Required = true, Description = "How many days to keep checkpoints. [default: 365.0]", Arity = ArgumentArity.ExactlyOne ) let diffCacheDays = new Option( OptionName.DiffCacheDays, Required = true, Description = "How many days to keep diff results cached in the database. [default: 3.0]", Arity = ArgumentArity.ExactlyOne ) let directoryVersionCacheDays = new Option( OptionName.DirectoryVersionCacheDays, Required = true, Description = "How many days to keep recursive directory version contents cached. [default: 3.0]", Arity = ArgumentArity.ExactlyOne ) let logicalDeleteDays = new Option( OptionName.LogicalDeleteDays, Required = true, Description = "How many days to keep deleted branches before permanently deleting them. [default: 30.0]", Arity = ArgumentArity.ExactlyOne ) let newName = new Option(OptionName.NewName, Required = true, Description = "The new name for the repository.", Arity = ArgumentArity.ExactlyOne) let deleteReason = new Option( OptionName.DeleteReason, Required = true, Description = "The reason for deleting the repository.", Arity = ArgumentArity.ExactlyOne ) let graceConfig = new Option( OptionName.GraceConfig, Required = false, Description = "The path of a Grace config file that you'd like to use instead of the default graceconfig.json.", Arity = ArgumentArity.ExactlyOne ) let force = new Option( OptionName.Force, Required = false, Description = "Deletes repository even if there are links to other repositories.", Arity = ArgumentArity.ExactlyOne ) let doNotSwitch = new Option( OptionName.DoNotSwitch, Required = false, Description = "Do not switch your current repository to the new repository after it is created. By default, the new repository becomes the current repository.", Arity = ArgumentArity.ZeroOrOne ) let directory = new Option( OptionName.Directory, Required = false, Description = "The directory to use when initializing the repository. [default: current directory]", Arity = ArgumentArity.ExactlyOne ) let includeDeleted = new Option( OptionName.IncludeDeleted, [| "-d" |], Required = false, Description = "Include deleted branches in the result.", DefaultValueFactory = (fun _ -> false) ) let anonymousAccess = new Option( OptionName.AnonymousAccess, Required = true, Description = "Enable or disable anonymous access for the repository.", Arity = ArgumentArity.ExactlyOne ) let allowsLargeFiles = new Option( OptionName.AllowsLargeFiles, Required = true, Description = "Enable or disable large file support for the repository.", Arity = ArgumentArity.ExactlyOne ) let conflictResolutionPolicy = (new Option( "--conflict-resolution-policy", Required = true, Description = "The repository's resolution conflict policy when conflicts are detected in a PromotionSet.", Arity = ArgumentArity.ExactlyOne )) .AcceptOnlyFromAmong(listCases ()) let confidenceThreshold = new Option( "--confidence-threshold", Required = false, Description = "The confidence threshold for auto-accepting conflict resolutions (0.0 to 1.0). Required when policy is ConflictsAllowedWithConfidence.", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> 0.8f) ) confidenceThreshold.Validators.Add (fun optionResult -> let parseResult = optionResult.GetValueOrDefault() if parseResult < 0.0f || parseResult > 1.0f then optionResult.AddError("The confidence threshold must be between 0.0 and 1.0.")) // Create subcommand. type Create() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult // In a Create() command, if --repository-id is implicit, that's actually the old RepositoryId taken from graceconfig.json, // and we need to set RepositoryId to a new Guid. let mutable graceIds = parseResult |> getNormalizedIdsAndNames if parseResult.GetResult(Options.ownerId).Implicit then let repositoryId = Guid.NewGuid() graceIds <- { graceIds with RepositoryId = repositoryId; RepositoryIdString = $"{repositoryId}" } let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let repositoryIdOption = parseResult.GetResult(Options.repositoryId) let repositoryId = if isNull repositoryIdOption || repositoryIdOption.Implicit then Guid.NewGuid().ToString() else graceIds.RepositoryIdString let ownerId = if graceIds.HasOwner then graceIds.OwnerIdString else $"{Current().OwnerId}" let organizationId = if graceIds.HasOrganization then graceIds.OrganizationIdString else $"{Current().OrganizationId}" let parameters = Repository.CreateRepositoryParameters( RepositoryId = repositoryId, RepositoryName = graceIds.RepositoryName, OwnerId = ownerId, OwnerName = graceIds.OwnerName, OrganizationId = organizationId, OrganizationName = graceIds.OrganizationName, CorrelationId = getCorrelationId parseResult ) if parseResult |> hasOutput then let! result = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = Repository.Create(parameters) t0.Increment(100.0) return result }) match result with | Ok returnValue -> if not <| parseResult.GetValue(Options.doNotSwitch) then let newConfig = Current() newConfig.RepositoryId <- Guid.Parse($"{returnValue.Properties[nameof RepositoryId]}") newConfig.RepositoryName <- $"{returnValue.Properties[nameof RepositoryName]}" newConfig.BranchId <- Guid.Parse($"{returnValue.Properties[nameof BranchId]}") newConfig.BranchName <- $"{returnValue.Properties[nameof BranchName]}" newConfig.DefaultBranchName <- "main" newConfig.ObjectStorageProvider <- ObjectStorageProvider.AzureBlobStorage updateConfiguration newConfig return result |> renderOutput parseResult | Error error -> logToAnsiConsole Colors.Error (Markup.Escape($"{error}")) return result |> renderOutput parseResult else let! result = Repository.Create(parameters) match result with | Ok returnValue -> if not <| parseResult.GetValue(Options.doNotSwitch) then let newConfig = Current() newConfig.RepositoryId <- Guid.Parse($"{returnValue.Properties[nameof RepositoryId]}") newConfig.RepositoryName <- $"{returnValue.Properties[nameof RepositoryName]}" newConfig.BranchId <- Guid.Parse($"{returnValue.Properties[nameof BranchId]}") newConfig.BranchName <- $"{returnValue.Properties[nameof BranchName]}" newConfig.DefaultBranchName <- "main" newConfig.ObjectStorageProvider <- ObjectStorageProvider.AzureBlobStorage updateConfiguration newConfig return result |> renderOutput parseResult | Error error -> logToAnsiConsole Colors.Error (Markup.Escape($"{error}")) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } // Init subcommand /// Validates that the directory exists. Placed here so it can use InitParameters. let ``Directory must be a valid path`` (parseResult: ParseResult) = if parseResult.CommandResult.Command.Options.Contains(Options.directory) && not <| Directory.Exists(parseResult.GetValue(Options.directory)) then Error(GraceError.Create (RepositoryError.getErrorMessage InvalidDirectory) (getCorrelationId parseResult)) else Ok parseResult let private initHandler (parseResult: ParseResult) (parameters: InitParameters) = task { try if parseResult |> verbose then printParseResult parseResult let directoryIsValid = parseResult |> ``Directory must be a valid path`` match directoryIsValid with | Ok _ -> let validateIncomingParameters = parseResult |> CommonValidations match validateIncomingParameters with | Ok _ -> let graceIds = parseResult |> getNormalizedIdsAndNames let isEmptyParameters = Repository.IsEmptyParameters( OwnerId = parameters.OwnerId, OwnerName = parameters.OwnerName, OrganizationId = parameters.OrganizationId, OrganizationName = parameters.OrganizationName, RepositoryId = parameters.RepositoryId, RepositoryName = parameters.RepositoryName, CorrelationId = parameters.CorrelationId ) let! repositoryIsEmpty = Repository.IsEmpty isEmptyParameters match repositoryIsEmpty with | Ok isEmpty -> if isEmpty.ReturnValue = true then let repositoryId = RepositoryId.Parse(parameters.RepositoryId) if parseResult |> hasOutput then let! graceStatus = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Reading existing Grace index file.[/]") let t1 = progressContext.AddTask($"[{Color.DodgerBlue1}]Computing new Grace index file.[/]", autoStart = false) let t2 = progressContext.AddTask($"[{Color.DodgerBlue1}]Writing new Grace index file.[/]", autoStart = false) let t3 = progressContext.AddTask( $"[{Color.DodgerBlue1}]Ensure files are in the object cache.[/]", autoStart = false ) let t4 = progressContext.AddTask( $"[{Color.DodgerBlue1}]Ensure object cache index is up-to-date.[/]", autoStart = false ) let t5 = progressContext.AddTask( $"[{Color.DodgerBlue1}]Ensure files are uploaded to object storage.[/]", autoStart = false ) let t6 = progressContext.AddTask( $"[{Color.DodgerBlue1}]Ensure directory versions are uploaded to Grace Server.[/]", autoStart = false ) // Read the existing Grace status file. t0.Increment(0.0) let! previousGraceStatus = readGraceStatusFile () t0.Increment(100.0) // Compute the new Grace status file, based on the contents of the working directory. t1.StartTask() let! graceStatus = createNewGraceStatusFile previousGraceStatus parseResult t1.Value <- 100.0 // Write the new Grace status file to disk. t2.StartTask() do! writeGraceStatusFile graceStatus t2.Value <- 100.0 // Ensure all files are in the object cache. t3.StartTask() let fileVersions = ConcurrentDictionary() // Loop through the local directory versions, and populate fileVersions with all of the files in the repo. let plr = Parallel.ForEach( graceStatus.Index.Values, Constants.ParallelOptions, (fun ldv -> for fileVersion in ldv.Files do fileVersions.TryAdd(fileVersion.RelativePath, fileVersion) |> ignore) ) let incrementAmount = 100.0 / double fileVersions.Count // Loop through the files, and copy them to the object cache if they don't already exist. let plr = Parallel.ForEach( fileVersions, Constants.ParallelOptions, (fun kvp _ -> let fileVersion = kvp.Value let fullObjectPath = fileVersion.FullObjectPath if not <| File.Exists(fullObjectPath) then Directory.CreateDirectory(Path.GetDirectoryName(fullObjectPath)) |> ignore // If the directory already exists, this will do nothing. File.Copy(Path.Combine(Current().RootDirectory, fileVersion.RelativePath), fullObjectPath) t3.Increment(incrementAmount)) ) t3.Value <- 100.0 // Ensure the object cache index is up-to-date. t4.StartTask() do! upsertObjectCache graceStatus.Index.Values t4.Value <- 100.0 // Ensure all files are uploaded to object storage. t5.StartTask() let incrementAmount = 100.0 / double fileVersions.Count match Current().ObjectStorageProvider with | ObjectStorageProvider.Unknown -> () | AzureBlobStorage -> // Breaking the uploads into chunks allows us to interleave checking to see if files are already uploaded with actually uploading them when they don't. let chunkSize = 32 let fileVersionGroups = fileVersions.Chunk(chunkSize) let succeeded = ConcurrentQueue>() let errors = ConcurrentQueue() // Loop through the groups of file versions, and upload files that aren't already in object storage. do! Parallel.ForEachAsync( fileVersionGroups, Constants.ParallelOptions, (fun fileVersions ct -> ValueTask( task { let getUploadMetadataForFilesParameters = GetUploadMetadataForFilesParameters( OwnerId = parameters.OwnerId, OwnerName = parameters.OwnerName, OrganizationId = parameters.OrganizationId, OrganizationName = parameters.OrganizationName, RepositoryId = parameters.RepositoryId, RepositoryName = parameters.RepositoryName, CorrelationId = getCorrelationId parseResult, FileVersions = (fileVersions |> Seq.map (fun kvp -> kvp.Value.ToFileVersion) |> Seq.toArray) ) let! graceResult = Storage.GetUploadMetadataForFiles getUploadMetadataForFilesParameters match graceResult with | Ok graceReturnValue -> let uploadMetadata = graceReturnValue.ReturnValue // Increment the counter for the files that we don't have to upload. t5.Increment( incrementAmount * double (fileVersions.Count() - uploadMetadata.Count) ) // Index all of the file versions by their SHA256 hash; we'll look up the files to upload with it. let filesIndexedBySha256Hash = Dictionary( fileVersions.Select (fun kvp -> KeyValuePair(kvp.Value.Sha256Hash, kvp.Value)) ) // Upload the files in this chunk to object storage. do! Parallel.ForEachAsync( uploadMetadata, Constants.ParallelOptions, (fun upload ct -> ValueTask( task { let fileVersion = filesIndexedBySha256Hash[upload.Sha256Hash] .ToFileVersion let! result = Storage.SaveFileToObjectStorage repositoryId fileVersion (upload.BlobUriWithSasToken) (getCorrelationId parseResult) // Increment the counter for each file that we do upload. t5.Increment(incrementAmount) match result with | Ok result -> succeeded.Enqueue(result) | Error error -> errors.Enqueue(error) } )) ) | Error error -> AnsiConsole.Write((new Panel($"{error}")).BorderColor(Color.Red3)) } )) ) // Print out any errors that occurred. if errors |> Seq.isEmpty then () else AnsiConsole.MarkupLine($"{errors.Count} errors occurred.") let mutable error = GraceError.Create String.Empty String.Empty while not <| errors.IsEmpty do if errors.TryDequeue(&error) then AnsiConsole.MarkupLine($"[{Colors.Error}]{error.Error.EscapeMarkup()}[/]") () | AWSS3 -> () | GoogleCloudStorage -> () t5.Value <- 100.0 // Ensure all directory versions are uploaded to Grace Server. t6.StartTask() let chunkSize = 16 let succeeded = ConcurrentQueue>() let errors = ConcurrentQueue() let incrementAmount = 100.0 / double graceStatus.Index.Count // We'll segment the uploads by the number of segments in the path, // so we process the deepest paths first, and the new children exist before the parent is created. // Within each segment group, we'll parallelize the processing for performance. let segmentGroups = graceStatus .Index .Values .GroupBy(fun dv -> countSegments dv.RelativePath) .OrderByDescending(fun group -> group.Key) for group in segmentGroups do let directoryVersionGroups = group.Chunk(chunkSize) do! Parallel.ForEachAsync( directoryVersionGroups, Constants.ParallelOptions, (fun directoryVersionGroup ct -> ValueTask( task { let saveParameters = SaveDirectoryVersionsParameters() saveParameters.OwnerId <- parameters.OwnerId saveParameters.OwnerName <- parameters.OwnerName saveParameters.OrganizationId <- parameters.OrganizationId saveParameters.OrganizationName <- parameters.OrganizationName saveParameters.RepositoryId <- parameters.RepositoryId saveParameters.RepositoryName <- parameters.RepositoryName saveParameters.CorrelationId <- getCorrelationId parseResult saveParameters.DirectoryVersions <- directoryVersionGroup .Select(fun dv -> dv.ToDirectoryVersion) .ToList() let! sdvResult = DirectoryVersion.SaveDirectoryVersions saveParameters match sdvResult with | Ok result -> succeeded.Enqueue(result) | Error error -> errors.Enqueue(error) t6.Increment( incrementAmount * double directoryVersionGroup.Length ) } )) ) t6.Value <- 100.0 AnsiConsole.MarkupLine($"[{Colors.Important}]succeeded: {succeeded.Count}; errors: {errors.Count}.[/]") let mutable error = GraceError.Create String.Empty String.Empty while not <| errors.IsEmpty do errors.TryDequeue(&error) |> ignore if error.Error.Contains("TRetval") then logToConsole $"********* {error.Error}" AnsiConsole.MarkupLine($"[{Colors.Error}]{error.Error.EscapeMarkup()}[/]") return graceStatus }) let fileCount = graceStatus .Index .Values .Select(fun directoryVersion -> directoryVersion.Files.Count) .Sum() let totalFileSize = graceStatus.Index.Values.Sum(fun directoryVersion -> directoryVersion.Files.Sum(fun f -> int64 f.Size)) let rootDirectoryVersion = graceStatus.Index.Values.First(fun d -> d.RelativePath = Constants.RootDirectoryPath) AnsiConsole.MarkupLine($"[{Colors.Highlighted}]Number of directories scanned: {graceStatus.Index.Count}.[/]") AnsiConsole.MarkupLine( $"[{Colors.Highlighted}]Number of files scanned: {fileCount}; total file size: {totalFileSize:N0}.[/]" ) AnsiConsole.MarkupLine $"[{Colors.Highlighted}]Root SHA-256 hash: {rootDirectoryVersion.Sha256Hash.Substring(0, 8)}[/]" return Ok(GraceReturnValue.Create "Initialized repository." (parseResult |> getCorrelationId)) else // Do the whole thing with no output return Ok(GraceReturnValue.Create "Initialized repository." (parseResult |> getCorrelationId)) else return Error(GraceError.Create (RepositoryError.getErrorMessage RepositoryIsAlreadyInitialized) (parseResult |> getCorrelationId)) | Error error -> return Error error // Take functionality from grace maint update... most of it is already there. // We need to double-check that we have the correct owner/organization/repository because we're // going to be uploading files to object storage placed in containers named after the owner/organization/repository. // Test on small, medium, and large repositories. // Test on repositories with multiple branches - should fail. // Test on repositories with only initial branch and no references - should succeed. // Test on repositories with only initial branch and references - should fail. // Test on repositories with multiple branches and references - should fail. | Error error -> return Error error | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId)) } type Init() = inherit AsynchronousCommandLineAction() override this.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let graceIds = parseResult |> getNormalizedIdsAndNames // Build the InitParameters object from the parsed values so we can reuse existing handler logic. let initParameters = InitParameters() initParameters.OwnerId <- graceIds.OwnerIdString initParameters.OwnerName <- graceIds.OwnerName initParameters.OrganizationId <- graceIds.OrganizationIdString initParameters.OrganizationName <- graceIds.OrganizationName initParameters.RepositoryId <- graceIds.RepositoryIdString initParameters.RepositoryName <- graceIds.RepositoryName initParameters.GraceConfig <- parseResult.GetValue(Options.graceConfig) initParameters.CorrelationId <- getCorrelationId parseResult let! result = initHandler parseResult initParameters return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } /// Repository.Get subcommand definition type Get() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Parameters.Repository.GetRepositoryParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Repository.Get(parameters) t0.Increment(100.0) return response }) else Repository.Get(parameters) match result with | Ok graceReturnValue -> let jsonText = JsonText(serialize graceReturnValue.ReturnValue) AnsiConsole.Write(jsonText) AnsiConsole.WriteLine() return Ok graceReturnValue |> renderOutput parseResult | Error graceError -> return Error graceError |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } /// Repository.GetBranches subcommand definition type GetBranches() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Repository.GetBranchesParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, IncludeDeleted = parseResult.GetValue(Options.includeDeleted), CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Repository.GetBranches(parameters) t0.Increment(100.0) return response }) else Repository.GetBranches(parameters) match result with | Ok returnValue -> let rendered = Ok returnValue |> renderOutput parseResult if parseResult |> hasOutput then let table = Table(Border = TableBorder.DoubleEdge) table.AddColumns( [| TableColumn($"[{Colors.Important}]Branch name[/]") TableColumn($"[{Colors.Important}]Branch Id[/]") TableColumn($"[{Colors.Important}]SHA-256 hash[/]") TableColumn($"[{Colors.Important}]Based on latest promotion[/]") TableColumn($"[{Colors.Important}]Parent branch[/]") TableColumn($"[{Colors.Important}]When[/]", Alignment = Justify.Right) TableColumn($"[{Colors.Important}]Updated at[/]") |] ) |> ignore let allBranches = returnValue.ReturnValue // Get the parent branch names and latest promotions for all branches let parents = allBranches.Select (fun branch -> {| BranchId = branch.BranchId BranchName = if branch.ParentBranchId = Constants.DefaultParentBranchId then "root" else allBranches .Where(fun br -> br.BranchId = branch.ParentBranchId) .Select(fun br -> br.BranchName) .First() LatestPromotion = if branch.ParentBranchId = Constants.DefaultParentBranchId then branch.LatestPromotion else allBranches .Where(fun br -> br.BranchId = branch.ParentBranchId) .Select(fun br -> br.LatestPromotion) .First() |}) let branchesWithParentNames = allBranches .Join( parents, (fun branch -> branch.BranchId), (fun parent -> parent.BranchId), (fun branch parent -> {| BranchId = branch.BranchId BranchName = branch.BranchName Sha256Hash = branch.LatestReference.Sha256Hash UpdatedAt = branch.UpdatedAt Ago = ago branch.CreatedAt ParentBranchName = parent.BranchName BasedOnLatestPromotion = (branch.BasedOn.ReferenceId = parent.LatestPromotion.ReferenceId) |}) ) .OrderBy(fun branch -> branch.UpdatedAt) for br in branchesWithParentNames do let updatedAt = match br.UpdatedAt with | Some t -> instantToLocalTime (t) | None -> String.Empty table.AddRow( br.BranchName, $"[{Colors.Deemphasized}]{br.BranchId}[/]", br.Sha256Hash |> getShortSha256Hash, (if br.BasedOnLatestPromotion then $"[{Colors.Added}]Yes[/]" else $"[{Colors.Important}]No[/]"), br.ParentBranchName, br.Ago, $"[{Colors.Deemphasized}]{updatedAt}[/]" ) |> ignore AnsiConsole.Write(table) return rendered | Error _ -> return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } /// Repository.SetVisibility subcommand definition type SetVisibility() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let visibilityParameters = Repository.SetRepositoryVisibilityParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, Visibility = (parseResult.GetValue(Options.visibility)) .ToString(), CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Repository.SetVisibility(visibilityParameters) t0.Increment(100.0) return response }) else Repository.SetVisibility(visibilityParameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } /// Repository.SetStatus subcommand definition type SetStatus() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Repository.SetRepositoryStatusParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, Status = parseResult.GetValue(Options.status), CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Repository.SetStatus(parameters) t0.Increment(100.0) return response }) else Repository.SetStatus(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } /// Repository.SetRecordSaves subcommand definition type SetRecordSaves() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Repository.RecordSavesParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, RecordSaves = parseResult.GetValue(Options.recordSaves), CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Repository.SetRecordSaves(parameters) t0.Increment(100.0) return response }) else Repository.SetRecordSaves(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } /// Repository.SetSaveDays subcommand definition type SetSaveDays() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Repository.SetSaveDaysParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = getCorrelationId parseResult, SaveDays = parseResult.GetValue(Options.saveDays) ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Repository.SetSaveDays(parameters) t0.Increment(100.0) return response }) else Repository.SetSaveDays(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } /// Repository.SetCheckpointDays subcommand definition type SetCheckpointDays() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Repository.SetCheckpointDaysParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = getCorrelationId parseResult, CheckpointDays = parseResult.GetValue(Options.checkpointDays) ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Repository.SetCheckpointDays(parameters) t0.Increment(100.0) return response }) else Repository.SetCheckpointDays(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } /// Repository.SetDiffCacheDays subcommand definition type SetDiffCacheDays() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Repository.SetDiffCacheDaysParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = getCorrelationId parseResult, DiffCacheDays = parseResult.GetValue(Options.diffCacheDays) ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Repository.SetDiffCacheDays(parameters) t0.Increment(100.0) return response }) else Repository.SetDiffCacheDays(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } /// Repository.SetDirectoryVersionCacheDays subcommand definition type SetDirectoryVersionCacheDays() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Repository.SetDirectoryVersionCacheDaysParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = getCorrelationId parseResult, DirectoryVersionCacheDays = parseResult.GetValue(Options.directoryVersionCacheDays) ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Repository.SetDirectoryVersionCacheDays(parameters) t0.Increment(100.0) return response }) else Repository.SetDirectoryVersionCacheDays(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } /// Repository.SetLogicalDeleteDays subcommand definition type SetLogicalDeleteDays() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Repository.SetLogicalDeleteDaysParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = getCorrelationId parseResult, LogicalDeleteDays = parseResult.GetValue(Options.logicalDeleteDays) ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Repository.SetLogicalDeleteDays(parameters) t0.Increment(100.0) return response }) else Repository.SetLogicalDeleteDays(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } // Enable promotion type subcommands type EnablePromotionTypeCommand = EnablePromotionTypeParameters -> Task> type EnablePromotionParameters() = member val public Enabled = false with get, set let private enablePromotionTypeHandler (parseResult: ParseResult) (parameters: EnablePromotionParameters) (command: EnablePromotionTypeCommand) (promotionType: PromotionType) = task { try if parseResult |> verbose then printParseResult parseResult let validateIncomingParameters = CommonValidations parseResult match validateIncomingParameters with | Ok _ -> let graceIds = parseResult |> getNormalizedIdsAndNames let enablePromotionTypeParameters = EnablePromotionTypeParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId, Enabled = parameters.Enabled ) if parseResult |> hasOutput then return! progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = command enablePromotionTypeParameters t0.Increment(100.0) return result }) else return! command enablePromotionTypeParameters | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId)) } // Set-DefaultServerApiVersion subcommand /// Repository.SetDefaultServerApiVersion subcommand definition type SetDefaultServerApiVersion() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Repository.SetDefaultServerApiVersionParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = getCorrelationId parseResult, DefaultServerApiVersion = parseResult.GetValue(Options.defaultServerApiVersion) ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Repository.SetDefaultServerApiVersion(parameters) t0.Increment(100.0) return response }) else Repository.SetDefaultServerApiVersion(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } /// Repository.SetName subcommand definition type SetName() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Repository.SetRepositoryNameParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = getCorrelationId parseResult, NewName = parseResult.GetValue(Options.newName) ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Repository.SetName(parameters) t0.Increment(100.0) return response }) else Repository.SetName(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } /// Repository.SetDescription subcommand definition type SetDescription() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Repository.SetRepositoryDescriptionParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = getCorrelationId parseResult, Description = parseResult.GetValue(Options.description) ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Repository.SetDescription(parameters) t0.Increment(100.0) return response }) else Repository.SetDescription(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } /// Repository.SetConflictResolutionPolicy subcommand definition type SetConflictResolutionPolicy() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Repository.SetConflictResolutionPolicyParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = getCorrelationId parseResult, ConflictResolutionPolicy = parseResult.GetValue(Options.conflictResolutionPolicy), ConfidenceThreshold = parseResult.GetValue(Options.confidenceThreshold) ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Grace.SDK.Repository.SetConflictResolutionPolicy(parameters) t0.Increment(100.0) return response }) else Grace.SDK.Repository.SetConflictResolutionPolicy(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } /// Repository.Delete subcommand definition type Delete() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Repository.DeleteRepositoryParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = getCorrelationId parseResult, Force = parseResult.GetValue(Options.force), DeleteReason = parseResult.GetValue(Options.deleteReason) ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Repository.Delete(parameters) t0.Increment(100.0) return response }) else Repository.Delete(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } /// Repository.Undelete subcommand definition type Undelete() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Repository.UndeleteRepositoryParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = Repository.Undelete(parameters) t0.Increment(100.0) return response }) else Repository.Undelete(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } /// Repository.SetAnonymousAccess subcommand definition type SetAnonymousAccess() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Repository.SetAnonymousAccessParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, AnonymousAccess = parseResult.GetValue(Options.anonymousAccess), CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Setting anonymous access.[/]") let! response = Repository.SetAnonymousAccess(parameters) t0.Increment(100.0) return response }) else Repository.SetAnonymousAccess(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } /// Repository.SetAllowsLargeFiles subcommand definition type SetAllowsLargeFiles() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> Grace.CLI.Common.Validations.CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Repository.SetAllowsLargeFilesParameters( OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, AllowsLargeFiles = parseResult.GetValue(Options.allowsLargeFiles), CorrelationId = getCorrelationId parseResult ) let! result = if parseResult |> hasOutput then progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Setting allows large files.[/]") let! response = Repository.SetAllowsLargeFiles(parameters) t0.Increment(100.0) return response }) else Repository.SetAllowsLargeFiles(parameters) return result |> renderOutput parseResult | Error error -> return Error error |> renderOutput parseResult with | ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } /// Builds the Repository subcommand. let Build = let addCommonOptionsExceptForRepositoryInfo (command: Command) = command |> addOption Options.ownerName |> addOption Options.ownerId |> addOption Options.organizationName |> addOption Options.organizationId |> addOption Options.repositoryId let addCommonOptions (command: Command) = command |> addOption Options.repositoryName |> addCommonOptionsExceptForRepositoryInfo // Create main command and aliases, if any. let repositoryCommand = new Command("repository", Description = "Creates, changes, and deletes repository-level information.") repositoryCommand.Aliases.Add("repo") // Add subcommands. let repositoryCreateCommand = new Command("create", Description = "Creates a new repository.") |> addOption Options.requiredRepositoryName |> addCommonOptionsExceptForRepositoryInfo |> addOption Options.doNotSwitch repositoryCreateCommand.Action <- new Create() repositoryCommand.Subcommands.Add(repositoryCreateCommand) let repositoryGetCommand = new Command("get", Description = "Gets information about a repository.") |> addCommonOptions repositoryGetCommand.Action <- new Get() repositoryCommand.Subcommands.Add(repositoryGetCommand) let repositoryInitCommand = new Command("init", Description = "Initializes a new repository with the contents of a directory.") |> addOption Options.directory |> addOption Options.graceConfig |> addCommonOptions repositoryInitCommand.Action <- new Init() repositoryCommand.Subcommands.Add(repositoryInitCommand) //let repositoryDownloadCommand = new Command("download", Description = "Downloads the current version of the repository.") |> addOption Options.requiredRepositoryName |> addOption Options.graceConfig |> addCommonOptionsExceptForRepositoryInfo //repositoryInitCommand.Action <- Init //repositoryCommand.Subcommands.Add(repositoryInitCommand) let getBranchesCommand = new Command("get-branches", Description = "Gets a list of branches in the repository.") |> addOption Options.includeDeleted |> addCommonOptions getBranchesCommand.Action <- new GetBranches() repositoryCommand.Subcommands.Add(getBranchesCommand) let setVisibilityCommand = new Command("set-visibility", Description = "Sets the visibility of the repository.") |> addOption Options.visibility |> addCommonOptions setVisibilityCommand.Action <- new SetVisibility() repositoryCommand.Subcommands.Add(setVisibilityCommand) let setStatusCommand = new Command("set-status", Description = "Sets the status of the repository.") |> addOption Options.status |> addCommonOptions setStatusCommand.Action <- new SetStatus() repositoryCommand.Subcommands.Add(setStatusCommand) let setAnonymousAccessCommand = new Command("set-anonymous-access", Description = "Sets the anonymous access status of the repository.") |> addOption Options.anonymousAccess |> addCommonOptions setAnonymousAccessCommand.Action <- new SetAnonymousAccess() repositoryCommand.Subcommands.Add(setAnonymousAccessCommand) let setAllowsLargeFilesCommand = new Command("set-allows-large-files", Description = "Sets the large files status of the repository.") |> addOption Options.allowsLargeFiles |> addCommonOptions setAllowsLargeFilesCommand.Action <- new SetAllowsLargeFiles() repositoryCommand.Subcommands.Add(setAllowsLargeFilesCommand) let setRecordSavesCommand = new Command("set-record-saves", Description = "Sets whether the repository defaults to recording every save.") |> addOption Options.recordSaves |> addCommonOptions setRecordSavesCommand.Action <- new SetRecordSaves() repositoryCommand.Subcommands.Add(setRecordSavesCommand) let setDefaultServerApiVersionCommand = new Command( "set-default-server-api-version", Description = "Sets the default server API version for clients to use when accessing this repository." ) |> addOption Options.defaultServerApiVersion |> addCommonOptions setDefaultServerApiVersionCommand.Action <- new SetDefaultServerApiVersion() repositoryCommand.Subcommands.Add(setDefaultServerApiVersionCommand) let setSaveDaysCommand = new Command("set-save-days", Description = "Sets the number of days to keep saves in the repository.") |> addOption Options.saveDays |> addCommonOptions setSaveDaysCommand.Action <- new SetSaveDays() repositoryCommand.Subcommands.Add(setSaveDaysCommand) let setCheckpointDaysCommand = new Command("set-checkpoint-days", Description = "Sets the number of days to keep checkpoints in the repository.") |> addOption Options.checkpointDays |> addCommonOptions setCheckpointDaysCommand.Action <- new SetCheckpointDays() repositoryCommand.Subcommands.Add(setCheckpointDaysCommand) let setDiffCacheDaysCommand = new Command("set-diff-cache-days", Description = "Sets the number of days to keep diff results cached in the repository.") |> addOption Options.diffCacheDays |> addCommonOptions setDiffCacheDaysCommand.Action <- new SetDiffCacheDays() repositoryCommand.Subcommands.Add(setDiffCacheDaysCommand) let setDirectoryVersionCacheDaysCommand = new Command( "set-directory-version-cache-days", Description = "Sets how long to keep recursive directory version contents cached in the repository." ) |> addOption Options.directoryVersionCacheDays |> addCommonOptions setDirectoryVersionCacheDaysCommand.Action <- new SetDirectoryVersionCacheDays() repositoryCommand.Subcommands.Add(setDirectoryVersionCacheDaysCommand) let setLogicalDeleteDaysCommand = new Command( "set-logical-delete-days", Description = "Sets the number of days to keep deleted branches in the repository before permanently deleting them." ) |> addOption Options.logicalDeleteDays |> addCommonOptions setLogicalDeleteDaysCommand.Action <- new SetLogicalDeleteDays() repositoryCommand.Subcommands.Add(setLogicalDeleteDaysCommand) let setNameCommand = new Command("set-name", Description = "Sets the name of the repository.") |> addOption Options.newName |> addCommonOptions setNameCommand.Action <- new SetName() repositoryCommand.Subcommands.Add(setNameCommand) let setDescriptionCommand = new Command("set-description", Description = "Sets the description of the repository.") |> addOption Options.description |> addCommonOptions setDescriptionCommand.Action <- new SetDescription() repositoryCommand.Subcommands.Add(setDescriptionCommand) let setConflictResolutionPolicyCommand = new Command("set-conflict-resolution-policy", Description = "Sets the conflict resolution policy for the repository.") |> addOption Options.conflictResolutionPolicy |> addOption Options.confidenceThreshold |> addCommonOptions setConflictResolutionPolicyCommand.Action <- new SetConflictResolutionPolicy() repositoryCommand.Subcommands.Add(setConflictResolutionPolicyCommand) let deleteCommand = new Command("delete", Description = "Deletes a repository.") |> addOption Options.deleteReason |> addOption Options.force |> addCommonOptions deleteCommand.Action <- new Delete() repositoryCommand.Subcommands.Add(deleteCommand) let undeleteCommand = new Command("undelete", Description = "Undeletes the repository.") |> addCommonOptions undeleteCommand.Action <- new Undelete() repositoryCommand.Subcommands.Add(undeleteCommand) repositoryCommand ================================================ FILE: src/Grace.CLI/Command/Review.CLI.fs ================================================ namespace Grace.CLI.Command open Grace.CLI.Common open Grace.CLI.Services open Grace.CLI.Text open Grace.SDK open Grace.Shared open Grace.Shared.Utilities open Grace.Shared.Validation.Errors open Grace.Types.Policy open Grace.Types.Review open Grace.Types.Types open Spectre.Console open System open System.CommandLine open System.CommandLine.Invocation open System.CommandLine.Parsing open System.IO open System.Text open System.Threading open System.Threading.Tasks module ReviewCommand = module private Options = let promotionSetId = new Option( "--promotion-set", [| "--promotion-set-id" |], Required = false, Description = "The promotion set ID .", Arity = ArgumentArity.ExactlyOne ) let referenceId = new Option( OptionName.ReferenceId, Required = true, Description = "The reference ID to mark reviewed.", Arity = ArgumentArity.ExactlyOne ) let policySnapshotId = new Option( "--policy-snapshot-id", Required = false, Description = "Policy snapshot ID for this checkpoint.", Arity = ArgumentArity.ExactlyOne ) let findingId = new Option("--finding-id", Required = true, Description = "The finding ID .", Arity = ArgumentArity.ExactlyOne) let approve = new Option( "--approve", Required = false, Description = "Approve the finding.", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> false) ) let requestChanges = new Option( "--request-changes", Required = false, Description = "Request changes for the finding.", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> false) ) let note = new Option("--note", Required = false, Description = "Optional note for the resolution.", Arity = ArgumentArity.ExactlyOne) let chapterId = new Option("--chapter", Required = false, Description = "Chapter ID for targeted deepening.", Arity = ArgumentArity.ExactlyOne) let candidateId = new Option( "--candidate", [| "--candidate-id" |], Required = true, Description = "The candidate ID .", Arity = ArgumentArity.ExactlyOne ) let reportFormat = new Option("--format", Required = true, Description = "Export format: markdown or json.", Arity = ArgumentArity.ExactlyOne) let outputFile = new Option( "--output-file", [| "-f" |], Required = true, Description = "Write exported report content to this file path.", Arity = ArgumentArity.ExactlyOne ) let targetBranch = new Option( "--target-branch", Required = false, Description = "Target branch ID or name for review inbox.", Arity = ArgumentArity.ExactlyOne ) let ownerId = new Option( OptionName.OwnerId, Required = false, Description = "The repository's owner ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OwnerId.Empty) ) let ownerName = new Option( OptionName.OwnerName, Required = false, Description = "The repository's owner name. [default: current owner]", Arity = ArgumentArity.ExactlyOne ) let organizationId = new Option( OptionName.OrganizationId, Required = false, Description = "The organization's ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OrganizationId.Empty) ) let organizationName = new Option( OptionName.OrganizationName, Required = false, Description = "The organization's name. [default: current organization]", Arity = ArgumentArity.ExactlyOne ) let repositoryId = new Option( OptionName.RepositoryId, Required = false, Description = "The repository's ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> RepositoryId.Empty) ) let repositoryName = new Option( OptionName.RepositoryName, Required = false, Description = "The repository's name. [default: current repository]", Arity = ArgumentArity.ExactlyOne ) let private tryParseGuid (value: string) (error: ReviewError) (parseResult: ParseResult) = let mutable parsed = Guid.Empty if String.IsNullOrWhiteSpace(value) || Guid.TryParse(value, &parsed) = false || parsed = Guid.Empty then Error(GraceError.Create (ReviewError.getErrorMessage error) (getCorrelationId parseResult)) else Ok parsed let internal resolvePolicySnapshotIdWith (getPromotionSet: Parameters.PromotionSet.GetPromotionSetParameters -> Task>) (getPolicy: Parameters.Policy.GetPolicyParameters -> Task>) (parseResult: ParseResult) (graceIds: GraceIds) (promotionSetId: Guid) = task { let rawPolicySnapshotId = parseResult.GetValue(Options.policySnapshotId) |> Option.ofObj |> Option.defaultValue String.Empty if not (String.IsNullOrWhiteSpace rawPolicySnapshotId) then return Ok rawPolicySnapshotId else let promotionSetParameters = Parameters.PromotionSet.GetPromotionSetParameters( PromotionSetId = promotionSetId.ToString(), OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) match! getPromotionSet promotionSetParameters with | Error error -> return Error error | Ok promotionSetReturnValue -> let promotionSet = promotionSetReturnValue.ReturnValue let policyParameters = Parameters.Policy.GetPolicyParameters( TargetBranchId = promotionSet.TargetBranchId.ToString(), OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) match! getPolicy policyParameters with | Error error -> return Error error | Ok policyReturnValue -> match policyReturnValue.ReturnValue with | Some snapshot when not (String.IsNullOrWhiteSpace snapshot.PolicySnapshotId) -> return Ok snapshot.PolicySnapshotId | _ -> return Error(GraceError.Create (ReviewError.getErrorMessage ReviewError.InvalidPolicySnapshotId) (getCorrelationId parseResult)) } let private resolvePolicySnapshotId (parseResult: ParseResult) (graceIds: GraceIds) (promotionSetId: Guid) = resolvePolicySnapshotIdWith PromotionSet.Get Policy.GetCurrent parseResult graceIds promotionSetId type private ReportExportFormat = | Markdown | Json let private parseReportExportFormat (rawValue: string) (parseResult: ParseResult) = match rawValue.Trim().ToLowerInvariant() with | "markdown" -> Ok Markdown | "json" -> Ok Json | _ -> Error(GraceError.Create "Format must be either 'markdown' or 'json'." (getCorrelationId parseResult)) let private resolveCandidateId (parseResult: ParseResult) = let candidateIdRaw = parseResult.GetValue(Options.candidateId) |> Option.ofObj |> Option.defaultValue String.Empty let mutable parsed = Guid.Empty if String.IsNullOrWhiteSpace(candidateIdRaw) || not (Guid.TryParse(candidateIdRaw, &parsed)) || parsed = Guid.Empty then Error(GraceError.Create "CandidateId must be a valid non-empty Guid." (getCorrelationId parseResult)) else Ok(parsed.ToString()) let private buildCandidateProjectionParameters (graceIds: GraceIds) (candidateId: string) = Parameters.Review.CandidateProjectionParameters( CandidateId = candidateId, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let internal normalizeReviewReportForOutput (report: ReviewReportResult) = let normalized = ReviewReportResult() normalized.ReviewReportSchemaVersion <- report.ReviewReportSchemaVersion normalized.SectionOrder <- report.SectionOrder let sectionOrderRank = normalized.SectionOrder |> List.mapi (fun index section -> section, index) |> dict let normalizedSections = report.Sections |> List.map (fun section -> let normalizedSection = ReviewReportSection() normalizedSection.Section <- section.Section normalizedSection.Title <- section.Title normalizedSection.SourceState <- section.SourceState normalizedSection.SourceStates <- section.SourceStates |> List.sortBy (fun sourceState -> sourceState.Section, sourceState.SourceState, sourceState.Detail) normalizedSection.Entries <- section.Entries |> List.sortBy (fun entry -> entry.Key) |> List.map (fun entry -> let normalizedEntry = ReviewReportEntry() normalizedEntry.Key <- entry.Key normalizedEntry.Values <- entry.Values |> List.sort normalizedEntry) normalizedSection.Diagnostics <- section.Diagnostics |> List.sort normalizedSection) |> List.sortBy (fun section -> (if sectionOrderRank.ContainsKey(section.Section) then sectionOrderRank[section.Section] else Int32.MaxValue), section.Section) normalized.Sections <- normalizedSections normalized let internal renderReviewReportMarkdown (report: ReviewReportResult) = let normalized = normalizeReviewReportForOutput report let markdown = StringBuilder() markdown.AppendLine($"# Review Report (schema {normalized.ReviewReportSchemaVersion})") |> ignore for section in normalized.Sections do markdown.AppendLine() |> ignore markdown.AppendLine($"## {section.Title}") |> ignore markdown.AppendLine($"- Section: {section.Section}") |> ignore markdown.AppendLine($"- SourceState: {section.SourceState}") |> ignore for entry in section.Entries do if entry.Values.IsEmpty then markdown.AppendLine($"- {entry.Key}: NotAvailable") |> ignore elif entry.Values.Length = 1 then markdown.AppendLine($"- {entry.Key}: {entry.Values[0]}") |> ignore else markdown.AppendLine($"- {entry.Key}:") |> ignore for value in entry.Values do markdown.AppendLine($" - {value}") |> ignore if not section.Diagnostics.IsEmpty then markdown.AppendLine("- Diagnostics:") |> ignore for diagnostic in section.Diagnostics do markdown.AppendLine($" - {diagnostic}") |> ignore if not section.SourceStates.IsEmpty then markdown.AppendLine("- SourceStates:") |> ignore for sourceState in section.SourceStates do markdown.AppendLine($" - {sourceState.Section}: {sourceState.SourceState} ({sourceState.Detail})") |> ignore markdown.ToString().TrimEnd() let internal serializeReviewReportJson (report: ReviewReportResult) = let normalized = normalizeReviewReportForOutput report serialize normalized let private writeNotesSummary (parseResult: ParseResult) (notes: ReviewNotes) = if not (parseResult |> json) && not (parseResult |> silent) then AnsiConsole.MarkupLine($"[bold]Review Notes[/] {Markup.Escape(notes.ReviewNotesId.ToString())}") if not (String.IsNullOrWhiteSpace notes.Summary) then AnsiConsole.MarkupLine($"[bold]Summary:[/] {Markup.Escape(notes.Summary)}") AnsiConsole.MarkupLine($"[bold]Chapters:[/] {notes.Chapters.Length} [bold]Findings:[/] {notes.Findings.Length}") let private inboxHandler (parseResult: ParseResult) = task { let graceError = GraceError.Create "Review inbox is not implemented yet." (getCorrelationId parseResult) return Error graceError } type Inbox() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = inboxHandler parseResult return result |> renderOutput parseResult } let private openHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let promotionSetIdRaw = parseResult.GetValue(Options.promotionSetId) |> Option.ofObj |> Option.defaultValue String.Empty if String.IsNullOrWhiteSpace promotionSetIdRaw then return Error(GraceError.Create (ReviewError.getErrorMessage ReviewError.InvalidPromotionSetId) (getCorrelationId parseResult)) else match tryParseGuid promotionSetIdRaw ReviewError.InvalidPromotionSetId parseResult with | Error error -> return Error error | Ok promotionSetId -> let parameters = Parameters.Review.GetReviewNotesParameters( PromotionSetId = promotionSetId.ToString(), OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let! result = Review.GetNotes(parameters) match result with | Ok returnValue -> match returnValue.ReturnValue with | Some notes -> writeNotesSummary parseResult notes | None -> if not (parseResult |> json) && not (parseResult |> silent) then AnsiConsole.MarkupLine("[yellow]No review notes found.[/]") return Ok returnValue | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type Open() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = openHandler parseResult return result |> renderOutput parseResult } let private checkpointHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let promotionSetIdRaw = parseResult.GetValue(Options.promotionSetId) |> Option.ofObj |> Option.defaultValue String.Empty match tryParseGuid promotionSetIdRaw ReviewError.InvalidPromotionSetId parseResult with | Error error -> return Error error | Ok promotionSetId -> let referenceIdRaw = parseResult.GetValue(Options.referenceId) match tryParseGuid referenceIdRaw ReviewError.InvalidReferenceId parseResult with | Error error -> return Error error | Ok referenceId -> let! policySnapshotIdResult = resolvePolicySnapshotId parseResult graceIds promotionSetId match policySnapshotIdResult with | Error error -> return Error error | Ok policySnapshotId -> let parameters = Parameters.Review.ReviewCheckpointParameters( PromotionSetId = promotionSetId.ToString(), ReviewedUpToReferenceId = referenceId.ToString(), PolicySnapshotId = policySnapshotId, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) return! Review.Checkpoint(parameters) with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type Checkpoint() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = checkpointHandler parseResult return result |> renderOutput parseResult } let private resolveHandlerImpl (parseResult: ParseResult) = if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let promotionSetIdRaw = parseResult.GetValue(Options.promotionSetId) |> Option.ofObj |> Option.defaultValue String.Empty match tryParseGuid promotionSetIdRaw ReviewError.InvalidPromotionSetId parseResult with | Error error -> Task.FromResult(Error error) | Ok promotionSetId -> let findingIdRaw = parseResult.GetValue(Options.findingId) match tryParseGuid findingIdRaw ReviewError.InvalidFindingId parseResult with | Error error -> Task.FromResult(Error error) | Ok findingId -> let approve = parseResult.GetValue(Options.approve) let requestChanges = parseResult.GetValue(Options.requestChanges) if approve = requestChanges then Task.FromResult(Error(GraceError.Create "Specify exactly one of --approve or --request-changes." (getCorrelationId parseResult))) else let resolutionState = if approve then FindingResolutionState.Approved else FindingResolutionState.NeedsChanges let note = parseResult.GetValue(Options.note) |> Option.ofObj |> Option.defaultValue String.Empty let parameters = Parameters.Review.ResolveFindingParameters( PromotionSetId = promotionSetId.ToString(), FindingId = findingId.ToString(), ResolutionState = getDiscriminatedUnionCaseName resolutionState, Note = note, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) Review.ResolveFinding(parameters) let private resolveHandler (parseResult: ParseResult) = task { try return! resolveHandlerImpl parseResult with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type Resolve() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = resolveHandler parseResult return result |> renderOutput parseResult } let private deepenHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let promotionSetIdRaw = parseResult.GetValue(Options.promotionSetId) |> Option.ofObj |> Option.defaultValue String.Empty match tryParseGuid promotionSetIdRaw ReviewError.InvalidPromotionSetId parseResult with | Error error -> return Error error | Ok promotionSetId -> let chapterId = parseResult.GetValue(Options.chapterId) |> Option.ofObj |> Option.defaultValue String.Empty let parameters = Parameters.Review.DeepenReviewParameters( PromotionSetId = promotionSetId.ToString(), ChapterId = chapterId, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) return! Review.Deepen(parameters) with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type Deepen() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = deepenHandler parseResult return result |> renderOutput parseResult } let private reportShowHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames match resolveCandidateId parseResult with | Error error -> return Error error | Ok candidateId -> let parameters = buildCandidateProjectionParameters graceIds candidateId let! result = Review.GetReviewReport(parameters) match result with | Ok returnValue -> if not (parseResult |> json) && not (parseResult |> silent) then let markdown = renderReviewReportMarkdown returnValue.ReturnValue Console.WriteLine(markdown) return Ok returnValue | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type ReportShow() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = reportShowHandler parseResult return result |> renderOutput parseResult } let private reportExportHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let outputFile = parseResult.GetValue(Options.outputFile) |> Option.ofObj |> Option.defaultValue String.Empty if String.IsNullOrWhiteSpace outputFile then return Error(GraceError.Create "Output file path is required." (getCorrelationId parseResult)) else let reportFormatRaw = parseResult.GetValue(Options.reportFormat) |> Option.ofObj |> Option.defaultValue String.Empty match parseReportExportFormat reportFormatRaw parseResult with | Error error -> return Error error | Ok reportFormat -> match resolveCandidateId parseResult with | Error error -> return Error error | Ok candidateId -> let parameters = buildCandidateProjectionParameters graceIds candidateId let! result = Review.GetReviewReport(parameters) match result with | Error error -> return Error error | Ok returnValue -> let report = returnValue.ReturnValue let content = match reportFormat with | Markdown -> renderReviewReportMarkdown report | Json -> serializeReviewReportJson report let outputDirectory = Path.GetDirectoryName(outputFile) if not (String.IsNullOrWhiteSpace outputDirectory) then Directory.CreateDirectory(outputDirectory) |> ignore do! File.WriteAllTextAsync(outputFile, content) if not (parseResult |> json) && not (parseResult |> silent) then let formatText = match reportFormat with | Markdown -> "markdown" | Json -> "json" AnsiConsole.MarkupLine($"[green]Review report exported ({formatText}) to[/] {Markup.Escape(outputFile)}") return Ok returnValue with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type ReportExport() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, _: CancellationToken) : Task = task { let! result = reportExportHandler parseResult return result |> renderOutput parseResult } let Build = let addCommonOptions (command: Command) = command |> addOption Options.ownerName |> addOption Options.ownerId |> addOption Options.organizationName |> addOption Options.organizationId |> addOption Options.repositoryName |> addOption Options.repositoryId let reviewCommand = new Command("review", Description = "Promotion-set review operations plus candidate report output.") let inboxCommand = new Command("inbox", Description = "Show review inbox (stub).") inboxCommand |> addOption Options.targetBranch |> addCommonOptions |> ignore inboxCommand.Action <- new Inbox() reviewCommand.Subcommands.Add(inboxCommand) let openCommand = new Command("open", Description = "Open review notes for a promotion set.") openCommand |> addOption Options.promotionSetId |> addCommonOptions |> ignore openCommand.Action <- new Open() reviewCommand.Subcommands.Add(openCommand) let checkpointCommand = new Command("checkpoint", Description = "Record a review checkpoint for a promotion set.") |> addOption Options.promotionSetId |> addOption Options.referenceId |> addOption Options.policySnapshotId |> addCommonOptions checkpointCommand.Action <- new Checkpoint() reviewCommand.Subcommands.Add(checkpointCommand) let resolveCommand = new Command("resolve", Description = "Resolve a review finding for a promotion set.") |> addOption Options.promotionSetId |> addOption Options.findingId |> addOption Options.approve |> addOption Options.requestChanges |> addOption Options.note |> addCommonOptions resolveCommand.Action <- new Resolve() reviewCommand.Subcommands.Add(resolveCommand) let deepenCommand = new Command("deepen", Description = "Request deeper analysis (stub).") |> addOption Options.promotionSetId |> addOption Options.chapterId |> addCommonOptions deepenCommand.Action <- new Deepen() reviewCommand.Subcommands.Add(deepenCommand) let reportCommand = new Command("report", Description = "Generate candidate-first unified review reports.") let reportShowCommand = new Command("show", Description = "Show review report sections in deterministic markdown order.") |> addOption Options.candidateId |> addCommonOptions reportShowCommand.Action <- new ReportShow() reportCommand.Subcommands.Add(reportShowCommand) let reportExportCommand = new Command("export", Description = "Export review report as markdown or json.") |> addOption Options.candidateId |> addOption Options.reportFormat |> addOption Options.outputFile |> addCommonOptions reportExportCommand.Action <- new ReportExport() reportCommand.Subcommands.Add(reportExportCommand) reviewCommand.Subcommands.Add(reportCommand) reviewCommand ================================================ FILE: src/Grace.CLI/Command/Services.CLI.fs ================================================ namespace Grace.CLI open Microsoft.Extensions open FSharp.Collections open Grace.CLI.Text open Grace.SDK open Grace.Shared.Client.Configuration open Grace.Shared open Grace.Shared.Constants open Grace.Shared.Parameters.DirectoryVersion open Grace.Shared.Services open Grace.Types.Types open Grace.Shared.Utilities open Grace.Shared.Validation.Errors open NodaTime open NodaTime.Text open Spectre.Console open System open System.Collections.Concurrent open System.Collections.Generic open System.CommandLine open System.CommandLine.Parsing open System.Diagnostics open System.Globalization open System.IO open System.IO.Compression open System.IO.Enumeration open System.IO.Pipelines open System.Linq open System.Security.Cryptography open System.Text open System.Text.Json open System.Threading.Tasks open System.Reactive.Linq open System.Threading open Grace.Shared.Parameters open Grace.Shared.Parameters.Storage open System.Runtime.Intrinsics.Arm module Services = let mutable lockObject = new Lock() /// Utility method to write to the console using color. let logToAnsiConsole (color: string) message = lock lockObject (fun () -> AnsiConsole.MarkupLine $"[{color}]{getCurrentInstantExtended ()} {Environment.CurrentManagedThreadId:X2} {Markup.Escape(message)}[/]") /// A cache of paths that we've already decided to ignore or not. let private shouldIgnoreCache = ConcurrentDictionary() // This section is "borrowed" from Common.CLI.fs, because Services.CLI.fs comes before Common.CLI.fs in the build order. /// Checks if the output format from the command line is a specific format. let private isOutputFormat (outputFormat: string) (parseResult: ParseResult) = if parseResult .ToString() .IndexOf($"<{outputFormat}>", StringComparison.InvariantCultureIgnoreCase) > 0 then true else if outputFormat = "Normal" then true else false /// GraceWatchStatus defines the schema for the inter-process communication (IPC) file that lets Grace know if `grace watch` is already running. /// /// It's written by `grace watch`. It holds everything required to allow other instances of Grace to skip checking current status. [] type GraceWatchStatus = { UpdatedAt: Instant RootDirectoryId: DirectoryVersionId RootDirectorySha256Hash: Sha256Hash LastFileUploadInstant: Instant LastDirectoryVersionInstant: Instant DirectoryIds: HashSet } static member Default = { UpdatedAt = Instant.MinValue RootDirectoryId = Guid.Empty RootDirectorySha256Hash = Sha256Hash String.Empty LastFileUploadInstant = Instant.MinValue LastDirectoryVersionInstant = Instant.MinValue DirectoryIds = HashSet() } let mutable graceWatchStatusUpdateTime = Instant.MinValue let mutable parseResult: ParseResult = null let mutable private invocationCorrelationId: CorrelationId option = None let resetInvocationCorrelationId () = invocationCorrelationId <- None // Extension methods for dealing with local file changes. type DirectoryVersion with /// Gets the full path for this file in the working directory. member this.FullName = Path.Combine(Current().RootDirectory, $"{this.RelativePath}") // Extension methods for dealing with local files. type LocalDirectoryVersion with /// Gets the full path for this file in the working directory. member this.FullName = Path.Combine(Current().RootDirectory, $"{this.RelativePath}") /// Gets a DirectoryInfo instance for the parent directory of this local file. member this.DirectoryInfo = DirectoryInfo(this.FullName) // Extension methods for dealing with local files. type LocalFileVersion with /// Gets the full path for this file in the working directory. member this.FullName = getNativeFilePath (Path.Combine(Current().RootDirectory, $"{this.RelativePath}")) /// Gets the full working directory path for this file. member this.FullRelativePath = FileInfo(this.FullName).DirectoryName /// Gets a FileInfo instance for this local file. member this.FileInfo = FileInfo(this.FullName) /// Gets the RelativeDirectory for this file. member this.RelativeDirectory = Path.GetRelativePath(Current().RootDirectory, this.FileInfo.DirectoryName) /// Gets the full name of the object file for this LocalFileVersion. member this.FullObjectPath = getNativeFilePath (Path.Combine(Current().ObjectDirectory, this.RelativePath, this.GetObjectFileName)) /// Flag to determine if we should do case-insensitive file name processing on the current platform. let ignoreCase = runningOnWindows /// Returns true if fileToCheck matches this graceIgnoreEntry; otherwise returns false. let checkIgnoreLineAgainstFile (fileToCheck: FilePath) (graceIgnoreEntry: string) = let fileName = Path.GetFileName(fileToCheck) let ignoreEntryMatches = FileSystemName.MatchesSimpleExpression(graceIgnoreEntry, fileName, ignoreCase) ignoreEntryMatches /// Returns true if directory matches this graceIgnoreEntry; otherwise returns false. let checkIgnoreLineAgainstDirectory (directoryInfoToCheck: DirectoryInfo) (graceIgnoreEntry: string) = let normalizedDirectoryPath = if Path.EndsInDirectorySeparator(directoryInfoToCheck.FullName) then normalizeFilePath directoryInfoToCheck.FullName else normalizeFilePath (directoryInfoToCheck.FullName + "/") if FileSystemName.MatchesSimpleExpression(graceIgnoreEntry, normalizedDirectoryPath, ignoreCase) then //logToAnsiConsole Colors.Changed $"checkIgnoreLineAgainstDirectory: directory '{normalizedDirectoryPath}' matches ignore entry '{graceIgnoreEntry}'." true else //logToAnsiConsole // Colors.Verbose // $"checkIgnoreLineAgainstDirectory: directory '{normalizedDirectoryPath}' does not match ignore entry '{graceIgnoreEntry}'." false /// Returns true if filePath should be ignored by Grace, otherwise returns false. let shouldIgnoreFile (filePath: FilePath) = let mutable shouldIgnore = false let wasAlreadyCached = shouldIgnoreCache.TryGetValue(filePath, &shouldIgnore) //logToConsole $"In shouldIgnoreFile: filePath: {filePath}; wasAlreadyCached: {wasAlreadyCached}; shouldIgnore: {shouldIgnore}" if wasAlreadyCached then shouldIgnore else // Ignore it if: // it's in the .grace directory, or // it's the Grace Status file, or // it's a Grace-owned temporary file, or // it's a directory itself, or // it matches something in graceignore.txt. let fileInfo = FileInfo(filePath) let shouldIgnoreThisFile = filePath.StartsWith(Current().GraceDirectory, StringComparison.InvariantCultureIgnoreCase) // it's in the /.grace directory || filePath.Equals(Current().GraceStatusFile, StringComparison.InvariantCultureIgnoreCase) // it's the Grace local state DB || filePath.Equals(Current().GraceStatusFile + "-wal", StringComparison.InvariantCultureIgnoreCase) // sqlite WAL || filePath.Equals(Current().GraceStatusFile + "-shm", StringComparison.InvariantCultureIgnoreCase) // sqlite SHM || filePath.Equals(Current().GraceStatusFile + "-journal", StringComparison.InvariantCultureIgnoreCase) // sqlite journal || filePath.EndsWith(".gracetmp") // it's a Grace temporary file || Directory.Exists(filePath) // it's a directory //|| fileInfo.Attributes.HasFlag(FileAttributes.Temporary) // it's temporary - why doesn't this work || Current().GraceDirectoryIgnoreEntries // one of the directories in the path matches a directory ignore line |> Array.exists (fun graceIgnoreLine -> checkIgnoreLineAgainstDirectory fileInfo.Directory graceIgnoreLine) || Current().GraceDirectoryIgnoreEntries // the file name matches a directory ignore line (which is weird, but possible) |> Array.exists (fun graceIgnoreLine -> checkIgnoreLineAgainstFile filePath graceIgnoreLine) || Current().GraceFileIgnoreEntries // the file name matches a file ignore line |> Array.exists (fun graceIgnoreLine -> checkIgnoreLineAgainstFile filePath graceIgnoreLine) //logToAnsiConsole Colors.Verbose $"In shouldIgnoreFile: filePath: {filePath}; shouldIgnore: {shouldIgnoreThisFile}" shouldIgnoreCache.TryAdd(filePath, shouldIgnoreThisFile) |> ignore shouldIgnoreThisFile let private notString = "not " /// Returns true if directoryPath should be ignored by Grace, otherwise returns false. let shouldIgnoreDirectory (directoryPath: string) = let mutable shouldIgnore = false let wasAlreadyCached = shouldIgnoreCache.TryGetValue(directoryPath, &shouldIgnore) if wasAlreadyCached then shouldIgnore else let directoryInfo = DirectoryInfo(directoryPath) let shouldIgnoreDirectory = directoryInfo.FullName.StartsWith(Current().GraceDirectory) || Current() .GraceDirectoryIgnoreEntries.Any(fun graceIgnoreLine -> checkIgnoreLineAgainstDirectory directoryInfo graceIgnoreLine) shouldIgnoreCache.TryAdd(directoryPath, shouldIgnoreDirectory) |> ignore //logToAnsiConsole Colors.Verbose $"In shouldIgnoreDirectory: directoryPath: {directoryPath}; shouldIgnore: {shouldIgnoreDirectory}" shouldIgnoreDirectory /// Returns true if directoryPath should not be ignored by Grace, otherwise returns false. let shouldNotIgnoreDirectory (directoryPath: string) = not <| shouldIgnoreDirectory directoryPath /// Creates a LocalFileVersion for the given FileInfo instance. let createLocalFileVersion (fileInfo: FileInfo) = task { if fileInfo.Exists then try let relativePath = Path.GetRelativePath(Current().RootDirectory, fileInfo.FullName) use stream = fileInfo.Open(fileStreamOptionsRead) let! isBinary = isBinaryFile stream stream.Position <- 0 let! sha256Hash = computeSha256ForFile stream relativePath let returnValue = LocalFileVersion.Create relativePath sha256Hash isBinary fileInfo.Length (Instant.FromDateTimeUtc(fileInfo.LastWriteTimeUtc)) true fileInfo.LastWriteTimeUtc return Some returnValue with | ex -> logToAnsiConsole Colors.Error $"Exception in createLocalFileVersion for file {fileInfo.FullName}:" logToAnsiConsole Colors.Error $"{ExceptionResponse.Create ex}" return None else return None } /// Gets the LocalDirectoryVersion for the root directory of the repository from GraceStatus. let getRootDirectoryVersion (graceStatus: GraceStatus) = graceStatus.Index.Values.FirstOrDefault( (fun localDirectoryVersion -> localDirectoryVersion.RelativePath = Constants.RootDirectoryPath), LocalDirectoryVersion.Default ) let localWriteTimes = ConcurrentDictionary() /// Gets a dictionary of local paths and their last write times. let rec getWorkingDirectoryWriteTimes (directoryInfo: DirectoryInfo) = if shouldNotIgnoreDirectory directoryInfo.FullName then // Add the current directory to the lookup dictionary let directoryFullPath = RelativePath(normalizeFilePath (Path.GetRelativePath(Current().RootDirectory, directoryInfo.FullName))) localWriteTimes.AddOrUpdate( (FileSystemEntryType.Directory, directoryFullPath), (fun _ -> directoryInfo.LastWriteTimeUtc), (fun _ _ -> directoryInfo.LastWriteTimeUtc) ) |> ignore // Add each file to the lookup dictionary for f in directoryInfo .GetFiles() .Where(fun f -> not <| shouldIgnoreFile f.FullName) do let fileFullPath = RelativePath(normalizeFilePath (Path.GetRelativePath(Current().RootDirectory, f.FullName))) localWriteTimes.AddOrUpdate((FileSystemEntryType.File, fileFullPath), (fun _ -> f.LastWriteTimeUtc), (fun _ _ -> f.LastWriteTimeUtc)) |> ignore // Call recursively for each subdirectory let parallelLoopResult = Parallel.ForEach(directoryInfo.GetDirectories(), Constants.ParallelOptions, (fun d -> getWorkingDirectoryWriteTimes d |> ignore)) if parallelLoopResult.IsCompleted then () else printfn $"Failed while gathering local write times." localWriteTimes let private getLocalStateDbPath () = Current().GraceStatusFile /// Reads only GraceStatus meta fields (no index). let readGraceStatusMeta () = task { let! meta = LocalStateDb.readStatusMeta (getLocalStateDbPath ()) return { GraceStatus.Default with RootDirectoryId = meta.RootDirectoryId RootDirectorySha256Hash = meta.RootDirectorySha256Hash LastSuccessfulFileUpload = meta.LastSuccessfulFileUpload LastSuccessfulDirectoryVersionUpload = meta.LastSuccessfulDirectoryVersionUpload } } /// Reads the full GraceStatus snapshot including the index. let readGraceStatusSnapshot () = LocalStateDb.readStatusSnapshot (getLocalStateDbPath ()) /// Retrieves the Grace status snapshot (compatibility wrapper). let readGraceStatusFile () = readGraceStatusSnapshot () /// Writes the full Grace status snapshot to disk. let writeGraceStatusFile (graceStatus: GraceStatus) = LocalStateDb.replaceStatusSnapshot (getLocalStateDbPath ()) graceStatus /// Applies incremental Grace status updates to the local DB. let applyGraceStatusIncremental (graceStatus: GraceStatus) (newDirectoryVersions: IEnumerable) (differences: IEnumerable) = LocalStateDb.applyStatusIncremental (getLocalStateDbPath ()) graceStatus newDirectoryVersions differences /// Upserts new directory versions into the object cache tables. let upsertObjectCache (newDirectoryVersions: IEnumerable) = LocalStateDb.upsertObjectCache (getLocalStateDbPath ()) newDirectoryVersions /// Compared the repository's working directory against the Grace index file and returns the differences. let scanForDifferences (previousGraceStatus: GraceStatus) = task { try let lookupCache = Dictionary() let differences = ConcurrentStack() let mutable fileCount = 0 // Create an indexed lookup table of path -> lastWriteTimeUtc from the Grace Status index. for kvp in previousGraceStatus.Index do let directoryVersion = kvp.Value lookupCache.TryAdd( (FileSystemEntryType.Directory, directoryVersion.RelativePath), (directoryVersion.LastWriteTimeUtc, directoryVersion.Sha256Hash) ) |> ignore for file in directoryVersion.Files do fileCount <- fileCount + 1 lookupCache.TryAdd((FileSystemEntryType.File, file.RelativePath), (file.LastWriteTimeUtc, file.Sha256Hash)) |> ignore if parseResult |> isOutputFormat "Verbose" then logToAnsiConsole Colors.Verbose $"scanForDifferences: previousGraceStatus contains {previousGraceStatus.Index.Count} DirectoryVersion entries, and {fileCount} files." // Get an indexed lookup dictionary of path -> lastWriteTimeUtc from the working directory. let localWriteTimes = getWorkingDirectoryWriteTimes (DirectoryInfo(Current().RootDirectory)) // Loop through the working directory list and compare it to the Grace Status index. for kvp in localWriteTimes do let ((fileSystemEntryType, relativePath), lastWriteTimeUtc) = kvp.Deconstruct() // Check for additions if not <| lookupCache.ContainsKey((fileSystemEntryType, relativePath)) then // This is new file or directory. differences.Push(FileSystemDifference.Create Add fileSystemEntryType relativePath) // Check for changes if lookupCache.ContainsKey((fileSystemEntryType, relativePath)) then let (knownLastWriteTimeUtc, existingSha256Hash) = lookupCache[(fileSystemEntryType, relativePath)] // Has the LastWriteTimeUtc changed from the one in GraceStatus? if fileSystemEntryType.IsFile && lastWriteTimeUtc <> knownLastWriteTimeUtc then // If it's a directory, ignore it. I don't care when the local directory was created vs. the one stored in GraceIndex. // If this is a file, then check that the contents have actually changed. let fileInfo = FileInfo(Path.Combine(Current().RootDirectory, relativePath)) match! createLocalFileVersion fileInfo with | Some newFileVersion -> if newFileVersion.Sha256Hash <> existingSha256Hash then differences.Push(FileSystemDifference.Create Change fileSystemEntryType relativePath) if parseResult |> isOutputFormat "Verbose" then logToAnsiConsole Colors.Verbose $"scanForDifferences: Found change in file: {relativePath}; existing Sha256Hash: {getShortSha256Hash existingSha256Hash}; new Sha256Hash: {getShortSha256Hash newFileVersion.Sha256Hash}." | None -> () // Check for deletions for keyValuePair in lookupCache do let (fileSystemEntryType, relativePath) = keyValuePair.Key let (knownLastWriteTimeUtc, existingSha256Hash) = keyValuePair.Value if not <| localWriteTimes.ContainsKey((fileSystemEntryType, relativePath)) then if parseResult |> isOutputFormat "Verbose" then logToAnsiConsole Colors.Verbose $"scanForDifferences: Deletion found: {relativePath}." differences.Push(FileSystemDifference.Create Delete fileSystemEntryType relativePath) return differences.ToList() with | ex -> logToAnsiConsole Colors.Error $"{ExceptionResponse.Create ex}" return List() } //let processedThings = ConcurrentQueue() let mutable newDirectoryVersionCount = 0 let mutable existingDirectoryVersionCount = 0 /// Gathers all of the LocalDirectoryVersions and LocalFileVersions for the requested directory and its subdirectories, and returns them along with the Sha256Hash of the requested directory. let rec collectDirectoriesAndFiles (relativeDirectoryPath: RelativePath) (previousDirectoryVersions: Dictionary) (newGraceStatus: GraceStatus) (parseResult: ParseResult) = /// Gets a list of subdirectories and files from a specific directory let getDirectoryContents (previousDirectoryVersions: Dictionary) (directoryInfo: DirectoryInfo) = task { try let files = ConcurrentQueue() let directories = ConcurrentQueue() // Create LocalFileVersion instances for each file in this directory. do! Parallel.ForEachAsync( directoryInfo .GetFiles() .Where(fun f -> not <| shouldIgnoreFile f.FullName), Constants.ParallelOptions, (fun fileInfo continuationToken -> ValueTask( task { try match! createLocalFileVersion fileInfo with | Some fileVersion -> files.Enqueue(fileVersion) | None -> () with | ex -> logToAnsiConsole Colors.Error $"Exception in getDirectoryContents (Parallel.ForEachAsync):" logToAnsiConsole Colors.Error $"{ExceptionResponse.Create ex}" } )) ) // Create or reuse existing LocalDirectoryVersion instances for each subdirectory in this directory. let defaultDirectoryVersion = KeyValuePair(String.Empty, LocalDirectoryVersion.Default) do! Parallel.ForEachAsync( directoryInfo .GetDirectories() .Where(fun d -> shouldNotIgnoreDirectory d.FullName), Constants.ParallelOptions, (fun subdirectoryInfo continuationToken -> ValueTask( task { try let subdirectoryRelativePath = Path.GetRelativePath(Current().RootDirectory, subdirectoryInfo.FullName) let! (subdirectoryVersions: List, filesInSubdirectory: List, sha256Hash) = collectDirectoriesAndFiles subdirectoryRelativePath previousDirectoryVersions newGraceStatus parseResult // Check if we already have a LocalDirectoryVersion for this subdirectory. let existingSubdirectoryVersion = previousDirectoryVersions .FirstOrDefault( (fun existingDirectoryVersion -> existingDirectoryVersion.Key = normalizeFilePath subdirectoryRelativePath), defaultValue = defaultDirectoryVersion ) .Value // Check if we already have this exact SHA-256 hash for this relative path; if so, keep the existing SubdirectoryVersion and its Guid. // If the DirectoryId is Guid.Empty (from LocalDirectoryVersion.Default), or the Sha256Hash doesn't match, create a new LocalDirectoryVersion reflecting the changes. if existingSubdirectoryVersion.DirectoryVersionId = Guid.Empty || existingSubdirectoryVersion.Sha256Hash <> sha256Hash then let directoryIds = subdirectoryVersions .OrderBy(fun d -> d.RelativePath) .Select(fun d -> d.DirectoryVersionId) .ToList() let subdirectoryVersion = LocalDirectoryVersion.Create (Guid.NewGuid()) (Current().OwnerId) (Current().OrganizationId) (Current().RepositoryId) (RelativePath(normalizeFilePath subdirectoryRelativePath)) sha256Hash directoryIds filesInSubdirectory (getLocalDirectorySize filesInSubdirectory) subdirectoryInfo.LastWriteTimeUtc //processedThings.Enqueue($"New {subdirectoryVersion.RelativePath}") newGraceStatus.Index.TryAdd(subdirectoryVersion.DirectoryVersionId, subdirectoryVersion) |> ignore Interlocked.Increment(&newDirectoryVersionCount) |> ignore directories.Enqueue(subdirectoryVersion) else //processedThings.Enqueue($"Existing {existingSubdirectoryVersion.RelativePath}") newGraceStatus.Index.TryAdd(existingSubdirectoryVersion.DirectoryVersionId, existingSubdirectoryVersion) |> ignore Interlocked.Increment(&existingDirectoryVersionCount) |> ignore directories.Enqueue(existingSubdirectoryVersion) with | ex -> logToAnsiConsole Colors.Error $"Exception in getDirectoryContents: {ExceptionResponse.Create ex}" } )) ) return (directories .OrderBy(fun d -> d.RelativePath) .ToList(), files.OrderBy(fun d -> d.RelativePath).ToList()) with | ex -> logToAnsiConsole Colors.Error $"Exception in getDirectoryContents:" logToAnsiConsole Colors.Error $"{ExceptionResponse.Create ex}" return (List(), List()) } task { try let directoryInfo = DirectoryInfo(Path.Combine(Current().RootDirectory, relativeDirectoryPath)) let! (directories, files) = getDirectoryContents previousDirectoryVersions directoryInfo //for file in files do processedThings.Enqueue(file.RelativePath) let sha256Hash = computeSha256ForDirectory relativeDirectoryPath directories files return (directories, files, sha256Hash) with | ex -> logToAnsiConsole Colors.Error $"Exception in collectDirectoriesAndFiles: {ExceptionResponse.Create ex}" return (List(), List(), Sha256Hash.Empty) } /// Creates the Grace index file by scanning the repository's working directory. let createNewGraceStatusFile (previousGraceStatus: GraceStatus) (parseResult: ParseResult) = task { try // Start with a new GraceStatus instance. let newGraceStatus = GraceStatus.Default let rootDirectoryInfo = DirectoryInfo(Current().RootDirectory) // Get the previous GraceStatus index values into a Dictionary for faster lookup. let previousDirectoryVersions = Dictionary() for kvp in previousGraceStatus.Index do if not <| previousDirectoryVersions.TryAdd(kvp.Value.RelativePath, kvp.Value) then logToAnsiConsole Colors.Error $"createNewGraceStatusFile: Failed to add {kvp.Value.RelativePath} to previousDirectoryVersions." let! (subdirectoriesInRootDirectory, filesInRootDirectory, rootSha256Hash) = collectDirectoriesAndFiles Constants.RootDirectoryPath previousDirectoryVersions newGraceStatus parseResult //let getBySha256HashParameters = GetBySha256HashParameters(RepositoryId = $"{Current().RepositoryId}", Sha256Hash = rootSha256Hash) //let! directoryId = Directory.GetBySha256Hash(getBySha256HashParameters) // Check for existing root directory version so we don't update the Guid if it already exists. let rootDirectoryVersion = let previousRootDirectoryVersion = previousDirectoryVersions .FirstOrDefault( (fun existingDirectoryVersion -> existingDirectoryVersion.Key = Constants.RootDirectoryPath), defaultValue = KeyValuePair(String.Empty, LocalDirectoryVersion.Default) ) .Value if previousRootDirectoryVersion.Sha256Hash = rootSha256Hash then previousRootDirectoryVersion else let subdirectoryIds = subdirectoriesInRootDirectory .OrderBy(fun d -> d.RelativePath) .Select(fun d -> d.DirectoryVersionId) .ToList() LocalDirectoryVersion.Create (Guid.NewGuid()) (Current().OwnerId) (Current().OrganizationId) (Current().RepositoryId) (RelativePath(normalizeFilePath Constants.RootDirectoryPath)) (Sha256Hash rootSha256Hash) subdirectoryIds filesInRootDirectory (getLocalDirectorySize filesInRootDirectory) rootDirectoryInfo.LastWriteTimeUtc newGraceStatus.Index.TryAdd(Guid.Parse($"{rootDirectoryVersion.DirectoryVersionId}"), rootDirectoryVersion) |> ignore //let sb = StringBuilder(processedThings.Count) //for dir in processedThings.OrderBy(fun d -> d) do // sb.AppendLine(dir) |> ignore //do! File.WriteAllTextAsync(@$"C:\Intel\ProcessedThings{sb.Length}.txt", sb.ToString()) let rootDirectoryVersion = getRootDirectoryVersion newGraceStatus if parseResult |> isOutputFormat "Verbose" then logToAnsiConsole Colors.Verbose $"Finished createNewGraceStatusFile. newGraceStatus.Index.Count: {newGraceStatus.Index.Count}." let newGraceStatus = { newGraceStatus with RootDirectoryId = rootDirectoryVersion.DirectoryVersionId; RootDirectorySha256Hash = rootDirectoryVersion.Sha256Hash } return newGraceStatus with | ex -> logToAnsiConsole Colors.Error $"Exception in createNewGraceStatusFile: {ExceptionResponse.Create ex}" return GraceStatus.Default } /// Adds a LocalDirectoryVersion to the local object cache. let addDirectoryToObjectCache (localDirectoryVersion: LocalDirectoryVersion) = task { let! exists = LocalStateDb.isDirectoryVersionInObjectCache (getLocalStateDbPath ()) localDirectoryVersion.DirectoryVersionId if not exists then let allFilesExist = localDirectoryVersion.Files |> Seq.forall (fun file -> File.Exists(Path.Combine(Current().ObjectDirectory, file.RelativeDirectory, file.GetObjectFileName))) if allFilesExist then do! upsertObjectCache [ localDirectoryVersion ] return Ok() else return Error "Directory could not be added to object cache. All files do not exist in /objects directory." else return Ok() } /// Removes a directory from the local object cache. let removeDirectoryFromObjectCache (directoryId: DirectoryVersionId) = task { do! LocalStateDb.removeObjectCacheDirectory (getLocalStateDbPath ()) directoryId } /// Downloads files from object storage that aren't already present in the local object cache. let downloadFilesFromObjectStorage (getDownloadUriParameters: GetDownloadUriParameters) (files: IEnumerable) (correlationId: string) = task { match Current().ObjectStorageProvider with | ObjectStorageProvider.Unknown -> return Ok() | AzureBlobStorage -> let results = files.ToArray() |> Array.where (fun f -> not <| File.Exists(f.FullObjectPath)) |> Array.Parallel.map (fun f -> (task { let parameters = GetDownloadUriParameters() parameters.OwnerId <- getDownloadUriParameters.OwnerId parameters.OwnerName <- getDownloadUriParameters.OwnerName parameters.OrganizationId <- getDownloadUriParameters.OrganizationId parameters.OrganizationName <- getDownloadUriParameters.OrganizationName parameters.RepositoryId <- getDownloadUriParameters.RepositoryId parameters.RepositoryName <- getDownloadUriParameters.RepositoryName parameters.FileVersion <- f.ToFileVersion parameters.CorrelationId <- getDownloadUriParameters.CorrelationId return! Storage.GetFileFromObjectStorage parameters correlationId }) .Result) let (results, errors) = results |> Array.partition (fun result -> match result with | Ok _ -> true | Error _ -> false) if errors.Count() > 0 then let sb = stringBuilderPool.Get() try sb.Append($"Some files could not be downloaded from object storage.{Environment.NewLine}") |> ignore errors |> Seq.iter (fun e -> match e with | Ok _ -> () | Error e -> sb.AppendLine($"{e.Error}{Environment.NewLine}{serialize e.Properties}") |> ignore) return Error(sb.ToString()) finally stringBuilderPool.Return(sb) |> ignore else return Ok() | AWSS3 -> return Ok() | GoogleCloudStorage -> return Ok() } /// Uploads all new or changed files from a directory to object storage. let uploadFilesToObjectStorage (parameters: GetUploadMetadataForFilesParameters) = task { match Current().ObjectStorageProvider with | ObjectStorageProvider.Unknown -> return Error(GraceError.Create (getErrorMessage StorageError.NotImplemented) parameters.CorrelationId) | AzureBlobStorage -> //logToAnsiConsole Colors.Verbose $"Uploading {fileVersions.Count()} files to object storage." if parameters.FileVersions.Count() > 0 then match! Storage.GetUploadMetadataForFiles parameters with | Ok graceReturnValue -> let filesToUpload = graceReturnValue.ReturnValue //logToAnsiConsole Colors.Verbose $"In Services.uploadFilesToObjectStorage(): filesToUpload: {serialize filesToUpload}." let errors = ConcurrentQueue() do! Parallel.ForEachAsync( filesToUpload, Constants.ParallelOptions, (fun uploadMetadata ct -> ValueTask( task { let fileVersion = (parameters.FileVersions.First(fun fileVersion -> fileVersion.Sha256Hash = uploadMetadata.Sha256Hash)) //logToAnsiConsole Colors.Verbose $"In Services.uploadFilesToObjectStorage(): Uploading {fileVersion.GetObjectFileName} to object storage." match! Storage.SaveFileToObjectStorage (RepositoryId.Parse(parameters.RepositoryId)) fileVersion (uploadMetadata.BlobUriWithSasToken) parameters.CorrelationId with | Ok result -> () //logToAnsiConsole Colors.Verbose $"In Services.uploadFilesToObjectStorage(): Uploaded {fileVersion.GetObjectFileName} to object storage." | Error error -> logToAnsiConsole Colors.Error $"Error uploading {fileVersion.GetObjectFileName} to object storage: {error.Error}" errors.Enqueue(error) } )) ) if errors |> Seq.isEmpty then return Ok(GraceReturnValue.Create true parameters.CorrelationId) else // use Seq.fold to create a single error message from the ConcurrentQueue let errorMessage = errors |> Seq.fold (fun acc error -> $"{acc}\n{error.Error}") "" let graceError = GraceError.Create (getErrorMessage StorageError.FailedUploadingFilesToObjectStorage) parameters.CorrelationId return Error graceError |> enhance "Errors" errorMessage | Error error -> return Error error else return Ok(GraceReturnValue.Create true parameters.CorrelationId) | AWSS3 -> return Error(GraceError.Create (getErrorMessage StorageError.NotImplemented) parameters.CorrelationId) | GoogleCloudStorage -> return Error(GraceError.Create (getErrorMessage StorageError.NotImplemented) parameters.CorrelationId) } /// Creates an updated LocalDirectoryVersion instance, with a new DirectoryId, based on changes to an existing one. /// /// If this is a new subdirectory, the LocalDirectoryVersion will have just been created with empty subdirectory and file lists. let processChangedDirectoryVersion (newGraceStatus: GraceStatus) (previousDirectoryVersion: LocalDirectoryVersion) = // We process the deepest directories first, so we know any subdirectories of this one will already be in newGraceStatus. // Get LocalDirectoryVersion instances for the subdirectories of this DirectoryVersion. let subdirectoryVersions = if previousDirectoryVersion.Directories.Count > 0 then previousDirectoryVersion .Directories .Select(fun directoryId -> let localDirectoryVersion = newGraceStatus .Index .FirstOrDefault( (fun kvp -> kvp.Key = directoryId), KeyValuePair(Guid.Empty, LocalDirectoryVersion.Default) ) .Value if localDirectoryVersion.DirectoryVersionId <> Guid.Empty then Some localDirectoryVersion else None) .Where(fun opt -> Option.isSome opt) .Select(fun opt -> Option.get opt) .ToList() else List() // Get the new SHA-256 hash for the updated contents of this directory. let newSha256Hash = computeSha256ForDirectory (previousDirectoryVersion.RelativePath) subdirectoryVersions previousDirectoryVersion.Files let directoryInfo = DirectoryInfo(Path.Combine(Current().RootDirectory, previousDirectoryVersion.RelativePath)) // Create a new LocalDirectoryVersion that contains a new Id and the updated SHA-256 hash. let newDirectoryVersion = LocalDirectoryVersion.Create (Guid.NewGuid()) previousDirectoryVersion.OwnerId previousDirectoryVersion.OrganizationId previousDirectoryVersion.RepositoryId previousDirectoryVersion.RelativePath newSha256Hash previousDirectoryVersion.Directories previousDirectoryVersion.Files (getLocalDirectorySize previousDirectoryVersion.Files) directoryInfo.LastWriteTimeUtc newDirectoryVersion /// Determines if the given difference is for a directory, instead of a file. let isDirectoryChange (difference: FileSystemDifference) = match difference.FileSystemEntryType with | FileSystemEntryType.Directory -> true | FileSystemEntryType.File -> false /// Determines if the given difference is for a file, instead of a directory. let isFileChange difference = not <| isDirectoryChange difference /// Processes directory additions or changes let private processDirectoryChange (newGraceStatus: GraceStatus) (previousGraceStatus: GraceStatus) (difference: FileSystemDifference) = let previousRootVersion = getRootDirectoryVersion previousGraceStatus let previousDirectoryVersion = previousGraceStatus .Index .FirstOrDefault( (fun kvp -> kvp.Value.RelativePath = difference.RelativePath), KeyValuePair(Guid.Empty, LocalDirectoryVersion.Default) ) .Value if previousDirectoryVersion.DirectoryVersionId <> Guid.Empty then Some(processChangedDirectoryVersion newGraceStatus previousDirectoryVersion) else let directoryInfo = DirectoryInfo(Path.Combine(Current().RootDirectory, difference.RelativePath)) let sha256Hash = computeSha256ForDirectory difference.RelativePath (List()) (List()) Some( LocalDirectoryVersion.Create (Guid.NewGuid()) previousRootVersion.OwnerId previousRootVersion.OrganizationId previousRootVersion.RepositoryId difference.RelativePath sha256Hash (List()) (List()) Constants.InitialDirectorySize directoryInfo.LastWriteTimeUtc ) /// Processes file additions to a directory let private processFileAddition (changedDirectoryVersions: ConcurrentDictionary) (newGraceStatus: GraceStatus) (difference: FileSystemDifference) = task { let fileInfo = FileInfo(Path.Combine(Current().RootDirectory, difference.RelativePath)) let relativeDirectoryPath = getLocalRelativeDirectory fileInfo.DirectoryName (Current().RootDirectory) let directoryVersion = let mutable changedDirectoryVersion = LocalDirectoryVersion.Default if changedDirectoryVersions.TryGetValue(relativeDirectoryPath, &changedDirectoryVersion) then changedDirectoryVersion else newGraceStatus.Index.Values.FirstOrDefault((fun dv -> dv.RelativePath = relativeDirectoryPath), LocalDirectoryVersion.Default) match! createLocalFileVersion fileInfo with | Some fileVersion -> directoryVersion.Files.Add(fileVersion) let updated = { directoryVersion with Size = directoryVersion.Files.Sum(fun file -> int64 (file.Size)) } changedDirectoryVersions.AddOrUpdate(directoryVersion.RelativePath, (fun _ -> updated), (fun _ _ -> updated)) |> ignore | None -> () } /// Processes file changes (updates) let private processFileChange (changedDirectoryVersions: ConcurrentDictionary) (newGraceStatus: GraceStatus) (difference: FileSystemDifference) = task { let fileInfo = FileInfo(Path.Combine(Current().RootDirectory, difference.RelativePath)) let relativeDirectoryPath = normalizeFilePath (getLocalRelativeDirectory fileInfo.DirectoryName (Current().RootDirectory)) let directoryVersion = let alreadyChanged = changedDirectoryVersions.Values.FirstOrDefault((fun dv -> dv.RelativePath = relativeDirectoryPath), LocalDirectoryVersion.Default) if alreadyChanged.DirectoryVersionId <> Guid.Empty then alreadyChanged else newGraceStatus.Index.Values.First(fun dv -> dv.RelativePath = relativeDirectoryPath) let existingFileIndex = directoryVersion.Files.FindIndex(fun file -> file.RelativePath = difference.RelativePath) let existingFileVersion = directoryVersion.Files[existingFileIndex] match! createLocalFileVersion fileInfo with | Some fileVersion -> if fileVersion.Sha256Hash <> existingFileVersion.Sha256Hash then directoryVersion.Files.RemoveAt(existingFileIndex) directoryVersion.Files.Add(fileVersion) let updated = { directoryVersion with Size = directoryVersion.Files.Sum(fun file -> int64 (file.Size)) } changedDirectoryVersions.AddOrUpdate(directoryVersion.RelativePath, (fun _ -> updated), (fun _ _ -> updated)) |> ignore | None -> () } /// Processes file deletions let private processFileDeletion (changedDirectoryVersions: ConcurrentDictionary) (newGraceStatus: GraceStatus) (difference: FileSystemDifference) = let fileInfo = FileInfo(Path.Combine(Current().RootDirectory, difference.RelativePath)) let relativeDirectoryPath = getLocalRelativeDirectory fileInfo.DirectoryName (Current().RootDirectory) let directoryVersion = newGraceStatus .Index .Values .Where(fun dv -> dv.RelativePath = relativeDirectoryPath) .ToList() match directoryVersion.Count with | 0 -> () | 1 -> let dv = directoryVersion[0] let index = dv.Files.FindIndex(fun file -> file.RelativePath = difference.RelativePath) dv.Files.RemoveAt(index) let updated = { dv with Size = dv.Files.Sum(fun file -> int64 (file.Size)) } changedDirectoryVersions.AddOrUpdate(dv.RelativePath, (fun _ -> updated), (fun _ _ -> updated)) |> ignore | _ -> () /// Recursively processes changed directories from leaf to root let rec private processChangedDirectoriesBottomUp (newGraceStatus: GraceStatus) (changedDirectoryVersions: ConcurrentDictionary) (newDirectoryVersions: List) = if changedDirectoryVersions.IsEmpty then () else let relativePath = changedDirectoryVersions .Keys .OrderByDescending(fun rp -> countSegments rp) .First() let mutable previousDirectoryVersion = LocalDirectoryVersion.Default if changedDirectoryVersions.TryRemove(relativePath, &previousDirectoryVersion) then let newDirectoryVersion = processChangedDirectoryVersion newGraceStatus previousDirectoryVersion newDirectoryVersions.Add(newDirectoryVersion) let mutable previous = LocalDirectoryVersion.Default let foundPrevious = newGraceStatus.Index.TryRemove(previousDirectoryVersion.DirectoryVersionId, &previous) newGraceStatus.Index.TryAdd(newDirectoryVersion.DirectoryVersionId, newDirectoryVersion) |> ignore match getParentPath relativePath with | Some path -> let dv = let alreadyChanged = changedDirectoryVersions.Values.FirstOrDefault((fun dv -> dv.RelativePath = path), LocalDirectoryVersion.Default) if alreadyChanged.DirectoryVersionId <> Guid.Empty then alreadyChanged else newGraceStatus.Index.Values.First(fun dv -> dv.RelativePath = path) if foundPrevious then dv.Directories.Remove(previous.DirectoryVersionId) |> ignore dv.Directories.Add(newDirectoryVersion.DirectoryVersionId) |> ignore changedDirectoryVersions.AddOrUpdate(path, (fun _ -> dv), (fun _ _ -> dv)) |> ignore // Recursively process the parent processChangedDirectoriesBottomUp newGraceStatus changedDirectoryVersions newDirectoryVersions | None -> () /// Main refactored function let getNewGraceStatusAndDirectoryVersions (previousGraceStatus: GraceStatus) (differences: IEnumerable) = task { if parseResult |> isOutputFormat "Verbose" then logToAnsiConsole Colors.Verbose $"In getNewGraceStatusAndDirectoryVersions: differences:{Environment.NewLine}{serialize differences}" let changedDirectoryVersions = ConcurrentDictionary() let mutable newGraceStatus = { previousGraceStatus with Index = GraceIndex(previousGraceStatus.Index) } // Process directory changes (Add/Change/Delete) for difference in differences.Where(fun d -> isDirectoryChange d) do match difference.DifferenceType with | Add | Change -> match processDirectoryChange newGraceStatus previousGraceStatus difference with | Some newDirectoryVersion -> let previousDirectoryVersions = newGraceStatus.Index.Values.Where(fun dv -> dv.RelativePath = difference.RelativePath) logToAnsiConsole Colors.Verbose $"Processing directory {difference.DifferenceType} for path: {difference.RelativePath}. Previous versions: {serialize previousDirectoryVersions}." let mutable previous = LocalDirectoryVersion.Default // Remove the previous directory version from the index. for previousDirectoryVersion in previousDirectoryVersions do newGraceStatus.Index.TryRemove(previousDirectoryVersion.DirectoryVersionId, &previous) |> ignore // Add the new directory version to the index. newGraceStatus.Index.AddOrUpdate( newDirectoryVersion.DirectoryVersionId, (fun _ -> newDirectoryVersion), (fun _ _ -> newDirectoryVersion) ) |> ignore if difference.DifferenceType = Add then changedDirectoryVersions.AddOrUpdate(difference.RelativePath, (fun _ -> newDirectoryVersion), (fun _ _ -> newDirectoryVersion)) |> ignore | None -> () | Delete -> let mutable directoryVersion = newGraceStatus.Index.Values.First(fun dv -> dv.RelativePath = difference.RelativePath) newGraceStatus.Index.TryRemove(directoryVersion.DirectoryVersionId, &directoryVersion) |> ignore // Process file changes (Add/Change/Delete) for difference in differences.Where(fun d -> isFileChange d) do match difference.DifferenceType with | Add -> do! processFileAddition changedDirectoryVersions newGraceStatus difference | Change -> do! processFileChange changedDirectoryVersions newGraceStatus difference | Delete -> processFileDeletion changedDirectoryVersions newGraceStatus difference // Recursively process changed directories from leaf to root let newDirectoryVersions = List() processChangedDirectoriesBottomUp newGraceStatus changedDirectoryVersions newDirectoryVersions if newDirectoryVersions.Count > 0 then let rootExists = newGraceStatus .Index .Values .FirstOrDefault( (fun dv -> dv.RelativePath = Constants.RootDirectoryPath), LocalDirectoryVersion.Default ) .DirectoryVersionId <> Guid.Empty if rootExists then let newRootDirectoryVersion = getRootDirectoryVersion newGraceStatus newGraceStatus <- { newGraceStatus with RootDirectoryId = newRootDirectoryVersion.DirectoryVersionId RootDirectorySha256Hash = newRootDirectoryVersion.Sha256Hash } return (newGraceStatus, newDirectoryVersions) else return (previousGraceStatus, newDirectoryVersions) } /// Ensures that the provided directory versions are uploaded to Grace Server. /// This will add new directory versions, and ignore existing directory versions, as they are immutable. let uploadDirectoryVersions (localDirectoryVersions: List) correlationId = let directoryVersions = localDirectoryVersions .Select(fun ldv -> ldv.ToDirectoryVersion) .ToList() let saveParameters = SaveDirectoryVersionsParameters() saveParameters.OwnerId <- $"{Current().OwnerId}" saveParameters.OwnerName <- Current().OwnerName saveParameters.OrganizationId <- $"{Current().OrganizationId}" saveParameters.OrganizationName <- Current().OrganizationName saveParameters.RepositoryId <- $"{Current().RepositoryId}" saveParameters.RepositoryName <- Current().RepositoryName saveParameters.CorrelationId <- correlationId saveParameters.DirectoryVersions <- directoryVersions DirectoryVersion.SaveDirectoryVersions saveParameters /// The full path of the inter-process communication file that grace watch uses to communicate with other invocations of Grace. let IpcFileName () = Path.Combine(Path.GetTempPath(), "Grace", Current().BranchName, Constants.IpcFileName) /// Updates the contents of the `grace watch` status inter-process communication file. let updateGraceWatchInterprocessFile (graceStatus: GraceStatus) (directoryIdsOverride: HashSet option) = task { try let directoryIds = match directoryIdsOverride with | Some ids -> HashSet(ids) | None -> HashSet(graceStatus.Index.Keys) let newGraceWatchStatus = { UpdatedAt = getCurrentInstant () RootDirectoryId = graceStatus.RootDirectoryId RootDirectorySha256Hash = graceStatus.RootDirectorySha256Hash LastFileUploadInstant = graceStatus.LastSuccessfulFileUpload LastDirectoryVersionInstant = graceStatus.LastSuccessfulDirectoryVersionUpload DirectoryIds = directoryIds } //logToAnsiConsole Colors.Important $"In updateGraceWatchStatus. newGraceWatchStatus.UpdatedAt: {newGraceWatchStatus.UpdatedAt.ToString(InstantPattern.ExtendedIso.PatternText, CultureInfo.InvariantCulture)}." //logToAnsiConsole Colors.Highlighted $"{Markup.Escape(EnhancedStackTrace.Current().ToString())}" Directory.CreateDirectory(Path.GetDirectoryName(IpcFileName())) |> ignore use fileStream = new FileStream(IpcFileName(), FileMode.Create, FileAccess.Write, FileShare.None) do! serializeAsync fileStream newGraceWatchStatus graceWatchStatusUpdateTime <- newGraceWatchStatus.UpdatedAt logToAnsiConsole Colors.Important $"Wrote inter-process communication file." with | ex -> logToAnsiConsole Colors.Error $"Exception in updateGraceWatchInterprocessFile." logToAnsiConsole Colors.Error $"ex.GetType: {ex.GetType().FullName}{Environment.NewLine}{Environment.NewLine}" logToAnsiConsole Colors.Error $"ex.Message: {ex.Message}{Environment.NewLine}{Environment.NewLine}{ex.StackTrace}" } /// Reads the `grace watch` status inter-process communication file. let getGraceWatchStatus () = task { try // If the file exists, `grace watch` is running. if File.Exists(IpcFileName()) then //logToAnsiConsole Colors.Verbose $"File {IpcFileName} exists." use fileStream = new FileStream(IpcFileName(), FileMode.Open, FileAccess.Read, FileShare.Read) let! graceWatchStatus = deserializeAsync fileStream // `grace watch` updates the file at least every five minutes to indicate that it's still alive. // When `grace watch` exits, the status file is deleted in a try...finally (Program.CLI.fs), so the only // circumstance where it would be on-disk without `grace watch` running is if the process were killed. // Just to be safe, we're going to check that the file has been written in the last five minutes. if graceWatchStatus.UpdatedAt > getCurrentInstant().Minus(Duration.FromMinutes(5.0)) then return Some graceWatchStatus else return None // File is more than five minutes old, so something weird happened and we shouldn't trust the information. else //logToAnsiConsole Colors.Verbose $"File {IpcFileName} does not exist." return None // `grace watch` isn't running. with | ex -> logToAnsiConsole Colors.Error $"Exception when reading inter-process communication file." logToAnsiConsole Colors.Error $"ex.GetType: {ex.GetType().FullName}." logToAnsiConsole Colors.Error $"ex.Message: {StringExtensions.EscapeMarkup(ex.Message)}." logToAnsiConsole Colors.Error $"{Environment.NewLine}{StringExtensions.EscapeMarkup(ex.StackTrace)}." return None } /// Checks if a file already exists in the object cache. let isFileInObjectCache (fileVersion: LocalFileVersion) = task { let objectFileName = fileVersion.GetObjectFileName let objectFilePath = Path.Combine(Current().ObjectDirectory, fileVersion.RelativeDirectory, objectFileName) return File.Exists(objectFilePath) } /// Updates the Grace Status index with new directory versions after getting them from the server. let updateGraceStatusWithNewDirectoryVersionsFromServer (graceStatus: GraceStatus) (newDirectoryVersionDtos: IEnumerable) = let newGraceIndex = GraceIndex(graceStatus.Index) let mutable dvForDeletions = LocalDirectoryVersion.Default if parseResult |> isOutputFormat "Verbose" then logToAnsiConsole Colors.Verbose $"In updateGraceStatusWithNewDirectoryVersionsFromServer: Processing {newDirectoryVersionDtos.Count()} new DirectoryVersions." // First, either add the new ones, or replace the existing ones. for newDirectoryVersionDto in newDirectoryVersionDtos do let newDirectoryVersion = newDirectoryVersionDto.DirectoryVersion logToAnsiConsole Colors.Verbose $"Processing new DirectoryVersion: {newDirectoryVersion.RelativePath}." let existingDirectoryVersion = newGraceIndex.Values.FirstOrDefault((fun dv -> dv.RelativePath = newDirectoryVersion.RelativePath), LocalDirectoryVersion.Default) if existingDirectoryVersion.DirectoryVersionId <> LocalDirectoryVersion.Default.DirectoryVersionId then // We already have an entry with the same RelativePath, so remove the old one and add the new one. if parseResult |> isOutputFormat "Verbose" then logToAnsiConsole Colors.Verbose $"Replacing existing DirectoryVersion for path: {newDirectoryVersion.RelativePath}." newGraceIndex.TryRemove(existingDirectoryVersion.DirectoryVersionId, &dvForDeletions) |> ignore if parseResult |> isOutputFormat "Verbose" then logToAnsiConsole Colors.Verbose $"Removed old DirectoryVersion for path: {newDirectoryVersion.RelativePath}." newGraceIndex.AddOrUpdate( newDirectoryVersion.DirectoryVersionId, (fun _ -> newDirectoryVersion.ToLocalDirectoryVersion DateTime.UtcNow), (fun _ _ -> newDirectoryVersion.ToLocalDirectoryVersion DateTime.UtcNow) ) |> ignore if parseResult |> isOutputFormat "Verbose" then logToAnsiConsole Colors.Verbose $"Added new DirectoryVersion for path: {newDirectoryVersion.RelativePath}." else // We didn't find the RelativePath, so it's a new DirectoryVersion. newGraceIndex.AddOrUpdate( newDirectoryVersion.DirectoryVersionId, (fun _ -> newDirectoryVersion.ToLocalDirectoryVersion DateTime.UtcNow), (fun _ _ -> newDirectoryVersion.ToLocalDirectoryVersion DateTime.UtcNow) ) |> ignore if parseResult |> isOutputFormat "Verbose" then logToAnsiConsole Colors.Verbose $"Added new DirectoryVersion for path: {newDirectoryVersion.RelativePath}." // Finally, delete any that don't exist anymore. // Get the list of the all of the subdirectories referenced in the DirectoryVersions left after replacing existing ones and adding new ones. let allSubdirectories = HashSet( newGraceIndex.Values.Select(fun dv -> dv.Directories) |> Seq.collect (fun dvs -> dvs) ) let allSubdirectoriesDistinct = allSubdirectories |> Seq.distinct // Now check every DirectoryVersion in newGraceIndex to see if it's in the list of subdirectories. // If it's no longer referenced by anyone, that means we can delete it. for directoryVersion in newGraceIndex.Values do if not <| (directoryVersion.RelativePath = Constants.RootDirectoryPath) && not <| allSubdirectoriesDistinct.Contains(directoryVersion.DirectoryVersionId) then if parseResult |> isOutputFormat "Verbose" then logToAnsiConsole Colors.Verbose $"Removing DirectoryVersion for path: {directoryVersion.RelativePath}; DirectoryVersionId: {directoryVersion.DirectoryVersionId}." newGraceIndex.TryRemove(directoryVersion.DirectoryVersionId, &dvForDeletions) |> ignore let rootDirectoryVersion = newGraceIndex.Values.First(fun dv -> dv.RelativePath = Constants.RootDirectoryPath) let newGraceStatus = { Index = newGraceIndex RootDirectoryId = rootDirectoryVersion.DirectoryVersionId RootDirectorySha256Hash = rootDirectoryVersion.Sha256Hash LastSuccessfulDirectoryVersionUpload = graceStatus.LastSuccessfulDirectoryVersionUpload LastSuccessfulFileUpload = graceStatus.LastSuccessfulFileUpload } if parseResult |> isOutputFormat "Verbose" then logToAnsiConsole Colors.Verbose $"In updateGraceStatusWithNewDirectoryVersionsFromServer: Returning new GraceStatus with {newGraceStatus.Index.Count} DirectoryVersions." newGraceStatus /// Gets the file name used to indicate to `grace watch` that updates are in progress from another Grace command, and that it should ignore them. let updateInProgressFileName () = let directory = Path.Combine(Path.GetTempPath(), "Grace", Current().BranchName) Directory.CreateDirectory(directory) |> ignore getNativeFilePath (Path.Combine(Path.GetTempPath(), "Grace", Current().BranchName, Constants.UpdateInProgressFileName)) /// Updates the working directory to match the contents of new DirectoryVersions. /// /// In general, this means copying new and changed files into place, and removing deleted files and directories. let updateWorkingDirectory (previousGraceStatus: GraceStatus) (updatedGraceStatus: GraceStatus) (newDirectoryVersionDtos: IEnumerable) (correlationId: CorrelationId) = task { // Loop through each new DirectoryVersion. for newDirectoryVersionDto in newDirectoryVersionDtos do let newDirectoryVersion = newDirectoryVersionDto.DirectoryVersion // Get the previous DirectoryVersion, so we can compare contents below. let previousDirectoryVersion = previousGraceStatus.Index.Values.FirstOrDefault( (fun dv -> dv.RelativePath = newDirectoryVersion.RelativePath), LocalDirectoryVersion.Default ) // Ensure that the directory exists on disk. let directoryInfo = Directory.CreateDirectory(newDirectoryVersion.FullName) // Copy new and existing files into place. let newLocalFileVersions = newDirectoryVersion.Files.Select(fun file -> file.ToLocalFileVersion DateTime.UtcNow) for fileVersion in newLocalFileVersions do let existingFileOnDisk = FileInfo(fileVersion.FullName) let objectFile = FileInfo(fileVersion.FullObjectPath) if not <| objectFile.Exists then // This is an error. There _should_ be a file in the object cache for every file in each DirectoryVersion. // Anyway, we'll just download it from the server (again). let getDownloadUriParameters = Storage.GetDownloadUriParameters( OwnerId = $"{Current().OwnerId}", OwnerName = Current().OwnerName, OrganizationId = $"{Current().OrganizationId}", OrganizationName = Current().OrganizationName, RepositoryId = $"{Current().RepositoryId}", RepositoryName = Current().RepositoryName, CorrelationId = correlationId ) match! downloadFilesFromObjectStorage getDownloadUriParameters [| fileVersion |] correlationId with | Ok _ -> logToAnsiConsole Colors.Verbose $"Downloaded {fileVersion.FullObjectPath} from the object storage provider." () | Error error -> logToAnsiConsole Colors.Error $"An error occurred while downloading a file from the object storage provider. CorrelationId: {correlationId}." logToAnsiConsole Colors.Error $"{error}" if existingFileOnDisk.Exists then // Need to compare existing file to new version from the object cache. let findFileVersionFromPreviousGraceStatus = previousDirectoryVersion.Files.Where(fun f -> f.RelativePath = fileVersion.RelativePath) if findFileVersionFromPreviousGraceStatus.Count() > 0 then let fileVersionFromPreviousGraceStatus = findFileVersionFromPreviousGraceStatus.First() // If the length is different, or the Sha256Hash is changing in the new version, we'll delete the // file in the working directory, and copy the version from the object cache to replace it. if existingFileOnDisk.Length <> fileVersion.Size || fileVersionFromPreviousGraceStatus.Sha256Hash <> fileVersion.Sha256Hash then //logToAnsiConsole // Colors.Verbose // $"Replacing {fileVersion.FullName}; previous length: {fileVersionFromPreviousGraceStatus.Size}; new length: {fileVersion.Size}." existingFileOnDisk.Delete() File.Copy(fileVersion.FullObjectPath, fileVersion.FullName) else // No existing file, so just copy it into place. //logToAnsiConsole Colors.Verbose $"Copying file {fileVersion.FullName} from object cache; no existing file." File.Copy(fileVersion.FullObjectPath, fileVersion.FullName) // Delete unnecessary directories. // Get DirectoryVersions for the subdirectories of the new DirectoryVersion. //logToAnsiConsole // Colors.Verbose // $"Services.CLI.fs: updateWorkingDirectory(): {Markup.Escape(serialize (updatedGraceStatus.Index.Select(fun x -> x.Value.DirectoryVersionId)))}" //logToAnsiConsole // Colors.Verbose // $"Services.CLI.fs: updateWorkingDirectory(): {Markup.Escape(serialize (newDirectoryVersions.Select(fun x -> x.DirectoryVersionId)))}" //let previousDirectoryIds = previousGraceStatus.Index.Values.Select(fun dv -> (dv.DirectoryVersionId, dv.RelativePath)) //let updatedDirectoryIds = updatedGraceStatus.Index.Values.Select(fun dv -> (dv.DirectoryVersionId, dv.RelativePath)) //logToAnsiConsole Colors.Verbose $"{serialize previousDirectoryIds}" //logToAnsiConsole Colors.Verbose $"{serialize updatedDirectoryIds}" let subdirectoryVersions = newDirectoryVersion.Directories.Select(fun directoryVersionId -> updatedGraceStatus.Index[directoryVersionId]) // Loop through the actual subdirectories on disk. for subdirectoryInfo in directoryInfo.EnumerateDirectories().ToArray() do // If we don't have this subdirectory listed in new parent DirectoryVersion, and it's a directory that we shouldn't ignore, // that means that it was deleted, and we should delete it from the working directory. let relativeSubdirectoryPath = Path.GetRelativePath(Current().RootDirectory, subdirectoryInfo.FullName) if not <| (subdirectoryVersions |> Seq.exists (fun subdirectoryVersion -> subdirectoryVersion.RelativePath = relativeSubdirectoryPath)) && shouldNotIgnoreDirectory subdirectoryInfo.FullName then //logToAnsiConsole Colors.Verbose $"Deleting directory {subdirectoryInfo.FullName}." subdirectoryInfo.Delete(true) // Delete unnecessary files. // Loop through the actual files on disk. for fileInfo in directoryInfo.EnumerateFiles() do // If we don't have this file in the new version of the directory, and it's a file that we shouldn't ignore, // that means that it was deleted, and we should delete it from the working directory. // Ignored files get... ignored. if not <| newLocalFileVersions.Any(fun fileVersion -> fileVersion.FullName = fileInfo.FullName) && not <| shouldIgnoreFile fileInfo.FullName then //logToAnsiConsole Colors.Verbose $"Deleting file {fileInfo.FullName}." fileInfo.Delete() } /// Creates a save reference with the given message. let createSaveReference rootDirectoryVersion message correlationId = task { //Activity.Current <- new Activity("createSaveReference") let createReferenceParameters = Parameters.Branch.CreateReferenceParameters( OwnerId = $"{Current().OwnerId}", OrganizationId = $"{Current().OrganizationId}", RepositoryId = $"{Current().RepositoryId}", BranchId = $"{Current().BranchId}", CorrelationId = correlationId, DirectoryVersionId = rootDirectoryVersion.DirectoryVersionId, Sha256Hash = rootDirectoryVersion.Sha256Hash, Message = message ) let! result = Branch.Save createReferenceParameters //Activity.Current.SetTag("CorrelationId", correlationId) |> ignore match result with | Ok returnValue -> //logToAnsiConsole Colors.Verbose $"Created a save in branch {Current().BranchName}. Sha256Hash: {rootDirectoryVersion.Sha256Hash.Substring(0, 8)}. CorrelationId: {returnValue.CorrelationId}." //Activity.Current.AddTag("Created Save reference", "true") // .SetStatus(ActivityStatusCode.Ok, returnValue.ReturnValue) |> ignore () | Error error -> logToAnsiConsole Colors.Error $"An error occurred while creating a save that contains the current differences. CorrelationId: {error.CorrelationId}." //Activity.Current.AddTag("Created Save reference", "false") // .AddTag("Server path", error.Properties["Path"]) // .SetStatus(ActivityStatusCode.Error, error.Error) |> ignore //Activity.Current.Dispose() return result } /// Generates a temporary file name within the ObjectDirectory, and returns the full file path. /// This file name will be used to copy modified files into before renaming them with their proper names and SHA256 values. let getTemporaryFilePath () = let tempDirectory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "Grace", Current().BranchName)) Path.GetFullPath(Path.Combine(tempDirectory.FullName, $"{Path.GetRandomFileName()}.gracetmp")) /// Copies a file to the Object Directory, and returns a new FileVersion. The SHA-256 hash is computed and included in the object file name. let copyToObjectDirectory (filePath: FilePath) : Task = task { try if File.Exists(filePath) then // First, capture the file by copying it to a temp name let tempFilePath = getTemporaryFilePath () //logToConsole $"filePath: {filePath}; tempFilePath: {tempFilePath}" let mutable iteration = 0 Constants.DefaultFileCopyRetryPolicy.Execute (fun () -> //iteration <- iteration + 1 //logToAnsiConsole Colors.Deemphasized $"Attempt #{iteration} to copy file to object directory..." File.Copy(sourceFileName = filePath, destFileName = tempFilePath, overwrite = true)) // Now that we've copied it, compute the SHA-256 hash. let relativeFilePath = Path.GetRelativePath(Current().RootDirectory, filePath) use tempFileStream = File.Open(tempFilePath, fileStreamOptionsRead) let! isBinary = isBinaryFile tempFileStream tempFileStream.Position <- 0 let! sha256Hash = computeSha256ForFile tempFileStream relativeFilePath //logToConsole $"filePath: {filePath}; tempFilePath: {tempFilePath}; SHA256: {sha256Hash}" // I'm going to rename the temp file below, using the SHA-256 hash, so I'll close the file and dispose the stream first. do! tempFileStream.DisposeAsync() // Get the new name for this version of the file, including the SHA-256 hash. let relativeDirectoryPath = getLocalRelativeDirectory filePath (Current().RootDirectory) let objectFileName = getObjectFileName filePath sha256Hash let objectDirectoryPath = Path.Combine(Current().ObjectDirectory, relativeDirectoryPath) let objectFilePath = Path.Combine(objectDirectoryPath, objectFileName) //logToConsole $"relativeDirectoryPath: {relativeDirectoryPath}; objectFileName: {objectFileName}; objectFilePath: {objectFilePath}" // If we don't already have this file, with this exact SHA256, make sure the directory exists, // and rename the temp file to the proper SHA256-enhanced name of the file. if not (File.Exists(objectFilePath)) then //logToConsole $"Before moving temp file to object storage..." Directory.CreateDirectory(objectDirectoryPath) |> ignore // No-op if the directory already exists File.Move(tempFilePath, objectFilePath) //logToConsole $"After moving temp file to object storage..." let objectFilePathInfo = FileInfo(objectFilePath) //logToConsole $"After creating FileInfo; Exists: {objectFilePathInfo.Exists}; FullName = {objectFilePathInfo.FullName}..." //logToConsole $"Finished copyToObjectDirectory for {filePath}; isBinary: {isBinary}; moved temp file to object directory." let relativePath = Path.GetRelativePath(Current().RootDirectory, filePath) return Some(FileVersion.Create (RelativePath relativePath) (Sha256Hash $"{sha256Hash}") ("") isBinary (objectFilePathInfo.Length)) else // If we do already have this exact version of the file, just delete the temp file. File.Delete(tempFilePath) //logToConsole $"Finished copyToObjectDirectory for {filePath}; object file already exists; deleted temp file." return None //return result else logToAnsiConsole Colors.Error $"File {filePath} does not exist." return None with | ex -> logToAnsiConsole Colors.Error $"Exception in copyToObjectDirectory: {ExceptionResponse.Create ex}" return None } /// Copies new and updated files found in a list of FileSystemDifferences to the object directory. let copyUpdatedFilesToObjectCache (t: ProgressTask) (differences: List) = task { // Get the list of files that have been added or changed. let relativePathsOfUpdatedFiles = differences .Select(fun difference -> match difference.DifferenceType with | Add -> match difference.FileSystemEntryType with | FileSystemEntryType.File -> Some difference.RelativePath | FileSystemEntryType.Directory -> None | Change -> match difference.FileSystemEntryType with | FileSystemEntryType.File -> Some difference.RelativePath | FileSystemEntryType.Directory -> None | Delete -> None) .Where(fun relativePathOption -> relativePathOption.IsSome) .Select(fun relativePath -> relativePath.Value) //logToAnsiConsole Colors.Verbose $"relativePathsOfUpdatedFiles: {serialize relativePathsOfUpdatedFiles}" // Create new LocalFileVersion instances for each updated file. let increment = if differences.Count > 0 then (100.0 - t.Value) / float differences.Count else 0.0 let newFileVersions = ConcurrentQueue() do! Parallel.ForEachAsync( relativePathsOfUpdatedFiles, Constants.ParallelOptions, (fun relativePath continuationToken -> ValueTask( task { //logToAnsiConsole Colors.Verbose $"In Services.CLI.copyToObjectDirectory: Copying {relativePath} to object storage." match! copyToObjectDirectory (Path.Combine(Current().RootDirectory, relativePath)) with | Some fileVersion -> newFileVersions.Enqueue(fileVersion.ToLocalFileVersion(DateTime.UtcNow)) //logToAnsiConsole Colors.Verbose $"Copied {fileVersion.RelativePath} to {fileVersion.GetObjectFileName} in object storage." | None -> logToAnsiConsole Colors.Error $"Failed to copy {relativePath} to object storage." () t.Increment(increment) } )) ) return newFileVersions } let private matchesOptionName (option: Option) (optionName: string) = let normalizedName = optionName.TrimStart('-') let hasAlias alias = option.Aliases |> Seq.exists (fun optionAlias -> optionAlias = alias) option.Name = optionName || option.Name = normalizedName || hasAlias optionName || hasAlias normalizedName let rec private hasOptionInCommandResult (commandResult: CommandResult) (optionName: string) = if isNull commandResult then false elif commandResult.Command.Options |> Seq.exists (fun option -> matchesOptionName option optionName) then true else match commandResult.Parent with | :? CommandResult as parent -> hasOptionInCommandResult parent optionName | _ -> false /// Checks if an option was present in the definition of the command. let isOptionPresent (parseResult: ParseResult) (optionName: string) = if not <| isNull (parseResult.GetResult(optionName)) then true else hasOptionInCommandResult parseResult.CommandResult optionName /// Checks if an option was implicitly specified (i.e. the default value was used), or explicitly specified by the user. let isOptionResultImplicit (parseResult: ParseResult) (optionName: string) = if isOptionPresent parseResult optionName then let result = parseResult.GetResult(optionName) if isNull result then true else let option = result :?> OptionResult option.Implicit else false let resolveCorrelationId (parseResult: ParseResult) : CorrelationId = if isOptionPresent parseResult OptionName.CorrelationId && not <| isOptionResultImplicit parseResult OptionName.CorrelationId then parseResult.GetValue(OptionName.CorrelationId) else match invocationCorrelationId with | Some correlationId -> correlationId | None -> let correlationId = generateCorrelationId () invocationCorrelationId <- Some correlationId correlationId /// Adjusts command-line options to account for whether Id's or Name's were explicitly specified by the user, /// or should be taken from default values. let getNormalizedIdsAndNames (parseResult: ParseResult) = let isExplicitName (nameOption: string) = isOptionPresent parseResult nameOption && not <| isOptionResultImplicit parseResult nameOption && not <| String.IsNullOrWhiteSpace(parseResult.GetValue(nameOption)) let needsFallback (idOption: string) (nameOption: string) = isOptionPresent parseResult idOption && isOptionResultImplicit parseResult idOption && parseResult.GetValue(idOption) = Guid.Empty && not <| isExplicitName nameOption let needsConfigFallback = needsFallback OptionName.OwnerId OptionName.OwnerName || needsFallback OptionName.OrganizationId OptionName.OrganizationName || needsFallback OptionName.RepositoryId OptionName.RepositoryName || needsFallback OptionName.BranchId OptionName.BranchName let config = if needsConfigFallback then Some(Current()) else None let getNormalizedId (idOption: string) (nameOption: string) (configValue: Guid) = let isImplicit = isOptionResultImplicit parseResult idOption let explicitName = isExplicitName nameOption let idValue = parseResult.GetValue(idOption) if isImplicit && explicitName then Guid.Empty elif isImplicit && idValue = Guid.Empty && not explicitName then configValue else idValue // If the name was specified on the command line, but the id wasn't (i.e. the default value was specified, and Implicit = true), // then we should only send the name, and we set the id to Guid.Empty. let mutable graceIds = GraceIds.Default if isOptionPresent parseResult OptionName.CorrelationId then graceIds <- { graceIds with CorrelationId = resolveCorrelationId parseResult } if isOptionPresent parseResult OptionName.OwnerId || isOptionPresent parseResult OptionName.OwnerName then let ownerId = let configValue = config |> Option.map (fun current -> current.OwnerId) |> Option.defaultValue OwnerId.Empty getNormalizedId OptionName.OwnerId OptionName.OwnerName configValue graceIds <- { graceIds with OwnerId = ownerId OwnerIdString = if ownerId = Guid.Empty then "" else $"{ownerId}" OwnerName = parseResult.GetValue(OptionName.OwnerName) HasOwner = true } if isOptionPresent parseResult OptionName.OrganizationId || isOptionPresent parseResult OptionName.OrganizationName then let organizationId = let configValue = config |> Option.map (fun current -> current.OrganizationId) |> Option.defaultValue OrganizationId.Empty getNormalizedId OptionName.OrganizationId OptionName.OrganizationName configValue graceIds <- { graceIds with OrganizationId = organizationId OrganizationIdString = if organizationId = Guid.Empty then "" else $"{organizationId}" OrganizationName = parseResult.GetValue(OptionName.OrganizationName) HasOrganization = true } if isOptionPresent parseResult OptionName.RepositoryId || isOptionPresent parseResult OptionName.RepositoryName then let repositoryId = let configValue = config |> Option.map (fun current -> current.RepositoryId) |> Option.defaultValue RepositoryId.Empty getNormalizedId OptionName.RepositoryId OptionName.RepositoryName configValue graceIds <- { graceIds with RepositoryId = repositoryId RepositoryIdString = if repositoryId = Guid.Empty then "" else $"{repositoryId}" RepositoryName = parseResult.GetValue(OptionName.RepositoryName) HasRepository = true } if isOptionPresent parseResult OptionName.BranchId || isOptionPresent parseResult OptionName.BranchName then let branchId = let configValue = config |> Option.map (fun current -> current.BranchId) |> Option.defaultValue BranchId.Empty getNormalizedId OptionName.BranchId OptionName.BranchName configValue graceIds <- { graceIds with BranchId = branchId BranchIdString = if branchId = Guid.Empty then "" else $"{branchId}" BranchName = parseResult.GetValue(OptionName.BranchName) HasBranch = true } graceIds ================================================ FILE: src/Grace.CLI/Command/Watch.CLI.fs ================================================ namespace Grace.CLI.Command open Grace.CLI.Common open Grace.CLI.Services open Grace.SDK open Grace.SDK.Common open Grace.Shared open Grace.Shared.Client.Configuration open Grace.Shared.Parameters.Branch open Grace.Shared.Services open Grace.Types.Types open Grace.Shared.Utilities open Microsoft.AspNetCore.Http.Connections open Microsoft.AspNetCore.SignalR.Client open NodaTime open Spectre.Console open System open System.Buffers open System.Collections.Generic open System.CommandLine open System.CommandLine.Invocation open System.CommandLine.Parsing open System.ComponentModel open System.Diagnostics open System.Globalization open System.IO open System.IO.Compression open System.IO.Enumeration open System.Linq open System.Net open System.Net.Http open System.Reactive.Linq open System.Security.Cryptography open System.Text.Json open System.Threading.Tasks open System.Threading open System.Collections.Concurrent open Spectre.Console open System.Text open Grace.Shared.Parameters.Storage open Grace.CLI.Text open Grace.Types.Automation module Watch = module private Options = let ownerId = new Option( OptionName.OwnerId, Required = false, Description = "The repository's owner ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OwnerId.Empty) ) let ownerName = new Option( OptionName.OwnerName, Required = false, Description = "The repository's owner name. [default: current owner]", Arity = ArgumentArity.ExactlyOne ) let organizationId = new Option( OptionName.OrganizationId, Required = false, Description = "The repository's organization ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> OrganizationId.Empty) ) let organizationName = new Option( OptionName.OrganizationName, Required = false, Description = "The repository's organization name. [default: current organization]", Arity = ArgumentArity.ZeroOrOne ) let repositoryId = new Option( OptionName.RepositoryId, [| "-r" |], Required = false, Description = "The repository's ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> RepositoryId.Empty) ) let repositoryName = new Option( OptionName.RepositoryName, [| "-n" |], Required = false, Description = "The name of the repository. [default: current repository]", Arity = ArgumentArity.ExactlyOne ) let branchId = new Option( OptionName.BranchId, [| "-i" |], Required = false, Description = "The branch's ID .", Arity = ArgumentArity.ExactlyOne, DefaultValueFactory = (fun _ -> BranchId.Empty) ) let branchName = new Option( OptionName.BranchName, [| "-b" |], Required = false, Description = "The name of the branch. [default: current branch]", Arity = ArgumentArity.ExactlyOne ) /// Holds a list of the created or changed files that we need to process, as determined by the FileSystemWatcher. /// /// Note: We're using ConcurrentDictionary because it's safe for multithreading, doesn't allow us to insert the same key twice, and for its algorithms. We're not using the values of the ConcurrentDictionary here, only the keys. let private filesToProcess = ConcurrentDictionary() /// Holds a list of the created or changed directories that we need to process, as determined by the FileSystemWatcher. /// /// Note: We're using ConcurrentDictionary because it's safe for multithreading, doesn't allow us to insert the same key twice, and for its algorithms. We're not using the values of the ConcurrentDictionary here, only the keys. let private directoriesToProcess = ConcurrentDictionary() type WatchParameters() = inherit ParameterBase() member val public RepositoryPath: string = String.Empty with get, set member val public NamedSections: string [] = Array.empty with get, set let mutable private graceStatus = GraceStatus.Default let mutable private graceStatusDirectoryIds = HashSet() let mutable graceStatusMemoryStream: MemoryStream = null let mutable graceStatusHasChanged = false let fileDeleted filePath = logToConsole $"In Delete: filePath: {filePath}" let isNotDirectory path = not <| Directory.Exists(path) let updateInProgress () = File.Exists(updateInProgressFileName ()) let updateNotInProgress () = not <| updateInProgress () let resolveSignalRAccessTokenResult (tokenResult: Result) = match tokenResult with | Ok(Some token) when not (String.IsNullOrWhiteSpace token) -> Ok token | Ok _ -> Error $"No access token is available. Run `grace auth login` or set {Constants.EnvironmentVariables.GraceToken} before starting watch." | Error message -> Error $"Unable to acquire an access token for SignalR notifications: {message}" let private getSignalRAccessToken () = task { let! tokenResult = Grace.CLI.Command.Auth.tryGetAccessToken () return resolveSignalRAccessTokenResult tokenResult } let private createSignalRConnection (signalRUrl: Uri) = HubConnectionBuilder() .WithAutomaticReconnect() .WithUrl( signalRUrl, fun options -> options.Transports <- HttpTransportType.ServerSentEvents options.AccessTokenProvider <- Func>(fun () -> task { let! accessTokenResult = getSignalRAccessToken () match accessTokenResult with | Ok accessToken -> return accessToken | Error message -> return raise (InvalidOperationException(message)) }) ) .Build() let private isGraceStatusArtifact (fullPath: string) = let statusFile = Current().GraceStatusFile fullPath.Equals(statusFile, StringComparison.InvariantCultureIgnoreCase) || fullPath.Equals(statusFile + "-wal", StringComparison.InvariantCultureIgnoreCase) || fullPath.Equals(statusFile + "-shm", StringComparison.InvariantCultureIgnoreCase) || fullPath.Equals(statusFile + "-journal", StringComparison.InvariantCultureIgnoreCase) let OnCreated (args: FileSystemEventArgs) = // Ignore directory creation; need to think about this more... should we capture new empty directories? if updateNotInProgress () && isNotDirectory args.FullPath then let shouldIgnore = shouldIgnoreFile args.FullPath //logToAnsiConsole Colors.Verbose $"Should ignore {args.FullPath}: {shouldIgnore}." if not <| shouldIgnore then logToAnsiConsole Colors.Added $"I saw that {args.FullPath} was created." filesToProcess.TryAdd(args.FullPath, ()) |> ignore if (isGraceStatusArtifact args.FullPath) && (not <| graceStatusHasChanged) then graceStatusHasChanged <- true let OnChanged (args: FileSystemEventArgs) = if updateNotInProgress () && isNotDirectory args.FullPath then let shouldIgnore = shouldIgnoreFile args.FullPath //logToAnsiConsole Colors.Verbose $"Should ignore {args.FullPath}: {shouldIgnore}." if not <| shouldIgnore then logToAnsiConsole Colors.Changed $"I saw that {args.FullPath} changed." filesToProcess.TryAdd(args.FullPath, ()) |> ignore // Special handling for the Grace status file; if that is the changed file, we'll set this flag so we reload it in OnWatch() in the main loop if (isGraceStatusArtifact args.FullPath) && (not <| graceStatusHasChanged) then //logToAnsiConsole Colors.Important $"Setting graceStatusHasChanged to true in OnChanged(). Current value: {graceStatusHasChanged}." graceStatusHasChanged <- true logToAnsiConsole Colors.Important $"Grace Status file has been updated." let OnDeleted (args: FileSystemEventArgs) = if updateNotInProgress () && isNotDirectory args.FullPath then let shouldIgnore = shouldIgnoreFile args.FullPath //logToAnsiConsole Colors.Verbose $"Should ignore {args.FullPath}: {shouldIgnore}." if not <| shouldIgnore then logToAnsiConsole Colors.Deleted $"I saw that {args.FullPath} was deleted." logToAnsiConsole Colors.Deleted $"Delete processing is not yet implemented." if (isGraceStatusArtifact args.FullPath) && (not <| graceStatusHasChanged) then graceStatusHasChanged <- true let OnRenamed (args: RenamedEventArgs) = if updateNotInProgress () then let shouldIgnoreOldFile = shouldIgnoreFile args.OldFullPath let shouldIgnoreNewFile = shouldIgnoreFile args.FullPath if not <| shouldIgnoreOldFile then logToAnsiConsole Colors.Changed $"I saw that {args.OldFullPath} was renamed to {args.FullPath}." //logToAnsiConsole Colors.Verbose $"Should ignore {args.OldFullPath}: {shouldIgnoreOldFile}. Should ignore {args.FullPath}: {shouldIgnoreNewFile}." logToAnsiConsole Colors.Changed $"Delete processing is not yet implemented." if not <| shouldIgnoreNewFile then logToAnsiConsole Colors.Changed $"I saw that {args.OldFullPath} was renamed to {args.FullPath}." //logToAnsiConsole Colors.Verbose $"Should ignore {args.OldFullPath}: {shouldIgnoreOldFile}. Should ignore {args.FullPath}: {shouldIgnoreNewFile}." filesToProcess.TryAdd(args.FullPath, ()) |> ignore let OnError (args: ErrorEventArgs) = let correlationId = generateCorrelationId () logToAnsiConsole Colors.Error $"I saw that the FileSystemWatcher threw an exception: {args.GetException().Message}. grace watch should be restarted." let OnGraceUpdateInProgressCreated (args: FileSystemEventArgs) = if args.FullPath = updateInProgressFileName () then if updateInProgress () then logToAnsiConsole Colors.Important $"Update is in progress from another Grace instance." else logToAnsiConsole Colors.Important $"{updateInProgressFileName ()} should already exist, but it doesn't." let OnGraceUpdateInProgressDeleted (args: FileSystemEventArgs) = if args.FullPath = updateInProgressFileName () then if updateNotInProgress () then logToAnsiConsole Colors.Important $"Update has finished in another Grace instance." else logToAnsiConsole Colors.Important $"{updateInProgressFileName ()} should have been deleted, but it hasn't yet." /// Creates a FileSystemWatcher for the given path. let createFileSystemWatcher path = let fileSystemWatcher = new FileSystemWatcher(path) fileSystemWatcher.InternalBufferSize <- (64 * 1024) // Default is 4K, choosing maximum of 64K for safety. fileSystemWatcher.IncludeSubdirectories <- true fileSystemWatcher.NotifyFilter <- NotifyFilters.DirectoryName ||| NotifyFilters.FileName ||| NotifyFilters.LastWrite ||| NotifyFilters.Security fileSystemWatcher let printDifferences (differences: List) = if differences.Count > 0 then logToAnsiConsole Colors.Verbose $"Differences detected since last save/checkpoint/commit:" for difference in differences.OrderBy(fun diff -> diff.RelativePath) do logToAnsiConsole Colors.Verbose $"{getDiscriminatedUnionCaseName difference.DifferenceType} for {getDiscriminatedUnionCaseName difference.FileSystemEntryType} {difference.RelativePath}" /// Update the Grace Object Cache file with the new DirectoryVersions. let updateObjectCacheFile (newDirectoryVersions: List) = task { do! upsertObjectCache newDirectoryVersions } /// Updates the Grace Status file's Index with updates detected from the file system. let updateGraceStatus graceStatus correlationId = task { // Get the list of differences between what's in the working directory, and what Grace Index knows about. let! differences = scanForDifferences graceStatus printDifferences differences // Get an updated Grace Index, and any new DirectoryVersions that were needed to build it. let! (newGraceStatus, newDirectoryVersions) = getNewGraceStatusAndDirectoryVersions graceStatus differences // Log the changes. for dv in newDirectoryVersions do logToAnsiConsole Colors.Verbose $"new Sha256Hash: {dv.Sha256Hash.Substring(0, 8)}; DirectoryId: {dv.DirectoryVersionId.ToString().Substring(0, 9)}...; RelativePath: {dv.RelativePath}" // Upload the new directory versions. let! result = uploadDirectoryVersions newDirectoryVersions correlationId match result with | Ok returnValue -> do! updateObjectCacheFile newDirectoryVersions let fileDifferences = differences .Where(fun diff -> diff.FileSystemEntryType = FileSystemEntryType.File) .ToList() let message = if fileDifferences |> Seq.isEmpty then String.Empty else let sb = stringBuilderPool.Get() try for fileDifference in fileDifferences do //sb.AppendLine($"{(getDiscriminatedUnionCaseNameToString fileDifference.DifferenceType)}: {fileDifference.RelativePath}") |> ignore match fileDifference.DifferenceType with | Change -> sb.AppendLine($"{fileDifference.RelativePath}") |> ignore | Add -> sb.AppendLine($"Add {fileDifference.RelativePath}") |> ignore | Delete -> sb.AppendLine($"Delete {fileDifference.RelativePath}") |> ignore let saveMessage = sb.ToString() saveMessage.Remove(saveMessage.LastIndexOf(Environment.NewLine), Environment.NewLine.Length) finally stringBuilderPool.Return(sb) // If there are changes either to files or just to directories, create a save reference. if (differences.Count > 0) then match! createSaveReference (getRootDirectoryVersion newGraceStatus) message correlationId with | Ok returnValue -> let newGraceStatusWithUpdatedTime = { newGraceStatus with LastSuccessfulDirectoryVersionUpload = getCurrentInstant () } // Apply incremental changes to the Grace Status DB. do! applyGraceStatusIncremental newGraceStatusWithUpdatedTime newDirectoryVersions differences //logToAnsiConsole Colors.Important $"Setting graceStatusHasChanged to false in updateGraceStatus(). Current value: {graceStatusHasChanged}." graceStatusHasChanged <- false // We *just* changed it ourselves, so we don't have to re-process it in the timer loop. return Some newGraceStatusWithUpdatedTime | Error error -> logToAnsiConsole Colors.Error $"{Markup.Escape(error.Error)}" return None else // There were no changes to process, so just return the existing GraceStatus. //logToAnsiConsole Colors.Verbose "No fileDifferences or newDirectoryVersions to process; not updating GraceStatus." return Some graceStatus | Error error -> logToAnsiConsole Colors.Error $"{Markup.Escape(error.Error)}" return None } /// Copies a file from the working directory to the object directory, with its SHA-256 hash, and then uploads it to storage. let copyFileToObjectDirectoryAndUploadToStorage (getUploadMetadataForFilesParameters: GetUploadMetadataForFilesParameters) fullPath = task { //logToConsole $"*In fileChanged for {fullPath}." match! copyToObjectDirectory fullPath with | Some fileVersion -> getUploadMetadataForFilesParameters.FileVersions <- [| fileVersion |] match! uploadFilesToObjectStorage getUploadMetadataForFilesParameters with | Ok returnValue -> logToAnsiConsole Colors.Verbose $"File {fileVersion.GetObjectFileName} has been uploaded to storage." | Error error -> logToAnsiConsole Colors.Error $"**Failed to upload {fileVersion.GetObjectFileName} to storage." | None -> () } /// Decompresses the GraceStatus information from the memory stream. let retrieveGraceStatusFromMemoryStream () = task { logToAnsiConsole Colors.Verbose $"Retrieving Grace Status from compressed memory stream." graceStatusMemoryStream.Position <- 0 use gzStream = new GZipStream(graceStatusMemoryStream, CompressionMode.Decompress) let! retrievedGraceStatus = JsonSerializer.DeserializeAsync(gzStream, Constants.JsonSerializerOptions) graceStatus <- retrievedGraceStatus logToAnsiConsole Colors.Verbose $"Retrieved Grace Status from compressed memory stream." do! gzStream.DisposeAsync() // Dispose the GZipStream first, before disposing the MemoryStream. do! graceStatusMemoryStream.DisposeAsync() graceStatusMemoryStream <- null } /// Compresses the GraceStatus information into a gzipped memory stream. let storeGraceStatusInMemoryStream () = task { logToAnsiConsole Colors.Verbose $"Storing Grace Status in compressed memory stream." graceStatusMemoryStream <- new MemoryStream() use gzStream = new GZipStream(graceStatusMemoryStream, CompressionLevel.SmallestSize, leaveOpen = true) do! serializeAsync gzStream graceStatus do! gzStream.FlushAsync() do! graceStatusMemoryStream.FlushAsync() logToAnsiConsole Colors.Verbose $"Stored Grace Status in compressed memory stream." do! gzStream.DisposeAsync() graceStatus <- GraceStatus.Default } let updateGraceStatusDirectoryIds (status: GraceStatus) = graceStatusDirectoryIds <- status.Index.Keys.ToHashSet() /// Processes any changed files since the last timer tick. let processChangedFiles () = task { // First, check if there's anything to process. if not ( filesToProcess.IsEmpty && directoriesToProcess.IsEmpty ) then try let correlationId = generateCorrelationId () let! graceStatusFromDisk = readGraceStatusMeta () graceStatus <- graceStatusFromDisk let mutable lastFileUploadInstant = graceStatus.LastSuccessfulFileUpload let mutable processedAnyFile = false /// This is just a way to throw away the unit value from the ConcurrentDictionary. let mutable unitValue = () // Loop through no more than 50 files. Copy them to the objects directory, and upload them to storage. // In the incredibly rare event that more than 50 files have changed, we'll get 50-per-timer-tick, // and clear the queue quickly without overwhelming the system. let getUploadMetadataForFilesParameters = GetUploadMetadataForFilesParameters( OwnerId = $"{Current().OwnerId}", OrganizationId = $"{Current().OrganizationId}", RepositoryId = $"{Current().RepositoryId}", CorrelationId = correlationId ) for fileName in filesToProcess.Keys.Take(50) do if filesToProcess.TryRemove(fileName, &unitValue) then logToAnsiConsole Colors.Verbose $"Processing {fileName}. filesToProcess.Count: {filesToProcess.Count}." do! copyFileToObjectDirectoryAndUploadToStorage getUploadMetadataForFilesParameters (FilePath fileName) processedAnyFile <- true lastFileUploadInstant <- getCurrentInstant () if processedAnyFile then graceStatus <- { graceStatus with LastSuccessfulFileUpload = lastFileUploadInstant } do! applyGraceStatusIncremental graceStatus Seq.empty Seq.empty // If we've drained all of the files that changed (and we'll almost always have done so), update all the things: // GraceStatus, directory versions, etc. if filesToProcess.IsEmpty then let! graceStatusSnapshot = readGraceStatusFile () graceStatus <- graceStatusSnapshot match! (updateGraceStatus graceStatus correlationId) with | Some newGraceStatus -> graceStatus <- newGraceStatus | None -> logToAnsiConsole Colors.Important $"Grace Status file was not updated." () // Something went wrong, don't update the in-memory Grace Status. updateGraceStatusDirectoryIds graceStatus do! updateGraceWatchInterprocessFile graceStatus (Some graceStatusDirectoryIds) // Reset the in-memory Grace Status to empty to minimize memory usage. graceStatus <- GraceStatus.Default GC.Collect(2, GCCollectionMode.Forced, blocking = true, compacting = true) with | ex -> logToAnsiConsole Colors.Error $"Error in processChangedFiles: Message: {ex.Message}{Environment.NewLine}{Environment.NewLine}{ex.StackTrace}" // Refresh the file every (just under) 5 minutes to indicate that `grace watch` is still alive. elif graceWatchStatusUpdateTime < getCurrentInstant().Minus(Duration.FromMinutes(4.8)) then let! graceStatusFromDisk = readGraceStatusMeta () do! updateGraceWatchInterprocessFile graceStatusFromDisk (Some graceStatusDirectoryIds) GC.Collect(2, GCCollectionMode.Forced, blocking = true, compacting = true) } type Watch() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) = task { try // Create the FileSystemWatcher, but don't enable it yet. use rootDirectoryFileSystemWatcher = createFileSystemWatcher (Current().RootDirectory) use created = Observable .FromEventPattern(rootDirectoryFileSystemWatcher, "Created") .Select(fun e -> e.EventArgs) .Subscribe(OnCreated) use changed = Observable .FromEventPattern(rootDirectoryFileSystemWatcher, "Changed") .Select(fun e -> e.EventArgs) .Subscribe(OnChanged) use deleted = Observable .FromEventPattern(rootDirectoryFileSystemWatcher, "Deleted") .Select(fun e -> e.EventArgs) .Subscribe(OnDeleted) use renamed = Observable .FromEventPattern(rootDirectoryFileSystemWatcher, "Renamed") .Select(fun e -> e.EventArgs) .Subscribe(OnRenamed) use errored = Observable .FromEventPattern(rootDirectoryFileSystemWatcher, "Error") .Select(fun e -> e.EventArgs) .Subscribe(OnError) // I want all of the errors. Directory.CreateDirectory(Path.GetDirectoryName(updateInProgressFileName ())) |> ignore use updateInProgressFileSystemWatcher = createFileSystemWatcher (Path.GetDirectoryName(updateInProgressFileName ())) use updateInProgressChanged = Observable .FromEventPattern(updateInProgressFileSystemWatcher, "Created") .Select(fun e -> e.EventArgs) .Subscribe(OnGraceUpdateInProgressCreated) use updateInProgressDeleted = Observable .FromEventPattern(updateInProgressFileSystemWatcher, "Deleted") .Select(fun e -> e.EventArgs) .Subscribe(OnGraceUpdateInProgressDeleted) // Load the Grace Index file. let! status = readGraceStatusFile () graceStatus <- status updateGraceStatusDirectoryIds graceStatus // Create the inter-process communication file. do! updateGraceWatchInterprocessFile graceStatus (Some graceStatusDirectoryIds) // Enable the FileSystemWatcher. rootDirectoryFileSystemWatcher.EnableRaisingEvents <- true updateInProgressFileSystemWatcher.EnableRaisingEvents <- true let timerTimeSpan = TimeSpan.FromSeconds(1.0) logToAnsiConsole Colors.Verbose $"The change processor timer will tick every {timerTimeSpan.TotalSeconds:F1} seconds." // Open a SignalR connection to the server. let signalRUrl = Uri($"{Current().ServerUri}/notifications") logToConsole $"signalRUrl: {signalRUrl}." match! getSignalRAccessToken () with | Error message -> raise (InvalidOperationException(message)) | Ok _ -> () use signalRConnection = createSignalRConnection signalRUrl let mutable watchedParentBranchId = BranchId.Empty use notifyRepository = signalRConnection.On( "NotifyRepository", fun repositoryId referenceId -> (task { logToAnsiConsole Colors.Highlighted $"ReferenceId {referenceId} was created in repository {repositoryId}." }) :> Task ) use serverToClient = signalRConnection.On( "ServerToClientMessage", (fun message -> logToAnsiConsole Colors.Important $"From Grace Server: {message}") ) use notifyAutomationEvent = signalRConnection.On( "NotifyAutomationEvent", fun envelope -> (task { if envelope.EventType = AutomationEventType.PromotionSetApplied then try use document = JsonDocument.Parse(envelope.DataJson) let root = document.RootElement let tryParseGuidProperty (propertyName: string) : Guid option = let mutable propertyValue = Unchecked.defaultof if root.TryGetProperty(propertyName, &propertyValue) then let propertyText = propertyValue.GetString() let mutable parsedGuid = Guid.Empty if String.IsNullOrWhiteSpace propertyText |> not && Guid.TryParse(propertyText, &parsedGuid) then Some parsedGuid else Option.None else Option.None let targetBranchId = tryParseGuidProperty "targetBranchId" |> Option.defaultValue BranchId.Empty let terminalReferenceId = tryParseGuidProperty "terminalPromotionReferenceId" |> Option.defaultValue ReferenceId.Empty if watchedParentBranchId = targetBranchId && watchedParentBranchId <> BranchId.Empty then logToAnsiConsole Colors.Highlighted $"Parent branch {watchedParentBranchId} received terminal promotion {terminalReferenceId}; starting auto-rebase." let! currentStatus = readGraceStatusFile () let! _ = Branch.rebaseHandler (parseResult |> getNormalizedIdsAndNames) currentStatus () with | ex -> logToAnsiConsole Colors.Error $"Failed to process automation event payload for {envelope.EventType}: {Markup.Escape(ex.Message)}." }) :> Task ) use notifyOnSave = signalRConnection.On( "NotifyOnSave", fun branchName parentBranchName parentBranchId referenceId -> (task { logToAnsiConsole Colors.Highlighted $"Branch {branchName} with parent branch {parentBranchName} has a new save; referenceId: {referenceId}." }) :> Task ) use notifyOnCheckpoint = signalRConnection.On( "NotifyOnCheckpoint", fun branchName parentBranchName parentBranchId referenceId -> (task { logToAnsiConsole Colors.Highlighted $"Branch {branchName} with parent branch {parentBranchName} has a new checkpoint; referenceId: {referenceId}." }) :> Task ) use notifyOnCommit = signalRConnection.On( "NotifyOnCommit", fun branchName parentBranchName parentBranchId referenceId -> (task { logToAnsiConsole Colors.Highlighted $"Branch {branchName} with parent branch {parentBranchName} has a new commit; referenceId: {referenceId}." }) :> Task ) signalRConnection.add_Closed (fun ex -> task { logToAnsiConsole Colors.Error $"SignalR connection closed: {ex.Message}." }) signalRConnection.add_Reconnecting (fun ex -> task { logToAnsiConsole Colors.Important $"SignalR connection reconnecting: {ex.Message}." }) signalRConnection.add_Reconnected (fun connectionId -> task { logToAnsiConsole Colors.Important $"SignalR connection reconnected: {connectionId}." }) do! signalRConnection.StartAsync(cancellationToken) do! signalRConnection.InvokeAsync("RegisterRepository", Current().RepositoryId, cancellationToken) logToAnsiConsole Colors.Highlighted $"SignalR Hub connection state: {signalRConnection.State}. Listening for changes in repository {Current().RepositoryName} ({Current().RepositoryId}); connectionId: {signalRConnection.ConnectionId}." // Get the parent BranchId so we can tell SignalR what to notify us about. let branchGetParameters = GetBranchParameters( OwnerId = $"{Current().OwnerId}", OrganizationId = $"{Current().OrganizationId}", RepositoryId = $"{Current().RepositoryId}", BranchId = $"{Current().BranchId}" ) match! Branch.GetParentBranch branchGetParameters with | Ok returnValue -> let parentBranchDto = returnValue.ReturnValue watchedParentBranchId <- parentBranchDto.BranchId do! signalRConnection.InvokeAsync("RegisterParentBranch", Current().BranchId, parentBranchDto.BranchId, cancellationToken) logToAnsiConsole Colors.Highlighted $"SignalR Hub connection state: {signalRConnection.State}. Listening for changes in parent branch {parentBranchDto.BranchName} ({parentBranchDto.BranchId}); connectionId: {signalRConnection.ConnectionId}." | Error error -> logToAnsiConsole Colors.Error $"Failed to retrieve branch metadata. Cannot connect to SignalR Hub." logToAnsiConsole Colors.Error $"{Markup.Escape(error.ToString())}" // Check for changes that occurred while not running. logToAnsiConsole Colors.Verbose $"Scanning for differences." let! differences = scanForDifferences graceStatus // <--- This always finds the directories with updated write times, but we never update GraceStatus below.. if differences |> Seq.isEmpty then logToAnsiConsole Colors.Verbose $"Already up-to-date." else logToAnsiConsole Colors.Verbose $"Found {differences.Count} differences." for difference in differences do match difference.FileSystemEntryType with | Directory -> directoriesToProcess.TryAdd(difference.RelativePath, ()) |> ignore | File -> filesToProcess.TryAdd(difference.RelativePath, ()) |> ignore // Process any changes that occurred while not running. graceStatus <- GraceStatus.Default do! processChangedFiles () // Create a timer to process the file changes detected by the FileSystemWatcher. // This timer is the reason that there's a delay in stopping `grace watch`. logToAnsiConsole Colors.Verbose $"Starting timer." use periodicTimer = new PeriodicTimer(timerTimeSpan) let! tick = periodicTimer.WaitForNextTickAsync() let mutable previousGC = getCurrentInstant () let mutable ticked = true while ticked && not (cancellationToken.IsCancellationRequested) do // Grace Status may have changed from branch switch, or other commands. if graceStatusHasChanged then let! updatedGraceStatus = readGraceStatusFile () graceStatus <- updatedGraceStatus updateGraceStatusDirectoryIds graceStatus do! updateGraceWatchInterprocessFile graceStatus (Some graceStatusDirectoryIds) //logToAnsiConsole Colors.Important $"Setting graceStatusHasChanged to false in OnWatch(). Current value: {graceStatusHasChanged}." graceStatusHasChanged <- false do! processChangedFiles () let! tick = periodicTimer.WaitForNextTickAsync() ticked <- tick // About once a minute, do a full GC to be kind with our memory usage. This is for looks, not for function. // // In .NET, when a computer has lots of available memory, and there's no memory pressure signal from the OS, GC doesn't happen much, if at all. // With no memory pressure, `grace watch` wouldn't bother releasing its unused heap after handling events like saves and auto-rebases. // Seeing that kind of memory usage could lead to uninformed people saying things like, "OMG, `grace watch` takes up so much memory!" // Actually, `grace watch` only grabs a lot of memory at the moment of processing events. As soon as we're done, we want to release that // memory back to the OS, that means forcing a full GC. // // Because of DATAS (see https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/datas), it may take more than one GC.Collect() // call to fully compact the heap (and that's OK). If we weren't being so aggressive about memory usage, we would just let DATAS compute // a close-to-optimal heap size on its own over time. if previousGC < getCurrentInstant().Minus(Duration.FromMinutes(1.0)) then //let memoryBeforeGC = Process.GetCurrentProcess().WorkingSet64 GC.Collect(2, GCCollectionMode.Forced, blocking = true, compacting = true) //logToAnsiConsole Colors.Verbose $"Memory before GC: {memoryBeforeGC:N0}; after: {Process.GetCurrentProcess().WorkingSet64:N0}." previousGC <- getCurrentInstant () return 0 with | :? HttpRequestException as httpEx when httpEx.StatusCode.HasValue && httpEx.StatusCode.Value = HttpStatusCode.Unauthorized -> logToAnsiConsole Colors.Error $"SignalR negotiation failed with 401 Unauthorized. Run `grace auth login` or set {Constants.EnvironmentVariables.GraceToken}, then retry `grace watch`." return -1 | :? InvalidOperationException as invalidOperationException when invalidOperationException.Message.Contains("access token", StringComparison.OrdinalIgnoreCase) -> logToAnsiConsole Colors.Error $"{Markup.Escape(invalidOperationException.Message)}" return -1 | ex -> //let exceptionMarkup = Markup.Escape($"{ExceptionResponse.Create ex}").Replace("\\\\", @"\").Replace("\r\n", Environment.NewLine) //logToAnsiConsole Colors.Error $"{exceptionMarkup}" let exceptionSettings = ExceptionSettings() // Need to fill in some exception styles here. exceptionSettings.Format <- ExceptionFormats.Default AnsiConsole.WriteException(ex, exceptionSettings) return -1 } let Build = let addCommonOptions (command: Command) = command |> addOption Options.ownerName |> addOption Options.ownerId |> addOption Options.organizationName |> addOption Options.organizationId |> addOption Options.repositoryName |> addOption Options.repositoryId |> addOption Options.branchName |> addOption Options.branchId // Create main command and aliases, if any. let watchCommand = new Command("watch", Description = "Watches your repo for changes, and uploads new versions of your files.") |> addCommonOptions watchCommand.Aliases.Add("w") watchCommand.Action <- Watch() watchCommand ================================================ FILE: src/Grace.CLI/Command/WorkItem.CLI.fs ================================================ namespace Grace.CLI.Command open Azure.Storage.Blobs open Grace.CLI.Common open Grace.CLI.Services open Grace.CLI.Text open Grace.SDK open Grace.Shared open Grace.Shared.Utilities open Grace.Shared.Validation.Errors open Grace.Types.Artifact open Grace.Types.WorkItem open Grace.Types.Types open Spectre.Console open Spectre.Console.Json open System open System.CommandLine open System.CommandLine.Invocation open System.CommandLine.Parsing open System.IO open System.Security.Cryptography open System.Text open System.Threading open System.Threading.Tasks module WorkItemCommand = module private Options = let workItemId = new Option( "--work-item-id", [| "--work-item"; "-w" |], Required = false, Description = "The work item ID . Used only on create to override the generated ID.", Arity = ArgumentArity.ExactlyOne ) let title = new Option("--title", Required = true, Description = "Title for the work item.", Arity = ArgumentArity.ExactlyOne) let description = new Option( OptionName.Description, [| "-d" |], Required = false, Description = "Description for the work item.", Arity = ArgumentArity.ExactlyOne ) let statusSet = (new Option("--set", Required = true, Description = "Set the work item status.", Arity = ArgumentArity.ExactlyOne)) .AcceptOnlyFromAmong(listCases ()) let file = new Option( "--file", [| "-f" |], Required = false, Description = "Read attachment content from this file path.", Arity = ArgumentArity.ExactlyOne ) let text = new Option("--text", [| "-t" |], Required = false, Description = "Attach inline text content directly.", Arity = ArgumentArity.ExactlyOne) let stdin = new Option("--stdin", Required = false, Description = "Read attachment content from standard input.", Arity = ArgumentArity.ZeroOrOne) let attachmentType = (new Option( "--type", Required = true, Description = "Attachment type to target: summary, prompt, or notes.", Arity = ArgumentArity.ExactlyOne )) .AcceptOnlyFromAmong([| "summary"; "prompt"; "notes" |]) let latest = new Option( "--latest", Required = false, Description = "Select the most recently created attachment for the requested type.", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> false) ) let artifactId = new Option("--artifact-id", Required = true, Description = "Attachment artifact ID .", Arity = ArgumentArity.ExactlyOne) let outputFile = new Option( "--output-file", [| "-f" |], Required = true, Description = "Write downloaded attachment bytes to this file path.", Arity = ArgumentArity.ExactlyOne ) let ownerId = new Option( OptionName.OwnerId, Required = false, Description = "The repository's owner ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OwnerId.Empty) ) let ownerName = new Option( OptionName.OwnerName, Required = false, Description = "The repository's owner name. [default: current owner]", Arity = ArgumentArity.ExactlyOne ) let organizationId = new Option( OptionName.OrganizationId, Required = false, Description = "The organization's ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> OrganizationId.Empty) ) let organizationName = new Option( OptionName.OrganizationName, Required = false, Description = "The organization's name. [default: current organization]", Arity = ArgumentArity.ExactlyOne ) let repositoryId = new Option( OptionName.RepositoryId, Required = false, Description = "The repository's ID .", Arity = ArgumentArity.ZeroOrOne, DefaultValueFactory = (fun _ -> RepositoryId.Empty) ) let repositoryName = new Option( OptionName.RepositoryName, Required = false, Description = "The repository's name. [default: current repository]", Arity = ArgumentArity.ExactlyOne ) module private Arguments = let workItemIdentifier = new Argument("work-item", Description = "Work item ID or work item number .") let referenceId = new Argument("reference-id", Description = "Reference ID .") let promotionSetId = new Argument("promotion-set-id", Description = "Promotion set ID .") type private AttachmentInput = { Bytes: byte array; MimeType: string } type private AttachmentResult = { WorkItem: string; ArtifactId: ArtifactId; ArtifactType: string } type private AttachmentDownloadResult = { WorkItem: string; ArtifactId: ArtifactId; AttachmentType: string; OutputFile: string; Size: int64 } let private tryParseGuid (value: string) (error: WorkItemError) (parseResult: ParseResult) = let mutable parsed = Guid.Empty if String.IsNullOrWhiteSpace(value) || Guid.TryParse(value, &parsed) = false || parsed = Guid.Empty then Error(GraceError.Create (WorkItemError.getErrorMessage error) (getCorrelationId parseResult)) else Ok parsed let private tryNormalizeWorkItemIdentifier (value: string) (parseResult: ParseResult) = let mutable parsedGuid = Guid.Empty if String.IsNullOrWhiteSpace(value) then Error(GraceError.Create (WorkItemError.getErrorMessage WorkItemError.InvalidWorkItemId) (getCorrelationId parseResult)) elif Guid.TryParse(value, &parsedGuid) && parsedGuid <> Guid.Empty then Ok(parsedGuid.ToString()) else let mutable parsedNumber = 0L if Int64.TryParse(value, &parsedNumber) then if parsedNumber > 0L then Ok(parsedNumber.ToString()) else Error(GraceError.Create (WorkItemError.getErrorMessage WorkItemError.InvalidWorkItemNumber) (getCorrelationId parseResult)) else Error(GraceError.Create (WorkItemError.getErrorMessage WorkItemError.InvalidWorkItemId) (getCorrelationId parseResult)) let private createWorkItemWithProgress (parameters: Parameters.WorkItem.CreateWorkItemParameters) = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! result = WorkItem.Create(parameters) t0.Increment(100.0) return result }) let private inferMimeTypeFromFilePath (filePath: string) = match Path.GetExtension(filePath).ToLowerInvariant() with | ".md" -> "text/markdown" | ".txt" -> "text/plain" | ".json" -> "application/json" | _ -> "application/octet-stream" let private computeSha256 (contentBytes: byte array) = use hasher = SHA256.Create() let hash = hasher.ComputeHash(contentBytes) Convert.ToHexString(hash).ToLowerInvariant() let private uploadArtifactContent (uploadUri: UriWithSharedAccessSignature) (contentBytes: byte array) = task { use stream = new MemoryStream(contentBytes) let blobClient = BlobClient(uploadUri) let! _ = blobClient.UploadAsync(stream, overwrite = true) return () } let private tryGetAttachmentInput (parseResult: ParseResult) = task { let filePath = parseResult.GetValue(Options.file) |> Option.ofObj |> Option.defaultValue String.Empty let textInput = parseResult.GetValue(Options.text) |> Option.ofObj |> Option.defaultValue String.Empty let readFromStdin = parseResult.GetValue(Options.stdin) let selectedCount = (if String.IsNullOrWhiteSpace(filePath) then 0 else 1) + (if String.IsNullOrWhiteSpace(textInput) then 0 else 1) + (if readFromStdin then 1 else 0) if selectedCount <> 1 then return Error(GraceError.Create "Specify exactly one of --file, --text, or --stdin." (getCorrelationId parseResult)) elif not <| String.IsNullOrWhiteSpace(filePath) then if not <| File.Exists(filePath) then return Error(GraceError.Create $"File does not exist: {filePath}" (getCorrelationId parseResult)) else let bytes = File.ReadAllBytes(filePath) return Ok { Bytes = bytes; MimeType = inferMimeTypeFromFilePath filePath } elif not <| String.IsNullOrWhiteSpace(textInput) then return Ok { Bytes = Encoding.UTF8.GetBytes(textInput); MimeType = "text/plain" } else let! stdinText = Console.In.ReadToEndAsync() return Ok { Bytes = Encoding.UTF8.GetBytes(stdinText); MimeType = "text/plain" } } let private createAndUploadArtifact (graceIds: GraceIds) (artifactType: ArtifactType) (attachmentInput: AttachmentInput) = task { let createParameters = Parameters.Artifact.CreateArtifactParameters( ArtifactType = getDiscriminatedUnionCaseName artifactType, MimeType = attachmentInput.MimeType, Size = int64 attachmentInput.Bytes.LongLength, Sha256 = computeSha256 attachmentInput.Bytes, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) match! Artifact.Create(createParameters) with | Error error -> return Error error | Ok createResult -> let createdArtifact = createResult.ReturnValue try do! uploadArtifactContent createdArtifact.UploadUri attachmentInput.Bytes return Ok createdArtifact.ArtifactId with | ex -> return Error( GraceError.Create ($"Failed to upload {getDiscriminatedUnionCaseName artifactType} artifact content: {ex.Message}") graceIds.CorrelationId ) } let private tryResolveAttachmentType (parseResult: ParseResult) = let attachmentTypeRaw = parseResult.GetValue(Options.attachmentType) |> Option.ofObj |> Option.defaultValue String.Empty if String.IsNullOrWhiteSpace attachmentTypeRaw then Error(GraceError.Create (WorkItemError.getErrorMessage WorkItemError.InvalidArtifactType) (getCorrelationId parseResult)) else Ok(attachmentTypeRaw.Trim().ToLowerInvariant()) let private tryResolveOutputFilePath (parseResult: ParseResult) = let outputFileRaw = parseResult.GetValue(Options.outputFile) |> Option.ofObj |> Option.defaultValue String.Empty if String.IsNullOrWhiteSpace outputFileRaw then Error(GraceError.Create "Output file path is required." (getCorrelationId parseResult)) else try let outputFilePath = Path.GetFullPath(outputFileRaw) if Directory.Exists(outputFilePath) then Error(GraceError.Create $"Output file path points to a directory: {outputFilePath}" (getCorrelationId parseResult)) else Ok outputFilePath with | ex -> Error(GraceError.Create $"Output file path is invalid: {ex.Message}" (getCorrelationId parseResult)) let private downloadAttachmentBytes (downloadUri: string) (parseResult: ParseResult) = task { if String.IsNullOrWhiteSpace(downloadUri) then return Error(GraceError.Create "Attachment download URI was empty." (getCorrelationId parseResult)) else try let blobClient = BlobClient(Uri(downloadUri)) let! downloadResult = blobClient.DownloadContentAsync() return Ok(downloadResult.Value.Content.ToArray()) with | ex -> return Error(GraceError.Create ($"Failed to download attachment bytes: {ex.Message}") (getCorrelationId parseResult)) } let private createHandlerImpl (parseResult: ParseResult) = if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let title = parseResult.GetValue(Options.title) if String.IsNullOrWhiteSpace title then Task.FromResult(Error(GraceError.Create "Title is required." (getCorrelationId parseResult))) else let description = parseResult.GetValue(Options.description) |> Option.ofObj |> Option.defaultValue String.Empty let workItemId = parseResult.GetValue(Options.workItemId) |> Option.ofObj |> Option.defaultValue (Guid.NewGuid().ToString()) let parameters = Parameters.WorkItem.CreateWorkItemParameters( WorkItemId = workItemId, Title = title, Description = description, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) if parseResult |> hasOutput then createWorkItemWithProgress parameters else WorkItem.Create(parameters) let private createHandler (parseResult: ParseResult) = task { try return! createHandlerImpl parseResult with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type Create() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { let! result = createHandler parseResult return result |> renderOutput parseResult } let private showHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let workItemRaw = parseResult.GetValue(Arguments.workItemIdentifier) match tryNormalizeWorkItemIdentifier workItemRaw parseResult with | Error error -> return Error error | Ok workItem -> let parameters = Parameters.WorkItem.GetWorkItemParameters( WorkItemId = workItem, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let! result = WorkItem.Get(parameters) match result with | Ok graceReturnValue -> if parseResult |> hasOutput then let jsonText = JsonText(serialize graceReturnValue.ReturnValue) AnsiConsole.Write(jsonText) AnsiConsole.WriteLine() return Ok graceReturnValue | Error error -> return Error error with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type Show() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { let! result = showHandler parseResult return result |> renderOutput parseResult } let private statusHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let workItemRaw = parseResult.GetValue(Arguments.workItemIdentifier) match tryNormalizeWorkItemIdentifier workItemRaw parseResult with | Error error -> return Error error | Ok workItem -> let statusValue = parseResult.GetValue(Options.statusSet) match discriminatedUnionFromString statusValue with | None -> return Error(GraceError.Create (WorkItemError.getErrorMessage WorkItemError.InvalidStatus) (getCorrelationId parseResult)) | Some status -> let parameters = Parameters.WorkItem.UpdateWorkItemParameters( WorkItemId = workItem, Status = status.ToString(), OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) return! WorkItem.Update(parameters) with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type Status() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { let! result = statusHandler parseResult return result |> renderOutput parseResult } let private linkReferenceHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let workItemRaw = parseResult.GetValue(Arguments.workItemIdentifier) let referenceIdRaw = parseResult.GetValue(Arguments.referenceId) match tryNormalizeWorkItemIdentifier workItemRaw parseResult with | Error error -> return Error error | Ok workItem -> match tryParseGuid referenceIdRaw WorkItemError.InvalidReferenceId parseResult with | Error error -> return Error error | Ok referenceId -> let parameters = Parameters.WorkItem.LinkReferenceParameters( WorkItemId = workItem, ReferenceId = referenceId.ToString(), OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) return! WorkItem.LinkReference(parameters) with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type LinkReference() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { let! result = linkReferenceHandler parseResult return result |> renderOutput parseResult } let private linkPromotionSetHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let workItemRaw = parseResult.GetValue(Arguments.workItemIdentifier) let promotionSetIdRaw = parseResult.GetValue(Arguments.promotionSetId) match tryNormalizeWorkItemIdentifier workItemRaw parseResult with | Error error -> return Error error | Ok workItem -> match tryParseGuid promotionSetIdRaw WorkItemError.InvalidPromotionSetId parseResult with | Error error -> return Error error | Ok promotionSetId -> let parameters = Parameters.WorkItem.LinkPromotionSetParameters( WorkItemId = workItem, PromotionSetId = promotionSetId.ToString(), OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) return! WorkItem.LinkPromotionSet(parameters) with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type LinkPromotionSet() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { let! result = linkPromotionSetHandler parseResult return result |> renderOutput parseResult } let private attachHandler (artifactType: ArtifactType) (artifactTypeLabel: string) (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let workItemRaw = parseResult.GetValue(Arguments.workItemIdentifier) match tryNormalizeWorkItemIdentifier workItemRaw parseResult with | Error error -> return Error error | Ok workItem -> match! tryGetAttachmentInput parseResult with | Error error -> return Error error | Ok attachmentInput -> match! createAndUploadArtifact graceIds artifactType attachmentInput with | Error error -> return Error error | Ok artifactId -> let linkParameters = Parameters.WorkItem.LinkArtifactParameters( WorkItemId = workItem, ArtifactId = artifactId.ToString(), OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) match! WorkItem.LinkArtifact(linkParameters) with | Error error -> return Error error | Ok _ -> let result = { WorkItem = workItem; ArtifactId = artifactId; ArtifactType = artifactTypeLabel } if not (parseResult |> json) && not (parseResult |> silent) then AnsiConsole.MarkupLine( $"[green]Attached {Markup.Escape(artifactTypeLabel)} content[/] [grey](artifact {Markup.Escape(artifactId.ToString())})[/] [green]to work item[/] {Markup.Escape(workItem)}" ) return Ok(GraceReturnValue.Create result graceIds.CorrelationId) with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type AttachSummary() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { let! result = attachHandler ArtifactType.AgentSummary "summary" parseResult return result |> renderOutput parseResult } type AttachPrompt() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { let! result = attachHandler ArtifactType.Prompt "prompt" parseResult return result |> renderOutput parseResult } type AttachNotes() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { let! result = attachHandler ArtifactType.ReviewNotes "notes" parseResult return result |> renderOutput parseResult } let private writeAttachmentListTable (attachments: Parameters.WorkItem.ListWorkItemAttachmentsResult) = let table = Table(Border = TableBorder.Rounded) table.AddColumn("[bold]Artifact ID[/]") |> ignore table.AddColumn("[bold]Type[/]") |> ignore table.AddColumn("[bold]Mime type[/]") |> ignore table.AddColumn("[bold]Size (bytes)[/]") |> ignore table.AddColumn("[bold]Created at[/]") |> ignore let attachmentArray = attachments.Attachments |> Seq.toArray let mutable i = 0 while i < attachmentArray.Length do let attachment = attachmentArray[i] table.AddRow( Markup.Escape(attachment.ArtifactId), Markup.Escape(attachment.AttachmentType), Markup.Escape(attachment.MimeType), attachment.Size.ToString(), Markup.Escape(attachment.CreatedAt) ) |> ignore i <- i + 1 AnsiConsole.MarkupLine($"[bold]Work item ID:[/] {Markup.Escape(attachments.WorkItemId)}") AnsiConsole.MarkupLine($"[bold]Work item number:[/] {attachments.WorkItemNumber}") AnsiConsole.Write(table) let private writeShowAttachmentOutput (workItem: string) (showResult: Parameters.WorkItem.ShowWorkItemAttachmentResult) = let selection = if showResult.SelectedUsingLatest then "latest" else "earliest" AnsiConsole.MarkupLine($"[bold]Work item ID:[/] {Markup.Escape(showResult.WorkItemId)}") AnsiConsole.MarkupLine($"[bold]Work item number:[/] {showResult.WorkItemNumber}") AnsiConsole.MarkupLine($"[bold]Attachment type:[/] {Markup.Escape(showResult.AttachmentType)}") AnsiConsole.MarkupLine($"[bold]Artifact ID:[/] {Markup.Escape(showResult.ArtifactId)}") AnsiConsole.MarkupLine($"[bold]Mime type:[/] {Markup.Escape(showResult.MimeType)}") AnsiConsole.MarkupLine($"[bold]Size (bytes):[/] {showResult.Size}") AnsiConsole.MarkupLine($"[bold]Created at:[/] {Markup.Escape(showResult.CreatedAt)}") AnsiConsole.MarkupLine($"[bold]Selection:[/] {selection}") AnsiConsole.MarkupLine($"[bold]Available attachments of this type:[/] {showResult.AvailableAttachmentCount}") AnsiConsole.WriteLine() if showResult.IsTextContent then AnsiConsole.MarkupLine("[bold]Content:[/]") Console.WriteLine(showResult.Content) else AnsiConsole.MarkupLine("[yellow]Attachment content is binary or non-text and was not rendered inline.[/]") AnsiConsole.MarkupLine( $"[yellow]Use[/] [bold]grace workitem attachments download {Markup.Escape(workItem)} --artifact-id {Markup.Escape(showResult.ArtifactId)} --output-file [/] [yellow]to save this attachment.[/]" ) let private attachmentsListHandlerImpl (parseResult: ParseResult) = task { if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let workItemRaw = parseResult.GetValue(Arguments.workItemIdentifier) match tryNormalizeWorkItemIdentifier workItemRaw parseResult with | Error error -> return Error error | Ok workItem -> let parameters = Parameters.WorkItem.ListWorkItemAttachmentsParameters( WorkItemId = workItem, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let! result = WorkItem.ListAttachments(parameters) match result with | Error error -> return Error error | Ok graceReturnValue -> if not (parseResult |> json) && not (parseResult |> silent) then writeAttachmentListTable graceReturnValue.ReturnValue return Ok graceReturnValue } let private attachmentsListHandler (parseResult: ParseResult) = task { try return! attachmentsListHandlerImpl parseResult with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type AttachmentsList() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { let! result = attachmentsListHandler parseResult return result |> renderOutput parseResult } let private attachmentsShowHandlerImpl (parseResult: ParseResult) = task { if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let workItemRaw = parseResult.GetValue(Arguments.workItemIdentifier) match tryNormalizeWorkItemIdentifier workItemRaw parseResult with | Error error -> return Error error | Ok workItem -> match tryResolveAttachmentType parseResult with | Error error -> return Error error | Ok attachmentType -> let latest = parseResult.GetValue(Options.latest) let parameters = Parameters.WorkItem.ShowWorkItemAttachmentParameters( WorkItemId = workItem, AttachmentType = attachmentType, Latest = latest, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let! result = WorkItem.ShowAttachment(parameters) match result with | Error error -> return Error error | Ok graceReturnValue -> if not (parseResult |> json) && not (parseResult |> silent) then writeShowAttachmentOutput workItem graceReturnValue.ReturnValue return Ok graceReturnValue } let private attachmentsShowHandler (parseResult: ParseResult) = task { try return! attachmentsShowHandlerImpl parseResult with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type AttachmentsShow() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { let! result = attachmentsShowHandler parseResult return result |> renderOutput parseResult } let private attachmentsDownloadHandlerImpl (parseResult: ParseResult) = task { if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let workItemRaw = parseResult.GetValue(Arguments.workItemIdentifier) let artifactIdRaw = parseResult.GetValue(Options.artifactId) match tryNormalizeWorkItemIdentifier workItemRaw parseResult with | Error error -> return Error error | Ok workItem -> match tryParseGuid artifactIdRaw WorkItemError.InvalidArtifactId parseResult with | Error error -> return Error error | Ok artifactId -> match tryResolveOutputFilePath parseResult with | Error error -> return Error error | Ok outputFilePath -> let parameters = Parameters.WorkItem.DownloadWorkItemAttachmentParameters( WorkItemId = workItem, ArtifactId = artifactId.ToString(), OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) match! WorkItem.DownloadAttachment(parameters) with | Error error -> return Error error | Ok returnValue -> match! downloadAttachmentBytes returnValue.ReturnValue.DownloadUri parseResult with | Error error -> return Error error | Ok bytes -> let outputDirectory = Path.GetDirectoryName(outputFilePath) if not (String.IsNullOrWhiteSpace outputDirectory) then Directory.CreateDirectory(outputDirectory) |> ignore do! File.WriteAllBytesAsync(outputFilePath, bytes) if not (parseResult |> json) && not (parseResult |> silent) then AnsiConsole.MarkupLine( $"[green]Downloaded[/] {Markup.Escape(returnValue.ReturnValue.AttachmentType)} [green]attachment[/] [grey](artifact {Markup.Escape(returnValue.ReturnValue.ArtifactId)})[/] [green]to[/] {Markup.Escape(outputFilePath)}" ) let output = { WorkItem = workItem ArtifactId = artifactId AttachmentType = returnValue.ReturnValue.AttachmentType OutputFile = outputFilePath Size = int64 bytes.LongLength } return Ok(GraceReturnValue.Create output graceIds.CorrelationId) } let private attachmentsDownloadHandler (parseResult: ParseResult) = task { try return! attachmentsDownloadHandlerImpl parseResult with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type AttachmentsDownload() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { let! result = attachmentsDownloadHandler parseResult return result |> renderOutput parseResult } let private formatGuidList (values: Guid list) = if values.IsEmpty then "-" else values |> List.map (fun value -> value.ToString()) |> String.concat Environment.NewLine let private writeLinksTable (links: WorkItemLinksDto) = let table = Table(Border = TableBorder.Rounded) table.AddColumn("[bold]Link category[/]") |> ignore table.AddColumn("[bold]Values[/]") |> ignore table.AddRow("Work item ID", Markup.Escape(links.WorkItemId.ToString())) |> ignore table.AddRow("Work item number", links.WorkItemNumber.ToString()) |> ignore table.AddRow("References", Markup.Escape(formatGuidList links.ReferenceIds)) |> ignore table.AddRow("Promotion sets", Markup.Escape(formatGuidList links.PromotionSetIds)) |> ignore table.AddRow("Summary attachments", Markup.Escape(formatGuidList links.AgentSummaryArtifactIds)) |> ignore table.AddRow("Prompt attachments", Markup.Escape(formatGuidList links.PromptArtifactIds)) |> ignore table.AddRow("Notes attachments", Markup.Escape(formatGuidList links.ReviewNotesArtifactIds)) |> ignore table.AddRow("Other attachments", Markup.Escape(formatGuidList links.OtherArtifactIds)) |> ignore AnsiConsole.Write(table) let private linksListHandlerImpl (parseResult: ParseResult) = task { if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let workItemRaw = parseResult.GetValue(Arguments.workItemIdentifier) match tryNormalizeWorkItemIdentifier workItemRaw parseResult with | Error error -> return Error error | Ok workItem -> let parameters = Parameters.WorkItem.GetWorkItemLinksParameters( WorkItemId = workItem, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) let! result = WorkItem.GetLinks(parameters) match result with | Error error -> return Error error | Ok graceReturnValue -> if not (parseResult |> json) && not (parseResult |> silent) then writeLinksTable graceReturnValue.ReturnValue return Ok graceReturnValue } let private linksListHandler (parseResult: ParseResult) = task { try return! linksListHandlerImpl parseResult with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type LinksList() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { let! result = linksListHandler parseResult return result |> renderOutput parseResult } let private removeReferenceLinkHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let workItemRaw = parseResult.GetValue(Arguments.workItemIdentifier) let referenceIdRaw = parseResult.GetValue(Arguments.referenceId) match tryNormalizeWorkItemIdentifier workItemRaw parseResult with | Error error -> return Error error | Ok workItem -> match tryParseGuid referenceIdRaw WorkItemError.InvalidReferenceId parseResult with | Error error -> return Error error | Ok referenceId -> let parameters = Parameters.WorkItem.RemoveReferenceLinkParameters( WorkItemId = workItem, ReferenceId = referenceId.ToString(), OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) return! WorkItem.RemoveReferenceLink(parameters) with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type RemoveReferenceLink() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { let! result = removeReferenceLinkHandler parseResult return result |> renderOutput parseResult } let private removePromotionSetLinkHandler (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let workItemRaw = parseResult.GetValue(Arguments.workItemIdentifier) let promotionSetIdRaw = parseResult.GetValue(Arguments.promotionSetId) match tryNormalizeWorkItemIdentifier workItemRaw parseResult with | Error error -> return Error error | Ok workItem -> match tryParseGuid promotionSetIdRaw WorkItemError.InvalidPromotionSetId parseResult with | Error error -> return Error error | Ok promotionSetId -> let parameters = Parameters.WorkItem.RemovePromotionSetLinkParameters( WorkItemId = workItem, PromotionSetId = promotionSetId.ToString(), OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) return! WorkItem.RemovePromotionSetLink(parameters) with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type RemovePromotionSetLink() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { let! result = removePromotionSetLinkHandler parseResult return result |> renderOutput parseResult } let private removeArtifactTypeLinksHandler (artifactType: string) (parseResult: ParseResult) = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let workItemRaw = parseResult.GetValue(Arguments.workItemIdentifier) match tryNormalizeWorkItemIdentifier workItemRaw parseResult with | Error error -> return Error error | Ok workItem -> let parameters = Parameters.WorkItem.RemoveArtifactTypeLinksParameters( WorkItemId = workItem, ArtifactType = artifactType, OwnerId = graceIds.OwnerIdString, OwnerName = graceIds.OwnerName, OrganizationId = graceIds.OrganizationIdString, OrganizationName = graceIds.OrganizationName, RepositoryId = graceIds.RepositoryIdString, RepositoryName = graceIds.RepositoryName, CorrelationId = graceIds.CorrelationId ) return! WorkItem.RemoveArtifactTypeLinks(parameters) with | ex -> return Error(GraceError.Create $"{ExceptionResponse.Create ex}" (getCorrelationId parseResult)) } type RemoveSummaryLinks() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { let! result = removeArtifactTypeLinksHandler "summary" parseResult return result |> renderOutput parseResult } type RemovePromptLinks() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { let! result = removeArtifactTypeLinksHandler "prompt" parseResult return result |> renderOutput parseResult } type RemoveNotesLinks() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task = task { let! result = removeArtifactTypeLinksHandler "notes" parseResult return result |> renderOutput parseResult } let Build = let addCommonOptions (command: Command) = command |> addOption Options.ownerName |> addOption Options.ownerId |> addOption Options.organizationName |> addOption Options.organizationId |> addOption Options.repositoryName |> addOption Options.repositoryId let addAttachInputOptions (command: Command) = command |> addOption Options.file |> addOption Options.text |> addOption Options.stdin let workCommand = new Command("workitem", Description = "Create and manage work items (GUID or positive-number identifiers).") workCommand.Aliases.Add("work") workCommand.Aliases.Add("work-item") workCommand.Aliases.Add("wi") let createCommand = new Command("create", Description = "Create a new work item.") |> addOption Options.workItemId |> addOption Options.title |> addOption Options.description |> addCommonOptions createCommand.Action <- new Create() workCommand.Subcommands.Add(createCommand) let showCommand = new Command("show", Description = "Show a work item by ID or number.") |> addCommonOptions showCommand.Arguments.Add(Arguments.workItemIdentifier) showCommand.Action <- new Show() workCommand.Subcommands.Add(showCommand) let statusCommand = new Command("status", Description = "Update the status of a work item by ID or number.") |> addOption Options.statusSet |> addCommonOptions statusCommand.Arguments.Add(Arguments.workItemIdentifier) statusCommand.Action <- new Status() workCommand.Subcommands.Add(statusCommand) let linkCommand = new Command("link", Description = "Link related entities to a work item.") let linkRefCommand = new Command("ref", Description = "Link a reference to a work item.") |> addCommonOptions linkRefCommand.Arguments.Add(Arguments.workItemIdentifier) linkRefCommand.Arguments.Add(Arguments.referenceId) linkRefCommand.Action <- new LinkReference() linkCommand.Subcommands.Add(linkRefCommand) let linkPromotionSetCommand = new Command("prset", Description = "Link a promotion set to a work item.") |> addCommonOptions linkPromotionSetCommand.Arguments.Add(Arguments.workItemIdentifier) linkPromotionSetCommand.Arguments.Add(Arguments.promotionSetId) linkPromotionSetCommand.Action <- new LinkPromotionSet() linkCommand.Subcommands.Add(linkPromotionSetCommand) workCommand.Subcommands.Add(linkCommand) let attachCommand = new Command("attach", Description = "Attach summary, prompt, or notes content to a work item.") let attachSummaryCommand = new Command("summary", Description = "Attach summary content to a work item.") |> addAttachInputOptions |> addCommonOptions attachSummaryCommand.Arguments.Add(Arguments.workItemIdentifier) attachSummaryCommand.Action <- new AttachSummary() attachCommand.Subcommands.Add(attachSummaryCommand) let attachPromptCommand = new Command("prompt", Description = "Attach prompt content to a work item.") |> addAttachInputOptions |> addCommonOptions attachPromptCommand.Arguments.Add(Arguments.workItemIdentifier) attachPromptCommand.Action <- new AttachPrompt() attachCommand.Subcommands.Add(attachPromptCommand) let attachNotesCommand = new Command("notes", Description = "Attach notes content to a work item.") |> addAttachInputOptions |> addCommonOptions attachNotesCommand.Arguments.Add(Arguments.workItemIdentifier) attachNotesCommand.Action <- new AttachNotes() attachCommand.Subcommands.Add(attachNotesCommand) workCommand.Subcommands.Add(attachCommand) let attachmentsCommand = new Command("attachments", Description = "List, show, and download reviewer attachments by work item ID or number.") let attachmentsListCommand = new Command("list", Description = "List summary, prompt, and notes attachments for a work item.") |> addCommonOptions attachmentsListCommand.Arguments.Add(Arguments.workItemIdentifier) attachmentsListCommand.Action <- new AttachmentsList() attachmentsCommand.Subcommands.Add(attachmentsListCommand) let attachmentsShowCommand = new Command("show", Description = "Show one attachment with safe inline text rendering.") |> addOption Options.attachmentType |> addOption Options.latest |> addCommonOptions attachmentsShowCommand.Arguments.Add(Arguments.workItemIdentifier) attachmentsShowCommand.Action <- new AttachmentsShow() attachmentsCommand.Subcommands.Add(attachmentsShowCommand) let attachmentsDownloadCommand = new Command("download", Description = "Download attachment bytes to a local file path.") |> addOption Options.artifactId |> addOption Options.outputFile |> addCommonOptions attachmentsDownloadCommand.Arguments.Add(Arguments.workItemIdentifier) attachmentsDownloadCommand.Action <- new AttachmentsDownload() attachmentsCommand.Subcommands.Add(attachmentsDownloadCommand) workCommand.Subcommands.Add(attachmentsCommand) let linksCommand = new Command("links", Description = "Inspect and remove work item links.") let linksListCommand = new Command("list", Description = "List current links for a work item.") |> addCommonOptions linksListCommand.Arguments.Add(Arguments.workItemIdentifier) linksListCommand.Action <- new LinksList() linksCommand.Subcommands.Add(linksListCommand) let linksRemoveCommand = new Command("remove", Description = "Remove one or more links from a work item.") let removeReferenceCommand = new Command("ref", Description = "Remove a reference link from a work item.") |> addCommonOptions removeReferenceCommand.Arguments.Add(Arguments.workItemIdentifier) removeReferenceCommand.Arguments.Add(Arguments.referenceId) removeReferenceCommand.Action <- new RemoveReferenceLink() linksRemoveCommand.Subcommands.Add(removeReferenceCommand) let removePromotionSetCommand = new Command("prset", Description = "Remove a promotion set link from a work item.") |> addCommonOptions removePromotionSetCommand.Arguments.Add(Arguments.workItemIdentifier) removePromotionSetCommand.Arguments.Add(Arguments.promotionSetId) removePromotionSetCommand.Action <- new RemovePromotionSetLink() linksRemoveCommand.Subcommands.Add(removePromotionSetCommand) let removeSummaryLinksCommand = new Command("summary", Description = "Remove all summary attachments from a work item.") |> addCommonOptions removeSummaryLinksCommand.Arguments.Add(Arguments.workItemIdentifier) removeSummaryLinksCommand.Action <- new RemoveSummaryLinks() linksRemoveCommand.Subcommands.Add(removeSummaryLinksCommand) let removePromptLinksCommand = new Command("prompt", Description = "Remove all prompt attachments from a work item.") |> addCommonOptions removePromptLinksCommand.Arguments.Add(Arguments.workItemIdentifier) removePromptLinksCommand.Action <- new RemovePromptLinks() linksRemoveCommand.Subcommands.Add(removePromptLinksCommand) let removeNotesLinksCommand = new Command("notes", Description = "Remove all notes attachments from a work item.") |> addCommonOptions removeNotesLinksCommand.Arguments.Add(Arguments.workItemIdentifier) removeNotesLinksCommand.Action <- new RemoveNotesLinks() linksRemoveCommand.Subcommands.Add(removeNotesLinksCommand) linksCommand.Subcommands.Add(linksRemoveCommand) workCommand.Subcommands.Add(linksCommand) workCommand ================================================ FILE: src/Grace.CLI/Conversion.md ================================================ # Grace CLI Command Conversion Guide This note bundles the shared knowledge needed to migrate legacy CLI subcommands from `CommandHandler.Create` to the `AsynchronousCommandLineAction` pattern. Use it to plan batches, avoid re-scanning the same F# sources, and keep future prompts short. --- ## 1. Core Pattern - **Entry point**: Replace each `CommandHandler.Create` assignment with `command.Action <- new Xxx()` where `Xxx` is a new action class in the same module. - **Class template**: ```fsharp /// Module.CommandName subcommand definition type CommandName() = inherit AsynchronousCommandLineAction() override _.InvokeAsync(parseResult: ParseResult, ct: CancellationToken) : Tasks.Task = task { try if parseResult |> verbose then printParseResult parseResult let graceIds = parseResult |> getNormalizedIdsAndNames let validateIncomingParameters = parseResult |> CommonValidations match validateIncomingParameters with | Ok _ -> let parameters = Parameters.Namespace.SomeSdkParameters( // map IDs and options here ) if parseResult |> hasOutput then let! result = progress .Columns(progressColumns) .StartAsync(fun progressContext -> task { let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]") let! response = SdkModule.Operation(parameters) t0.Increment(100.0) return response }) return result |> renderOutput parseResult else let! result = SdkModule.Operation(parameters) return result |> renderOutput parseResult | Error error -> return (Error error) |> renderOutput parseResult with ex -> return renderOutput parseResult (GraceResult.Error(GraceError.Create $"{Utilities.ExceptionResponse.Create ex}" (parseResult |> getCorrelationId))) } ``` - **Validation & normalization**: - `let graceIds = parseResult |> getNormalizedIdsAndNames` - `let validateIncomingParameters = parseResult |> CommonValidations` - When existing helpers already normalize parameters, reuse them, but remove obsolete `XxxParameters` types and private handler functions. - **SDK parameter builders**: Map from `graceIds.*` and `parseResult.GetValue Options.xxx`. Keep correlation IDs: `CorrelationId = getCorrelationId parseResult`. - **Progress UI**: Preserve any existing `progress.Columns(progressColumns)` blocks and nested tasks exactly; only wrap them with `let! result = ...` and `return result |> renderOutput parseResult`. - **Output**: Always render using `result |> renderOutput parseResult`. When the old code updated configuration (e.g., `Organization.Create`), keep that logic inside the success branch before returning. - **Error handling**: Use the `with ex ->` clause shown above to wrap in `GraceResult.Error`. - **Build hygiene**: Each file should compile independently. Run `dotnet build --configuration Release` after a batch rather than after every command unless you suspect a break in the current file. --- ## 2. Suggested Working Cadence 1. Choose a module (e.g., `Repository.CLI.fs`) and convert 2–4 commands per prompt iteration. 2. After finishing a batch, list which handlers are done and which remain; skip per-command “ready checks” to save tokens. 3. Run `dotnet build --configuration Release` when the current file is green. Report only compile issues tied to the edited file. 4. When pausing, note the next handler you intend to convert to resume smoothly without re-reading the module. --- ## 3. Module Cheat Sheets Use these summaries to map options to SDK parameters quickly. ### Repository (`Grace.CLI/Command/Repository.CLI.fs`) | Command | SDK target | Key options / notes | | --- | --- | --- | | `Create` | `Repository.Create` | Uses org and owner IDs; mirrors existing config update logic. | | `Init` | `Repository.Init` | accepts `--graceConfig`; may read defaults from file system. | | `Get` / `GetBranches` | `Repository.Get*` | Expect pagination flags (check existing handler). | | `SetVisibility` | `Repository.SetVisibility` | `Options.visibility` maps to `RepositoryType`. | | `SetStatus` | `Repository.SetStatus` | `--status` from `RepositoryStatus`. | | `SetRecordSaves` | `Repository.SetRecordSaves` | Boolean `--recordSaves`. | | `SetSaveDays` / `SetCheckpointDays` / `SetDiffCacheDays` / `SetDirectoryVersionCacheDays` / `SetLogicalDeleteDays` | corresponding storage parameter types | Single-precision floats from options (keep cast). | | `SetDefaultServerApiVersion` | `Repository.SetDefaultServerApiVersion` | `--defaultServerApiVersion`. | | `SetName` / `SetDescription` | `Repository.SetName`, `Repository.SetDescription` | `OptionName.NewName` / `OptionName.Description`. | | `Delete` / `Undelete` | `Repository.Delete` / `Repository.Undelete` | Include `--deleteReason` and `--force` flags as applicable. | | `SetAnonymousAccess`, `SetAllowsLargeFiles` | `Repository.SetAnonymousAccess`, `Repository.SetAllowsLargeFiles` | Boolean toggles; preserve output text. | Special considerations: - Many commands rely on the shared `graceIds.RepositoryIdString`. When options are optional, fetch `graceIds` first and rely on defaults set in parsing logic. - Some handlers adjust configuration or display multi-step progress; keep any post-call mutations (e.g., updating `Current()`). ### Owner (`Grace.CLI/Command/Owner.CLI.fs`) | Command | Notes | | --- | --- | | `Create`, `Get` | Similar to Organization pattern; ensure new owner IDs fall back to defaults when implicit. | | `SetName`, `SetType`, `SetSearchVisibility`, `SetDescription` | Mirror `Organization.SetType` example for structure; options map to string enums validated via `.AcceptOnlyFromAmong`. | | `Delete`, `Undelete` | Include `--deleteReason` / `--force` handling if present. | ### Branch (`Grace.CLI/Command/Branch.CLI.fs`) - Command list: `Create`, `Switch`, `Status`, `Promote`, `Commit`, `Checkpoint`, `Save`, `Tag`, `CreateExternal`, `Rebase`, `ListContents`, `GetRecursiveSize`, `Get` (and event wrappers), `GetReferences`, `GetPromotions`, `GetCommits`, `GetCheckpoints`, `GetSaves`, `GetTags`, `GetExternals`, `Assign`, `SetName`, `Delete`, plus any feature toggles still using `CommandHandler.Create`. - Highlights: - Many handlers compose multiple SDK calls with shared progress tasks; reproduce nested tasks exactly. - Option modules contain GUID validation using `validateGuid`; keep parse-time validation as-is. - Several commands stream console output (`Console.WriteLine`, `AnsiConsole.Write`) in addition to returning results; leave those statements untouched. - `Switch`/`Create` update local configuration; ensure config updates stay before returning. ### Reference (`Grace.CLI/Command/Reference.CLI.fs`) - Commands mirror branch operations (`Promote`, `Commit`, `Checkpoint`, `Save`, `Tag`, `CreateExternal`, `Get`, `Delete`, `Assign`). - Shares many options via `Branch` helper modules; focus on using `graceIds` for repository context and explicit branch/reference IDs supplied via options. ### Maintenance (`Grace.CLI/Command/Maintenance.CLI.fs`) - Commands: `Test`, `UpdateIndex`, `Scan`, `Stats`, `ListContents`. - Usually take only common parameters plus optional filters. - Each handler may print structured diagnostics; keep logging intact. - `ListContents` uses a custom `ListContentsParameters`; delete the type after inlining option reads. ### DirectoryVersion (`Grace.CLI/Command/DirectoryVersion.CLI.fs`) - All current `CommandHandler.Create` wrappers (`Get`, `Save`, `GetZipFile`, etc.) must convert. - Parameters often rely on directory paths and recursion flags; map options to `Grace.Shared.Parameters.DirectoryVersion` builders. - Some commands interact with file system (e.g., writing zip). Keep `use` bindings and `Directory.CreateDirectory` calls. --- ## 4. Tracking Progress - Use `rg "CommandHandler.Create" Grace.CLI/Command` to confirm remaining legacy handlers. - Maintain a simple checklist (e.g., in your prompt or local notes) marking each handler as converted. Reference this doc instead of re-opening every file. - When resuming work, note which `command.Action` assignments are still old-style; they provide a quick diff target. --- ## 5. Verification - After finishing each file: - `dotnet build --configuration Release` (captures errors across the solution). - Optionally run targeted tests if the module has coverage (`dotnet test --no-build --filter FullyQualifiedName~Grace.CLI`). - Ensure Fantomas formatting if required: `dotnet tool run fantomas Grace.CLI/Command/.fs`. --- By sticking to this reference and working in batches, you can minimize token usage per iteration while migrating all CLI subcommands to the new action pattern. Keep this document open during conversion sessions and update it if new patterns emerge (e.g., additional validation helpers or SDK parameter changes). ================================================ FILE: src/Grace.CLI/Grace.CLI.fsproj ================================================ net10.0 preview true Exe 0.1 The command-line interface for Grace. true true true FS0025 67;1057,3391 AnyCPU False --test:GraphBasedChecking --test:ParallelOptimization --test:ParallelIlxGen Grace Version Control System CLI grace ================================================ FILE: src/Grace.CLI/HistoryStorage.CLI.fs ================================================ namespace Grace.CLI open Grace.CLI.Text open Grace.Shared open Grace.Shared.Client open Grace.Shared.Utilities open NodaTime open System open System.Collections.Generic open System.Diagnostics open System.IO open System.Reflection open System.Text open System.Text.Json open System.Text.RegularExpressions open System.Threading module HistoryStorage = [] let Placeholder = "__REDACTED__" let private jsonlOptions = let options = JsonSerializerOptions(Constants.JsonSerializerOptions) options.WriteIndented <- false options type Redaction = { kind: string; name: string; argIndex: int; originalLength: int option; placeholder: string } type HistoryEntry = { id: Guid timestampUtc: Instant argvOriginal: string array argvNormalized: string array commandLine: string cwd: string repoRoot: string option repoName: string option repoBranch: string option graceVersion: string exitCode: int durationMs: int64 parseSucceeded: bool redactions: Redaction list source: string option } type ReadResult = { Entries: HistoryEntry list; CorruptCount: int } type RecordInput = { argvOriginal: string array argvNormalized: string array cwd: string exitCode: int durationMs: int64 parseSucceeded: bool timestampUtc: Instant source: string option } let private lockBackoffMs = [| 25 50 100 150 200 250 300 400 500 750 |] let getHistoryFilePath () = let userGraceDir = UserConfiguration.getUserGraceDirectory () Path.Combine(userGraceDir, "history.jsonl") let getHistoryLockPath () = let userGraceDir = UserConfiguration.getUserGraceDirectory () Path.Combine(userGraceDir, "history.lock") let private ensureHistoryDirectory () = UserConfiguration.ensureUserGraceDirectory () |> ignore let private getGraceVersion () = try let version = Assembly.GetEntryAssembly().GetName().Version if isNull version then Constants.CurrentConfigurationVersion else version.ToString() with | _ -> Constants.CurrentConfigurationVersion let private tryAcquireLock () = ensureHistoryDirectory () let lockPath = getHistoryLockPath () let mutable acquired: FileStream option = None for attempt in 0 .. lockBackoffMs.Length - 1 do if acquired.IsNone then try let stream = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None) acquired <- Some stream with | :? IOException -> Thread.Sleep(lockBackoffMs[attempt]) acquired let private withHistoryLock (onLocked: unit -> 'T) (onFailure: unit -> 'T) = match tryAcquireLock () with | Some stream -> try onLocked () finally stream.Dispose() | None -> onFailure () let private quoteArg (arg: string) = if String.IsNullOrEmpty(arg) then "\"\"" elif arg.IndexOfAny([| ' '; '\t'; '"' |]) >= 0 then "\"" + arg.Replace("\"", "\\\"") + "\"" else arg let buildCommandLine (argv: string array) = argv |> Array.map quoteArg |> String.concat " " let tryFindRepoRoot (startDirectory: string) = try let mutable current = DirectoryInfo(startDirectory) let mutable found: string option = None while (not <| isNull current) && found.IsNone do let configPath = Path.Combine(current.FullName, Constants.GraceConfigDirectory, Constants.GraceConfigFileName) if File.Exists(configPath) then found <- Some current.FullName else current <- current.Parent found with | _ -> None let tryParseDuration (value: string) = if String.IsNullOrWhiteSpace(value) then Error "Duration cannot be empty." else let trimmed = value.Trim() let suffix = trimmed[trimmed.Length - 1] let numberPart = trimmed.Substring(0, trimmed.Length - 1) match Double.TryParse(numberPart) with | true, amount -> match suffix with | 's' -> Ok(Duration.FromSeconds(amount)) | 'm' -> Ok(Duration.FromMinutes(amount)) | 'h' -> Ok(Duration.FromHours(amount)) | 'd' -> Ok(Duration.FromDays(amount)) | _ -> Error "Duration must end with s, m, h, or d." | _ -> Error "Duration must be a number followed by s, m, h, or d." let private tryGetRepoName (repoRoot: string option) = match repoRoot with | Some root when not <| String.IsNullOrWhiteSpace(root) -> try let name = DirectoryInfo(root).Name if String.IsNullOrWhiteSpace(name) then None else Some name with | _ -> None | _ -> None let private hasGitMetadata (repoRoot: string) = let gitPath = Path.Combine(repoRoot, ".git") Directory.Exists(gitPath) || File.Exists(gitPath) let private tryGetGitBranch (repoRoot: string option) = match repoRoot with | Some root when not <| String.IsNullOrWhiteSpace(root) && hasGitMetadata root -> try let startInfo = ProcessStartInfo() startInfo.FileName <- "git" startInfo.Arguments <- "rev-parse --abbrev-ref HEAD" startInfo.WorkingDirectory <- root startInfo.RedirectStandardOutput <- true startInfo.RedirectStandardError <- true startInfo.UseShellExecute <- false startInfo.CreateNoWindow <- true use proc = new Process() proc.StartInfo <- startInfo if proc.Start() then if proc.WaitForExit(2000) then let output = proc.StandardOutput.ReadToEnd().Trim() if proc.ExitCode = 0 && not <| String.IsNullOrWhiteSpace(output) && not (output.Equals("HEAD", StringComparison.OrdinalIgnoreCase)) then Some output else None else try proc.Kill(true) with | _ -> () None else None with | _ -> None | _ -> None let private buildSensitiveOptionSet (historyConfig: UserConfiguration.HistoryConfiguration) = let names = HashSet(StringComparer.InvariantCultureIgnoreCase) for name in (UserConfiguration.defaultRedactOptionNames ()) do names.Add(name) |> ignore if not <| isNull historyConfig.RedactOptionNames then for name in historyConfig.RedactOptionNames do if not <| String.IsNullOrWhiteSpace(name) then names.Add(name.Trim()) |> ignore names let private buildRegexes (patterns: string array) = let regexes = ResizeArray() if not <| isNull patterns then for pattern in patterns do if not <| String.IsNullOrWhiteSpace(pattern) then try regexes.Add( Regex( pattern, RegexOptions.Compiled ||| RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1.0) ) ) with | _ -> () regexes |> Seq.toList let private redactOptions (args: string array) (sensitiveOptions: HashSet) = let redactions = ResizeArray() let redacted = Array.copy args let mutable i = 0 while i < redacted.Length do let current = redacted[i] if not <| String.IsNullOrWhiteSpace(current) && current.StartsWith("--") then let optionPart = current.Substring(2) let equalsIndex = optionPart.IndexOf('=') if equalsIndex >= 0 then let optionName = optionPart.Substring(0, equalsIndex) let optionValue = optionPart.Substring(equalsIndex + 1) if sensitiveOptions.Contains(optionName) then redacted[i] <- $"--{optionName}={Placeholder}" redactions.Add( { kind = "OptionValue"; name = optionName; argIndex = i; originalLength = Some optionValue.Length; placeholder = Placeholder } ) else let optionName = optionPart if sensitiveOptions.Contains(optionName) then if i + 1 < redacted.Length then let optionValue = redacted[i + 1] redacted[i + 1] <- Placeholder redactions.Add( { kind = "OptionValue" name = optionName argIndex = i + 1 originalLength = Some optionValue.Length placeholder = Placeholder } ) i <- i + 1 redacted, redactions |> Seq.toList let private applyRegexRedactions (args: string array) (regexes: Regex list) = let redactions = ResizeArray() let redacted = Array.copy args for i in 0 .. redacted.Length - 1 do let mutable updated = redacted[i] for regex in regexes do if not <| String.IsNullOrWhiteSpace(updated) then let matches = regex.Matches(updated) if matches.Count > 0 then for m in matches do let prefix = if m.Groups.Count > 1 then m.Groups[1].Value else String.Empty let sensitiveLength = if m.Groups.Count > 1 then Math.Max(0, m.Value.Length - prefix.Length) else m.Value.Length redactions.Add( { kind = "RegexMatch"; name = regex.ToString(); argIndex = i; originalLength = Some sensitiveLength; placeholder = Placeholder } ) updated <- regex.Replace(updated, (fun (m: Match) -> if m.Groups.Count > 1 then m.Groups[1].Value + Placeholder else Placeholder)) redacted[i] <- updated redacted, redactions |> Seq.toList let redactArguments (args: string array) (historyConfig: UserConfiguration.HistoryConfiguration) = if isNull args then Array.empty, List.empty else let sensitiveOptions = buildSensitiveOptionSet historyConfig let regexes = buildRegexes historyConfig.RedactRegexes let redactedAfterOptions, optionRedactions = redactOptions args sensitiveOptions let fullyRedacted, regexRedactions = applyRegexRedactions redactedAfterOptions regexes fullyRedacted, (optionRedactions @ regexRedactions) let readHistoryEntries () = ensureHistoryDirectory () let path = getHistoryFilePath () if not <| File.Exists(path) then { Entries = List.empty; CorruptCount = 0 } else let mutable attempts = 0 let mutable success = false let mutable result = { Entries = List.empty; CorruptCount = 0 } while attempts < lockBackoffMs.Length && not success do try let entries = ResizeArray() let mutable corrupt = 0 use stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite) use reader = new StreamReader(stream, Encoding.UTF8) let mutable line = reader.ReadLine() while not <| isNull line do if not <| String.IsNullOrWhiteSpace(line) then try let entry = JsonSerializer.Deserialize(line, Constants.JsonSerializerOptions) if obj.ReferenceEquals(entry, null) then corrupt <- corrupt + 1 else entries.Add(entry) with | _ -> corrupt <- corrupt + 1 line <- reader.ReadLine() result <- { Entries = entries |> Seq.toList; CorruptCount = corrupt } success <- true with | :? IOException -> Thread.Sleep(lockBackoffMs[attempts]) attempts <- attempts + 1 result let private writeHistoryEntries (entries: HistoryEntry list) = ensureHistoryDirectory () let historyPath = getHistoryFilePath () let tempPath = historyPath + ".tmp" let backupPath = historyPath + ".bak" let tryDeleteFile (path: string) = let mutable attempts = 0 let mutable deleted = false while attempts < lockBackoffMs.Length && not deleted do try if File.Exists(path) then File.Delete(path) deleted <- true with | :? IOException | :? UnauthorizedAccessException -> Thread.Sleep(lockBackoffMs[attempts]) attempts <- attempts + 1 do use stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None) use writer = new StreamWriter(stream, Encoding.UTF8) for entry in entries do let json = JsonSerializer.Serialize(entry, jsonlOptions) writer.WriteLine(json) writer.Flush() stream.Flush(true) let mutable attempts = 0 let mutable replaced = false while attempts < lockBackoffMs.Length && not replaced do try if File.Exists(historyPath) then try File.Replace(tempPath, historyPath, backupPath, true) tryDeleteFile backupPath with | :? IOException -> File.Move(tempPath, historyPath, true) else File.Move(tempPath, historyPath) replaced <- true with | :? IOException | :? UnauthorizedAccessException -> Thread.Sleep(lockBackoffMs[attempts]) attempts <- attempts + 1 if not replaced then tryDeleteFile tempPath let private pruneIfNeeded (historyConfig: UserConfiguration.HistoryConfiguration) = let historyPath = getHistoryFilePath () let fileInfo = FileInfo(historyPath) let readResult = readHistoryEntries () let entries = readResult.Entries let retentionCutoff = if historyConfig.RetentionDays > 0 then Some( getCurrentInstant() .Minus(Duration.FromDays(float historyConfig.RetentionDays)) ) else None let retained = match retentionCutoff with | Some cutoff -> entries |> List.filter (fun entry -> entry.timestampUtc >= cutoff) | None -> entries let trimmed = if historyConfig.MaxEntries > 0 && retained.Length > historyConfig.MaxEntries then retained |> List.sortByDescending (fun entry -> entry.timestampUtc) |> List.truncate historyConfig.MaxEntries else retained let trimmedOrdered = trimmed |> List.sortBy (fun entry -> entry.timestampUtc) let exceedsSize = if historyConfig.MaxFileBytes > 0L then fileInfo.Exists && fileInfo.Length > historyConfig.MaxFileBytes else false let exceedsCount = historyConfig.MaxEntries > 0 && entries.Length > historyConfig.MaxEntries let exceedsRetention = retained.Length <> entries.Length if exceedsSize || exceedsCount || exceedsRetention then writeHistoryEntries trimmedOrdered readResult let private appendHistoryEntry (entry: HistoryEntry) (historyConfig: UserConfiguration.HistoryConfiguration) = ensureHistoryDirectory () let historyPath = getHistoryFilePath () let json = JsonSerializer.Serialize(entry, jsonlOptions) do use stream = new FileStream(historyPath, FileMode.Append, FileAccess.Write, FileShare.Read) use writer = new StreamWriter(stream, Encoding.UTF8) writer.WriteLine(json) writer.Flush() stream.Flush(true) pruneIfNeeded historyConfig |> ignore let private tryGetTopLevelCommand (tokens: string array) = if isNull tokens || tokens.Length = 0 then None else let comparison = if runningOnWindows then StringComparison.InvariantCultureIgnoreCase else StringComparison.InvariantCulture let isOptionWithValue (token: string) = token.Equals(OptionName.Output, comparison) || token.Equals("-o", comparison) || token.Equals(OptionName.CorrelationId, comparison) || token.Equals("-c", comparison) || token.Equals(OptionName.Source, comparison) let rec loop index = if index >= tokens.Length then None else let token = tokens[index] if token = "--" then if index + 1 < tokens.Length then Some tokens[index + 1] else None elif token.StartsWith("-", StringComparison.Ordinal) then let nextIndex = if isOptionWithValue token then index + 2 else index + 1 loop nextIndex else Some token loop 0 let private normalizeSourceOption (value: string option) = value |> Option.bind (fun source -> if String.IsNullOrWhiteSpace(source) then None else Some(source.Trim())) let shouldRecord (input: RecordInput) (historyConfig: UserConfiguration.HistoryConfiguration) = if not historyConfig.Enabled then false else let tokens = if isNull input.argvNormalized then Array.empty else input.argvNormalized let commandName = tryGetTopLevelCommand tokens |> Option.defaultValue String.Empty let isHistory = commandName.Equals("history", StringComparison.InvariantCultureIgnoreCase) if isHistory && not historyConfig.RecordHistoryCommands then false else true let recordInvocation (input: RecordInput) = let loadResult = UserConfiguration.loadUserConfiguration () if not <| shouldRecord input loadResult.Configuration.History then None else let redactedNormalized, redactions = redactArguments input.argvNormalized loadResult.Configuration.History let redactedOriginal, _ = redactArguments input.argvOriginal loadResult.Configuration.History let entry = let repoRoot = tryFindRepoRoot input.cwd let repoName = tryGetRepoName repoRoot let repoBranch = tryGetGitBranch repoRoot { id = Guid.NewGuid() timestampUtc = input.timestampUtc argvOriginal = redactedOriginal argvNormalized = redactedNormalized commandLine = buildCommandLine redactedNormalized cwd = input.cwd repoRoot = repoRoot repoName = repoName repoBranch = repoBranch graceVersion = getGraceVersion () exitCode = input.exitCode durationMs = input.durationMs parseSucceeded = input.parseSucceeded redactions = redactions source = normalizeSourceOption input.source } Some(entry, loadResult.Configuration.History) let tryRecordInvocation (input: RecordInput) = match recordInvocation input with | None -> () | Some (entry, historyConfig) -> let onFailure () = Console.Error.WriteLine("Grace history: failed to acquire history lock; skipping history recording.") withHistoryLock (fun () -> appendHistoryEntry entry historyConfig ()) onFailure let clearHistory () = let onFailure () = Error "Grace history: failed to acquire history lock." withHistoryLock (fun () -> ensureHistoryDirectory () let path = getHistoryFilePath () let removedCount = if File.Exists(path) then File.ReadLines(path) |> Seq.filter (fun line -> not <| String.IsNullOrWhiteSpace(line)) |> Seq.length else 0 File.WriteAllText(path, String.Empty) Ok removedCount) onFailure let isDestructive (commandLine: string) (historyConfig: UserConfiguration.HistoryConfiguration) = let patterns = historyConfig.DestructiveTokenRegexes let regexes = buildRegexes patterns regexes |> List.exists (fun regex -> regex.IsMatch(commandLine)) ================================================ FILE: src/Grace.CLI/LocalStateDb.CLI.fs ================================================ namespace Grace.CLI open System open System.Collections.Concurrent open System.Collections.Generic open System.Diagnostics open System.IO open System.Threading open System.Threading.Tasks open Grace.Shared.Client.Configuration open Grace.Shared.Utilities open Grace.Types.Types open Microsoft.Data.Sqlite open NodaTime open SQLitePCL module LocalStateDb = [] let private SchemaVersion = "2" [] let private BusyTimeoutMs = 30000 let private retryDelaysMs = [| 50; 100; 200; 400; 800; 1600 |] let mutable private verboseEnabled = false let setVerbose enabled = verboseEnabled <- enabled let private traceFilePath = Environment.GetEnvironmentVariable("GRACE_LOCALSTATE_DB_TRACE_PATH") let private traceOpenConnections = not (String.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("GRACE_LOCALSTATE_DB_TRACE_OPEN"))) let private initLocks = ConcurrentDictionary(StringComparer.OrdinalIgnoreCase) let private initializedDbs = ConcurrentDictionary(StringComparer.OrdinalIgnoreCase) let private sqliteInitialized = lazy (Batteries_V2.Init() true) let private logVerbose message = if verboseEnabled then Log.LogVerbose message let private logTrace message = if not (String.IsNullOrWhiteSpace(traceFilePath)) then try File.AppendAllText(traceFilePath, $"{DateTime.UtcNow:O} {message}{Environment.NewLine}") with | _ -> () let private logTraceStatement label (statement: string) = let trimmed = if statement.Length > 240 then statement.Substring(0, 240) + "..." else statement logTrace $"{label}: {trimmed}" let private isBusyOrLocked (ex: SqliteException) = ex.SqliteErrorCode = 5 || ex.SqliteErrorCode = 6 let private executeWithRetry (operation: unit -> Task) = let rec run attempt = task { try do! operation () with | :? SqliteException as ex when isBusyOrLocked ex -> if attempt >= retryDelaysMs.Length then return raise ex let jitter = Random.Shared.Next(0, 50) let delayMs = retryDelaysMs[attempt] + jitter do! Task.Delay(delayMs) return! run (attempt + 1) | ex -> return raise ex } run 0 let private executeNonQuery (connection: SqliteConnection) (sql: string) = use cmd = connection.CreateCommand() cmd.CommandText <- sql cmd.ExecuteNonQuery() |> ignore let private executePragma (connection: SqliteConnection) (sql: string) = use cmd = connection.CreateCommand() cmd.CommandText <- sql cmd.ExecuteNonQuery() |> ignore let private executeNonQueryWithParams (connection: SqliteConnection) (sql: string) (configureParameters: SqliteParameterCollection -> unit) = use cmd = connection.CreateCommand() cmd.CommandText <- sql configureParameters cmd.Parameters cmd.ExecuteNonQuery() |> ignore let private applyConnectionPragmas (connection: SqliteConnection) = executePragma connection $"PRAGMA busy_timeout = {BusyTimeoutMs};" executePragma connection "PRAGMA foreign_keys = ON;" executePragma connection "PRAGMA synchronous = NORMAL;" executePragma connection "PRAGMA temp_store = MEMORY;" let private ensureJournalMode (connection: SqliteConnection) = executePragma connection "PRAGMA journal_mode = WAL;" let private openConnection (dbPath: string) = sqliteInitialized.Value |> ignore let directoryPath = Path.GetDirectoryName(dbPath) logVerbose $"LocalStateDb.openConnection starting. dbPath={dbPath} dir={directoryPath}" if traceOpenConnections then logTrace $"openConnection starting. dbPath={dbPath} dir={directoryPath}" let stopwatch = Stopwatch.StartNew() Directory.CreateDirectory(directoryPath) |> ignore logVerbose $"LocalStateDb.openConnection directory ensured in {stopwatch.ElapsedMilliseconds}ms" if traceOpenConnections then logTrace $"openConnection directory ensured in {stopwatch.ElapsedMilliseconds}ms" let connectionString = let builder = SqliteConnectionStringBuilder() builder.DataSource <- dbPath builder.Mode <- SqliteOpenMode.ReadWriteCreate builder.Pooling <- true builder.DefaultTimeout <- BusyTimeoutMs / 1000 builder.ToString() let connection = new SqliteConnection(connectionString) try connection.Open() applyConnectionPragmas connection logVerbose $"LocalStateDb.openConnection opened connection in {stopwatch.ElapsedMilliseconds}ms" if traceOpenConnections then logTrace $"openConnection opened connection in {stopwatch.ElapsedMilliseconds}ms" connection with | ex -> try connection.Dispose() with | _ -> () raise ex let private schemaStatements = [| "CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);" "CREATE TABLE IF NOT EXISTS status_meta (id INTEGER PRIMARY KEY CHECK (id = 1), root_directory_version_id TEXT NOT NULL, root_directory_sha256_hash TEXT NOT NULL, last_successful_file_upload_unix_ticks INTEGER NOT NULL, last_successful_directory_version_upload_unix_ticks INTEGER NOT NULL);" "CREATE TABLE IF NOT EXISTS status_directories (relative_path TEXT PRIMARY KEY, parent_path TEXT NOT NULL, directory_version_id TEXT NOT NULL, sha256_hash TEXT NOT NULL, size_bytes INTEGER NOT NULL, created_at_unix_ticks INTEGER NOT NULL, last_write_time_utc_ticks INTEGER NOT NULL);" "CREATE INDEX IF NOT EXISTS ix_status_directories_parent ON status_directories(parent_path);" "CREATE UNIQUE INDEX IF NOT EXISTS ix_status_directories_directory_version_id ON status_directories(directory_version_id);" "CREATE TABLE IF NOT EXISTS status_files (relative_path TEXT PRIMARY KEY, directory_path TEXT NOT NULL, directory_version_id TEXT NOT NULL, sha256_hash TEXT NOT NULL, is_binary INTEGER NOT NULL, size_bytes INTEGER NOT NULL, created_at_unix_ticks INTEGER NOT NULL, uploaded_to_object_storage INTEGER NOT NULL, last_write_time_utc_ticks INTEGER NOT NULL, FOREIGN KEY (directory_version_id) REFERENCES status_directories(directory_version_id) ON DELETE CASCADE);" "CREATE INDEX IF NOT EXISTS ix_status_files_directory_path ON status_files(directory_path);" "CREATE INDEX IF NOT EXISTS ix_status_files_directory_version_id ON status_files(directory_version_id);" "CREATE INDEX IF NOT EXISTS ix_status_files_sha256 ON status_files(sha256_hash);" "CREATE TABLE IF NOT EXISTS object_cache_directories (directory_version_id TEXT PRIMARY KEY, relative_path TEXT NOT NULL, sha256_hash TEXT NOT NULL, size_bytes INTEGER NOT NULL, created_at_unix_ticks INTEGER NOT NULL, last_write_time_utc_ticks INTEGER NOT NULL);" "CREATE INDEX IF NOT EXISTS ix_object_cache_directories_relative_path ON object_cache_directories(relative_path);" "CREATE TABLE IF NOT EXISTS object_cache_directory_children (parent_directory_version_id TEXT NOT NULL, child_directory_version_id TEXT NOT NULL, ordinal INTEGER NOT NULL, PRIMARY KEY (parent_directory_version_id, child_directory_version_id), FOREIGN KEY (parent_directory_version_id) REFERENCES object_cache_directories(directory_version_id) ON DELETE CASCADE, FOREIGN KEY (child_directory_version_id) REFERENCES object_cache_directories(directory_version_id) ON DELETE RESTRICT);" "CREATE INDEX IF NOT EXISTS ix_object_cache_children_parent ON object_cache_directory_children(parent_directory_version_id);" "CREATE TABLE IF NOT EXISTS object_cache_directory_files (directory_version_id TEXT NOT NULL, relative_path TEXT NOT NULL, sha256_hash TEXT NOT NULL, is_binary INTEGER NOT NULL, size_bytes INTEGER NOT NULL, created_at_unix_ticks INTEGER NOT NULL, uploaded_to_object_storage INTEGER NOT NULL, last_write_time_utc_ticks INTEGER NOT NULL, PRIMARY KEY (directory_version_id, relative_path), FOREIGN KEY (directory_version_id) REFERENCES object_cache_directories(directory_version_id) ON DELETE CASCADE);" "CREATE INDEX IF NOT EXISTS ix_object_cache_files_path_hash ON object_cache_directory_files(relative_path, sha256_hash);" |] let private tryGetMetaValue (connection: SqliteConnection) (key: string) = use cmd = connection.CreateCommand() cmd.CommandText <- "SELECT value FROM meta WHERE key = $key LIMIT 1;" cmd.Parameters.AddWithValue("$key", key) |> ignore use reader = cmd.ExecuteReader() if reader.Read() then Some(reader.GetString(0)) else None let private setMetaValue (connection: SqliteConnection) (key: string) (value: string) = executeNonQueryWithParams connection "INSERT OR REPLACE INTO meta (key, value) VALUES ($key, $value);" (fun parameters -> parameters.AddWithValue("$key", key) |> ignore parameters.AddWithValue("$value", value) |> ignore) let private insertStatusMetaIfMissing (connection: SqliteConnection) = let defaultStatus = GraceStatus.Default executeNonQueryWithParams connection "INSERT OR IGNORE INTO status_meta (id, root_directory_version_id, root_directory_sha256_hash, last_successful_file_upload_unix_ticks, last_successful_directory_version_upload_unix_ticks) VALUES (1, $root_id, $root_hash, $last_file, $last_dir);" (fun parameters -> parameters.AddWithValue("$root_id", defaultStatus.RootDirectoryId.ToString()) |> ignore parameters.AddWithValue("$root_hash", defaultStatus.RootDirectorySha256Hash) |> ignore parameters.AddWithValue("$last_file", defaultStatus.LastSuccessfulFileUpload.ToUnixTimeTicks()) |> ignore parameters.AddWithValue("$last_dir", defaultStatus.LastSuccessfulDirectoryVersionUpload.ToUnixTimeTicks()) |> ignore) let private recreateDatabase (dbPath: string) = try SqliteConnection.ClearAllPools() with | _ -> () if File.Exists(dbPath) then let timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss") let directoryPath = Path.GetDirectoryName(dbPath) let corruptPath = Path.Combine(directoryPath, $"grace-local.corrupt.{timestamp}.db") File.Move(dbPath, corruptPath, true) let sidecars = [| "-wal"; "-shm"; "-journal" |] sidecars |> Array.iter (fun suffix -> let sidecarPath = dbPath + suffix if File.Exists(sidecarPath) then File.Delete(sidecarPath)) let ensureDbInitialized (dbPath: string) = task { let normalizedPath = Path.GetFullPath(dbPath) let mutable loopCount = 0 match initializedDbs.TryGetValue(normalizedPath) with | true, _ -> () | _ -> let semaphore = initLocks.GetOrAdd(normalizedPath, (fun _ -> new SemaphoreSlim(1, 1))) do! semaphore.WaitAsync() try match initializedDbs.TryGetValue(normalizedPath) with | true, _ -> () | _ -> do! executeWithRetry (fun () -> task { let runSchema (connection: SqliteConnection) = ensureJournalMode connection schemaStatements |> Array.iteri (fun index statement -> logTraceStatement $"schema[{index}] start" statement executeNonQuery connection statement logTrace $"schema[{index}] done") let schemaExists (connection: SqliteConnection) = use cmd = connection.CreateCommand() cmd.CommandText <- "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta' LIMIT 1;" use reader = cmd.ExecuteReader() reader.Read() let mutable recreate = false do do try use schemaConnection = openConnection normalizedPath if not (schemaExists schemaConnection) then runSchema schemaConnection with | :? SqliteException as ex when ex.SqliteErrorCode = 26 -> recreate <- true loopCount <- loopCount + 1 logTrace $"Local state DB schema check attempt {loopCount} for {normalizedPath}" try use connection = openConnection normalizedPath try ensureJournalMode connection match tryGetMetaValue connection "schema_version" with | Some version when version = SchemaVersion -> () | Some _ -> recreate <- true | None -> logTrace "meta schema_version missing; writing defaults" let createdAtTicks = getCurrentInstant().ToUnixTimeTicks() setMetaValue connection "schema_version" SchemaVersion setMetaValue connection "created_at_unix_ticks" $"{createdAtTicks}" if not recreate then logTrace "status_meta ensuring default row" insertStatusMetaIfMissing connection with | :? SqliteException as ex when ex.SqliteErrorCode = 26 -> recreate <- true with | :? SqliteException as ex when ex.SqliteErrorCode = 26 -> recreate <- true if recreate then logVerbose $"Local state DB schema mismatch or corruption detected. Recreating {normalizedPath}." logTrace "recreateDatabase triggered" recreateDatabase normalizedPath do use schemaConnection = openConnection normalizedPath runSchema schemaConnection use connection = openConnection normalizedPath ensureJournalMode connection setMetaValue connection "schema_version" SchemaVersion setMetaValue connection "created_at_unix_ticks" $"{getCurrentInstant().ToUnixTimeTicks()}" logTrace "status_meta ensuring default row" insertStatusMetaIfMissing connection }) initializedDbs[normalizedPath] <- true finally semaphore.Release() |> ignore } type StatusMeta = { RootDirectoryId: DirectoryVersionId RootDirectorySha256Hash: Sha256Hash LastSuccessfulFileUpload: Instant LastSuccessfulDirectoryVersionUpload: Instant } let private readStatusMetaInternal (connection: SqliteConnection) = use cmd = connection.CreateCommand() cmd.CommandText <- "SELECT root_directory_version_id, root_directory_sha256_hash, last_successful_file_upload_unix_ticks, last_successful_directory_version_upload_unix_ticks FROM status_meta WHERE id = 1;" use reader = cmd.ExecuteReader() if reader.Read() then let rootId = Guid.Parse(reader.GetString(0)) let rootHash = reader.GetString(1) let lastFile = Instant.FromUnixTimeTicks(reader.GetInt64(2)) let lastDir = Instant.FromUnixTimeTicks(reader.GetInt64(3)) { RootDirectoryId = rootId RootDirectorySha256Hash = rootHash LastSuccessfulFileUpload = lastFile LastSuccessfulDirectoryVersionUpload = lastDir } |> Some else None let readStatusMeta (dbPath: string) = task { do! ensureDbInitialized dbPath let connection = openConnection dbPath try match readStatusMetaInternal connection with | Some meta -> return meta | None -> let defaultStatus = GraceStatus.Default return { RootDirectoryId = defaultStatus.RootDirectoryId RootDirectorySha256Hash = defaultStatus.RootDirectorySha256Hash LastSuccessfulFileUpload = defaultStatus.LastSuccessfulFileUpload LastSuccessfulDirectoryVersionUpload = defaultStatus.LastSuccessfulDirectoryVersionUpload } finally connection.Dispose() } let private setStatusMeta (connection: SqliteConnection) (graceStatus: GraceStatus) = executeNonQueryWithParams connection "INSERT OR REPLACE INTO status_meta (id, root_directory_version_id, root_directory_sha256_hash, last_successful_file_upload_unix_ticks, last_successful_directory_version_upload_unix_ticks) VALUES (1, $root_id, $root_hash, $last_file, $last_dir);" (fun parameters -> parameters.AddWithValue("$root_id", graceStatus.RootDirectoryId.ToString()) |> ignore parameters.AddWithValue("$root_hash", graceStatus.RootDirectorySha256Hash) |> ignore parameters.AddWithValue("$last_file", graceStatus.LastSuccessfulFileUpload.ToUnixTimeTicks()) |> ignore parameters.AddWithValue("$last_dir", graceStatus.LastSuccessfulDirectoryVersionUpload.ToUnixTimeTicks()) |> ignore) let replaceStatusSnapshot (dbPath: string) (graceStatus: GraceStatus) = task { do! ensureDbInitialized dbPath return! executeWithRetry (fun () -> task { let connection = openConnection dbPath try executeNonQuery connection "BEGIN IMMEDIATE;" try executeNonQuery connection "DELETE FROM status_directories;" executeNonQuery connection "DELETE FROM status_files;" setStatusMeta connection graceStatus use directoryCommand = connection.CreateCommand() directoryCommand.CommandText <- "INSERT OR REPLACE INTO status_directories (relative_path, parent_path, directory_version_id, sha256_hash, size_bytes, created_at_unix_ticks, last_write_time_utc_ticks) VALUES ($relative_path, $parent_path, $directory_version_id, $sha256_hash, $size_bytes, $created_at, $last_write);" directoryCommand.Parameters.Add("$relative_path", SqliteType.Text) |> ignore directoryCommand.Parameters.Add("$parent_path", SqliteType.Text) |> ignore directoryCommand.Parameters.Add("$directory_version_id", SqliteType.Text) |> ignore directoryCommand.Parameters.Add("$sha256_hash", SqliteType.Text) |> ignore directoryCommand.Parameters.Add("$size_bytes", SqliteType.Integer) |> ignore directoryCommand.Parameters.Add("$created_at", SqliteType.Integer) |> ignore directoryCommand.Parameters.Add("$last_write", SqliteType.Integer) |> ignore use fileCommand = connection.CreateCommand() fileCommand.CommandText <- "INSERT OR REPLACE INTO status_files (relative_path, directory_path, directory_version_id, sha256_hash, is_binary, size_bytes, created_at_unix_ticks, uploaded_to_object_storage, last_write_time_utc_ticks) VALUES ($relative_path, $directory_path, $directory_version_id, $sha256_hash, $is_binary, $size_bytes, $created_at, $uploaded, $last_write);" fileCommand.Parameters.Add("$relative_path", SqliteType.Text) |> ignore fileCommand.Parameters.Add("$directory_path", SqliteType.Text) |> ignore fileCommand.Parameters.Add("$directory_version_id", SqliteType.Text) |> ignore fileCommand.Parameters.Add("$sha256_hash", SqliteType.Text) |> ignore fileCommand.Parameters.Add("$is_binary", SqliteType.Integer) |> ignore fileCommand.Parameters.Add("$size_bytes", SqliteType.Integer) |> ignore fileCommand.Parameters.Add("$created_at", SqliteType.Integer) |> ignore fileCommand.Parameters.Add("$uploaded", SqliteType.Integer) |> ignore fileCommand.Parameters.Add("$last_write", SqliteType.Integer) |> ignore graceStatus.Index.Values |> Seq.iter (fun directory -> let parentPath = match getParentPath directory.RelativePath with | Some path -> path | None -> String.Empty directoryCommand.Parameters["$relative_path"].Value <- directory.RelativePath directoryCommand.Parameters["$parent_path"].Value <- parentPath directoryCommand.Parameters["$directory_version_id"].Value <- directory.DirectoryVersionId.ToString() directoryCommand.Parameters["$sha256_hash"].Value <- directory.Sha256Hash directoryCommand.Parameters["$size_bytes"].Value <- directory.Size directoryCommand.Parameters["$created_at"].Value <- directory.CreatedAt.ToUnixTimeTicks() directoryCommand.Parameters["$last_write"].Value <- directory.LastWriteTimeUtc.Ticks directoryCommand.ExecuteNonQuery() |> ignore directory.Files |> Seq.iter (fun file -> fileCommand.Parameters["$relative_path"].Value <- file.RelativePath fileCommand.Parameters["$directory_path"].Value <- directory.RelativePath fileCommand.Parameters["$directory_version_id"].Value <- directory.DirectoryVersionId.ToString() fileCommand.Parameters["$sha256_hash"].Value <- file.Sha256Hash fileCommand.Parameters["$is_binary"].Value <- if file.IsBinary then 1 else 0 fileCommand.Parameters["$size_bytes"].Value <- file.Size fileCommand.Parameters["$created_at"].Value <- file.CreatedAt.ToUnixTimeTicks() fileCommand.Parameters["$uploaded"].Value <- if file.UploadedToObjectStorage then 1 else 0 fileCommand.Parameters["$last_write"].Value <- file.LastWriteTimeUtc.Ticks fileCommand.ExecuteNonQuery() |> ignore)) executeNonQuery connection "COMMIT;" with | ex -> executeNonQuery connection "ROLLBACK;" return raise ex finally connection.Dispose() }) } let upsertObjectCache (dbPath: string) (newDirectoryVersions: IEnumerable) = task { do! ensureDbInitialized dbPath let directoriesToUpsert = newDirectoryVersions |> Seq.toArray return! executeWithRetry (fun () -> task { let connection = openConnection dbPath try executeNonQuery connection "BEGIN IMMEDIATE;" try let knownDirectoryIds = HashSet(StringComparer.OrdinalIgnoreCase) use knownDirectoryIdsCommand = connection.CreateCommand() knownDirectoryIdsCommand.CommandText <- "SELECT directory_version_id FROM object_cache_directories;" use knownDirectoryIdsReader = knownDirectoryIdsCommand.ExecuteReader() while knownDirectoryIdsReader.Read() do knownDirectoryIds.Add(knownDirectoryIdsReader.GetString(0)) |> ignore use directoryCommand = connection.CreateCommand() directoryCommand.CommandText <- "INSERT INTO object_cache_directories (directory_version_id, relative_path, sha256_hash, size_bytes, created_at_unix_ticks, last_write_time_utc_ticks) VALUES ($directory_version_id, $relative_path, $sha256_hash, $size_bytes, $created_at, $last_write) ON CONFLICT(directory_version_id) DO UPDATE SET relative_path = excluded.relative_path, sha256_hash = excluded.sha256_hash, size_bytes = excluded.size_bytes, created_at_unix_ticks = excluded.created_at_unix_ticks, last_write_time_utc_ticks = excluded.last_write_time_utc_ticks;" directoryCommand.Parameters.Add("$directory_version_id", SqliteType.Text) |> ignore directoryCommand.Parameters.Add("$relative_path", SqliteType.Text) |> ignore directoryCommand.Parameters.Add("$sha256_hash", SqliteType.Text) |> ignore directoryCommand.Parameters.Add("$size_bytes", SqliteType.Integer) |> ignore directoryCommand.Parameters.Add("$created_at", SqliteType.Integer) |> ignore directoryCommand.Parameters.Add("$last_write", SqliteType.Integer) |> ignore use deleteChildrenCommand = connection.CreateCommand() deleteChildrenCommand.CommandText <- "DELETE FROM object_cache_directory_children WHERE parent_directory_version_id = $parent_directory_version_id;" deleteChildrenCommand.Parameters.Add("$parent_directory_version_id", SqliteType.Text) |> ignore use insertChildCommand = connection.CreateCommand() insertChildCommand.CommandText <- "INSERT INTO object_cache_directory_children (parent_directory_version_id, child_directory_version_id, ordinal) VALUES ($parent_directory_version_id, $child_directory_version_id, $ordinal) ON CONFLICT(parent_directory_version_id, child_directory_version_id) DO UPDATE SET ordinal = excluded.ordinal;" insertChildCommand.Parameters.Add("$parent_directory_version_id", SqliteType.Text) |> ignore insertChildCommand.Parameters.Add("$child_directory_version_id", SqliteType.Text) |> ignore insertChildCommand.Parameters.Add("$ordinal", SqliteType.Integer) |> ignore use deleteFilesCommand = connection.CreateCommand() deleteFilesCommand.CommandText <- "DELETE FROM object_cache_directory_files WHERE directory_version_id = $directory_version_id;" deleteFilesCommand.Parameters.Add("$directory_version_id", SqliteType.Text) |> ignore use insertFileCommand = connection.CreateCommand() insertFileCommand.CommandText <- "INSERT INTO object_cache_directory_files (directory_version_id, relative_path, sha256_hash, is_binary, size_bytes, created_at_unix_ticks, uploaded_to_object_storage, last_write_time_utc_ticks) VALUES ($directory_version_id, $relative_path, $sha256_hash, $is_binary, $size_bytes, $created_at, $uploaded, $last_write) ON CONFLICT(directory_version_id, relative_path) DO UPDATE SET sha256_hash = excluded.sha256_hash, is_binary = excluded.is_binary, size_bytes = excluded.size_bytes, created_at_unix_ticks = excluded.created_at_unix_ticks, uploaded_to_object_storage = excluded.uploaded_to_object_storage, last_write_time_utc_ticks = excluded.last_write_time_utc_ticks;" insertFileCommand.Parameters.Add("$directory_version_id", SqliteType.Text) |> ignore insertFileCommand.Parameters.Add("$relative_path", SqliteType.Text) |> ignore insertFileCommand.Parameters.Add("$sha256_hash", SqliteType.Text) |> ignore insertFileCommand.Parameters.Add("$is_binary", SqliteType.Integer) |> ignore insertFileCommand.Parameters.Add("$size_bytes", SqliteType.Integer) |> ignore insertFileCommand.Parameters.Add("$created_at", SqliteType.Integer) |> ignore insertFileCommand.Parameters.Add("$uploaded", SqliteType.Integer) |> ignore insertFileCommand.Parameters.Add("$last_write", SqliteType.Integer) |> ignore // Pass 1: Ensure all directory rows exist before adding any FK-dependent rows. directoriesToUpsert |> Seq.iter (fun directory -> let directoryVersionId = directory.DirectoryVersionId.ToString() directoryCommand.Parameters["$directory_version_id"].Value <- directoryVersionId directoryCommand.Parameters["$relative_path"].Value <- directory.RelativePath directoryCommand.Parameters["$sha256_hash"].Value <- directory.Sha256Hash directoryCommand.Parameters["$size_bytes"].Value <- directory.Size directoryCommand.Parameters["$created_at"].Value <- directory.CreatedAt.ToUnixTimeTicks() directoryCommand.Parameters["$last_write"].Value <- directory.LastWriteTimeUtc.Ticks directoryCommand.ExecuteNonQuery() |> ignore knownDirectoryIds.Add(directoryVersionId) |> ignore) // Pass 2: Refresh child and file links for each upserted directory. directoriesToUpsert |> Seq.iter (fun directory -> deleteChildrenCommand.Parameters["$parent_directory_version_id"].Value <- directory.DirectoryVersionId.ToString() deleteChildrenCommand.ExecuteNonQuery() |> ignore deleteFilesCommand.Parameters["$directory_version_id"].Value <- directory.DirectoryVersionId.ToString() deleteFilesCommand.ExecuteNonQuery() |> ignore directory.Directories |> Seq.iteri (fun index childId -> let childDirectoryVersionId = childId.ToString() if knownDirectoryIds.Contains(childDirectoryVersionId) then insertChildCommand.Parameters["$parent_directory_version_id"].Value <- directory.DirectoryVersionId.ToString() insertChildCommand.Parameters["$child_directory_version_id"].Value <- childDirectoryVersionId insertChildCommand.Parameters["$ordinal"].Value <- index insertChildCommand.ExecuteNonQuery() |> ignore else invalidOp $"Cannot upsert object cache because child DirectoryVersionId {childDirectoryVersionId} is missing. Parent DirectoryVersionId: {directory.DirectoryVersionId}." ) directory.Files |> Seq.iter (fun file -> insertFileCommand.Parameters["$directory_version_id"].Value <- directory.DirectoryVersionId.ToString() insertFileCommand.Parameters["$relative_path"].Value <- file.RelativePath insertFileCommand.Parameters["$sha256_hash"].Value <- file.Sha256Hash insertFileCommand.Parameters["$is_binary"].Value <- if file.IsBinary then 1 else 0 insertFileCommand.Parameters["$size_bytes"].Value <- file.Size insertFileCommand.Parameters["$created_at"].Value <- file.CreatedAt.ToUnixTimeTicks() insertFileCommand.Parameters["$uploaded"].Value <- if file.UploadedToObjectStorage then 1 else 0 insertFileCommand.Parameters["$last_write"].Value <- file.LastWriteTimeUtc.Ticks insertFileCommand.ExecuteNonQuery() |> ignore)) executeNonQuery connection "COMMIT;" with | ex -> executeNonQuery connection "ROLLBACK;" return raise ex finally connection.Dispose() }) } let isFileVersionInObjectCache (dbPath: string) (fileVersion: LocalFileVersion) = task { do! ensureDbInitialized dbPath let connection = openConnection dbPath try use cmd = connection.CreateCommand() cmd.CommandText <- "SELECT 1 FROM object_cache_directory_files WHERE relative_path = $relative_path AND sha256_hash = $sha256_hash LIMIT 1;" cmd.Parameters.AddWithValue("$relative_path", fileVersion.RelativePath) |> ignore cmd.Parameters.AddWithValue("$sha256_hash", fileVersion.Sha256Hash) |> ignore use reader = cmd.ExecuteReader() return reader.Read() finally connection.Dispose() } let isDirectoryVersionInObjectCache (dbPath: string) (directoryVersionId: DirectoryVersionId) = task { do! ensureDbInitialized dbPath let connection = openConnection dbPath try use cmd = connection.CreateCommand() cmd.CommandText <- "SELECT 1 FROM object_cache_directories WHERE directory_version_id = $id LIMIT 1;" cmd.Parameters.AddWithValue("$id", directoryVersionId.ToString()) |> ignore use reader = cmd.ExecuteReader() return reader.Read() finally connection.Dispose() } let removeObjectCacheDirectory (dbPath: string) (directoryVersionId: DirectoryVersionId) = task { do! ensureDbInitialized dbPath return! executeWithRetry (fun () -> task { let connection = openConnection dbPath try executeNonQuery connection "BEGIN IMMEDIATE;" try use cmd = connection.CreateCommand() cmd.CommandText <- "DELETE FROM object_cache_directories WHERE directory_version_id = $id;" cmd.Parameters.AddWithValue("$id", directoryVersionId.ToString()) |> ignore cmd.ExecuteNonQuery() |> ignore executeNonQuery connection "COMMIT;" with | ex -> executeNonQuery connection "ROLLBACK;" return raise ex finally connection.Dispose() }) } let applyStatusIncremental (dbPath: string) (newGraceStatus: GraceStatus) (newDirectoryVersions: IEnumerable) (differences: IEnumerable) = task { do! ensureDbInitialized dbPath return! executeWithRetry (fun () -> task { let connection = openConnection dbPath try executeNonQuery connection "BEGIN IMMEDIATE;" try setStatusMeta connection newGraceStatus use directoryCommand = connection.CreateCommand() directoryCommand.CommandText <- "INSERT OR REPLACE INTO status_directories (relative_path, parent_path, directory_version_id, sha256_hash, size_bytes, created_at_unix_ticks, last_write_time_utc_ticks) VALUES ($relative_path, $parent_path, $directory_version_id, $sha256_hash, $size_bytes, $created_at, $last_write);" directoryCommand.Parameters.Add("$relative_path", SqliteType.Text) |> ignore directoryCommand.Parameters.Add("$parent_path", SqliteType.Text) |> ignore directoryCommand.Parameters.Add("$directory_version_id", SqliteType.Text) |> ignore directoryCommand.Parameters.Add("$sha256_hash", SqliteType.Text) |> ignore directoryCommand.Parameters.Add("$size_bytes", SqliteType.Integer) |> ignore directoryCommand.Parameters.Add("$created_at", SqliteType.Integer) |> ignore directoryCommand.Parameters.Add("$last_write", SqliteType.Integer) |> ignore newDirectoryVersions |> Seq.iter (fun directory -> let parentPath = match getParentPath directory.RelativePath with | Some path -> path | None -> String.Empty directoryCommand.Parameters["$relative_path"].Value <- directory.RelativePath directoryCommand.Parameters["$parent_path"].Value <- parentPath directoryCommand.Parameters["$directory_version_id"].Value <- directory.DirectoryVersionId.ToString() directoryCommand.Parameters["$sha256_hash"].Value <- directory.Sha256Hash directoryCommand.Parameters["$size_bytes"].Value <- directory.Size directoryCommand.Parameters["$created_at"].Value <- directory.CreatedAt.ToUnixTimeTicks() directoryCommand.Parameters["$last_write"].Value <- directory.LastWriteTimeUtc.Ticks directoryCommand.ExecuteNonQuery() |> ignore) use fileUpsertCommand = connection.CreateCommand() fileUpsertCommand.CommandText <- "INSERT OR REPLACE INTO status_files (relative_path, directory_path, directory_version_id, sha256_hash, is_binary, size_bytes, created_at_unix_ticks, uploaded_to_object_storage, last_write_time_utc_ticks) VALUES ($relative_path, $directory_path, $directory_version_id, $sha256_hash, $is_binary, $size_bytes, $created_at, $uploaded, $last_write);" fileUpsertCommand.Parameters.Add("$relative_path", SqliteType.Text) |> ignore fileUpsertCommand.Parameters.Add("$directory_path", SqliteType.Text) |> ignore fileUpsertCommand.Parameters.Add("$directory_version_id", SqliteType.Text) |> ignore fileUpsertCommand.Parameters.Add("$sha256_hash", SqliteType.Text) |> ignore fileUpsertCommand.Parameters.Add("$is_binary", SqliteType.Integer) |> ignore fileUpsertCommand.Parameters.Add("$size_bytes", SqliteType.Integer) |> ignore fileUpsertCommand.Parameters.Add("$created_at", SqliteType.Integer) |> ignore fileUpsertCommand.Parameters.Add("$uploaded", SqliteType.Integer) |> ignore fileUpsertCommand.Parameters.Add("$last_write", SqliteType.Integer) |> ignore use fileDeleteCommand = connection.CreateCommand() fileDeleteCommand.CommandText <- "DELETE FROM status_files WHERE relative_path = $relative_path;" fileDeleteCommand.Parameters.Add("$relative_path", SqliteType.Text) |> ignore use directoryDeleteCommand = connection.CreateCommand() directoryDeleteCommand.CommandText <- "DELETE FROM status_directories WHERE relative_path = $relative_path;" directoryDeleteCommand.Parameters.Add("$relative_path", SqliteType.Text) |> ignore // Upsert every file in each changed/new directory version. This keeps unchanged sibling files // attached to the new directory_version_id when a directory row is replaced. newDirectoryVersions |> Seq.collect (fun directory -> directory.Files |> Seq.map (fun file -> (file, directory))) |> Seq.iter (fun (file, directory) -> fileUpsertCommand.Parameters["$relative_path"].Value <- file.RelativePath fileUpsertCommand.Parameters["$directory_path"].Value <- directory.RelativePath fileUpsertCommand.Parameters["$directory_version_id"].Value <- directory.DirectoryVersionId.ToString() fileUpsertCommand.Parameters["$sha256_hash"].Value <- file.Sha256Hash fileUpsertCommand.Parameters["$is_binary"].Value <- if file.IsBinary then 1 else 0 fileUpsertCommand.Parameters["$size_bytes"].Value <- file.Size fileUpsertCommand.Parameters["$created_at"].Value <- file.CreatedAt.ToUnixTimeTicks() fileUpsertCommand.Parameters["$uploaded"].Value <- if file.UploadedToObjectStorage then 1 else 0 fileUpsertCommand.Parameters["$last_write"].Value <- file.LastWriteTimeUtc.Ticks fileUpsertCommand.ExecuteNonQuery() |> ignore) differences |> Seq.iter (fun difference -> if difference.DifferenceType = Delete then if difference.FileSystemEntryType.IsFile then fileDeleteCommand.Parameters["$relative_path"].Value <- difference.RelativePath fileDeleteCommand.ExecuteNonQuery() |> ignore else directoryDeleteCommand.Parameters["$relative_path"].Value <- difference.RelativePath directoryDeleteCommand.ExecuteNonQuery() |> ignore) executeNonQuery connection "COMMIT;" with | ex -> executeNonQuery connection "ROLLBACK;" return raise ex finally connection.Dispose() }) } type private StatusDirectoryRow = { RelativePath: string ParentPath: string DirectoryVersionId: DirectoryVersionId Sha256Hash: Sha256Hash SizeBytes: int64 CreatedAt: Instant LastWriteTimeUtc: DateTime } type private StatusFileRow = { RelativePath: string DirectoryVersionId: DirectoryVersionId Sha256Hash: Sha256Hash IsBinary: bool SizeBytes: int64 CreatedAt: Instant UploadedToObjectStorage: bool LastWriteTimeUtc: DateTime } let readStatusSnapshot (dbPath: string) = task { do! ensureDbInitialized dbPath let connection = openConnection dbPath try let meta: StatusMeta = match readStatusMetaInternal connection with | Some value -> value | None -> let defaultStatus = GraceStatus.Default { RootDirectoryId = defaultStatus.RootDirectoryId RootDirectorySha256Hash = defaultStatus.RootDirectorySha256Hash LastSuccessfulFileUpload = defaultStatus.LastSuccessfulFileUpload LastSuccessfulDirectoryVersionUpload = defaultStatus.LastSuccessfulDirectoryVersionUpload } let directories = List() let files = List() use directoryCommand = connection.CreateCommand() directoryCommand.CommandText <- "SELECT relative_path, parent_path, directory_version_id, sha256_hash, size_bytes, created_at_unix_ticks, last_write_time_utc_ticks FROM status_directories;" use directoryReader = directoryCommand.ExecuteReader() while directoryReader.Read() do let relativePath = directoryReader.GetString(0) let parentPath = directoryReader.GetString(1) let directoryVersionId = Guid.Parse(directoryReader.GetString(2)) let sha256Hash = directoryReader.GetString(3) let sizeBytes = directoryReader.GetInt64(4) let createdAt = Instant.FromUnixTimeTicks(directoryReader.GetInt64(5)) let lastWriteTimeUtc = DateTime(directoryReader.GetInt64(6), DateTimeKind.Utc) directories.Add( { RelativePath = relativePath ParentPath = parentPath DirectoryVersionId = directoryVersionId Sha256Hash = sha256Hash SizeBytes = sizeBytes CreatedAt = createdAt LastWriteTimeUtc = lastWriteTimeUtc } ) use fileCommand = connection.CreateCommand() fileCommand.CommandText <- "SELECT relative_path, directory_version_id, sha256_hash, is_binary, size_bytes, created_at_unix_ticks, uploaded_to_object_storage, last_write_time_utc_ticks FROM status_files;" use fileReader = fileCommand.ExecuteReader() while fileReader.Read() do let relativePath = fileReader.GetString(0) let directoryVersionId = Guid.Parse(fileReader.GetString(1)) let sha256Hash = fileReader.GetString(2) let isBinary = fileReader.GetInt64(3) = 1L let sizeBytes = fileReader.GetInt64(4) let createdAt = Instant.FromUnixTimeTicks(fileReader.GetInt64(5)) let uploaded = fileReader.GetInt64(6) = 1L let lastWriteTimeUtc = DateTime(fileReader.GetInt64(7), DateTimeKind.Utc) files.Add( { RelativePath = relativePath DirectoryVersionId = directoryVersionId Sha256Hash = sha256Hash IsBinary = isBinary SizeBytes = sizeBytes CreatedAt = createdAt UploadedToObjectStorage = uploaded LastWriteTimeUtc = lastWriteTimeUtc } ) let directoriesByParent = Dictionary>() let filesByDirectory = Dictionary>() directories |> Seq.iter (fun directory -> let parentPath = directory.ParentPath let mutable existing = Unchecked.defaultof> if directoriesByParent.TryGetValue(parentPath, &existing) then existing.Add(directory.DirectoryVersionId) else directoriesByParent.Add(parentPath, List([ directory.DirectoryVersionId ]))) files |> Seq.iter (fun file -> let localFile = LocalFileVersion.Create file.RelativePath file.Sha256Hash file.IsBinary file.SizeBytes file.CreatedAt file.UploadedToObjectStorage file.LastWriteTimeUtc let mutable existing = Unchecked.defaultof> if filesByDirectory.TryGetValue(file.DirectoryVersionId, &existing) then existing.Add(localFile) else filesByDirectory.Add(file.DirectoryVersionId, List([ localFile ]))) let index = GraceIndex() directories |> Seq.iter (fun directory -> let directoriesForPath = let mutable list = Unchecked.defaultof> if directoriesByParent.TryGetValue(directory.RelativePath, &list) then list else List() let filesForPath = let mutable list = Unchecked.defaultof> if filesByDirectory.TryGetValue(directory.DirectoryVersionId, &list) then list else List() let localDirectory = LocalDirectoryVersion.Create directory.DirectoryVersionId (Current().OwnerId) (Current().OrganizationId) (Current().RepositoryId) directory.RelativePath directory.Sha256Hash directoriesForPath filesForPath directory.SizeBytes directory.LastWriteTimeUtc index.TryAdd(directory.DirectoryVersionId, localDirectory) |> ignore) return { Index = index RootDirectoryId = meta.RootDirectoryId RootDirectorySha256Hash = meta.RootDirectorySha256Hash LastSuccessfulFileUpload = meta.LastSuccessfulFileUpload LastSuccessfulDirectoryVersionUpload = meta.LastSuccessfulDirectoryVersionUpload } finally connection.Dispose() } ================================================ FILE: src/Grace.CLI/Log.CLI.fs ================================================ namespace Grace.CLI open Grace.Shared open Grace.Types.Types open Grace.Shared.Utilities open NodaTime open System open System.Globalization open System.Collections.Concurrent module Log = type LogLevel = | Verbose | Informational | Error let Log (level: LogLevel) (message: string) = let pattern = "uuuu'-'MM'-'dd'T'HH':'mm':'ss.fff" printfn $"({getCurrentInstantExtended ()} {Utilities.getDiscriminatedUnionFullName level} {message}" () let LogInformational (message: string) = Log LogLevel.Informational message let LogError (message: string) = Log LogLevel.Error message let LogVerbose (message: string) = Log LogLevel.Verbose message ================================================ FILE: src/Grace.CLI/Program.CLI.fs ================================================ namespace Grace.CLI open Grace.CLI.Command open Grace.CLI.Common open Grace.CLI.Services open Grace.CLI.Text open Grace.Shared open Grace.Shared.Client.Configuration open Grace.Shared.Converters open Grace.Shared.Resources.Text open Grace.Shared.Resources.Utilities open Grace.Types.Types open Grace.Shared.Utilities open Grace.Shared.Validation open Microsoft.Extensions.Logging open NodaTime open NodaTime.Text open Spectre.Console open System open System.Collections open System.Collections.Generic open System.CommandLine open System.CommandLine.Help open System.CommandLine.Parsing open System.Diagnostics open System.Globalization open System.IO open System.Linq open System.Text.RegularExpressions open System.Threading.Tasks open Microsoft.Extensions.Caching.Memory open System.CommandLine.Help open FSharpPlus.Control open System.CommandLine.Invocation module Configuration = type GraceCLIConfiguration = { GraceWatchStatus: GraceWatchStatus } let mutable private cliConfiguration = { GraceWatchStatus = GraceWatchStatus.Default } let CLIConfiguration () = cliConfiguration let updateConfiguration (config: GraceCLIConfiguration) = cliConfiguration <- config module GraceCommand = type OptionToUpdate = { optionAlias: string; display: string; displayOnCreate: string; createParentCommand: string } /// Built-in aliases for Grace commands. let private aliases = let aliases = Dictionary() aliases.Add("aliases", [ "alias"; "list" ]) aliases.Add("branches", [ "repository"; "get-branches" ]) aliases.Add("checkpoint", [ "branch"; "checkpoint" ]) aliases.Add("checkpoints", [ "branch"; "get-checkpoints" ]) aliases.Add("commit", [ "branch"; "commit" ]) aliases.Add("commits", [ "branch"; "get-commits" ]) aliases.Add("dir", [ "maint"; "list-contents" ]) aliases.Add("ls", [ "maint"; "list-contents" ]) aliases.Add("promote", [ "branch"; "promote" ]) aliases.Add("promotions", [ "branch"; "get-promotions" ]) aliases.Add("rebase", [ "branch"; "rebase" ]) aliases.Add("refs", [ "branch"; "get-references" ]) aliases.Add("save", [ "branch"; "save" ]) aliases.Add("saves", [ "branch"; "get-saves" ]) aliases.Add("sdir", [ "branch"; "list-contents" ]) aliases.Add("sls", [ "branch"; "list-contents" ]) aliases.Add("status", [ "branch"; "status" ]) aliases.Add("switch", [ "branch"; "switch" ]) aliases.Add("tag", [ "branch"; "tag" ]) aliases.Add("tags", [ "branch"; "get-tags" ]) //aliases.Add("", [""; ""]) aliases /// The character sequences that Grace will recognize as a request for help. let helpOptions = [| "-h"; "/h"; "--help"; "-?"; "/?" |] /// Prints the aliases for Grace commands. let printAliases () = let table = Table(Border = TableBorder.DoubleEdge) table .LeftAligned() .AddColumns( [| TableColumn($"[{Colors.Important}]Alias[/]") TableColumn($"[{Colors.Important}]Grace command[/]") |] ) |> ignore aliases |> Seq.iter (fun alias -> table.AddRow($"grace {alias.Key}", $"grace {alias.Value.First()} {alias.Value.Last()}") |> ignore) AnsiConsole.Write(table) let internal tryGetTopLevelCommandFromArgs (args: string array) (isCaseInsensitive: bool) = if isNull args || args.Length = 0 then None else let comparison = if isCaseInsensitive then StringComparison.InvariantCultureIgnoreCase else StringComparison.InvariantCulture let isOptionWithValue (token: string) = token.Equals(OptionName.Output, comparison) || token.Equals("-o", comparison) || token.Equals(OptionName.CorrelationId, comparison) || token.Equals("-c", comparison) || token.Equals(OptionName.Source, comparison) let rec loop index = if index >= args.Length then None else let token = args[index] if token = "--" then if index + 1 < args.Length then Some args[index + 1] else None elif token.StartsWith("-", StringComparison.Ordinal) then let nextIndex = if isOptionWithValue token then index + 2 else index + 1 loop nextIndex else Some token loop 0 /// Gathers the available options for the current command and all its parents, which are applied hierarchically. [] let rec gatherAllOptions (command: Command) (allOptions: List