workflows/default/systems/mcp/modules/TaskStore.psm1
|
<# .SYNOPSIS Centralised task store — atomic state transitions and CRUD operations. .DESCRIPTION Provides Move-TaskState (atomic, validated), Get-TaskByIdOrSlug (unified lookup), New-TaskRecord (create with defaults), and Update-TaskRecord (merge-update). TaskIndexCache.psm1 remains the read-only query layer. #> # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- $script:ValidStatuses = @( 'todo', 'analysing', 'needs-input', 'analysed', 'in-progress', 'done', 'split', 'skipped', 'cancelled' ) $script:ReservedFields = @('status', 'updated_at', 'id', 'created_at') # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- function Get-TasksBaseDir { return (Join-Path $global:DotbotProjectRoot ".bot\workspace\tasks") } function Get-StatusDir { param([string]$Status) return (Join-Path (Get-TasksBaseDir) $Status) } function Set-OrAddProperty { param( [Parameter(Mandatory)] [psobject]$Object, [Parameter(Mandatory)] [string]$Name, [Parameter()] $Value ) if ($Object.PSObject.Properties[$Name]) { $Object.$Name = $Value } else { $Object | Add-Member -NotePropertyName $Name -NotePropertyValue $Value -Force } } function Find-TaskFileById { <# .SYNOPSIS Searches status directories for a task JSON file matching the given ID. Returns @{ File = <FileInfo>; Status = <string>; Content = <PSObject> } or $null. #> param( [Parameter(Mandatory)][string]$TaskId, [string[]]$SearchStatuses ) if (-not $SearchStatuses) { $SearchStatuses = $script:ValidStatuses } foreach ($status in $SearchStatuses) { $dir = Get-StatusDir -Status $status if (-not (Test-Path $dir)) { continue } $files = Get-ChildItem -Path $dir -Filter "*.json" -File foreach ($file in $files) { try { $content = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json if ($content.id -eq $TaskId) { return @{ File = $file Status = $status Content = $content } } } catch { # Malformed JSON — skip } } } return $null } function Assert-ValidStatus { param([string]$Status, [string]$ParameterName) if ($Status -notin $script:ValidStatuses) { throw "$ParameterName '$Status' is not valid. Allowed: $($script:ValidStatuses -join ', ')" } } # --------------------------------------------------------------------------- # Move-TaskState # --------------------------------------------------------------------------- function Move-TaskState { <# .SYNOPSIS Atomic, validated task state transition. .DESCRIPTION Finds a task by ID in one of the -FromStates directories, validates the transition, applies -Updates, sets status/updated_at, and moves the file to the target status directory. Returns a result hashtable. Idempotent: if the task is already in -ToState, returns success with already_in_state = $true and does NOT apply -Updates. #> param( [Parameter(Mandatory)][string]$TaskId, [Parameter(Mandatory)][string[]]$FromStates, [Parameter(Mandatory)][string]$ToState, [hashtable]$Updates = @{} ) # Validate states foreach ($s in $FromStates) { Assert-ValidStatus -Status $s -ParameterName 'FromStates' } Assert-ValidStatus -Status $ToState -ParameterName 'ToState' # Block reserved fields in Updates foreach ($key in @($Updates.Keys)) { if ($key -in $script:ReservedFields) { throw "Cannot override reserved field '$key' via -Updates. Use Move-TaskState parameters instead." } } # Search in FromStates + ToState (for idempotent handling) $searchStatuses = @($FromStates) + @($ToState) | Select-Object -Unique $found = Find-TaskFileById -TaskId $TaskId -SearchStatuses $searchStatuses if (-not $found) { throw "Task '$TaskId' not found in statuses: $($searchStatuses -join ', ')" } # Idempotent: already in target state if ($found.Status -eq $ToState) { return @{ success = $true already_in_state = $true task_id = $TaskId task_name = $found.Content.name old_status = $ToState new_status = $ToState file_path = $found.File.FullName task_content = $found.Content } } $taskContent = $found.Content $oldStatus = $found.Status # Set standard fields Set-OrAddProperty -Object $taskContent -Name 'status' -Value $ToState Set-OrAddProperty -Object $taskContent -Name 'updated_at' -Value ((Get-Date).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")) # Apply caller updates foreach ($key in $Updates.Keys) { Set-OrAddProperty -Object $taskContent -Name $key -Value $Updates[$key] } # Ensure target directory exists $targetDir = Get-StatusDir -Status $ToState if (-not (Test-Path $targetDir)) { New-Item -ItemType Directory -Force -Path $targetDir | Out-Null } # Write to new location, then remove old file $newFilePath = Join-Path $targetDir $found.File.Name $oldPathResolved = [System.IO.Path]::GetFullPath($found.File.FullName) $newPathResolved = [System.IO.Path]::GetFullPath($newFilePath) if ($oldPathResolved -ne $newPathResolved) { $taskContent | ConvertTo-Json -Depth 20 | Set-Content -Path $newFilePath -Encoding UTF8 Remove-Item -Path $found.File.FullName -Force } else { # Same directory (e.g. re-skipping) — update in place $taskContent | ConvertTo-Json -Depth 20 | Set-Content -Path $found.File.FullName -Encoding UTF8 } return @{ success = $true already_in_state = $false task_id = $TaskId task_name = $taskContent.name old_status = $oldStatus new_status = $ToState file_path = if ($oldPathResolved -ne $newPathResolved) { $newFilePath } else { $found.File.FullName } task_content = $taskContent } } # --------------------------------------------------------------------------- # Get-TaskByIdOrSlug # --------------------------------------------------------------------------- function Get-TaskByIdOrSlug { <# .SYNOPSIS Unified lookup — finds a task by ID or slug across all status directories. #> param( [Parameter(Mandatory)][string]$Identifier ) foreach ($status in $script:ValidStatuses) { $dir = Get-StatusDir -Status $status if (-not (Test-Path $dir)) { continue } $files = Get-ChildItem -Path $dir -Filter "*.json" -File foreach ($file in $files) { try { $content = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json if ($content.id -eq $Identifier) { return @{ File = $file; Status = $status; Content = $content } } # Check slug (filename minus .json, or slug field) $slug = $file.BaseName if ($slug -eq $Identifier -or $content.slug -eq $Identifier) { return @{ File = $file; Status = $status; Content = $content } } } catch { # Malformed JSON — skip } } } return $null } # --------------------------------------------------------------------------- # New-TaskRecord # --------------------------------------------------------------------------- function New-TaskRecord { <# .SYNOPSIS Creates a new task with sensible defaults and writes it to the todo directory. #> param( [Parameter(Mandatory)][hashtable]$Properties ) if (-not $Properties.ContainsKey('name')) { throw "New-TaskRecord: 'name' property is required and must be a non-empty string." } $name = $Properties['name'] if (-not ($name -is [string]) -or [string]::IsNullOrWhiteSpace($name)) { throw "New-TaskRecord: 'name' property must be a non-empty string." } $now = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") $id = if ($Properties.ContainsKey('id')) { $Properties['id'] } else { [guid]::NewGuid().ToString() } $task = [PSCustomObject]@{ id = $id name = $name description = if ($Properties.ContainsKey('description')) { $Properties['description'] } else { '' } category = if ($Properties.ContainsKey('category')) { $Properties['category'] } else { 'feature' } status = 'todo' priority = if ($Properties.ContainsKey('priority')) { $Properties['priority'] } else { 50 } effort = if ($Properties.ContainsKey('effort')) { $Properties['effort'] } else { 'M' } created_at = $now updated_at = $now } # Merge any additional properties $coreKeys = @('id', 'name', 'description', 'category', 'status', 'priority', 'effort', 'created_at', 'updated_at') foreach ($key in $Properties.Keys) { if ($key -notin $coreKeys) { $task | Add-Member -NotePropertyName $key -NotePropertyValue $Properties[$key] -Force } } # Write to todo directory $todoDir = Get-StatusDir -Status 'todo' if (-not (Test-Path $todoDir)) { New-Item -ItemType Directory -Force -Path $todoDir | Out-Null } # Build filename $safeName = ( ($task.name -replace '[^a-zA-Z0-9\s-]', '') -replace '\s+', '-' ).ToLower() if ($safeName.Length -gt 50) { $safeName = $safeName.Substring(0, 50) } if ([string]::IsNullOrEmpty($safeName)) { $safeName = 'task' } $shortId = $id.Substring(0, [Math]::Min(8, $id.Length)) $fileName = "$safeName-$shortId.json" $filePath = Join-Path $todoDir $fileName $task | ConvertTo-Json -Depth 10 | Set-Content -Path $filePath -Encoding UTF8 return @{ success = $true task_id = $id task_name = $task.name file_path = $filePath task = $task } } # --------------------------------------------------------------------------- # Update-TaskRecord # --------------------------------------------------------------------------- function Update-TaskRecord { <# .SYNOPSIS Merge-updates a task's properties in place. Cannot change status — use Move-TaskState for that. #> param( [Parameter(Mandatory)][string]$TaskId, [Parameter(Mandatory)][hashtable]$Updates ) # Block status changes if ($Updates.ContainsKey('status')) { throw "Cannot update 'status' via Update-TaskRecord. Use Move-TaskState instead." } # Block other reserved fields foreach ($key in @($Updates.Keys)) { if ($key -in @('id', 'created_at')) { throw "Cannot update reserved field '$key'." } } # Silently remove updated_at if supplied — it's set automatically below $Updates.Remove('updated_at') | Out-Null $found = Find-TaskFileById -TaskId $TaskId if (-not $found) { throw "Task '$TaskId' not found" } $taskContent = $found.Content Set-OrAddProperty -Object $taskContent -Name 'updated_at' -Value ((Get-Date).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")) foreach ($key in $Updates.Keys) { Set-OrAddProperty -Object $taskContent -Name $key -Value $Updates[$key] } $taskContent | ConvertTo-Json -Depth 20 | Set-Content -Path $found.File.FullName -Encoding UTF8 return @{ success = $true task_id = $TaskId task_name = $taskContent.name status = $found.Status file_path = $found.File.FullName task_content = $taskContent } } # --------------------------------------------------------------------------- # Exports # --------------------------------------------------------------------------- Export-ModuleMember -Function @( 'Move-TaskState', 'Get-TaskByIdOrSlug', 'New-TaskRecord', 'Update-TaskRecord', 'Find-TaskFileById', 'Set-OrAddProperty', 'Get-TasksBaseDir', 'Get-StatusDir' ) |