workflows/default/systems/ui/modules/DecisionAPI.psm1
|
<# .SYNOPSIS Decision Record API module .DESCRIPTION Provides decision listing, retrieval, creation, status transitions, and updates. Decisions are stored as JSON files in status-based subdirectories. #> $script:Config = @{ BotRoot = $null } function Initialize-DecisionAPI { param( [Parameter(Mandatory)] [string]$BotRoot ) $script:Config.BotRoot = $BotRoot } function Get-DecisionsBaseDir { return (Join-Path $script:Config.BotRoot "workspace\decisions") } function Test-DecisionIdFormat([string]$Id) { return $Id -match '^dec-[a-f0-9]{8}$' } function Assert-ValidRelatedDecisions { param([array]$Items) $valid = @() foreach ($item in $Items) { if ($item -and $item -match '^dec-[a-f0-9]{8}$') { $valid += $item } } return $valid } function Find-DecisionFile { param([string]$DecisionId, [string[]]$Statuses) if (-not (Test-DecisionIdFormat $DecisionId)) { return $null } $base = Get-DecisionsBaseDir foreach ($s in $Statuses) { $dir = Join-Path $base $s if (-not (Test-Path $dir)) { continue } $files = Get-ChildItem -LiteralPath $dir -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -like "$DecisionId-*.json" -or $_.Name -eq "$DecisionId.json" } if ($files.Count -gt 0) { return @{ file = @($files)[0]; status = $s } } } return $null } # ── List ────────────────────────────────────────────────────────────────────── function Get-DecisionList { param([string]$StatusFilter) $base = Get-DecisionsBaseDir $allStatuses = @('proposed', 'accepted', 'deprecated', 'superseded') if ($StatusFilter -and $StatusFilter -notin $allStatuses) { return @{ _statusCode = 400; success = $false; error = "Invalid status filter '$StatusFilter'. Must be one of: $($allStatuses -join ', ')" } } $searchDirs = if ($StatusFilter) { @($StatusFilter) } else { $allStatuses } $decisions = @() foreach ($s in $searchDirs) { $dir = Join-Path $base $s if (-not (Test-Path $dir)) { continue } $files = Get-ChildItem -Path $dir -Filter "dec-*.json" -File -ErrorAction SilentlyContinue foreach ($f in $files) { try { $dec = Get-Content -Path $f.FullName -Raw | ConvertFrom-Json $decisions += @{ id = $dec.id title = $dec.title type = $dec.type status = $dec.status date = $dec.date impact = $dec.impact stakeholders = @($dec.stakeholders) tags = @($dec.tags) superseded_by = $dec.superseded_by related_decision_ids = @($dec.related_decision_ids) file_name = $f.Name } } catch { Write-BotLog -Level Debug -Message "Decision operation failed" -Exception $_ } } } $decisions = @($decisions | Sort-Object { $_.id }) return @{ success = $true; count = $decisions.Count; decisions = $decisions } } # ── Get ─────────────────────────────────────────────────────────────────────── function Get-DecisionDetail { param([string]$DecisionId) $found = Find-DecisionFile -DecisionId $DecisionId -Statuses @('proposed', 'accepted', 'deprecated', 'superseded') if (-not $found) { return @{ _statusCode = 404; success = $false; error = "Decision '$DecisionId' not found" } } $dec = Get-Content -Path $found.file.FullName -Raw | ConvertFrom-Json $result = @{ success = $true } foreach ($prop in $dec.PSObject.Properties) { $result[$prop.Name] = $prop.Value } return $result } # ── Create ──────────────────────────────────────────────────────────────────── function New-Decision { param([hashtable]$Body) $title = $Body['title'] $context = $Body['context'] $decisionText = $Body['decision'] if (-not $title -or -not $context -or -not $decisionText) { return @{ _statusCode = 400; success = $false; error = "title, context, and decision are required" } } $type = $Body['type'] ?? 'technical' $status = $Body['status'] ?? 'proposed' $impact = $Body['impact'] ?? 'medium' $validTypes = @('architecture', 'business', 'technical', 'process') $validStatuses = @('proposed', 'accepted') $validImpacts = @('high', 'medium', 'low') if ($type -notin $validTypes) { return @{ _statusCode = 400; success = $false; error = "type must be one of: $($validTypes -join ', ')" } } if ($status -notin $validStatuses) { return @{ _statusCode = 400; success = $false; error = "status must be proposed or accepted" } } if ($impact -notin $validImpacts) { return @{ _statusCode = 400; success = $false; error = "impact must be one of: $($validImpacts -join ', ')" } } $id = "dec-" + ([guid]::NewGuid().ToString('N').Substring(0, 8)) $slug = ($title -replace '[^\w\s-]', '' -replace '\s+', '-').ToLower() if ($slug.Length -gt 60) { $slug = $slug.Substring(0, 60).TrimEnd('-') } $date = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd") $stakeholders = @($Body['stakeholders'] | Where-Object { $_ }) $tags = @($Body['tags'] | Where-Object { $_ }) $relatedTaskIds = @($Body['related_task_ids'] | Where-Object { $_ }) $relatedDecisionIds = Assert-ValidRelatedDecisions @($Body['related_decision_ids'] | Where-Object { $_ }) $alternatives = @() if ($Body['alternatives_considered']) { foreach ($alt in $Body['alternatives_considered']) { if ($alt -is [hashtable] -or $alt -is [System.Collections.IDictionary]) { $alternatives += @{ option = "$($alt['option'])"; reason_rejected = "$($alt['reason_rejected'])" } } elseif ($alt.PSObject -and $alt.option) { $alternatives += @{ option = "$($alt.option)"; reason_rejected = "$($alt.reason_rejected)" } } } } $dec = [ordered]@{ id = $id title = $title type = $type status = $status date = $date context = $context decision = $decisionText consequences = $Body['consequences'] ?? '' alternatives_considered = $alternatives stakeholders = $stakeholders related_task_ids = $relatedTaskIds related_decision_ids = $relatedDecisionIds supersedes = $null superseded_by = $null tags = $tags impact = $impact deprecation_reason = $null } $targetDir = Join-Path (Get-DecisionsBaseDir) $status if (-not (Test-Path $targetDir)) { New-Item -ItemType Directory -Force -Path $targetDir | Out-Null } $filePath = Join-Path $targetDir "$id-$slug.json" $dec | ConvertTo-Json -Depth 10 | Set-Content -Path $filePath -Encoding UTF8 return @{ success = $true; decision_id = $id; status = $status; file_path = $filePath; message = "Decision '$title' created as $id" } } # ── Update ──────────────────────────────────────────────────────────────────── function Update-Decision { param([string]$DecisionId, [hashtable]$Body) $found = Find-DecisionFile -DecisionId $DecisionId -Statuses @('proposed', 'accepted', 'deprecated', 'superseded') if (-not $found) { return @{ _statusCode = 404; success = $false; error = "Decision '$DecisionId' not found" } } $dec = Get-Content -Path $found.file.FullName -Raw | ConvertFrom-Json $dec.date = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd") $stringFields = @('title', 'context', 'decision', 'consequences', 'type', 'impact') foreach ($field in $stringFields) { if ($Body.ContainsKey($field)) { $dec.$field = $Body[$field] } } $arrayFields = @('stakeholders', 'tags', 'related_task_ids') foreach ($field in $arrayFields) { if ($Body.ContainsKey($field)) { $dec.$field = @($Body[$field] | Where-Object { $_ }) } } if ($Body.ContainsKey('related_decision_ids')) { $dec.related_decision_ids = Assert-ValidRelatedDecisions @($Body['related_decision_ids'] | Where-Object { $_ }) } if ($Body.ContainsKey('alternatives_considered')) { $alternatives = @() foreach ($alt in $Body['alternatives_considered']) { if ($alt -is [hashtable] -or $alt -is [System.Collections.IDictionary]) { $alternatives += @{ option = "$($alt['option'])"; reason_rejected = "$($alt['reason_rejected'])" } } elseif ($alt.PSObject -and $alt.option) { $alternatives += @{ option = "$($alt.option)"; reason_rejected = "$($alt.reason_rejected)" } } } $dec.alternatives_considered = $alternatives } $dec | ConvertTo-Json -Depth 10 | Set-Content -Path $found.file.FullName -Encoding UTF8 return @{ success = $true; decision_id = $DecisionId; message = "Decision '$DecisionId' updated" } } # ── Status transitions ──────────────────────────────────────────────────────── function Set-DecisionStatus { param([string]$DecisionId, [string]$NewStatus, [string]$SupersededBy, [string]$Reason) $allStatuses = @('proposed', 'accepted', 'deprecated', 'superseded') if ($NewStatus -notin $allStatuses) { return @{ _statusCode = 400; success = $false; error = "Invalid status '$NewStatus'. Must be one of: $($allStatuses -join ', ')" } } if (-not (Test-DecisionIdFormat $DecisionId)) { return @{ _statusCode = 400; success = $false; error = "Invalid decision ID format '$DecisionId'. Expected: dec-XXXXXXXX" } } if ($NewStatus -eq 'superseded') { if (-not $SupersededBy) { return @{ _statusCode = 400; success = $false; error = "superseded_by is required when transitioning to superseded" } } if (-not (Test-DecisionIdFormat $SupersededBy)) { return @{ _statusCode = 400; success = $false; error = "Invalid superseded_by format '$SupersededBy'. Expected: dec-XXXXXXXX" } } } $validSources = @('proposed', 'accepted') $found = Find-DecisionFile -DecisionId $DecisionId -Statuses $validSources if (-not $found) { $existing = Find-DecisionFile -DecisionId $DecisionId -Statuses @($NewStatus) if ($existing) { return @{ success = $true; decision_id = $DecisionId; message = "Decision '$DecisionId' is already $NewStatus" } } return @{ _statusCode = 404; success = $false; error = "Decision '$DecisionId' not found in proposed or accepted" } } # Idempotency if ($found.status -eq $NewStatus) { return @{ success = $true; decision_id = $DecisionId; status = $NewStatus; file_path = $found.file.FullName; message = "Decision '$DecisionId' is already $NewStatus" } } $dec = Get-Content -Path $found.file.FullName -Raw | ConvertFrom-Json $dec.status = $NewStatus $dec.date = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd") if ($NewStatus -eq 'superseded' -and $SupersededBy) { $dec.superseded_by = $SupersededBy # Also update the superseding decision's 'supersedes' field $supersedesFound = Find-DecisionFile -DecisionId $SupersededBy -Statuses $allStatuses if ($supersedesFound) { $superDec = Get-Content -Path $supersedesFound.file.FullName -Raw | ConvertFrom-Json $superDec.supersedes = $DecisionId $superDec | ConvertTo-Json -Depth 10 | Set-Content -Path $supersedesFound.file.FullName -Encoding UTF8 } } if ($NewStatus -eq 'deprecated' -and $Reason) { $dec.deprecation_reason = $Reason } $base = Get-DecisionsBaseDir $targetDir = Join-Path $base $NewStatus if (-not (Test-Path $targetDir)) { New-Item -ItemType Directory -Force -Path $targetDir | Out-Null } $targetPath = Join-Path $targetDir $found.file.Name $dec | ConvertTo-Json -Depth 10 | Set-Content -Path $targetPath -Encoding UTF8 Remove-Item -Path $found.file.FullName -Force return @{ success = $true; decision_id = $DecisionId; status = $NewStatus; file_path = $targetPath; message = "Decision '$DecisionId' is now $NewStatus" } } Export-ModuleMember -Function @( 'Initialize-DecisionAPI', 'Get-DecisionList', 'Get-DecisionDetail', 'New-Decision', 'Update-Decision', 'Set-DecisionStatus' ) |