workflows/default/systems/ui/modules/StateBuilder.psm1
|
<# .SYNOPSIS Bot state builder module .DESCRIPTION Builds the comprehensive bot state object including task counts, session info, control signals, instance status, and loop state. Uses FileWatcher for caching and MCP session tools for live data. Extracted from server.ps1 for modularity. #> $script:Config = @{ BotRoot = $null ControlDir = $null ProcessesDir = $null } Import-Module (Join-Path $PSScriptRoot "..\..\runtime\modules\ConsoleSequenceSanitizer.psm1") function Initialize-StateBuilder { param( [Parameter(Mandatory)] [string]$BotRoot, [Parameter(Mandatory)] [string]$ControlDir, [Parameter(Mandatory)] [string]$ProcessesDir ) $script:Config.BotRoot = $BotRoot $script:Config.ControlDir = $ControlDir $script:Config.ProcessesDir = $ProcessesDir } function Get-RoadmapOverviewDependencyMap { param( [Parameter(Mandatory)] [string]$BotRoot ) $overviewPath = Join-Path $BotRoot "workspace\product\roadmap-overview.md" $dependencyMap = @{} if (-not (Test-Path $overviewPath)) { return $dependencyMap } foreach ($line in @(Get-Content -Path $overviewPath -ErrorAction SilentlyContinue)) { if ($line -notmatch '^\|\s*\d+\s*\|') { continue } $cells = ($line.Trim().Trim('|') -split '\s*\|\s*') if ($cells.Count -lt 5) { continue } $methodologyMatch = [regex]::Match($cells[2], '`([^`]+)`') if (-not $methodologyMatch.Success) { continue } $methodologyKey = $methodologyMatch.Groups[1].Value.Trim().ToLower() if (-not $methodologyKey) { continue } $dependencyText = $cells[3].Trim() if (-not $dependencyText -or $dependencyText -match '^(none|n/a)$') { $dependencyMap[$methodologyKey] = @() continue } $dependencyMap[$methodologyKey] = @($dependencyText) } return $dependencyMap } function Get-RoadmapTaskDependencies { param( [Parameter(Mandatory)] [object]$Task, [Parameter(Mandatory)] [hashtable]$DependencyMap ) $explicitDependencies = @(@($Task.dependencies) | Where-Object { $null -ne $_ -and "$($_)".Trim() }) if ($explicitDependencies.Count -gt 0) { return $explicitDependencies } $researchPrompt = "$($Task.research_prompt)".Trim().ToLower() if ($researchPrompt -and $DependencyMap.ContainsKey($researchPrompt)) { return @($DependencyMap[$researchPrompt]) } return @() } function Get-BotState { param( [DateTime]$IfModifiedSince = [DateTime]::MinValue ) $botRoot = $script:Config.BotRoot $controlDir = $script:Config.ControlDir $processesDir = $script:Config.ProcessesDir # Dot-source MCP session tools (must be in calling scope, not init scope) . "$botRoot\systems\mcp\tools\session-get-state\script.ps1" . "$botRoot\systems\mcp\tools\session-get-stats\script.ps1" # Check if we have a valid cache and no changes since last build $cacheMaxAge = 2 # seconds $now = [DateTime]::UtcNow $cachedState = Get-CachedState $cacheTime = Get-StateCacheTime if ($cachedState -and ($now - $cacheTime).TotalSeconds -lt $cacheMaxAge -and -not (Test-StateChanged -Since $cacheTime)) { # Return 304-equivalent marker if client already has this state if ($IfModifiedSince -ge $cacheTime) { return @{ NotModified = $true; CacheTime = $cacheTime } } return $cachedState } # Build fresh state $tasksDir = Join-Path $botRoot "workspace\tasks" $roadmapDependencyMap = Get-RoadmapOverviewDependencyMap -BotRoot $botRoot # Count tasks (including new analysis statuses) $todoTasks = @(Get-ChildItem -Path (Join-Path $tasksDir "todo") -Filter "*.json" -ErrorAction SilentlyContinue) $analysingTasks = @(Get-ChildItem -Path (Join-Path $tasksDir "analysing") -Filter "*.json" -ErrorAction SilentlyContinue) $needsInputTasks = @(Get-ChildItem -Path (Join-Path $tasksDir "needs-input") -Filter "*.json" -ErrorAction SilentlyContinue) $analysedTasks = @(Get-ChildItem -Path (Join-Path $tasksDir "analysed") -Filter "*.json" -ErrorAction SilentlyContinue) $splitTasks = @(Get-ChildItem -Path (Join-Path $tasksDir "split") -Filter "*.json" -ErrorAction SilentlyContinue) $inProgressTasks = @(Get-ChildItem -Path (Join-Path $tasksDir "in-progress") -Filter "*.json" -ErrorAction SilentlyContinue) $doneTasks = @(Get-ChildItem -Path (Join-Path $tasksDir "done") -Filter "*.json" -ErrorAction SilentlyContinue) $skippedTasks = @(Get-ChildItem -Path (Join-Path $tasksDir "skipped") -Filter "*.json" -ErrorAction SilentlyContinue) $cancelledTasks = @(Get-ChildItem -Path (Join-Path $tasksDir "cancelled") -Filter "*.json" -ErrorAction SilentlyContinue) # Get current task details $currentTask = $null if ($inProgressTasks.Count -gt 0) { $taskContent = Get-Content $inProgressTasks[0].FullName -Raw | ConvertFrom-Json $currentTask = @{ id = $taskContent.id name = $taskContent.name description = $taskContent.description category = $taskContent.category priority = $taskContent.priority effort = $taskContent.effort status = $taskContent.status acceptance_criteria = @($taskContent.acceptance_criteria) steps = @($taskContent.steps) dependencies = @($taskContent.dependencies) applicable_agents = @($taskContent.applicable_agents) applicable_standards = @($taskContent.applicable_standards) applicable_decisions = @($taskContent.applicable_decisions | Where-Object { $_ }) plan_path = $taskContent.plan_path created_at = $taskContent.created_at updated_at = $taskContent.updated_at started_at = $taskContent.started_at analysis = $taskContent.analysis questions_resolved = $taskContent.questions_resolved analysis_started_at = $taskContent.analysis_started_at analysis_completed_at = $taskContent.analysis_completed_at analysed_by = $taskContent.analysed_by workflow = $taskContent.workflow type = $taskContent.type } } elseif ($analysingTasks.Count -gt 0) { $taskContent = Get-Content $analysingTasks[0].FullName -Raw | ConvertFrom-Json $currentTask = @{ id = $taskContent.id name = $taskContent.name description = $taskContent.description category = $taskContent.category priority = $taskContent.priority effort = $taskContent.effort status = 'analysing' acceptance_criteria = @($taskContent.acceptance_criteria) steps = @($taskContent.steps) dependencies = @($taskContent.dependencies) applicable_agents = @($taskContent.applicable_agents) applicable_standards = @($taskContent.applicable_standards) applicable_decisions = @($taskContent.applicable_decisions | Where-Object { $_ }) plan_path = $taskContent.plan_path created_at = $taskContent.created_at updated_at = $taskContent.updated_at started_at = $taskContent.started_at analysis = $taskContent.analysis questions_resolved = $taskContent.questions_resolved analysis_started_at = $taskContent.analysis_started_at analysis_completed_at = $taskContent.analysis_completed_at analysed_by = $taskContent.analysed_by workflow = $taskContent.workflow type = $taskContent.type } } # Per-workflow task counts accumulator $workflowCounts = @{} # Get recent completed tasks (last 100 for infinite scroll) $recentCompleted = @() if ($doneTasks.Count -gt 0) { $recentCompleted = $doneTasks | ForEach-Object { try { $taskContent = Get-Content $_.FullName -Raw -ErrorAction Stop | ConvertFrom-Json @{ id = $taskContent.id name = $taskContent.name description = $taskContent.description category = $taskContent.category priority = $taskContent.priority effort = $taskContent.effort status = $taskContent.status acceptance_criteria = @($taskContent.acceptance_criteria) steps = @($taskContent.steps) dependencies = @($taskContent.dependencies) applicable_agents = @($taskContent.applicable_agents) applicable_standards = @($taskContent.applicable_standards) applicable_decisions = @($taskContent.applicable_decisions | Where-Object { $_ }) plan_path = $taskContent.plan_path created_at = $taskContent.created_at updated_at = $taskContent.updated_at ignore = $taskContent.ignore started_at = $taskContent.started_at completed_at = $taskContent.completed_at commit_sha = $taskContent.commit_sha commit_subject = $taskContent.commit_subject files_created = $taskContent.files_created files_modified = $taskContent.files_modified files_deleted = $taskContent.files_deleted commits = $taskContent.commits activity_log = $taskContent.activity_log execution_activity_log = $taskContent.execution_activity_log analysis = $taskContent.analysis analysis_started_at = $taskContent.analysis_started_at analysis_completed_at = $taskContent.analysis_completed_at analysed_by = $taskContent.analysed_by workflow = $taskContent.workflow type = $taskContent.type } } catch { $null } } | Where-Object { $_ -ne $null } | Sort-Object { if ($_.completed_at) { [DateTime]$_.completed_at } else { [DateTime]::MinValue } } -Descending | Select-Object -First 100 } # Get skipped tasks list $skippedTasksList = @() if ($skippedTasks.Count -gt 0) { $skippedTasksList = $skippedTasks | ForEach-Object { try { $taskContent = Get-Content $_.FullName -Raw -ErrorAction Stop | ConvertFrom-Json @{ id = $taskContent.id name = $taskContent.name description = $taskContent.description category = $taskContent.category priority = $taskContent.priority effort = $taskContent.effort status = $taskContent.status acceptance_criteria = @($taskContent.acceptance_criteria) steps = @($taskContent.steps) dependencies = @($taskContent.dependencies) applicable_agents = @($taskContent.applicable_agents) applicable_standards = @($taskContent.applicable_standards) applicable_decisions = @($taskContent.applicable_decisions | Where-Object { $_ }) analysis = $taskContent.analysis questions_resolved = $taskContent.questions_resolved analysis_started_at = $taskContent.analysis_started_at analysis_completed_at = $taskContent.analysis_completed_at analysed_by = $taskContent.analysed_by skip_history = $taskContent.skip_history created_at = $taskContent.created_at updated_at = $taskContent.updated_at workflow = $taskContent.workflow type = $taskContent.type } } catch { $null } } | Where-Object { $_ -ne $null } } # Get analysing tasks list $analysingTasksList = @() if ($analysingTasks.Count -gt 0) { $analysingTasksList = $analysingTasks | ForEach-Object { try { $taskContent = Get-Content $_.FullName -Raw -ErrorAction Stop | ConvertFrom-Json @{ id = $taskContent.id name = $taskContent.name description = $taskContent.description category = $taskContent.category priority = $taskContent.priority effort = $taskContent.effort status = $taskContent.status workflow = $taskContent.workflow type = $taskContent.type } } catch { $null } } | Where-Object { $_ -ne $null } } # Get needs-input tasks list $needsInputTasksList = @() if ($needsInputTasks.Count -gt 0) { $needsInputTasksList = $needsInputTasks | ForEach-Object { try { $taskContent = Get-Content $_.FullName -Raw -ErrorAction Stop | ConvertFrom-Json @{ id = $taskContent.id name = $taskContent.name description = $taskContent.description category = $taskContent.category priority = $taskContent.priority effort = $taskContent.effort status = $taskContent.status pending_question = $taskContent.pending_question questions_resolved = $taskContent.questions_resolved workflow = $taskContent.workflow type = $taskContent.type } } catch { $null } } | Where-Object { $_ -ne $null } } # Get analysed tasks list $analysedTasksList = @() if ($analysedTasks.Count -gt 0) { $analysedTasksList = $analysedTasks | ForEach-Object { try { $taskContent = Get-Content $_.FullName -Raw -ErrorAction Stop | ConvertFrom-Json [PSCustomObject]@{ id = $taskContent.id name = $taskContent.name description = $taskContent.description category = $taskContent.category priority = $taskContent.priority effort = $taskContent.effort status = $taskContent.status workflow = $taskContent.workflow type = $taskContent.type priority_num = [int]$taskContent.priority } } catch { $null } } | Where-Object { $_ -ne $null } | Sort-Object priority_num, name, id | ForEach-Object { @{ id = $_.id name = $_.name description = $_.description category = $_.category priority = $_.priority effort = $_.effort status = $_.status workflow = $_.workflow type = $_.type } } } # Get upcoming tasks (up to 100 in priority order for infinite scroll) $upcomingTasks = @() if ($todoTasks.Count -gt 0) { $upcomingTasks = $todoTasks | ForEach-Object { try { $taskContent = Get-Content $_.FullName -Raw -ErrorAction Stop | ConvertFrom-Json [PSCustomObject]@{ id = $taskContent.id name = $taskContent.name description = $taskContent.description category = $taskContent.category priority = $taskContent.priority effort = $taskContent.effort status = $taskContent.status acceptance_criteria = $taskContent.acceptance_criteria steps = $taskContent.steps dependencies = $taskContent.dependencies research_prompt = $taskContent.research_prompt roadmap_dependencies = Get-RoadmapTaskDependencies -Task $taskContent -DependencyMap $roadmapDependencyMap applicable_agents = $taskContent.applicable_agents applicable_standards = $taskContent.applicable_standards applicable_decisions = @($taskContent.applicable_decisions | Where-Object { $_ }) plan_path = $taskContent.plan_path created_at = $taskContent.created_at updated_at = $taskContent.updated_at ignore = $taskContent.ignore workflow = $taskContent.workflow type = $taskContent.type priority_num = [int]$taskContent.priority } } catch { $null } } | Where-Object { $_ -ne $null } | Sort-Object priority_num, name, id | Select-Object -First 100 | ForEach-Object { @{ id = $_.id name = $_.name description = $_.description category = $_.category priority = $_.priority effort = $_.effort status = $_.status acceptance_criteria = $_.acceptance_criteria steps = $_.steps dependencies = $_.dependencies research_prompt = $_.research_prompt roadmap_dependencies = $_.roadmap_dependencies applicable_agents = $_.applicable_agents applicable_standards = $_.applicable_standards applicable_decisions = @($_.applicable_decisions | Where-Object { $_ }) plan_path = $_.plan_path created_at = $_.created_at updated_at = $_.updated_at ignore = $_.ignore workflow = $_.workflow type = $_.type } } } # When in-progress/ is empty, currentTask may fall back to a task from analysing/. # Exclude that task from the analysing list to prevent duplicate cards in the UI. if ($currentTask -and $inProgressTasks.Count -eq 0) { $analysingTasksList = @($analysingTasksList | Where-Object { $_.id -ne $currentTask.id }) } # Build per-workflow task counts from all task lists $allTaskFiles = @() foreach ($statusDir in @('todo', 'analysing', 'needs-input', 'analysed', 'in-progress', 'done', 'skipped')) { $dir = Join-Path $tasksDir $statusDir if (Test-Path $dir) { $allTaskFiles += @(Get-ChildItem -Path $dir -Filter "*.json" -File -ErrorAction SilentlyContinue) } } foreach ($tf in $allTaskFiles) { try { $tc = Get-Content $tf.FullName -Raw -ErrorAction Stop | ConvertFrom-Json $wfName = $tc.workflow if (-not $wfName) { continue } if (-not $workflowCounts.ContainsKey($wfName)) { $workflowCounts[$wfName] = @{ todo = 0; analysing = 0; needs_input = 0; analysed = 0; in_progress = 0; done = 0; skipped = 0; total = 0 } } $wc = $workflowCounts[$wfName] $wc['total']++ switch ($tc.status) { 'todo' { $wc['todo']++ } 'analysing' { $wc['analysing']++ } 'needs-input' { $wc['needs_input']++ } 'analysed' { $wc['analysed']++ } 'in-progress' { $wc['in_progress']++ } 'done' { $wc['done']++ } 'skipped' { $wc['skipped']++ } } } catch { Write-BotLog -Level Debug -Message "Non-critical operation failed" -Exception $_ } } # Get session info from MCP tools $sessionInfo = $null $stateResult = Invoke-SessionGetState -Arguments @{} if ($stateResult.success) { $statsResult = Invoke-SessionGetStats -Arguments @{} if ($statsResult.success) { $sessionInfo = @{ session_id = $statsResult.session_id session_type = $statsResult.session_type status = $statsResult.status started_at = $stateResult.state.start_time start_time_raw = $stateResult.state.start_time tasks_completed = $statsResult.tasks_completed tasks_failed = $statsResult.tasks_failed tasks_skipped = $statsResult.tasks_skipped total_processed = $statsResult.total_processed consecutive_failures = $stateResult.state.consecutive_failures runtime_hours = $statsResult.runtime_hours runtime_minutes = $statsResult.runtime_minutes completion_rate = $statsResult.completion_rate failure_rate = $statsResult.failure_rate skip_rate = $statsResult.skip_rate avg_minutes_per_task = $statsResult.avg_minutes_per_task auth_method = $statsResult.auth_method current_task_id = $statsResult.current_task_id } } else { $sessionInfo = @{ session_id = $stateResult.state.session_id session_type = $stateResult.state.session_type status = $stateResult.state.status started_at = $stateResult.state.start_time start_time_raw = $stateResult.state.start_time tasks_completed = $stateResult.state.tasks_completed tasks_failed = $stateResult.state.tasks_failed tasks_skipped = $stateResult.state.tasks_skipped consecutive_failures = $stateResult.state.consecutive_failures current_task_id = $stateResult.state.current_task_id } } } # Read instance info from process registry only $instances = @{ analysis = $null execution = $null } $isAnalysisRunning = $false $isActuallyRunning = $false # Check process registry for running processes $runningProcesses = @() $processNeedsInputCount = 0 if (Test-Path $processesDir) { $procFiles = Get-ChildItem -Path $processesDir -Filter "*.json" -File -ErrorAction SilentlyContinue foreach ($pf in $procFiles) { try { $proc = Get-Content $pf.FullName -Raw | ConvertFrom-Json $proc = Update-ProcessHeartbeatFields -Process $proc # Count processes waiting for interview answers if ($proc.status -eq 'needs-input' -and $proc.pending_questions) { # Verify PID is still alive $needsInputAlive = $true if ($proc.pid) { try { $needsInputAlive = $null -ne (Get-Process -Id $proc.pid -ErrorAction SilentlyContinue) } catch { $needsInputAlive = $true } } if ($needsInputAlive) { $processNeedsInputCount++ } } if ($proc.status -in @('running', 'starting')) { $isAlive = $true if ($proc.pid) { try { $isAlive = $null -ne (Get-Process -Id $proc.pid -ErrorAction SilentlyContinue) } catch { $isAlive = $true } } # Mark dead PIDs as stopped and persist the change if (-not $isAlive) { $proc.status = 'stopped' $deadNow = [DateTime]::UtcNow.ToString("o") if (-not $proc.failed_at) { $proc | Add-Member -NotePropertyName 'failed_at' -NotePropertyValue $deadNow -Force } $proc | Add-Member -NotePropertyName 'error' -NotePropertyValue "Process terminated unexpectedly" -Force try { $proc | ConvertTo-Json -Depth 10 | Set-Content -Path $pf.FullName -Force -Encoding utf8NoBOM $actFile = Join-Path $processesDir "$($proc.id).activity.jsonl" $event = @{ timestamp = $deadNow; type = "text"; message = "Process terminated unexpectedly (PID $($proc.pid) no longer alive)" } | ConvertTo-Json -Compress Add-Content -Path $actFile -Value $event -ErrorAction SilentlyContinue } catch { Write-BotLog -Level Warn -Message "Failed to write file" -Exception $_ } continue # Skip adding to instances — it's dead } $runningProcesses += $proc if ($proc.type -eq 'analysis' -and -not $instances.analysis) { $instances.analysis = @{ instance_id = $proc.id pid = $proc.pid started_at = $proc.started_at last_heartbeat = $proc.last_heartbeat status = $proc.heartbeat_status next_action = $proc.heartbeat_next_action alive = $isAlive } $isAnalysisRunning = $true } if ($proc.type -eq 'execution' -and -not $instances.execution) { $instances.execution = @{ instance_id = $proc.id pid = $proc.pid started_at = $proc.started_at last_heartbeat = $proc.last_heartbeat status = $proc.heartbeat_status next_action = $proc.heartbeat_next_action alive = $isAlive } $isActuallyRunning = $true } if ($proc.type -eq 'task-runner' -and -not $instances.workflow) { $instances.workflow = @{ instance_id = $proc.id pid = $proc.pid started_at = $proc.started_at last_heartbeat = $proc.last_heartbeat status = $proc.heartbeat_status next_action = $proc.heartbeat_next_action alive = $isAlive } $isActuallyRunning = $true } } } catch { Write-BotLog -Level Debug -Message "Non-critical operation failed" -Exception $_ } } } # Track combined loop state $analysisAlive = ($null -ne $instances.analysis) -and ($instances.analysis.alive -eq $true) $executionAlive = ($null -ne $instances.execution) -and ($instances.execution.alive -eq $true) $workflowAlive = ($null -ne $instances.workflow) -and ($instances.workflow.alive -eq $true) $anyLoopRunning = $runningProcesses.Count -gt 0 $anyLoopAlive = $analysisAlive -or $executionAlive -or $workflowAlive # Mark per-workflow process_alive so UI can enable Stop buttons per workflow $activeWorkflowNames = @{} foreach ($proc in $runningProcesses) { if ($proc.workflow_name) { $activeWorkflowNames[$proc.workflow_name] = $true } } foreach ($key in @($workflowCounts.Keys)) { if ($activeWorkflowNames.ContainsKey($key)) { $workflowCounts[$key]['process_alive'] = $true } elseif ($activeWorkflowNames.Count -eq 0 -and ($workflowAlive -or $analysisAlive -or $executionAlive)) { # Fallback: if no processes have workflow_name set, mark all as alive (legacy compat) $workflowCounts[$key]['process_alive'] = $true } } # Check control signals — derive stop from per-process .stop files $anyStopPending = $false if (Test-Path $processesDir) { $anyStopPending = @(Get-ChildItem -Path $processesDir -Filter "*.stop" -File -ErrorAction SilentlyContinue).Count -gt 0 } $controlSignals = @{ pause = Test-Path (Join-Path $controlDir "pause.signal") stop = $anyStopPending resume = $false running = $isActuallyRunning } # Override session status if no loops are running but session state says running if ($sessionInfo -and -not $anyLoopRunning) { if ($sessionInfo.status -eq 'running') { $sessionInfo.status = 'stopped' } } # Read workspace instance ID from settings.default.json $workspaceInstanceId = $null $settingsPath = Join-Path $botRoot "settings\settings.default.json" if (Test-Path $settingsPath) { try { $settingsJson = Get-Content $settingsPath -Raw | ConvertFrom-Json if ($settingsJson.PSObject.Properties['instance_id'] -and $settingsJson.instance_id) { $workspaceInstanceId = "$($settingsJson.instance_id)" } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } } # Get steering status (for operator whisper channel) - legacy support $steeringStatus = $null $steeringStatusFile = Join-Path $controlDir "steering-status.json" if (Test-Path $steeringStatusFile) { try { $steeringStatus = Get-Content $steeringStatusFile -Raw | ConvertFrom-Json } catch { # Ignore read errors } } # Count decisions by status $decisionsBaseDir = Join-Path $botRoot "workspace\decisions" $decisionCounts = @{ proposed = 0; accepted = 0; deprecated = 0; superseded = 0; total = 0 } foreach ($decStatus in @('proposed', 'accepted', 'deprecated', 'superseded')) { $decDir = Join-Path $decisionsBaseDir $decStatus $decCount = if (Test-Path $decDir) { @(Get-ChildItem -Path $decDir -Filter "dec-*.json" -File -ErrorAction SilentlyContinue).Count } else { 0 } $decisionCounts[$decStatus] = $decCount $decisionCounts['total'] += $decCount } $state = @{ timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") instance_id = $workspaceInstanceId decisions = $decisionCounts tasks = @{ todo = $todoTasks.Count analysing = $analysingTasks.Count needs_input = $needsInputTasks.Count analysed = $analysedTasks.Count split = $splitTasks.Count in_progress = $inProgressTasks.Count done = $doneTasks.Count skipped = $skippedTasks.Count cancelled = $cancelledTasks.Count current = $currentTask upcoming = @($upcomingTasks) upcoming_total = if ($todoTasks.Count) { $todoTasks.Count } else { 0 } analysing_list = @($analysingTasksList) needs_input_list = @($needsInputTasksList) analysed_list = @($analysedTasksList) recent_completed = @($recentCompleted) completed_total = if ($doneTasks.Count) { $doneTasks.Count } else { 0 } skipped_list = @($skippedTasksList) action_required = $needsInputTasks.Count + $processNeedsInputCount } session = $sessionInfo control = $controlSignals analysis = @{ running = $isAnalysisRunning } loops = @{ any_running = $anyLoopRunning all_stopped = -not $anyLoopRunning analysis_alive = $analysisAlive execution_alive = $executionAlive workflow_alive = $workflowAlive any_alive = $anyLoopAlive } instances = $instances steering = $steeringStatus product_docs = @(Get-ChildItem -Path (Join-Path $botRoot "workspace\product") -Filter "*.md" -File -Recurse -ErrorAction SilentlyContinue).Count workflows = $workflowCounts } # Cache the result Set-CachedState -State $state return $state } Export-ModuleMember -Function @('Initialize-StateBuilder', 'Get-BotState') |