From 9fe8d1518a1d83f56a88ade87f5067698c9bf878 Mon Sep 17 00:00:00 2001 From: "emre.kabaoglu" Date: Mon, 22 Jun 2026 13:20:53 +0300 Subject: [PATCH 01/50] fix(runtime): use main project root for MCP preflight on task retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test-DotbotMcpReadiness set DOTBOT_PROJECT_ROOT to the worktree path when spawning the standalone preflight MCP process. On task retry the worktree's .control junction can be stale (teardown/re-create is not atomic), so the MCP server fails to resolve runtime.json and exits before the handshake begins — the 2-attempt retry loop from #479 cannot help because the process dies pre-handshake. This also bypassed #356: Resolve-ProjectRoot returns DOTBOT_PROJECT_ROOT verbatim when set, skipping git-common-dir detection entirely. Add an optional -ProjectRoot parameter to Test-DotbotMcpReadiness and pass the main project root at the call site. The main root always has a stable .control/ directory, so runtime.json resolution keeps working on retry. Backward compatible: callers that omit -ProjectRoot fall back to the worktree path. Closes #515 --- src/runtime/Scripts/Invoke-WorkflowProcess.ps1 | 11 +++++++++-- tests/Test-ProcessDispatch.ps1 | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 index ba141eb2..04ef03ab 100644 --- a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 +++ b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 @@ -490,6 +490,13 @@ function Read-DotbotMcpPreflightLine { function Test-DotbotMcpReadiness { param( [Parameter(Mandatory)] [string]$WorktreePath, + # Optional override for DOTBOT_PROJECT_ROOT. The worktree's .control + # junction can be stale on task retry (teardown/re-create is not + # atomic), which makes the standalone preflight MCP process exit before + # the handshake. Passing the main project root — which always has a + # stable .control/ — keeps runtime.json resolution working. Falls back + # to $WorktreePath when omitted (backward compatible). See #515. + [string]$ProjectRoot, [string[]]$RequiredTools = @('task_get_context','task_set_status','task_update','decision_create','decision_list') ) @@ -538,7 +545,7 @@ function Test-DotbotMcpReadiness { $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8 $psi.WorkingDirectory = $WorktreePath $psi.Environment['DOTBOT_HOME'] = $frameworkRoot - $psi.Environment['DOTBOT_PROJECT_ROOT'] = $WorktreePath + $psi.Environment['DOTBOT_PROJECT_ROOT'] = if ($ProjectRoot) { $ProjectRoot } else { $WorktreePath } $psi.Environment['__DOTBOT_MANAGED'] = '1' $maxAttempts = 2 @@ -1710,7 +1717,7 @@ try { } Write-Status "Checking dotbot MCP tools..." -Type Process - $mcpReady = Test-DotbotMcpReadiness -WorktreePath $worktreePath + $mcpReady = Test-DotbotMcpReadiness -WorktreePath $worktreePath -ProjectRoot $projectRoot if (-not $mcpReady.ok) { throw "dotbot MCP preflight failed ($($mcpReady.reason)): $($mcpReady.message)" } diff --git a/tests/Test-ProcessDispatch.ps1 b/tests/Test-ProcessDispatch.ps1 index 9480cc60..45a2cb3b 100644 --- a/tests/Test-ProcessDispatch.ps1 +++ b/tests/Test-ProcessDispatch.ps1 @@ -200,6 +200,21 @@ Assert-True -Name "Task-runner no longer carries a bespoke barrier switch case" -Condition ($workflowProcessContent -notmatch "'barrier'\s*\{") ` -Message "Barrier should be handled by the shipped barrier executor" +# #515: the MCP preflight must run against the main project root, not the +# worktree path, because the worktree's .control junction can be stale on +# task retry and would make the preflight MCP process exit before handshake. +Assert-True -Name "Test-DotbotMcpReadiness accepts a ProjectRoot override" ` + -Condition ($workflowProcessContent -match '(?s)function Test-DotbotMcpReadiness.*?\[string\]\$ProjectRoot') ` + -Message "Test-DotbotMcpReadiness should expose an optional `$ProjectRoot parameter (#515)" + +Assert-True -Name "MCP preflight prefers ProjectRoot over worktree for DOTBOT_PROJECT_ROOT" ` + -Condition ($workflowProcessContent -match "DOTBOT_PROJECT_ROOT'\]\s*=\s*if\s*\(\`$ProjectRoot\)\s*\{\s*\`$ProjectRoot\s*\}\s*else\s*\{\s*\`$WorktreePath\s*\}") ` + -Message "Preflight should set DOTBOT_PROJECT_ROOT to `$ProjectRoot when supplied, falling back to `$WorktreePath (#515)" + +Assert-True -Name "Preflight call site passes the main project root" ` + -Condition ($workflowProcessContent -match 'Test-DotbotMcpReadiness\s+-WorktreePath\s+\$worktreePath\s+-ProjectRoot\s+\$projectRoot') ` + -Message "Test-DotbotMcpReadiness call site should pass -ProjectRoot `$projectRoot (#515)" + $enterDoneHook = Join-Path $runtimeDir "Plugins/Hooks/Transitions/enter-done/script.ps1" $enterDoneContent = Get-Content $enterDoneHook -Raw Assert-True -Name "enter-done hook imports Dotbot.Content from DOTBOT_HOME" ` From ae2558d21e35ff2ebc2e78a9fd934b28ee4c3119 Mon Sep 17 00:00:00 2001 From: "emre.kabaoglu" Date: Tue, 23 Jun 2026 12:08:46 +0300 Subject: [PATCH 02/50] fix(workflow): pass output validation on resume-after-approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test-TaskOutput counted only files produced during the current run (delta vs baseline). On resume-after-approval the worktree already holds the artifact from the prior run, so the agent calls task_set_status(done) without re-writing files — delta is 0 and validation failed with "produced 0 new file(s)", escalating a correctly-completed task to needs-input. For non-tasks/ outputs, fall back to the absolute file count when the delta is below min_output_count: if the required files already exist, pass. tasks/ outputs keep strict delta enforcement because manifest pre-creation makes the absolute count always look satisfied. Fixes #518 --- src/runtime/Scripts/Invoke-WorkflowProcess.ps1 | 14 +++++++++++++- tests/Test-WorkflowManifest.ps1 | 7 +++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 index ba141eb2..248ef449 100644 --- a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 +++ b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 @@ -733,7 +733,19 @@ function Test-TaskOutput { if ($BaselineCount -ge 0) { $delta = $fileCount - $BaselineCount if ($delta -lt $minCount) { - return "Task output directory '$taskOutputsDir' produced $delta new file(s), expected at least $minCount" + # Resume-after-approval: on a resumed run the worktree already + # holds the artifact from the prior run, so the agent correctly + # calls task_set_status(done) without re-writing files that + # already exist — delta is 0 even though the required output is + # present and correct. For non-tasks/ outputs, fall back to the + # absolute file count: if the required files are already there + # (absolute count >= min), pass. tasks/ outputs keep strict + # delta enforcement because manifest pre-creation makes the + # absolute count always look satisfied, leaving delta the only + # meaningful signal. + if ($isTasksOutput -or $fileCount -lt $minCount) { + return "Task output directory '$taskOutputsDir' produced $delta new file(s), expected at least $minCount" + } } } elseif ($fileCount -lt $minCount) { return "Task output directory '$taskOutputsDir' has $fileCount file(s), expected at least $minCount" diff --git a/tests/Test-WorkflowManifest.ps1 b/tests/Test-WorkflowManifest.ps1 index d2eacb81..12d59145 100644 --- a/tests/Test-WorkflowManifest.ps1 +++ b/tests/Test-WorkflowManifest.ps1 @@ -1509,6 +1509,13 @@ Assert-True -Name "Test-TaskOutput supports legacy required_outputs alias" ` -Condition ($workflowSrc -match "'required_outputs'") Assert-True -Name "Test-TaskOutput supports outputs_dir + min_output_count" ` -Condition (($workflowSrc -match 'outputs_dir') -and ($workflowSrc -match 'min_output_count')) +# Resume-after-approval: when the delta is below min_output_count, non-tasks/ +# outputs must fall back to the absolute file count so a resumed run whose +# artifact already exists in the worktree passes validation instead of being +# escalated to needs-input. tasks/ outputs keep strict delta enforcement. +Assert-True -Name "Test-TaskOutput falls back to absolute count for non-tasks/ on zero delta" ` + -Condition ($workflowSrc -match 'if\s*\(\$isTasksOutput\s+-or\s+\$fileCount\s+-lt\s+\$minCount\)') ` + -Message "Resume-after-approval would fail when delta is 0 and the artifact already exists unless non-tasks/ outputs fall back to the absolute file count." Assert-True -Name "Measure-TaskFile counts workflow-run task files" ` -Condition (($workflowSrc -match 'Get-ChildItem\s+-LiteralPath\s+\$tasksRoot\s+-Recurse\s+-Filter\s+''\*\.json''') -and ($workflowSrc -match "\$_.Name\s+-ne\s+'run\.json'")) ` From 03748a3cf0e08b2f822e55dcb79727dacfea5dd3 Mon Sep 17 00:00:00 2001 From: Ognjen Gligoric Date: Tue, 23 Jun 2026 13:19:15 +0200 Subject: [PATCH 03/50] fix(ci): use pull_request_target so fork PRs get Discord notification Closes #487 Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/notify-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/notify-pr.yml b/.github/workflows/notify-pr.yml index 1065717b..43b5b4c6 100644 --- a/.github/workflows/notify-pr.yml +++ b/.github/workflows/notify-pr.yml @@ -1,7 +1,7 @@ name: "Notify: PR activity → #pull-requests" on: - pull_request: + pull_request_target: types: [opened, ready_for_review, closed] permissions: From 8582ab2306932d572b2a568e8c08be6603f1721f Mon Sep 17 00:00:00 2001 From: Ognjen Gligoric Date: Tue, 23 Jun 2026 13:22:57 +0200 Subject: [PATCH 04/50] Enable Playwright retries in CI Set Playwright retries to 2 when running in CI (process.env.CI ? 2 : 0) in tests/e2e/playwright.config.ts to reduce transient test flakiness on CI while keeping retries disabled locally. --- tests/e2e/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index cbe8792d..e33257b7 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ testDir: "./specs", fullyParallel: false, workers: 1, - retries: 0, + retries: process.env.CI ? 2 : 0, reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]], From ac8815a5a93518e648c8bb4ec48b5777c3646999 Mon Sep 17 00:00:00 2001 From: "emre.kabaoglu" Date: Tue, 23 Jun 2026 14:40:10 +0300 Subject: [PATCH 05/50] Separating DOTBOT_PROJECT_ROOT and DOTBOT_STATE_ROOT --- src/mcp/Resolve-ProjectRoot.ps1 | 48 ++++++++++++++----- .../Adapters/ClaudeCodeAdapter.ps1 | 4 ++ .../Dotbot.Harness/Adapters/CodexAdapter.ps1 | 13 +++-- .../Adapters/CopilotAdapter.ps1 | 6 +++ .../Dotbot.Harness/Private/ConsoleRender.ps1 | 7 +++ .../Dotbot.Harness/Private/ProcessStream.ps1 | 5 ++ .../Dotbot.Worktree/Dotbot.Worktree.psm1 | 38 ++++++++++----- .../Scripts/Invoke-WorkflowProcess.ps1 | 18 ++++--- tests/Test-Components.ps1 | 12 +++++ tests/Test-ProcessDispatch.ps1 | 20 +++++--- tests/Test-TaskActions.ps1 | 30 ++++++++++++ 11 files changed, 160 insertions(+), 41 deletions(-) diff --git a/src/mcp/Resolve-ProjectRoot.ps1 b/src/mcp/Resolve-ProjectRoot.ps1 index a463ec59..4e0338d1 100644 --- a/src/mcp/Resolve-ProjectRoot.ps1 +++ b/src/mcp/Resolve-ProjectRoot.ps1 @@ -18,6 +18,17 @@ returns the path to the main repo's `.git/` regardless of whether the caller is inside the main checkout or a linked worktree. The walk-up is kept as a fallback for the no-git case (test fixtures, etc.). + + DOTBOT_STATE_ROOT vs DOTBOT_PROJECT_ROOT (issue #515): during task + execution the agent's working directory is a linked worktree, but dotbot's + runtime/task state lives in the *main* repository. Overloading + DOTBOT_PROJECT_ROOT for both meanings forced state resolution onto the + worktree, whose `.bot/.control` junction can be stale during retry/teardown + windows — the MCP server then resolves runtime.json to a dead link and + exits. DOTBOT_STATE_ROOT carries the stable main root explicitly and takes + precedence here, so state resolution never depends on a worktree junction. + When unset, the resolver falls back to DOTBOT_PROJECT_ROOT (backward + compatible) and then to git detection. #> function Resolve-DotbotProjectRoot { @@ -27,24 +38,37 @@ function Resolve-DotbotProjectRoot { [string]$StartPath ) - $envProjectRoot = [Environment]::GetEnvironmentVariable('DOTBOT_PROJECT_ROOT') - if (-not [string]::IsNullOrWhiteSpace($envProjectRoot)) { - $envProjectRoot = $envProjectRoot.Trim() - if ($envProjectRoot -eq '~') { - $envProjectRoot = $HOME - } elseif ($envProjectRoot.StartsWith('~/') -or $envProjectRoot.StartsWith('~\')) { - $envProjectRoot = Join-Path $HOME $envProjectRoot.Substring(2) + # Normalise an env-var path: expand ~, make absolute, and require it to be + # an existing directory. Returns $null when the value cannot be used. + $resolveEnvRoot = { + param([string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return $null } + $Value = $Value.Trim() + if ($Value -eq '~') { + $Value = $HOME + } elseif ($Value.StartsWith('~/') -or $Value.StartsWith('~\')) { + $Value = Join-Path $HOME $Value.Substring(2) } - try { - $envProjectRoot = [System.IO.Path]::GetFullPath($envProjectRoot) + $Value = [System.IO.Path]::GetFullPath($Value) } catch { return $null } + if (Test-Path -LiteralPath $Value -PathType Container) { return $Value } + return $null + } - if (Test-Path -LiteralPath $envProjectRoot -PathType Container) { - return $envProjectRoot - } + # DOTBOT_STATE_ROOT wins when it points at a real directory. It is the + # stable main root, immune to worktree junction staleness (#515). A set-but- + # invalid value falls through rather than failing, so a misconfigured state + # root degrades to the previous DOTBOT_PROJECT_ROOT/git behaviour. + $stateRoot = & $resolveEnvRoot ([Environment]::GetEnvironmentVariable('DOTBOT_STATE_ROOT')) + if ($stateRoot) { return $stateRoot } + + $envProjectRoot = [Environment]::GetEnvironmentVariable('DOTBOT_PROJECT_ROOT') + if (-not [string]::IsNullOrWhiteSpace($envProjectRoot)) { + $resolvedProjectRoot = & $resolveEnvRoot $envProjectRoot + if ($resolvedProjectRoot) { return $resolvedProjectRoot } return $null } diff --git a/src/runtime/Modules/Dotbot.Harness/Adapters/ClaudeCodeAdapter.ps1 b/src/runtime/Modules/Dotbot.Harness/Adapters/ClaudeCodeAdapter.ps1 index 0a631eab..ddd05364 100644 --- a/src/runtime/Modules/Dotbot.Harness/Adapters/ClaudeCodeAdapter.ps1 +++ b/src/runtime/Modules/Dotbot.Harness/Adapters/ClaudeCodeAdapter.ps1 @@ -205,6 +205,10 @@ function Invoke-ClaudeCodeAdapterStream { $mcpProjectRoot = if ($WorkingDirectory) { $WorkingDirectory } elseif ($psi.WorkingDirectory) { $psi.WorkingDirectory } else { $global:DotbotProjectRoot } if ($frameworkRootForMcp) { $psi.Environment["DOTBOT_HOME"] = $frameworkRootForMcp } if ($mcpProjectRoot) { $psi.Environment["DOTBOT_PROJECT_ROOT"] = $mcpProjectRoot } + # Runtime/task state lives in the main repo, not the worktree. Pin the + # MCP server's state resolution to the stable root so it never depends + # on the worktree's .control junction being valid (#515). + if ($global:DotbotProjectRoot) { $psi.Environment["DOTBOT_STATE_ROOT"] = $global:DotbotProjectRoot } $claudeProc = New-Object System.Diagnostics.Process $claudeProc.StartInfo = $psi diff --git a/src/runtime/Modules/Dotbot.Harness/Adapters/CodexAdapter.ps1 b/src/runtime/Modules/Dotbot.Harness/Adapters/CodexAdapter.ps1 index ff77e184..e45c7a43 100644 --- a/src/runtime/Modules/Dotbot.Harness/Adapters/CodexAdapter.ps1 +++ b/src/runtime/Modules/Dotbot.Harness/Adapters/CodexAdapter.ps1 @@ -209,6 +209,15 @@ function Add-CodexWorktreeArgs { $frameworkRoot = Get-DotbotInstallPath $mcpScript = Join-Path $frameworkRoot 'src/mcp/dotbot-mcp.ps1' + # Pin MCP state resolution to the stable main root so it never depends on + # the worktree's .control junction being valid during retry/teardown (#515). + $envEntries = @( + ('DOTBOT_HOME={0}' -f (ConvertTo-CodexTomlString $frameworkRoot)) + ('DOTBOT_PROJECT_ROOT={0}' -f (ConvertTo-CodexTomlString $WorkingDirectory)) + ) + if ($global:DotbotProjectRoot) { + $envEntries += ('DOTBOT_STATE_ROOT={0}' -f (ConvertTo-CodexTomlString $global:DotbotProjectRoot)) + } $worktreeArgs = @( '-C', $WorkingDirectory, '-c', ('mcp_servers.dotbot.command={0}' -f (ConvertTo-CodexTomlString 'pwsh')), @@ -219,9 +228,7 @@ function Add-CodexWorktreeArgs { (ConvertTo-CodexTomlString '-File'), (ConvertTo-CodexTomlString $mcpScript) )), - '-c', ('mcp_servers.dotbot.env={{DOTBOT_HOME={0}, DOTBOT_PROJECT_ROOT={1}}}' -f ` - (ConvertTo-CodexTomlString $frameworkRoot), ` - (ConvertTo-CodexTomlString $WorkingDirectory)) + '-c', ('mcp_servers.dotbot.env={{{0}}}' -f ($envEntries -join ', ')) ) if ($CliArgs.Count -gt 0 -and $CliArgs[0] -eq 'exec') { diff --git a/src/runtime/Modules/Dotbot.Harness/Adapters/CopilotAdapter.ps1 b/src/runtime/Modules/Dotbot.Harness/Adapters/CopilotAdapter.ps1 index b859233d..9aabe6ef 100644 --- a/src/runtime/Modules/Dotbot.Harness/Adapters/CopilotAdapter.ps1 +++ b/src/runtime/Modules/Dotbot.Harness/Adapters/CopilotAdapter.ps1 @@ -72,6 +72,12 @@ function ConvertTo-CopilotMcpConfigJson { } } + # MCP state resolution targets the stable main root, not the worktree + # junction which can be stale during retry/teardown windows (#515). + if ($global:DotbotProjectRoot) { + $config.mcpServers.dotbot.env['DOTBOT_STATE_ROOT'] = $global:DotbotProjectRoot + } + return ($config | ConvertTo-Json -Compress -Depth 8) } diff --git a/src/runtime/Modules/Dotbot.Harness/Private/ConsoleRender.ps1 b/src/runtime/Modules/Dotbot.Harness/Private/ConsoleRender.ps1 index 13a1a469..1d9bddf5 100644 --- a/src/runtime/Modules/Dotbot.Harness/Private/ConsoleRender.ps1 +++ b/src/runtime/Modules/Dotbot.Harness/Private/ConsoleRender.ps1 @@ -261,6 +261,7 @@ function Invoke-WithHarnessProcessContext { $pushedLocation = $false $savedProjectRoot = $env:DOTBOT_PROJECT_ROOT + $savedStateRoot = $env:DOTBOT_STATE_ROOT $savedDotbotHome = $env:DOTBOT_HOME try { @@ -268,6 +269,9 @@ function Invoke-WithHarnessProcessContext { Push-Location -LiteralPath $WorkingDirectory $pushedLocation = $true $env:DOTBOT_PROJECT_ROOT = $WorkingDirectory + # State resolution stays pinned to the stable main root so in-process + # MCP calls don't follow the worktree junction (#515). + if ($global:DotbotProjectRoot) { $env:DOTBOT_STATE_ROOT = $global:DotbotProjectRoot } } $frameworkRoot = Get-DotbotInstallPath @@ -278,6 +282,9 @@ function Invoke-WithHarnessProcessContext { if ($null -ne $savedProjectRoot) { $env:DOTBOT_PROJECT_ROOT = $savedProjectRoot } else { Remove-Item Env:DOTBOT_PROJECT_ROOT -ErrorAction SilentlyContinue } + if ($null -ne $savedStateRoot) { $env:DOTBOT_STATE_ROOT = $savedStateRoot } + else { Remove-Item Env:DOTBOT_STATE_ROOT -ErrorAction SilentlyContinue } + if ($null -ne $savedDotbotHome) { $env:DOTBOT_HOME = $savedDotbotHome } else { Remove-Item Env:DOTBOT_HOME -ErrorAction SilentlyContinue } diff --git a/src/runtime/Modules/Dotbot.Harness/Private/ProcessStream.ps1 b/src/runtime/Modules/Dotbot.Harness/Private/ProcessStream.ps1 index 86174617..538b1d8e 100644 --- a/src/runtime/Modules/Dotbot.Harness/Private/ProcessStream.ps1 +++ b/src/runtime/Modules/Dotbot.Harness/Private/ProcessStream.ps1 @@ -66,6 +66,11 @@ function Invoke-HarnessProcessStream { if ($mcpProjectRoot) { $psi.Environment["DOTBOT_PROJECT_ROOT"] = $mcpProjectRoot } + # Pin MCP state resolution to the stable main root, independent of the + # worktree junction's validity (#515). + if ($global:DotbotProjectRoot) { + $psi.Environment["DOTBOT_STATE_ROOT"] = $global:DotbotProjectRoot + } $proc = [System.Diagnostics.Process]::new() $proc.StartInfo = $psi diff --git a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 index 3e8bf4a7..e2ee3485 100644 --- a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 +++ b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 @@ -588,7 +588,11 @@ function Set-DotbotMcpServerJson { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$FrameworkRoot, - [Parameter(Mandatory)][string]$WorktreePath + [Parameter(Mandatory)][string]$WorktreePath, + # Stable main repo root for runtime/task-state resolution (#515). The + # agent's cwd stays the worktree (DOTBOT_PROJECT_ROOT); state resolution + # follows DOTBOT_STATE_ROOT so it never relies on the worktree junction. + [string]$StateRoot ) $mcpConfig = [pscustomobject]@{ mcpServers = [pscustomobject]@{} } @@ -612,6 +616,7 @@ function Set-DotbotMcpServerJson { DOTBOT_PROJECT_ROOT = $WorktreePath } } + if (-not [string]::IsNullOrWhiteSpace($StateRoot)) { $server.env['DOTBOT_STATE_ROOT'] = $StateRoot } $mcpConfig.mcpServers | Add-Member -NotePropertyName dotbot -NotePropertyValue $server -Force $dir = Split-Path $Path -Parent @@ -623,10 +628,18 @@ function Set-DotbotCodexMcpConfig { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$FrameworkRoot, - [Parameter(Mandatory)][string]$WorktreePath + [Parameter(Mandatory)][string]$WorktreePath, + [string]$StateRoot ) $mcpScript = Join-Path $FrameworkRoot 'src/mcp/dotbot-mcp.ps1' + $envLines = @( + ('DOTBOT_HOME = {0}' -f (ConvertTo-DotbotTomlString $FrameworkRoot)) + ('DOTBOT_PROJECT_ROOT = {0}' -f (ConvertTo-DotbotTomlString $WorktreePath)) + ) + if (-not [string]::IsNullOrWhiteSpace($StateRoot)) { + $envLines += ('DOTBOT_STATE_ROOT = {0}' -f (ConvertTo-DotbotTomlString $StateRoot)) + } $lines = @( '# Generated by dotbot for this execution worktree.' '[mcp_servers.dotbot]' @@ -634,10 +647,7 @@ function Set-DotbotCodexMcpConfig { ('args = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", {0}]' -f (ConvertTo-DotbotTomlString $mcpScript)) '' '[mcp_servers.dotbot.env]' - ('DOTBOT_HOME = {0}' -f (ConvertTo-DotbotTomlString $FrameworkRoot)) - ('DOTBOT_PROJECT_ROOT = {0}' -f (ConvertTo-DotbotTomlString $WorktreePath)) - '' - ) + ) + $envLines + @('') $dir = Split-Path $Path -Parent if (-not (Test-Path -LiteralPath $dir)) { New-Item -Path $dir -ItemType Directory -Force | Out-Null } Set-Content -Path $Path -Value ($lines -join "`n") -Encoding utf8NoBOM @@ -647,7 +657,8 @@ function Set-DotbotAntigravityMcpConfig { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$FrameworkRoot, - [Parameter(Mandatory)][string]$WorktreePath + [Parameter(Mandatory)][string]$WorktreePath, + [string]$StateRoot ) $settings = [pscustomobject]@{ mcpServers = [pscustomobject]@{} } @@ -671,6 +682,7 @@ function Set-DotbotAntigravityMcpConfig { DOTBOT_PROJECT_ROOT = $WorktreePath } } + if (-not [string]::IsNullOrWhiteSpace($StateRoot)) { $server.env['DOTBOT_STATE_ROOT'] = $StateRoot } $settings.mcpServers | Add-Member -NotePropertyName dotbot -NotePropertyValue $server -Force $dir = Split-Path $Path -Parent @@ -682,7 +694,8 @@ function Set-DotbotOpenCodeMcpConfig { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$FrameworkRoot, - [Parameter(Mandatory)][string]$WorktreePath + [Parameter(Mandatory)][string]$WorktreePath, + [string]$StateRoot ) $config = [pscustomobject]@{ @@ -713,6 +726,7 @@ function Set-DotbotOpenCodeMcpConfig { DOTBOT_PROJECT_ROOT = $WorktreePath } } + if (-not [string]::IsNullOrWhiteSpace($StateRoot)) { $server.environment['DOTBOT_STATE_ROOT'] = $StateRoot } $config.mcp | Add-Member -NotePropertyName dotbot -NotePropertyValue $server -Force $dir = Split-Path $Path -Parent @@ -838,10 +852,10 @@ function Initialize-DotbotWorktreeExecutionEnvironment { Copy-DotbotDirectoryContents -Source (Join-Path $BotRoot 'settings') -Destination (Join-Path $worktreeBotRoot 'settings') Copy-DotbotProviderContent -WorktreePath $WorktreePath -BotRoot $BotRoot -FrameworkRoot $frameworkRoot - Set-DotbotMcpServerJson -Path (Join-Path $WorktreePath '.mcp.json') -FrameworkRoot $frameworkRoot -WorktreePath $WorktreePath - Set-DotbotCodexMcpConfig -Path (Join-Path $WorktreePath '.codex/config.toml') -FrameworkRoot $frameworkRoot -WorktreePath $WorktreePath - Set-DotbotAntigravityMcpConfig -Path (Join-Path $WorktreePath '.agents/mcp_config.json') -FrameworkRoot $frameworkRoot -WorktreePath $WorktreePath - Set-DotbotOpenCodeMcpConfig -Path (Join-Path $WorktreePath '.opencode/opencode.json') -FrameworkRoot $frameworkRoot -WorktreePath $WorktreePath + Set-DotbotMcpServerJson -Path (Join-Path $WorktreePath '.mcp.json') -FrameworkRoot $frameworkRoot -WorktreePath $WorktreePath -StateRoot $ProjectRoot + Set-DotbotCodexMcpConfig -Path (Join-Path $WorktreePath '.codex/config.toml') -FrameworkRoot $frameworkRoot -WorktreePath $WorktreePath -StateRoot $ProjectRoot + Set-DotbotAntigravityMcpConfig -Path (Join-Path $WorktreePath '.agents/mcp_config.json') -FrameworkRoot $frameworkRoot -WorktreePath $WorktreePath -StateRoot $ProjectRoot + Set-DotbotOpenCodeMcpConfig -Path (Join-Path $WorktreePath '.opencode/opencode.json') -FrameworkRoot $frameworkRoot -WorktreePath $WorktreePath -StateRoot $ProjectRoot } function Test-JunctionsExist { diff --git a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 index 04ef03ab..4d32665a 100644 --- a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 +++ b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 @@ -490,12 +490,15 @@ function Read-DotbotMcpPreflightLine { function Test-DotbotMcpReadiness { param( [Parameter(Mandatory)] [string]$WorktreePath, - # Optional override for DOTBOT_PROJECT_ROOT. The worktree's .control - # junction can be stale on task retry (teardown/re-create is not - # atomic), which makes the standalone preflight MCP process exit before - # the handshake. Passing the main project root — which always has a - # stable .control/ — keeps runtime.json resolution working. Falls back - # to $WorktreePath when omitted (backward compatible). See #515. + # Stable main repo root, exported as DOTBOT_STATE_ROOT so the preflight + # MCP process resolves runtime.json against the main .control/ rather + # than the worktree's junction, which can be stale on task retry + # (teardown/re-create is not atomic) and would make the server exit + # before the handshake. This mirrors the real provider session, which + # also runs with cwd/DOTBOT_PROJECT_ROOT = worktree and + # DOTBOT_STATE_ROOT = main root — so preflight tests the same config the + # task actually runs under. Omitted → no state-root override (backward + # compatible). See #515. [string]$ProjectRoot, [string[]]$RequiredTools = @('task_get_context','task_set_status','task_update','decision_create','decision_list') ) @@ -545,7 +548,8 @@ function Test-DotbotMcpReadiness { $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8 $psi.WorkingDirectory = $WorktreePath $psi.Environment['DOTBOT_HOME'] = $frameworkRoot - $psi.Environment['DOTBOT_PROJECT_ROOT'] = if ($ProjectRoot) { $ProjectRoot } else { $WorktreePath } + $psi.Environment['DOTBOT_PROJECT_ROOT'] = $WorktreePath + if ($ProjectRoot) { $psi.Environment['DOTBOT_STATE_ROOT'] = $ProjectRoot } $psi.Environment['__DOTBOT_MANAGED'] = '1' $maxAttempts = 2 diff --git a/tests/Test-Components.ps1 b/tests/Test-Components.ps1 index d19ca06d..1a226146 100644 --- a/tests/Test-Components.ps1 +++ b/tests/Test-Components.ps1 @@ -477,18 +477,30 @@ if (Test-Path $worktreeManagerModule) { -Expected $e2eResult.worktree_path -Actual $mcpData.mcpServers.dotbot.env.DOTBOT_PROJECT_ROOT Assert-Equal -Name "E2E: worktree MCP records DOTBOT_HOME" ` -Expected $dotbotDir -Actual $mcpData.mcpServers.dotbot.env.DOTBOT_HOME + # #515: state resolution must target the stable main root, not the worktree. + Assert-Equal -Name "E2E: worktree MCP pins DOTBOT_STATE_ROOT to main project root" ` + -Expected $e2eRoot -Actual $mcpData.mcpServers.dotbot.env.DOTBOT_STATE_ROOT $antigravityMcpData = Get-Content -LiteralPath $worktreeAntigravityMcp -Raw | ConvertFrom-Json Assert-Equal -Name "E2E: Antigravity MCP points at worktree project root" ` -Expected $e2eResult.worktree_path -Actual $antigravityMcpData.mcpServers.dotbot.env.DOTBOT_PROJECT_ROOT Assert-Equal -Name "E2E: Antigravity MCP records DOTBOT_HOME" ` -Expected $dotbotDir -Actual $antigravityMcpData.mcpServers.dotbot.env.DOTBOT_HOME + Assert-Equal -Name "E2E: Antigravity MCP pins DOTBOT_STATE_ROOT to main project root" ` + -Expected $e2eRoot -Actual $antigravityMcpData.mcpServers.dotbot.env.DOTBOT_STATE_ROOT $openCodeMcpData = Get-Content -LiteralPath $worktreeOpenCodeConfig -Raw | ConvertFrom-Json Assert-Equal -Name "E2E: OpenCode MCP points at worktree project root" ` -Expected $e2eResult.worktree_path -Actual $openCodeMcpData.mcp.dotbot.environment.DOTBOT_PROJECT_ROOT Assert-Equal -Name "E2E: OpenCode MCP records DOTBOT_HOME" ` -Expected $dotbotDir -Actual $openCodeMcpData.mcp.dotbot.environment.DOTBOT_HOME + Assert-Equal -Name "E2E: OpenCode MCP pins DOTBOT_STATE_ROOT to main project root" ` + -Expected $e2eRoot -Actual $openCodeMcpData.mcp.dotbot.environment.DOTBOT_STATE_ROOT + + $codexConfigText = Get-Content -LiteralPath $worktreeCodexConfig -Raw + Assert-True -Name "E2E: Codex MCP config pins DOTBOT_STATE_ROOT to main project root" ` + -Condition ($codexConfigText -match 'DOTBOT_STATE_ROOT\s*=') ` + -Message "Codex config.toml should export DOTBOT_STATE_ROOT for stable state resolution (#515)" $generatedStatus = @(git -C $e2eResult.worktree_path status --porcelain -- .mcp.json .claude .codex .opencode .agents .gemini .bot/content .bot/hooks .bot/settings 2>$null) Assert-True -Name "E2E: generated provider/MCP files are locally ignored" ` diff --git a/tests/Test-ProcessDispatch.ps1 b/tests/Test-ProcessDispatch.ps1 index 45a2cb3b..cb0f6c81 100644 --- a/tests/Test-ProcessDispatch.ps1 +++ b/tests/Test-ProcessDispatch.ps1 @@ -200,16 +200,22 @@ Assert-True -Name "Task-runner no longer carries a bespoke barrier switch case" -Condition ($workflowProcessContent -notmatch "'barrier'\s*\{") ` -Message "Barrier should be handled by the shipped barrier executor" -# #515: the MCP preflight must run against the main project root, not the -# worktree path, because the worktree's .control junction can be stale on -# task retry and would make the preflight MCP process exit before handshake. -Assert-True -Name "Test-DotbotMcpReadiness accepts a ProjectRoot override" ` +# #515: the MCP preflight must resolve task state against the stable main root +# via DOTBOT_STATE_ROOT, while keeping cwd/DOTBOT_PROJECT_ROOT on the worktree +# so it mirrors the real provider session. The worktree's .control junction can +# be stale on task retry and would make the preflight MCP process exit before +# the handshake if state resolution depended on it. +Assert-True -Name "Test-DotbotMcpReadiness accepts a ProjectRoot (state-root) override" ` -Condition ($workflowProcessContent -match '(?s)function Test-DotbotMcpReadiness.*?\[string\]\$ProjectRoot') ` -Message "Test-DotbotMcpReadiness should expose an optional `$ProjectRoot parameter (#515)" -Assert-True -Name "MCP preflight prefers ProjectRoot over worktree for DOTBOT_PROJECT_ROOT" ` - -Condition ($workflowProcessContent -match "DOTBOT_PROJECT_ROOT'\]\s*=\s*if\s*\(\`$ProjectRoot\)\s*\{\s*\`$ProjectRoot\s*\}\s*else\s*\{\s*\`$WorktreePath\s*\}") ` - -Message "Preflight should set DOTBOT_PROJECT_ROOT to `$ProjectRoot when supplied, falling back to `$WorktreePath (#515)" +Assert-True -Name "MCP preflight keeps DOTBOT_PROJECT_ROOT on the worktree" ` + -Condition ($workflowProcessContent -match "DOTBOT_PROJECT_ROOT'\]\s*=\s*\`$WorktreePath") ` + -Message "Preflight cwd/DOTBOT_PROJECT_ROOT should stay the worktree to mirror the real session (#515)" + +Assert-True -Name "MCP preflight pins DOTBOT_STATE_ROOT to the supplied main root" ` + -Condition ($workflowProcessContent -match "if\s*\(\`$ProjectRoot\)\s*\{\s*\`$psi\.Environment\['DOTBOT_STATE_ROOT'\]\s*=\s*\`$ProjectRoot\s*\}") ` + -Message "Preflight should export DOTBOT_STATE_ROOT = `$ProjectRoot for stable state resolution (#515)" Assert-True -Name "Preflight call site passes the main project root" ` -Condition ($workflowProcessContent -match 'Test-DotbotMcpReadiness\s+-WorktreePath\s+\$worktreePath\s+-ProjectRoot\s+\$projectRoot') ` diff --git a/tests/Test-TaskActions.ps1 b/tests/Test-TaskActions.ps1 index df1df422..4525b679 100644 --- a/tests/Test-TaskActions.ps1 +++ b/tests/Test-TaskActions.ps1 @@ -1903,6 +1903,36 @@ try { Assert-Equal -Name "Resolve-DotbotProjectRoot honors DOTBOT_PROJECT_ROOT override" ` -Expected ([System.IO.Path]::GetFullPath($worktreePath)) ` -Actual $envResolved + + # #515 failure mode: during task retry the worktree path in + # DOTBOT_PROJECT_ROOT can point at a torn-down/stale junction, but the + # stable main root is exported as DOTBOT_STATE_ROOT. State resolution + # must follow DOTBOT_STATE_ROOT, never the fragile worktree value. + $savedStateRootEnv = $env:DOTBOT_STATE_ROOT + try { + $env:DOTBOT_STATE_ROOT = $testProject + $env:DOTBOT_PROJECT_ROOT = Join-Path $testProject 'does-not-exist-worktree' + $stateResolved = Resolve-DotbotProjectRoot -StartPath $repoRoot + Assert-Equal -Name "Resolve-DotbotProjectRoot prefers DOTBOT_STATE_ROOT over a stale worktree project root (#515)" ` + -Expected ([System.IO.Path]::GetFullPath($testProject)) ` + -Actual $stateResolved + + # A blank/missing DOTBOT_STATE_ROOT must not regress the legacy + # DOTBOT_PROJECT_ROOT behaviour. + $env:DOTBOT_STATE_ROOT = '' + $env:DOTBOT_PROJECT_ROOT = $worktreePath + $fallbackResolved = Resolve-DotbotProjectRoot -StartPath $repoRoot + Assert-Equal -Name "Resolve-DotbotProjectRoot falls back to DOTBOT_PROJECT_ROOT when state root is unset (#515)" ` + -Expected ([System.IO.Path]::GetFullPath($worktreePath)) ` + -Actual $fallbackResolved + } finally { + if ($null -eq $savedStateRootEnv) { + Remove-Item Env:DOTBOT_STATE_ROOT -ErrorAction SilentlyContinue + } else { + $env:DOTBOT_STATE_ROOT = $savedStateRootEnv + } + } + if ($null -eq $savedDotbotProjectRootEnv) { Remove-Item Env:DOTBOT_PROJECT_ROOT -ErrorAction SilentlyContinue } else { From ae7bdae5969612bc73b368ebd708d42068e8f7ed Mon Sep 17 00:00:00 2001 From: Ivan Bondarenko Date: Tue, 23 Jun 2026 15:02:57 +0300 Subject: [PATCH 06/50] fix(product-api): surface pending-review worktree artifacts on Products page --- src/ui/modules/ProductAPI.psm1 | 152 ++++++++++++++++++++++++++------- tests/Test-Components.ps1 | 97 +++++++++++++++++++++ 2 files changed, 219 insertions(+), 30 deletions(-) diff --git a/src/ui/modules/ProductAPI.psm1 b/src/ui/modules/ProductAPI.psm1 index 064e2625..f288aa7c 100644 --- a/src/ui/modules/ProductAPI.psm1 +++ b/src/ui/modules/ProductAPI.psm1 @@ -31,6 +31,86 @@ function Initialize-ProductAPI { $script:Config.ControlDir = $ControlDir } +function Get-PendingReviewProductRoot { + $botRoot = $script:Config.BotRoot + + # Collect task IDs in needs-review state from all task layouts: + # - schema v2: workflow-runs/{run}/*.json and standalone/*.json (status field) + # - legacy flat: workspace/tasks/needs-review/*.json + $reviewTaskIds = [System.Collections.Generic.HashSet[string]]@() + $tasksRoot = Join-Path $botRoot "workspace/tasks" + + $taskSearchDirs = [System.Collections.Generic.List[string]]@() + # v2 layout + $wrDir = Join-Path $tasksRoot "workflow-runs" + if (Test-Path $wrDir) { + Get-ChildItem -Path $wrDir -Directory -ErrorAction SilentlyContinue | + ForEach-Object { $taskSearchDirs.Add($_.FullName) } + } + $saDir = Join-Path $tasksRoot "standalone" + if (Test-Path $saDir) { $taskSearchDirs.Add($saDir) } + # legacy flat layout + $legacyDir = Join-Path $tasksRoot "needs-review" + if (Test-Path $legacyDir) { $taskSearchDirs.Add($legacyDir) } + + foreach ($dir in $taskSearchDirs) { + Get-ChildItem -Path $dir -Filter "*.json" -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -ne 'run.json' } | + ForEach-Object { + try { + $t = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json + # v2 tasks: check status field; legacy tasks: presence in needs-review dir is enough + $isReview = ($t.status -eq 'needs-review') -or ($dir -eq $legacyDir) + if ($isReview -and $t.id) { [void]$reviewTaskIds.Add($t.id) } + } catch { + Write-BotLog -Level Debug -Message "Skipping malformed task file '$($_.FullName)'" -Exception $_ + } + } + } + if ($reviewTaskIds.Count -eq 0) { return @() } + + $mapPath = Join-Path $script:Config.ControlDir "worktree-map.json" + if (-not (Test-Path $mapPath)) { return @() } + try { + $json = Get-Content $mapPath -Raw | ConvertFrom-Json + } catch { return @() } + + $roots = [System.Collections.Generic.List[hashtable]]@() + foreach ($prop in $json.PSObject.Properties) { + $taskId = $prop.Name + if (-not $reviewTaskIds.Contains($taskId)) { continue } + $entry = $prop.Value + $productDir = Join-Path $entry.worktree_path ".bot" "workspace" "product" + if (Test-Path $productDir) { + $roots.Add(@{ + ProductDir = $productDir + TaskId = $taskId + TaskName = $entry.task_name + }) + } + } + return $roots +} + +function New-RawProductResponse { + param([Parameter(Mandatory)][string]$FullPath) + $ext = [System.IO.Path]::GetExtension($FullPath).ToLowerInvariant() + $mimeType = switch ($ext) { + '.png' { 'image/png' } + '.jpg' { 'image/jpeg' } + '.jpeg' { 'image/jpeg' } + '.gif' { 'image/gif' } + '.svg' { 'image/svg+xml' } + '.txt' { 'text/plain; charset=utf-8' } + default { 'application/octet-stream' } + } + $isBinary = $ext -in @('.png', '.jpg', '.jpeg', '.gif') + if ($isBinary) { + return @{ Found = $true; MimeType = $mimeType; BinaryData = [System.IO.File]::ReadAllBytes($FullPath) } + } + return @{ Found = $true; MimeType = $mimeType; TextContent = (Get-Content -LiteralPath $FullPath -Raw) } +} + function Resolve-ProductDocumentInfo { param( [Parameter(Mandatory)] [System.IO.FileInfo]$File, @@ -257,6 +337,27 @@ function Get-ProductList { } } + $existingNames = [System.Collections.Generic.HashSet[string]]($docs | ForEach-Object { $_['name'] }) + foreach ($root in (Get-PendingReviewProductRoot)) { + $pendingFiles = @(Get-ChildItem -Path $root.ProductDir -File -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.Name -ne '.gitkeep' }) + foreach ($file in $pendingFiles) { + $doc = Resolve-ProductDocumentInfo -File $file -ProductDir $root.ProductDir + if ($existingNames.Contains($doc.Name)) { continue } + [void]$existingNames.Add($doc.Name) + $docs += @{ + name = $doc.Name + filename = $doc.Filename + depth = $doc.Depth + type = $doc.Type + size = $doc.Size + pending_review = $true + task_id = $root.TaskId + task_name = $root.TaskName + } + } + } + return @{ docs = $docs } } @@ -275,13 +376,21 @@ function Get-ProductDocument { name = $resolvedDoc.Name content = $docContent } - } else { - return @{ - _statusCode = 404 - success = $false - error = "Document not found: $Name" + } + + foreach ($root in (Get-PendingReviewProductRoot)) { + $pendingDoc = Resolve-ProductDocumentPath -Name $Name -ProductDir $root.ProductDir + if ($pendingDoc -and (Test-Path -LiteralPath $pendingDoc.FullPath)) { + $docContent = Get-Content -LiteralPath $pendingDoc.FullPath -Raw + return @{ success = $true; name = $pendingDoc.Name; content = $docContent } } } + + return @{ + _statusCode = 404 + success = $false + error = "Document not found: $Name" + } } function Get-ProductDocumentRaw { @@ -292,35 +401,18 @@ function Get-ProductDocumentRaw { $productDir = Join-Path $botRoot "workspace/product" $resolvedDoc = Resolve-ProductDocumentPath -Name $Name -ProductDir $productDir - if (-not $resolvedDoc -or -not (Test-Path -LiteralPath $resolvedDoc.FullPath)) { - return @{ Found = $false } - } - - $ext = [System.IO.Path]::GetExtension($resolvedDoc.FullPath).ToLowerInvariant() - $mimeType = switch ($ext) { - '.png' { 'image/png' } - '.jpg' { 'image/jpeg' } - '.jpeg' { 'image/jpeg' } - '.gif' { 'image/gif' } - '.svg' { 'image/svg+xml' } - '.txt' { 'text/plain; charset=utf-8' } - default { 'application/octet-stream' } + if ($resolvedDoc -and (Test-Path -LiteralPath $resolvedDoc.FullPath)) { + return New-RawProductResponse -FullPath $resolvedDoc.FullPath } - $isBinary = $ext -in @('.png', '.jpg', '.jpeg', '.gif') - if ($isBinary) { - return @{ - Found = $true - MimeType = $mimeType - BinaryData = [System.IO.File]::ReadAllBytes($resolvedDoc.FullPath) - } - } else { - return @{ - Found = $true - MimeType = $mimeType - TextContent = (Get-Content -LiteralPath $resolvedDoc.FullPath -Raw) + foreach ($root in (Get-PendingReviewProductRoot)) { + $pendingDoc = Resolve-ProductDocumentPath -Name $Name -ProductDir $root.ProductDir + if ($pendingDoc -and (Test-Path -LiteralPath $pendingDoc.FullPath)) { + return New-RawProductResponse -FullPath $pendingDoc.FullPath } } + + return @{ Found = $false } } function Get-PreflightResults { diff --git a/tests/Test-Components.ps1 b/tests/Test-Components.ps1 index 02b33c41..7a4af4a7 100644 --- a/tests/Test-Components.ps1 +++ b/tests/Test-Components.ps1 @@ -4394,6 +4394,103 @@ if (Test-Path $productApiModule) { -Condition ($rawTraversal.Found -eq $false) ` -Message "Path traversal should return not found" + # ── Pending-review worktree product tests (issue #519) ── + + $pendingWtRoot = Join-Path ([System.IO.Path]::GetTempPath()) "dotbot-pending-wt-$([guid]::NewGuid().ToString().Substring(0,8))" + $pendingWtProductDir = Join-Path $pendingWtRoot ".bot" "workspace" "product" + New-Item -Path $pendingWtProductDir -ItemType Directory -Force | Out-Null + + $pendingTaskId = [guid]::NewGuid().ToString() + # Use v2 layout: workflow-runs/{run}/*.json with status field + $wrRunDir = Join-Path $productBotRoot "workspace/tasks/workflow-runs/wr_test01" + New-Item -Path $wrRunDir -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path $wrRunDir "task-pending.json") ` + -Value (@{ id = $pendingTaskId; name = "Pending Task"; status = "needs-review"; schema_version = 2 } | ConvertTo-Json) ` + -Encoding UTF8 + + $wtMap = @{ $pendingTaskId = @{ worktree_path = $pendingWtRoot; task_name = "Pending Task"; branch_name = "task/test" } } + Set-Content -Path (Join-Path $controlDir "worktree-map.json") ` + -Value ($wtMap | ConvertTo-Json -Depth 5) -Encoding UTF8 + + Set-Content -Path (Join-Path $pendingWtProductDir "feature-spec.md") -Value "# Feature Spec" -Encoding UTF8 + + # Re-initialize so the module picks up the new worktree-map + tasks dir + Initialize-ProductAPI -BotRoot $productBotRoot -ControlDir $controlDir + + $docsWithPending = @((Get-ProductList).docs) + $pendingEntry = $docsWithPending | Where-Object { $_.name -eq 'feature-spec' } + Assert-True -Name "ProductAPI includes needs-review worktree artifact in list" ` + -Condition ($null -ne $pendingEntry) ` + -Message "Pending artifact 'feature-spec' missing from product list" + Assert-True -Name "ProductAPI sets pending_review=true on worktree artifact" ` + -Condition ($pendingEntry.pending_review -eq $true) ` + -Message "Expected pending_review=true on 'feature-spec'" + Assert-Equal -Name "ProductAPI sets correct task_id on pending artifact" ` + -Expected $pendingTaskId ` + -Actual $pendingEntry.task_id + Assert-Equal -Name "ProductAPI sets correct task_name on pending artifact" ` + -Expected "Pending Task" ` + -Actual $pendingEntry.task_name + + # Main doc takes precedence — mission.md exists in main, should NOT gain pending_review flag + Set-Content -Path (Join-Path $pendingWtProductDir "mission.md") -Value "# Pending Mission" -Encoding UTF8 + $docsDedup = @((Get-ProductList).docs) + $missionEntries = @($docsDedup | Where-Object { $_.name -eq 'mission' }) + Assert-Equal -Name "ProductAPI deduplicates: main doc wins over pending copy" ` + -Expected 1 ` + -Actual $missionEntries.Count + Assert-True -Name "ProductAPI main doc retains no pending_review flag" ` + -Condition (-not $missionEntries[0].ContainsKey('pending_review') -or $missionEntries[0].pending_review -ne $true) ` + -Message "Main workspace doc should not be marked pending_review" + + # Get-ProductDocument falls back to pending worktree + $pendingDocResult = Get-ProductDocument -Name "feature-spec" + Assert-True -Name "ProductDocument falls back to needs-review worktree" ` + -Condition ($pendingDocResult.success -eq $true -and $pendingDocResult.content -match 'Feature Spec') ` + -Message "Get-ProductDocument did not fall back to pending worktree" + + # Get-ProductDocumentRaw falls back to pending worktree + Set-Content -Path (Join-Path $pendingWtProductDir "diagram.svg") ` + -Value '' -Encoding UTF8 + $pendingRaw = Get-ProductDocumentRaw -Name "diagram-pending.svg" + Assert-True -Name "ProductDocumentRaw returns Found=false for truly missing file" ` + -Condition ($pendingRaw.Found -eq $false) ` + -Message "File not in any worktree should return Found=false" + + # In-progress task (status=in-progress in v2 layout) must not be surfaced + $inProgressTaskId = [guid]::NewGuid().ToString() + $inProgressWtRoot = Join-Path ([System.IO.Path]::GetTempPath()) "dotbot-inprogress-wt-$([guid]::NewGuid().ToString().Substring(0,8))" + $inProgressProductDir = Join-Path $inProgressWtRoot ".bot" "workspace" "product" + New-Item -Path $inProgressProductDir -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path $inProgressProductDir "wip-doc.md") -Value "# WIP" -Encoding UTF8 + # task file in v2 layout with status=in-progress + Set-Content -Path (Join-Path $wrRunDir "task-inprogress.json") ` + -Value (@{ id = $inProgressTaskId; name = "Running Task"; status = "in-progress"; schema_version = 2 } | ConvertTo-Json) ` + -Encoding UTF8 + + $wtMapWithInProgress = @{ + $pendingTaskId = @{ worktree_path = $pendingWtRoot; task_name = "Pending Task"; branch_name = "task/test" } + $inProgressTaskId = @{ worktree_path = $inProgressWtRoot; task_name = "Running Task"; branch_name = "task/running" } + } + Set-Content -Path (Join-Path $controlDir "worktree-map.json") ` + -Value ($wtMapWithInProgress | ConvertTo-Json -Depth 5) -Encoding UTF8 + + $docsNoWip = @((Get-ProductList).docs) + $wipEntry = $docsNoWip | Where-Object { $_.name -eq 'wip-doc' } + Assert-True -Name "ProductAPI does NOT surface in-progress task artifacts" ` + -Condition ($null -eq $wipEntry) ` + -Message "In-progress task artifact 'wip-doc' should not appear in product list" + + # No worktree-map: graceful empty result + Remove-Item -Path (Join-Path $controlDir "worktree-map.json") -Force -ErrorAction SilentlyContinue + $docsNoMap = @((Get-ProductList).docs) + Assert-True -Name "ProductAPI handles missing worktree-map gracefully" ` + -Condition ($null -ne $docsNoMap) ` + -Message "Get-ProductList should not throw when worktree-map.json is absent" + + if (Test-Path $pendingWtRoot) { Remove-Item $pendingWtRoot -Recurse -Force -ErrorAction SilentlyContinue } + if (Test-Path $inProgressWtRoot) { Remove-Item $inProgressWtRoot -Recurse -Force -ErrorAction SilentlyContinue } + # ═════════════════════════════════════════════════════════════════ # Get-WorkflowStatus — script-phase probe + process-type filter # Regression tests for #244: Overview stuck on Task Group Expansion From 69f65d5dfe6c70e1382d7ece5ef08182a5505ccb Mon Sep 17 00:00:00 2001 From: Ivan Bondarenko Date: Tue, 23 Jun 2026 20:01:18 +0300 Subject: [PATCH 07/50] fix: address pwsh-review --- src/ui/modules/ProductAPI.psm1 | 26 +++++++++++++--------- tests/Test-Components.ps1 | 40 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/ui/modules/ProductAPI.psm1 b/src/ui/modules/ProductAPI.psm1 index f288aa7c..a5320906 100644 --- a/src/ui/modules/ProductAPI.psm1 +++ b/src/ui/modules/ProductAPI.psm1 @@ -58,10 +58,10 @@ function Get-PendingReviewProductRoot { Where-Object { $_.Name -ne 'run.json' } | ForEach-Object { try { - $t = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json - # v2 tasks: check status field; legacy tasks: presence in needs-review dir is enough - $isReview = ($t.status -eq 'needs-review') -or ($dir -eq $legacyDir) - if ($isReview -and $t.id) { [void]$reviewTaskIds.Add($t.id) } + $taskData = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json + # v2 tasks: check status field; legacy tasks: presence in needs-review dir is enough (id field required) + $isReview = ($taskData.status -eq 'needs-review') -or ($dir -eq $legacyDir) + if ($isReview -and $taskData.id) { [void]$reviewTaskIds.Add($taskData.id) } } catch { Write-BotLog -Level Debug -Message "Skipping malformed task file '$($_.FullName)'" -Exception $_ } @@ -70,18 +70,22 @@ function Get-PendingReviewProductRoot { if ($reviewTaskIds.Count -eq 0) { return @() } $mapPath = Join-Path $script:Config.ControlDir "worktree-map.json" - if (-not (Test-Path $mapPath)) { return @() } + if (-not (Test-Path -LiteralPath $mapPath)) { return @() } try { - $json = Get-Content $mapPath -Raw | ConvertFrom-Json - } catch { return @() } + $worktreeMap = Get-Content -LiteralPath $mapPath -Raw | ConvertFrom-Json + } catch { + Write-BotLog -Level Warning -Message "Failed to parse worktree-map.json at '$mapPath'" -Exception $_ + return @() + } $roots = [System.Collections.Generic.List[hashtable]]@() - foreach ($prop in $json.PSObject.Properties) { + foreach ($prop in $worktreeMap.PSObject.Properties) { $taskId = $prop.Name if (-not $reviewTaskIds.Contains($taskId)) { continue } $entry = $prop.Value + if ([string]::IsNullOrWhiteSpace($entry.worktree_path)) { continue } $productDir = Join-Path $entry.worktree_path ".bot" "workspace" "product" - if (Test-Path $productDir) { + if (Test-Path -LiteralPath $productDir) { $roots.Add(@{ ProductDir = $productDir TaskId = $taskId @@ -92,6 +96,7 @@ function Get-PendingReviewProductRoot { return $roots } +# Builds response hashtable for raw file download (Found, MimeType, BinaryData|TextContent). function New-RawProductResponse { param([Parameter(Mandatory)][string]$FullPath) $ext = [System.IO.Path]::GetExtension($FullPath).ToLowerInvariant() @@ -337,7 +342,8 @@ function Get-ProductList { } } - $existingNames = [System.Collections.Generic.HashSet[string]]($docs | ForEach-Object { $_['name'] }) + $existingNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($d in $docs) { [void]$existingNames.Add($d['name']) } foreach ($root in (Get-PendingReviewProductRoot)) { $pendingFiles = @(Get-ChildItem -Path $root.ProductDir -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne '.gitkeep' }) diff --git a/tests/Test-Components.ps1 b/tests/Test-Components.ps1 index 7a4af4a7..7e976f26 100644 --- a/tests/Test-Components.ps1 +++ b/tests/Test-Components.ps1 @@ -4491,6 +4491,46 @@ if (Test-Path $productApiModule) { if (Test-Path $pendingWtRoot) { Remove-Item $pendingWtRoot -Recurse -Force -ErrorAction SilentlyContinue } if (Test-Path $inProgressWtRoot) { Remove-Item $inProgressWtRoot -Recurse -Force -ErrorAction SilentlyContinue } + # Empty main workspace/product + pending worktree: regression for HashSet null crash (#534) + $emptyBotRoot = Join-Path ([System.IO.Path]::GetTempPath()) "dotbot-empty-$([guid]::NewGuid().ToString().Substring(0,8))" + $emptyCtrlDir = Join-Path $emptyBotRoot ".control" + $emptyTasksDir = Join-Path $emptyBotRoot "workspace" "tasks" "workflow-runs" "wr_empty01" + New-Item -Path $emptyCtrlDir -ItemType Directory -Force | Out-Null + New-Item -Path $emptyTasksDir -ItemType Directory -Force | Out-Null + # No workspace/product directory — main productDir intentionally absent + + $emptyPendingWtRoot = Join-Path ([System.IO.Path]::GetTempPath()) "dotbot-empty-wt-$([guid]::NewGuid().ToString().Substring(0,8))" + $emptyPendingProdDir = Join-Path $emptyPendingWtRoot ".bot" "workspace" "product" + New-Item -Path $emptyPendingProdDir -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path $emptyPendingProdDir "fresh-spec.md") -Value "# Fresh Spec" -Encoding UTF8 + + $emptyTaskId = [guid]::NewGuid().ToString() + Set-Content -Path (Join-Path $emptyTasksDir "task-empty.json") ` + -Value (@{ id = $emptyTaskId; name = "Empty Task"; status = "needs-review"; schema_version = 2 } | ConvertTo-Json) ` + -Encoding UTF8 + $emptyWtMap = @{ $emptyTaskId = @{ worktree_path = $emptyPendingWtRoot; task_name = "Empty Task"; branch_name = "task/empty" } } + Set-Content -Path (Join-Path $emptyCtrlDir "worktree-map.json") ` + -Value ($emptyWtMap | ConvertTo-Json -Depth 5) -Encoding UTF8 + + Initialize-ProductAPI -BotRoot $emptyBotRoot -ControlDir $emptyCtrlDir + $emptyDocs = $null + $emptyListError = $null + try { $emptyDocs = @((Get-ProductList).docs) } catch { $emptyListError = $_ } + + Assert-True -Name "ProductAPI does not crash when main workspace/product is absent (HashSet null guard)" ` + -Condition ($null -eq $emptyListError) ` + -Message "Get-ProductList threw when main productDir absent: $emptyListError" + $freshEntry = if ($emptyDocs) { $emptyDocs | Where-Object { $_.name -eq 'fresh-spec' } } else { $null } + Assert-True -Name "ProductAPI surfaces pending artifact when main workspace/product is absent" ` + -Condition ($null -ne $freshEntry -and $freshEntry.pending_review -eq $true) ` + -Message "Expected 'fresh-spec' with pending_review=true when main productDir absent" + + if (Test-Path $emptyBotRoot) { Remove-Item $emptyBotRoot -Recurse -Force -ErrorAction SilentlyContinue } + if (Test-Path $emptyPendingWtRoot) { Remove-Item $emptyPendingWtRoot -Recurse -Force -ErrorAction SilentlyContinue } + + # Restore module to original test fixture for subsequent tests + Initialize-ProductAPI -BotRoot $productBotRoot -ControlDir $controlDir + # ═════════════════════════════════════════════════════════════════ # Get-WorkflowStatus — script-phase probe + process-type filter # Regression tests for #244: Overview stuck on Task Group Expansion From fa15cdced7ad2750b030f5bf75af8744e1b9118a Mon Sep 17 00:00:00 2001 From: Ivan Bondarenko Date: Thu, 25 Jun 2026 13:03:28 +0300 Subject: [PATCH 08/50] fix(worktree): claim non-prompt tasks before worktree creation to prevent duplicate-worktree race --- .../Scripts/Invoke-WorkflowProcess.ps1 | 60 +++++++++++++++++-- tests/Test-ProcessDispatch.ps1 | 4 +- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 index ba141eb2..913449e2 100644 --- a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 +++ b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 @@ -1375,7 +1375,8 @@ try { # --- Multi-slot claim guard --- # When running with -Slot (concurrent workflow processes), another slot may # have claimed this task between our Get-NextWorkflowTask and this point. - # Only needed for prompt tasks — non-prompt tasks are guarded by the slot 0 check above. + # Only needed for prompt tasks — non-prompt tasks have their own claim guard + # before worktree creation below. if ($Slot -ge 0 -and $taskTypeCheck -eq 'prompt') { $claimOk = $false for ($claimAttempt = 0; $claimAttempt -lt 5; $claimAttempt++) { @@ -1474,6 +1475,60 @@ try { Write-Status "Auto-dispatching $taskTypeVal task: $($task.name)" -Type Process Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Auto-dispatch $taskTypeVal task: $($task.name)" + # --- Non-prompt task claim guard (before worktree) --- + # Unconditional (no $Slot guard): covers standalone runners (Slot = null/0) + # AND multi-slot runners on slot 0. The prompt-task guard above is $Slot-gated + # because prompt concurrency only occurs in multi-slot mode; non-prompt races + # also occur between standalone processes sharing the same task pool. + $claimOk = $false + $claimAttemptsMade = 0 + for ($claimAttempt = 0; $claimAttempt -lt 5; $claimAttempt++) { + $claimAttemptsMade++ + try { + $claimResult = $null + if ($task.status -notin @('todo', 'needs-input', 'in-progress')) { + throw "Cannot dispatch non-prompt task '$($task.id)' from status '$($task.status)'" + } + if ($task.status -ne 'in-progress') { + $claimResult = Invoke-TaskMarkInProgress -Arguments @{ task_id = $task.id } + } + if ($claimResult -and -not $claimResult.success) { + $errMsg = if ($claimResult.message) { $claimResult.message } else { "HTTP $($claimResult.status_code)" } + throw "Claim failed: $errMsg" + } + if ($claimResult -and $claimResult.body -and $claimResult.body.no_op) { + throw "Task already claimed" + } + if ($claimResult) { $task.status = 'in-progress' } + $claimOk = $true + break + } catch { + $errMsg = $_.Exception.Message + if ($errMsg -notmatch 'already claimed|Claim failed') { + Write-Status "Fatal error claiming task $($task.id): $errMsg" -Type Error + throw + } + Write-Diag "Task $($task.id) claimed by another runner, retrying ($taskTypeVal)..." + Start-Sleep -Milliseconds 200 + $taskResult = Get-NextWorkflowTask -BotRoot $botRoot -RunId $RunId -WorkflowName $WorkflowName + if (-not $taskResult.task) { break } + $task = $taskResult.task + # Always break on task swap — outer loop re-processes the replacement + # with full task_gen/prompt_template recovery (lines 1434-1473). + # Claiming in-place would bypass that recovery logic. + break + } + } + if (-not $claimOk) { + Write-Status "Could not claim a $taskTypeVal task after $claimAttemptsMade attempts" -Type Warn + Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Could not claim $taskTypeVal task after $claimAttemptsMade attempts" + if ($Continue) { Start-Sleep -Seconds 2; continue } else { break } + } + # Task may have been replaced during claim retry; re-sync process metadata. + $processData.task_id = $task.id + $processData.task_name = $task.name + $env:DOTBOT_CURRENT_TASK_ID = $task.id + $worktreePath = $null $branchName = $null $worktreeSetup = Initialize-DotbotTaskWorktreeForProcess -Task $task ` @@ -1485,9 +1540,6 @@ try { $executionBotRoot = Join-Path $worktreePath ".bot" $executionProductDir = Join-Path (Join-Path $executionBotRoot 'workspace') 'product' - # Mark in-progress - Set-TaskInProgressForExecutorDispatch -Task $task - $typeSuccess = $false $typeError = $null $typeMergeBlocked = $false diff --git a/tests/Test-ProcessDispatch.ps1 b/tests/Test-ProcessDispatch.ps1 index 9480cc60..dcc3a956 100644 --- a/tests/Test-ProcessDispatch.ps1 +++ b/tests/Test-ProcessDispatch.ps1 @@ -193,7 +193,9 @@ Assert-True -Name "Task-runner finalizes WorkflowRun live status" ` -Message "Workflow runner should update .control/workflow-runs/.json when it exits" Assert-True -Name "Task-runner uses legal executor status transitions" ` - -Condition ($workflowProcessContent -match 'Set-TaskInProgressForExecutorDispatch' -and $workflowProcessContent -match 'Set-TaskTerminalFailureForExecutorDispatch') ` + -Condition ($workflowProcessContent -match 'Invoke-TaskMarkInProgress' -and + $workflowProcessContent -match 'Cannot dispatch non-prompt task' -and + $workflowProcessContent -match 'Set-TaskTerminalFailureForExecutorDispatch') ` -Message "Executor tasks must move through in-progress before terminal states" Assert-True -Name "Task-runner no longer carries a bespoke barrier switch case" ` From fb1e8c06c97b2d3685bbaa1767ea14e42f323446 Mon Sep 17 00:00:00 2001 From: Ivan Bondarenko Date: Thu, 25 Jun 2026 15:06:06 +0300 Subject: [PATCH 09/50] fix(worktree): prevent zombie worktree and map leak on partial cleanup failure --- .../Dotbot.Worktree/Dotbot.Worktree.psm1 | 55 ++++++++++++++++--- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 index 71fdb24e..aa2b05ac 100644 --- a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 +++ b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 @@ -1425,6 +1425,14 @@ function New-TaskWorktree { Remove-Item -Path $worktreePath -Recurse -Force -ErrorAction SilentlyContinue # Also prune git's worktree list so it doesn't think it still exists git -C $ProjectRoot worktree prune 2>$null + if (Test-Path $worktreePath) { + return @{ + worktree_path = $worktreePath + branch_name = $branchName + success = $false + message = "Stale worktree directory could not be removed: $worktreePath" + } + } } } @@ -1882,12 +1890,30 @@ function Complete-TaskWorktree { } git -C $ProjectRoot worktree remove $worktreePath 2>$null } - # Verify worktree is actually gone (Fix: silent removal failures) + # Fallback: direct filesystem delete when git worktree remove leaves dir behind (e.g. Windows open handles) + $worktreeParentDir = Join-Path (Split-Path $ProjectRoot -Parent) "worktrees" (Split-Path $ProjectRoot -Leaf) if (Test-Path $worktreePath) { - Write-BotLog -Level Warn -Message "Worktree removal incomplete — path still exists: $worktreePath. Will be retried on next startup." + Assert-PathWithinBounds -Path $worktreePath -ExpectedRoot $worktreeParentDir + Remove-Item -Path $worktreePath -Recurse -Force -ErrorAction SilentlyContinue + git -C $ProjectRoot worktree prune 2>$null } - git -C $ProjectRoot branch -D $branchName 2>$null + if (Test-Path $worktreePath) { + # Keep map entry to preserve tracking. NOTE: Remove-OrphanWorktrees skips done-status tasks, + # so no automatic retry fires. Manual cleanup required: remove $worktreePath and the map entry. + Write-BotLog -Level Error -Message "Worktree removal incomplete — path still exists: $worktreePath. Map entry kept. Manual cleanup required (Remove-OrphanWorktrees will not retry done-status tasks)." + return @{ + success = $true + merge_commit = $mergeCommit + message = "Squash-merged to $baseBranch (worktree directory cleanup failed — manual removal required: $worktreePath)" + conflict_files = @() + failure_kind = $null + failure_detail = "" + push_result = $pushResult + } + } + + git -C $ProjectRoot branch -D $branchName 2>$null # Remove from registry (locked read-modify-write to prevent concurrent entry loss) Invoke-WorktreeMapLocked -BotRoot $BotRoot -Action { $lockedMap = Read-WorktreeMap -BotRoot $BotRoot @@ -2146,6 +2172,7 @@ function Remove-OrphanWorktrees { $orphanIds = @($map.Keys | Where-Object { -not $activeIds.Contains($_) }) + $failedOrphanIds = [System.Collections.Generic.List[string]]::new() foreach ($taskId in $orphanIds) { $entry = $map[$taskId] $worktreePath = $entry.worktree_path @@ -2177,18 +2204,30 @@ function Remove-OrphanWorktrees { } git -C $ProjectRoot worktree remove $worktreePath 2>$null } - # Verify worktree is actually gone (Fix: silent removal failures) + + # Fallback: direct filesystem delete when git worktree remove leaves dir behind (e.g. Windows open handles) + $worktreeParentDir = Join-Path (Split-Path $ProjectRoot -Parent) "worktrees" (Split-Path $ProjectRoot -Leaf) + if ($worktreePath -and (Test-Path $worktreePath)) { + Assert-PathWithinBounds -Path $worktreePath -ExpectedRoot $worktreeParentDir + Remove-Item -Path $worktreePath -Recurse -Force -ErrorAction SilentlyContinue + git -C $ProjectRoot worktree prune 2>$null + } + if ($worktreePath -and (Test-Path $worktreePath)) { - Write-BotLog -Level Warn -Message "Orphan worktree removal incomplete — path still exists: $worktreePath" + # Directory survived all removal attempts — keep in map so next startup retries + Write-BotLog -Level Error -Message "Orphan worktree removal incomplete — path still exists: $worktreePath. Entry kept in map for next-startup retry." + $null = $failedOrphanIds.Add($taskId) + } else { + git -C $ProjectRoot branch -D $branchName 2>$null } - git -C $ProjectRoot branch -D $branchName 2>$null } - if ($orphanIds.Count -gt 0) { + $removedOrphanIds = @($orphanIds | Where-Object { -not $failedOrphanIds.Contains($_) }) + if ($removedOrphanIds.Count -gt 0) { # Locked read-modify-write — prevents concurrent processes from losing map entries Invoke-WorktreeMapLocked -BotRoot $BotRoot -Action { $lockedMap = Read-WorktreeMap -BotRoot $BotRoot - foreach ($id in $orphanIds) { $lockedMap.Remove($id) } + foreach ($id in $removedOrphanIds) { $lockedMap.Remove($id) } Write-WorktreeMap -Map $lockedMap -BotRoot $BotRoot } } From bedc896732102c9b90bf42f2136fba1dd60f7e52 Mon Sep 17 00:00:00 2001 From: Ivan Bondarenko Date: Thu, 25 Jun 2026 18:35:36 +0300 Subject: [PATCH 10/50] fix(worktree): remove dead Get-NextWorkflowTask fetch from claim-guard catch block --- src/runtime/Scripts/Invoke-WorkflowProcess.ps1 | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 index 913449e2..d6a7756e 100644 --- a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 +++ b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 @@ -1510,12 +1510,9 @@ try { } Write-Diag "Task $($task.id) claimed by another runner, retrying ($taskTypeVal)..." Start-Sleep -Milliseconds 200 - $taskResult = Get-NextWorkflowTask -BotRoot $botRoot -RunId $RunId -WorkflowName $WorkflowName - if (-not $taskResult.task) { break } - $task = $taskResult.task - # Always break on task swap — outer loop re-processes the replacement - # with full task_gen/prompt_template recovery (lines 1434-1473). - # Claiming in-place would bypass that recovery logic. + # Break unconditionally — outer loop re-fetches and re-processes the next + # task with full task_gen/prompt_template recovery. Fetching here is dead + # code (result discarded on break) and opens a small race window. break } } From 667cfb44d6b250d51033fa431f9c2f2499f49419 Mon Sep 17 00:00:00 2001 From: Ivan Bondarenko Date: Fri, 26 Jun 2026 00:19:25 +0300 Subject: [PATCH 11/50] test(ProductAPI): fix pending-worktree raw fallback test assertion --- tests/Test-Components.ps1 | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/Test-Components.ps1 b/tests/Test-Components.ps1 index 7e976f26..e8d56858 100644 --- a/tests/Test-Components.ps1 +++ b/tests/Test-Components.ps1 @@ -4450,11 +4450,24 @@ if (Test-Path $productApiModule) { -Message "Get-ProductDocument did not fall back to pending worktree" # Get-ProductDocumentRaw falls back to pending worktree - Set-Content -Path (Join-Path $pendingWtProductDir "diagram.svg") ` - -Value '' -Encoding UTF8 - $pendingRaw = Get-ProductDocumentRaw -Name "diagram-pending.svg" + # Use a name absent from main workspace/product so fallback path is exercised + $pendingSvgContent = '' + Set-Content -Path (Join-Path $pendingWtProductDir "pending-only.svg") ` + -Value $pendingSvgContent -Encoding UTF8 + $pendingRawSvg = Get-ProductDocumentRaw -Name "pending-only.svg" + Assert-True -Name "ProductDocumentRaw falls back to needs-review worktree for SVG" ` + -Condition ($pendingRawSvg.Found -eq $true) ` + -Message "Get-ProductDocumentRaw did not fall back to pending worktree for pending-only.svg" + Assert-Equal -Name "ProductDocumentRaw returns correct MIME type for pending SVG" ` + -Expected "image/svg+xml" ` + -Actual $pendingRawSvg.MimeType + Assert-True -Name "ProductDocumentRaw returns correct content for pending SVG" ` + -Condition ($pendingRawSvg.TextContent -eq $pendingSvgContent) ` + -Message "Pending SVG content mismatch" + + $pendingRawMissing = Get-ProductDocumentRaw -Name "diagram-pending.svg" Assert-True -Name "ProductDocumentRaw returns Found=false for truly missing file" ` - -Condition ($pendingRaw.Found -eq $false) ` + -Condition ($pendingRawMissing.Found -eq $false) ` -Message "File not in any worktree should return Found=false" # In-progress task (status=in-progress in v2 layout) must not be surfaced From c60145fa40a7a5e3de08a638a0b664e1f92989c5 Mon Sep 17 00:00:00 2001 From: Ivan Bondarenko Date: Fri, 26 Jun 2026 00:43:31 +0300 Subject: [PATCH 12/50] test: fix SVG content mismatch in pending-worktree raw fallback test --- tests/Test-Components.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Test-Components.ps1 b/tests/Test-Components.ps1 index e8d56858..4e23eb95 100644 --- a/tests/Test-Components.ps1 +++ b/tests/Test-Components.ps1 @@ -4453,7 +4453,7 @@ if (Test-Path $productApiModule) { # Use a name absent from main workspace/product so fallback path is exercised $pendingSvgContent = '' Set-Content -Path (Join-Path $pendingWtProductDir "pending-only.svg") ` - -Value $pendingSvgContent -Encoding UTF8 + -Value $pendingSvgContent -Encoding UTF8 -NoNewline $pendingRawSvg = Get-ProductDocumentRaw -Name "pending-only.svg" Assert-True -Name "ProductDocumentRaw falls back to needs-review worktree for SVG" ` -Condition ($pendingRawSvg.Found -eq $true) ` From b07b53386c6a7620e8043b4598b6538389ae42a7 Mon Sep 17 00:00:00 2001 From: Emre Kabaoglu Date: Fri, 26 Jun 2026 12:37:01 +0300 Subject: [PATCH 13/50] fix(task-input): own interview answers on task (#516) --- .../prompts/01-plan-product.md | 12 +-- .../Dotbot.TaskInput/Dotbot.TaskInput.psm1 | 80 +++---------------- tests/Test-TaskActions.ps1 | 19 ++++- 3 files changed, 30 insertions(+), 81 deletions(-) diff --git a/content/workflows/start-from-prompt/prompts/01-plan-product.md b/content/workflows/start-from-prompt/prompts/01-plan-product.md index 86ac0f57..f31cb043 100644 --- a/content/workflows/start-from-prompt/prompts/01-plan-product.md +++ b/content/workflows/start-from-prompt/prompts/01-plan-product.md @@ -229,16 +229,16 @@ the decision records created by Phase 1b.] Load dotbot MCP tools in a single ToolSearch call using the comma-separated `select:` form. Same pattern as `content/prompts/98-analyse-task.md`. ``` -ToolSearch({ query: "select:mcp__dotbot__task_set_status,mcp__dotbot__task_update,mcp__dotbot__decision_create,mcp__dotbot__decision_list" }) +ToolSearch({ query: "select:mcp__dotbot__task_get_context,mcp__dotbot__task_set_status,mcp__dotbot__task_update,mcp__dotbot__decision_create,mcp__dotbot__decision_list" }) ``` -If ToolSearch does not return all four selected `mcp__dotbot__*` tools after the documented warm-up retry, stop immediately and report that the dotbot MCP server is unavailable. Do not write `mission.md`, `tech-stack.md`, or `entity-model.md` without these tools; placeholder product docs are invalid output. +If ToolSearch does not return all five selected `mcp__dotbot__*` tools after the documented warm-up retry, stop immediately and report that the dotbot MCP server is unavailable. Do not write `mission.md`, `tech-stack.md`, or `entity-model.md` without these tools; placeholder product docs are invalid output. ### Phase 1: Read Source Material and Prior Answers 1. List `.bot/workspace/product/briefing/` and read every file. 2. Read `README.md` at the project root, `CLAUDE.md`, and any existing content in `docs/`. -3. Read `.bot/workspace/product/interview-answers.json` if it exists. This file holds answers from any prior clarification round on this task. Schema: `{ "answers": [{ "question_id", "question", "answer_key", "answer_label", "answer", "context", "answered_at" }, ...] }`. +3. Read this task's prior clarification answers from task state: call `mcp__dotbot__task_get_context({ task_id: "{{TASK_ID}}" })` and read `extensions.runner.questions_resolved` on the returned task record. This array holds every question already answered on this task (empty or absent on the first pass). Each entry: `{ "id", "question", "context", "answer_key", "answer_label", "answer", "answer_type", "answered_at" }`. (`id` is the question id.) 4. Call `mcp__dotbot__decision_list({ status: "accepted" })` to see accepted decisions already recorded for this project. These feed the Phase 4 dedupe and the Phase 5 `## Key Decisions` listing. ### Phase 2: Triage Ambiguities @@ -250,7 +250,7 @@ Build an internal list of every material ambiguity in the briefing — anything The bar for user-blocking: would a senior product owner want to be in the room for this call? If yes, ask. If no, decide. -Skip ambiguities already resolved in `interview-answers.json` from Phase 1. +Skip ambiguities already resolved in `questions_resolved` from Phase 1. **Hard cap of four.** The runtime supports exactly one clarification round per task. If the user-blocking bucket exceeds four items, rank by: @@ -289,7 +289,7 @@ mcp__dotbot__task_update({ mcp__dotbot__task_set_status({ task_id: "{{TASK_ID}}", status: "needs-input" }) ``` -Then STOP. The runner will pause the task, surface the questions to the user, and resume this prompt once every pending question has been answered. On resume, re-enter Phase 1 — `interview-answers.json` will contain the new answers. +Then STOP. The runner will pause the task, surface the questions to the user, and resume this prompt once every pending question has been answered. On resume, re-enter Phase 1 — `questions_resolved` will contain the new answers. Do not issue the pause pattern again on resume. The runtime sets `all_questions_answered = true` once the round closes and a second `task_set_status({ status: "needs-input" })` will throw. On resume, proceed straight from Phase 1 (re-read answers) to Phase 4 (record decisions) to Phase 5 (write deliverables). @@ -356,4 +356,4 @@ If, after Phase 3, no user-blocking question remained (everything was agent-deci - `tech-stack.md` covers the seven sections (Languages, Frameworks, Libraries, Tooling, Infrastructure, Dev Env, Rationale). Rationale references decision IDs where applicable. - `entity-model.md` includes at least one entity, all referenced enums, a Mermaid `erDiagram`, a Data Storage section, and a Design Decisions section that references decision IDs where applicable. - Every material ambiguity surfaced during planning has either a Decision record (status `accepted`) or is reflected in deliverable prose. Nothing is parked as an open question. -- For each user-answered question in `interview-answers.json`, a Decision exists with matching `title` and `tags` containing `clarification` and `stage:product-docs`. +- For each user-answered question in `questions_resolved`, a Decision exists with matching `title` and `tags` containing `clarification` and `stage:product-docs`. diff --git a/src/runtime/Modules/Dotbot.TaskInput/Dotbot.TaskInput.psm1 b/src/runtime/Modules/Dotbot.TaskInput/Dotbot.TaskInput.psm1 index 52e9aa70..da6facc1 100644 --- a/src/runtime/Modules/Dotbot.TaskInput/Dotbot.TaskInput.psm1 +++ b/src/runtime/Modules/Dotbot.TaskInput/Dotbot.TaskInput.psm1 @@ -333,57 +333,6 @@ function Resolve-TaskInputAnswer { } } -function Get-TaskInputProductDir { - param( - [Parameter(Mandatory)][string]$BotRoot, - [string]$TaskId - ) - - if ($TaskId -and (Get-Command Get-TaskWorktreeInfo -ErrorAction SilentlyContinue)) { - try { - $worktreeInfo = Get-TaskWorktreeInfo -TaskId $TaskId -BotRoot $BotRoot - $worktreePath = if ($worktreeInfo -and $worktreeInfo.PSObject.Properties['worktree_path']) { [string]$worktreeInfo.worktree_path } else { $null } - if ($worktreePath -and (Test-Path -LiteralPath $worktreePath)) { - $worktreeProductDir = Join-Path $worktreePath ".bot/workspace/product" - if (Test-Path -LiteralPath $worktreeProductDir) { - return $worktreeProductDir - } - } - } catch { - if (Get-Command Write-BotLog -ErrorAction SilentlyContinue) { - Write-BotLog -Level Debug -Message "Could not resolve task worktree product dir" -Exception $_ - } - } - } - - return (Join-Path (Join-Path $BotRoot "workspace") "product") -} - -function Write-TaskInputInterviewAnswer { - param( - [string]$BotRoot, - [string]$TaskId, - [hashtable]$Entry - ) - - $productDir = Get-TaskInputProductDir -BotRoot $BotRoot -TaskId $TaskId - if (-not (Test-Path -LiteralPath $productDir)) { return } - - $answersPath = Join-Path $productDir "interview-answers.json" - $existing = @() - if (Test-Path -LiteralPath $answersPath) { - try { - $existing = @((Get-Content -LiteralPath $answersPath -Raw | ConvertFrom-Json).answers) - } catch { - $existing = @() - } - } - - $existing = @($existing | Where-Object { $_.question_id -ne $Entry.question_id }) - $existing += [pscustomobject]$Entry - @{ answers = $existing } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $answersPath -Encoding UTF8NoBOM -} - function Get-TaskInputTargetPath { param( [Parameter(Mandatory)] [System.IO.FileInfo]$TaskFile, @@ -457,10 +406,19 @@ function Add-TaskInputResolvedQuestion { [array]$ReviewedAttachmentIds ) + # questions_resolved is the canonical, task-owned record of every answered + # question (issue #516). It carries the full answer detail the start-from-prompt + # workflow needs — context plus the structured option key/label — so that + # workflow can reread answers from task state instead of a shared product-dir + # file. Per-task state travels with the task and never collides across parallel + # worktrees, so no runtime lock/exclude/merge is required. $entry = @{ id = Get-TaskInputProp -Object $Question -Name 'id' question = Get-TaskInputProp -Object $Question -Name 'question' + context = Get-TaskInputProp -Object $Question -Name 'context' answer = $Resolved.answer + answer_key = $Resolved.answer_key + answer_label = $Resolved.answer_label answer_type = $Resolved.answer_type asked_at = Get-TaskInputProp -Object $Question -Name 'asked_at' answered_at = $AnsweredAt @@ -526,16 +484,6 @@ function Invoke-TaskQuestionAnswerTransition { $resolved = Resolve-TaskInputAnswer -Question $targetQuestion -Answer $Answer $now = Get-TaskInputTimestamp Add-TaskInputResolvedQuestion -RunnerBag $runner -Question $targetQuestion -Resolved $resolved -AnsweredAt $now -AnsweredVia $AnsweredVia -Attachments $Attachments -Comment $Comment -RankedItems $RankedItems -ReviewedAttachmentIds $ReviewedAttachmentIds | Out-Null - Write-TaskInputInterviewAnswer -BotRoot $BotRoot -TaskId $taskId -Entry @{ - task_id = $taskId - question_id = Get-TaskInputProp -Object $targetQuestion -Name 'id' - question = Get-TaskInputProp -Object $targetQuestion -Name 'question' - context = Get-TaskInputProp -Object $targetQuestion -Name 'context' - answer_key = $resolved.answer_key - answer_label = $resolved.answer_label - answer = $resolved.answer - answered_at = $now - } $targetQuestionId = Get-TaskInputProp -Object $targetQuestion -Name 'id' $remaining = @($pendingQuestions | Where-Object { (Get-TaskInputProp -Object $_ -Name 'id') -ne $targetQuestionId }) @@ -609,16 +557,6 @@ function Invoke-TaskQuestionAnswerTransition { $resolvedSingle = Resolve-TaskInputAnswer -Question $pendingQuestion -Answer $Answer $nowSingle = Get-TaskInputTimestamp Add-TaskInputResolvedQuestion -RunnerBag $runner -Question $pendingQuestion -Resolved $resolvedSingle -AnsweredAt $nowSingle -AnsweredVia $AnsweredVia -Attachments $Attachments -Comment $Comment -RankedItems $RankedItems -ReviewedAttachmentIds $ReviewedAttachmentIds | Out-Null - Write-TaskInputInterviewAnswer -BotRoot $BotRoot -TaskId $taskId -Entry @{ - task_id = $taskId - question_id = Get-TaskInputProp -Object $pendingQuestion -Name 'id' - question = Get-TaskInputProp -Object $pendingQuestion -Name 'question' - context = Get-TaskInputProp -Object $pendingQuestion -Name 'context' - answer_key = $resolvedSingle.answer_key - answer_label = $resolvedSingle.answer_label - answer = $resolvedSingle.answer - answered_at = $nowSingle - } Set-TaskInputProp -Object $runner -Name 'pending_question' -Value $null Remove-TaskInputProp -Object $runner -Name 'notification' diff --git a/tests/Test-TaskActions.ps1 b/tests/Test-TaskActions.ps1 index 4525b679..16289234 100644 --- a/tests/Test-TaskActions.ps1 +++ b/tests/Test-TaskActions.ps1 @@ -598,10 +598,21 @@ try { Assert-Equal -Name "TaskAPI batch answer records answered question id" ` -Expected "q1" ` -Actual $batchAfterFirst.extensions.runner.questions_resolved[0].id - $batchWorktreeAnswers = Get-Content (Join-Path $batchWorktreeProductDir "interview-answers.json") -Raw | ConvertFrom-Json - Assert-Equal -Name "TaskAPI batch answer writes interview answer to active task worktree" ` - -Expected "q1" ` - -Actual $batchWorktreeAnswers.answers[0].question_id + # The answer is owned by the task: questions_resolved is the canonical record + # and now carries the full answer detail the start-from-prompt workflow needs + # (issue #516). No interview-answers.json is written anywhere — per-task state + # travels with the task and never collides across parallel worktrees. + $batchResolved = $batchAfterFirst.extensions.runner.questions_resolved[0] + Assert-Equal -Name "TaskAPI batch answer enriches questions_resolved with answer_key" ` + -Expected "A" ` + -Actual $batchResolved.answer_key + Assert-Equal -Name "TaskAPI batch answer enriches questions_resolved with answer_label" ` + -Expected "First answer" ` + -Actual $batchResolved.answer_label + Assert-True -Name "TaskAPI batch answer writes no interview-answers.json (task-owned state)" ` + -Condition (-not (Test-Path -LiteralPath (Join-Path $botDir "workspace/product/interview-answers.json")) -and ` + -not (Test-Path -LiteralPath (Join-Path $batchWorktreeProductDir "interview-answers.json"))) ` + -Message "Expected no interview-answers.json in main checkout or worktree; answers live on the task" $secondBatchResult = Submit-TaskAnswer -TaskId $batchTaskId -QuestionId "q2" -Answer "B" Assert-True -Name "TaskAPI batch answer resumes after final question" ` From 6c93b583ea5a215fbb985005e779a68488693f37 Mon Sep 17 00:00:00 2001 From: Ivan Bondarenko Date: Fri, 26 Jun 2026 12:56:05 +0300 Subject: [PATCH 14/50] fix(worktree): gate filesystem-delete fallback on junction teardown --- .../Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 index aa2b05ac..dedaaba7 100644 --- a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 +++ b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 @@ -1890,9 +1890,13 @@ function Complete-TaskWorktree { } git -C $ProjectRoot worktree remove $worktreePath 2>$null } - # Fallback: direct filesystem delete when git worktree remove leaves dir behind (e.g. Windows open handles) + # Fallback: direct filesystem delete when git worktree remove leaves the dir + # behind (e.g. Windows open handles). Gated on junctions being gone — on + # Windows Remove-Item -Recurse follows junctions and would delete link + # targets (shared task state, product workspace). If junctions survived, + # skip the delete; the entry is kept below for manual cleanup. $worktreeParentDir = Join-Path (Split-Path $ProjectRoot -Parent) "worktrees" (Split-Path $ProjectRoot -Leaf) - if (Test-Path $worktreePath) { + if ((Test-Path $worktreePath) -and $junctionsClean -and -not (Test-JunctionsExist -WorktreePath $worktreePath)) { Assert-PathWithinBounds -Path $worktreePath -ExpectedRoot $worktreeParentDir Remove-Item -Path $worktreePath -Recurse -Force -ErrorAction SilentlyContinue git -C $ProjectRoot worktree prune 2>$null @@ -2205,9 +2209,13 @@ function Remove-OrphanWorktrees { git -C $ProjectRoot worktree remove $worktreePath 2>$null } - # Fallback: direct filesystem delete when git worktree remove leaves dir behind (e.g. Windows open handles) + # Fallback: direct filesystem delete when git worktree remove leaves the dir + # behind (e.g. Windows open handles). Gated on junctions being gone — on + # Windows Remove-Item -Recurse follows junctions and would delete link + # targets (shared task state, product workspace). If junctions survived, + # skip the delete; the entry is kept below for next-startup retry. $worktreeParentDir = Join-Path (Split-Path $ProjectRoot -Parent) "worktrees" (Split-Path $ProjectRoot -Leaf) - if ($worktreePath -and (Test-Path $worktreePath)) { + if ($worktreePath -and (Test-Path $worktreePath) -and $junctionsClean -and -not (Test-JunctionsExist -WorktreePath $worktreePath)) { Assert-PathWithinBounds -Path $worktreePath -ExpectedRoot $worktreeParentDir Remove-Item -Path $worktreePath -Recurse -Force -ErrorAction SilentlyContinue git -C $ProjectRoot worktree prune 2>$null From 4f99827088b74ca35d9613dd23d753561cad1163 Mon Sep 17 00:00:00 2001 From: Ivan Bondarenko Date: Fri, 26 Jun 2026 13:21:51 +0300 Subject: [PATCH 15/50] fix: address pwsh-review --- .../Dotbot.Worktree/Dotbot.Worktree.psm1 | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 index dedaaba7..5b9935f4 100644 --- a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 +++ b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 @@ -1577,12 +1577,17 @@ function Complete-TaskWorktree { .OUTPUTS Hashtable with: - success — $true on full merge+commit+cleanup, $false otherwise + success — $true when merge+commit succeeded. NOTE: $true does NOT + guarantee worktree cleanup succeeded — if the directory + could not be removed (e.g. Windows open handles), success + stays $true, failure_kind stays $null, and the cleanup + failure is reported only via 'message' and an Error log. merge_commit — SHA of the resulting commit (success only) or $null message — human-readable summary conflict_files — array of conflicting paths (only populated for failure_kind='rebase_conflict') - failure_kind — one of: $null (on success), 'rebase_conflict', + failure_kind — one of: $null (merge succeeded; see 'success' re: cleanup), + 'rebase_conflict', 'branch_missing', 'merge_command_failed', 'commit_failed', 'exception'. Drives the kind-specific pending_question built by Move-TaskToMergeFailureNeedsInput. @@ -1896,16 +1901,18 @@ function Complete-TaskWorktree { # targets (shared task state, product workspace). If junctions survived, # skip the delete; the entry is kept below for manual cleanup. $worktreeParentDir = Join-Path (Split-Path $ProjectRoot -Parent) "worktrees" (Split-Path $ProjectRoot -Leaf) + $rmErr = $null if ((Test-Path $worktreePath) -and $junctionsClean -and -not (Test-JunctionsExist -WorktreePath $worktreePath)) { Assert-PathWithinBounds -Path $worktreePath -ExpectedRoot $worktreeParentDir - Remove-Item -Path $worktreePath -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $worktreePath -Recurse -Force -ErrorAction SilentlyContinue -ErrorVariable rmErr git -C $ProjectRoot worktree prune 2>$null } if (Test-Path $worktreePath) { # Keep map entry to preserve tracking. NOTE: Remove-OrphanWorktrees skips done-status tasks, # so no automatic retry fires. Manual cleanup required: remove $worktreePath and the map entry. - Write-BotLog -Level Error -Message "Worktree removal incomplete — path still exists: $worktreePath. Map entry kept. Manual cleanup required (Remove-OrphanWorktrees will not retry done-status tasks)." + $rmDetail = if ($rmErr) { " Last delete error: $(($rmErr | Select-Object -First 1).Exception.Message)" } else { "" } + Write-BotLog -Level Error -Message "Worktree removal incomplete — path still exists: $worktreePath. Map entry kept. Manual cleanup required (Remove-OrphanWorktrees will not retry done-status tasks).$rmDetail" return @{ success = $true merge_commit = $mergeCommit @@ -2215,15 +2222,26 @@ function Remove-OrphanWorktrees { # targets (shared task state, product workspace). If junctions survived, # skip the delete; the entry is kept below for next-startup retry. $worktreeParentDir = Join-Path (Split-Path $ProjectRoot -Parent) "worktrees" (Split-Path $ProjectRoot -Leaf) + $rmErr = $null if ($worktreePath -and (Test-Path $worktreePath) -and $junctionsClean -and -not (Test-JunctionsExist -WorktreePath $worktreePath)) { - Assert-PathWithinBounds -Path $worktreePath -ExpectedRoot $worktreeParentDir - Remove-Item -Path $worktreePath -Recurse -Force -ErrorAction SilentlyContinue - git -C $ProjectRoot worktree prune 2>$null + try { + Assert-PathWithinBounds -Path $worktreePath -ExpectedRoot $worktreeParentDir + Remove-Item -Path $worktreePath -Recurse -Force -ErrorAction SilentlyContinue -ErrorVariable rmErr + git -C $ProjectRoot worktree prune 2>$null + } catch { + # A bounds-check failure (e.g. a legacy/non-canonical map entry) must + # never abort the whole sweep or skip the map prune below — keep the + # entry and move on to the next orphan. + Write-BotLog -Level Error -Message "Orphan worktree cleanup error for $taskId ($worktreePath): $($_.Exception.Message). Entry kept in map." + $null = $failedOrphanIds.Add($taskId) + continue + } } if ($worktreePath -and (Test-Path $worktreePath)) { # Directory survived all removal attempts — keep in map so next startup retries - Write-BotLog -Level Error -Message "Orphan worktree removal incomplete — path still exists: $worktreePath. Entry kept in map for next-startup retry." + $rmDetail = if ($rmErr) { " Last delete error: $(($rmErr | Select-Object -First 1).Exception.Message)" } else { "" } + Write-BotLog -Level Error -Message "Orphan worktree removal incomplete — path still exists: $worktreePath. Entry kept in map for next-startup retry.$rmDetail" $null = $failedOrphanIds.Add($taskId) } else { git -C $ProjectRoot branch -D $branchName 2>$null From 9c757bf62c2b245ebfa980af82b9acf54bdbc960 Mon Sep 17 00:00:00 2001 From: Ivan Bondarenko Date: Thu, 2 Jul 2026 11:18:33 +0300 Subject: [PATCH 16/50] fix(process): dep-gated conditions + barriers wait for spawned children (#569) --- .../Dotbot.Process/Dotbot.Process.psm1 | 67 +++++++++- tests/Test-TaskActions.ps1 | 120 ++++++++++++++++++ 2 files changed, 186 insertions(+), 1 deletion(-) diff --git a/src/runtime/Modules/Dotbot.Process/Dotbot.Process.psm1 b/src/runtime/Modules/Dotbot.Process/Dotbot.Process.psm1 index 93a9053a..2685b8d3 100644 --- a/src/runtime/Modules/Dotbot.Process/Dotbot.Process.psm1 +++ b/src/runtime/Modules/Dotbot.Process/Dotbot.Process.psm1 @@ -623,6 +623,51 @@ function Get-NextWorkflowTask { } return $true } + function _HasOutstandingGeneratedChildren { param($Task, $All) + # True when any non-terminal task was generated/expanded by one of $Task's + # dependencies. Children stamp extensions.runner.generated_by = + # and provenance.expanded_by = "task:"; a barrier's dependencies + # are canonical parent ids, so this is a direct id match (with a slug + # fallback for name-based deps). Scope note: this is invoked only for + # type=barrier tasks and matches direct children (not nested descendants); + # widening is a one-line change at the call site. + $deps = @($Task.dependencies) | Where-Object { $_ } | ForEach-Object { [string]$_ } + if ($deps.Count -eq 0) { return $false } + $depSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($d in $deps) { + [void]$depSet.Add($d) + $slug = ($d -replace '[^a-zA-Z0-9\s-]','' -replace '\s+','-').ToLowerInvariant() + if ($slug) { [void]$depSet.Add($slug) } + } + foreach ($t in $All) { + $c = $t.Content + # "Complete" uses the SAME rule as the dependency done-set above: a + # skipped child counts as complete only when its skip reason is + # intentional. A framework-error skip (max-retries / non-recoverable) + # is NOT complete — Reset-SkippedTasks retries it — so the barrier must + # keep waiting rather than fire over failed/​retried work. needs-input / + # needs-review children are likewise outstanding until resolved. + $childComplete = switch ([string]$c.status) { + 'done' { $true } + 'cancelled' { $true } + 'split' { $true } + 'skipped' { $r = Get-DotbotTaskSkipReason -TaskContent $c; [bool]($r -and $intentionalSkips -contains $r) } + default { $false } + } + if ($childComplete) { continue } + $gen = Get-DotbotTaskNestedProp -Object $c -Path @('extensions','runner','generated_by') + $exp = Get-DotbotTaskNestedProp -Object $c -Path @('provenance','expanded_by') + if ($exp -and "$exp" -match '^task:(.+)$') { $exp = $Matches[1] } + foreach ($ref in @($gen, $exp)) { + if (-not $ref) { continue } + $refStr = [string]$ref + if ($depSet.Contains($refStr)) { return $true } + $refSlug = ($refStr -replace '[^a-zA-Z0-9\s-]','' -replace '\s+','-').ToLowerInvariant() + if ($refSlug -and $depSet.Contains($refSlug)) { return $true } + } + } + return $false + } function _IsTaskIgnored { param($Task) if ($Task.PSObject.Properties['ignore'] -and $Task.ignore -and $Task.ignore.PSObject.Properties['manual'] -and $Task.ignore.manual -eq $true) { @@ -662,7 +707,18 @@ function Get-NextWorkflowTask { foreach ($cand in $candidates) { $c = $cand.Content if (_IsTaskIgnored -Task $c) { continue } + # Dependencies gate the manifest condition. Evaluating the condition first + # (as this loop used to) permanently skipped a task whose condition points + # at a file its own upstream dependency produces — the file does not exist + # yet at launch, so the task was marked condition-not-met before the + # producer ran, and that skip cascaded (condition-not-met satisfies + # dependents). Checking deps first keeps such a task 'todo' (blocked); the + # condition is re-evaluated on a later selection pass once the producer has + # completed (Get-NextWorkflowTask reloads task state from disk each call). + if (-not (_AreDepsMet -Task $c -Set $doneSet)) { $blockedCount++; continue } if (-not (_IsManifestConditionMet -Task $c)) { + # Deps are satisfied but the condition is genuinely unmet, so the + # producing task has already had its turn — a real condition-skip. try { _MarkConditionNotMet -Candidate $cand } catch { @@ -673,7 +729,16 @@ function Get-NextWorkflowTask { } continue } - if (-not (_AreDepsMet -Task $c -Set $doneSet)) { $blockedCount++; continue } + # A barrier must not fire while work its dependencies spawned into + # tasks/todo is still outstanding. Its explicit deps (the generator) go + # done the instant generation finishes, but the spawned children run after. + if (([string]$c.type -eq 'barrier') -and (_HasOutstandingGeneratedChildren -Task $c -All $allTasks)) { + if (Get-Command Write-BotLog -ErrorAction SilentlyContinue) { + Write-BotLog -Level Debug -Message "Barrier '$($c.name)' blocked: generated children of its dependencies are not yet complete." + } + $blockedCount++ + continue + } $eligible += $cand } if ($eligible.Count -gt 0) { diff --git a/tests/Test-TaskActions.ps1 b/tests/Test-TaskActions.ps1 index 16289234..e2da57e0 100644 --- a/tests/Test-TaskActions.ps1 +++ b/tests/Test-TaskActions.ps1 @@ -1675,6 +1675,126 @@ finally { } } +# ─── Get-NextWorkflowTask scheduling: condition-order + barrier wiring (#569) ── +# Bug 4: a task whose manifest condition points at a file its own upstream +# dependency produces must stay 'todo' (blocked) until the producer runs — not be +# skipped condition-not-met at launch. Bug 5: a barrier must not fire while work +# its dependencies spawned into tasks/todo is still outstanding. + +$testProject569 = $null +$savedDotbotProjectRoot569 = $global:DotbotProjectRoot +try { + $testProject569 = New-SourceBackedTestProject -RepoRoot $repoRoot + Push-Location $testProject569 + $botDir569 = Join-Path $testProject569 ".bot" + $runDir569 = Join-Path $botDir569 "workspace\tasks\workflow-runs\run-569" + New-Item -ItemType Directory -Path $runDir569 -Force | Out-Null + $global:DotbotProjectRoot = $testProject569 + + Import-Module (Join-Path $botDir569 "src/runtime/Modules/Dotbot.Task/Dotbot.Task.psd1") -Force -DisableNameChecking + Import-Module (Join-Path $botDir569 "src/runtime/Modules/Dotbot.Workflow/Dotbot.Workflow.psd1") -Force -DisableNameChecking + Import-Module (Join-Path $botDir569 "src/runtime/Modules/Dotbot.Process/Dotbot.Process.psd1") -Force -DisableNameChecking + + function New-SchedTaskFixture { + param( + [Parameter(Mandatory)][string]$TaskId, + [Parameter(Mandatory)][string]$Status, + [string]$Type = 'prompt', + [int]$Priority = 50, + [string[]]$Dependencies = @(), + [string]$Condition, + [string]$GeneratedBy + ) + $task = [ordered]@{ + id = $TaskId + name = "Fixture $TaskId" + description = "scheduling fixture" + status = $Status + type = $Type + priority = $Priority + dependencies = @($Dependencies) + provenance = [ordered]@{ workflow = 'sched569-cond'; run_id = 'run-569' } + updated_at = "2026-01-01T00:00:00Z" + } + $ext = [ordered]@{} + if ($Condition) { $ext['workflow'] = [ordered]@{ condition = $Condition } } + if ($GeneratedBy) { $ext['runner'] = [ordered]@{ generated_by = $GeneratedBy } } + if ($ext.Count -gt 0) { $task['extensions'] = $ext } + $task | ConvertTo-Json -Depth 10 | Set-Content -Path (Join-Path $runDir569 "$TaskId.json") -Encoding UTF8 + } + + # ── Bug 4: condition gated behind dependencies ── + $gateRel = ".bot/workspace/product/gate-569.md" + $gateFull = Join-Path $testProject569 $gateRel + New-Item -ItemType Directory -Path (Split-Path $gateFull -Parent) -Force | Out-Null + if (Test-Path $gateFull) { Remove-Item $gateFull -Force } + + New-SchedTaskFixture -TaskId "prod-569" -Status "todo" -Priority 90 + New-SchedTaskFixture -TaskId "cond-569" -Status "todo" -Priority 50 -Dependencies @("prod-569") -Condition $gateRel + + # Pass 1: gate file absent, producer not done. The conditioned task must NOT be + # selected and must NOT be marked skipped — it stays todo (blocked on deps). + $p1 = Get-NextWorkflowTask -BotRoot $botDir569 -WorkflowName 'sched569-cond' + Assert-True -Name "#569 bug4: producer selected first (conditioned task blocked)" ` + -Condition ($p1.success -and $p1.task -and $p1.task['id'] -eq 'prod-569') ` + -Message "Expected prod-569, got: $($p1 | ConvertTo-Json -Compress)" + $condOnDisk1 = (Get-Content (Join-Path $runDir569 "cond-569.json") -Raw | ConvertFrom-Json) + Assert-Equal -Name "#569 bug4: conditioned task stays todo (not condition-not-met) while deps unmet" ` + -Expected "todo" -Actual ([string]$condOnDisk1.status) + + # Producer completes and creates the gated artifact. + $prodDisk = Get-Content (Join-Path $runDir569 "prod-569.json") -Raw | ConvertFrom-Json + $prodDisk.status = "done" + $prodDisk | ConvertTo-Json -Depth 10 | Set-Content -Path (Join-Path $runDir569 "prod-569.json") -Encoding UTF8 + Set-Content -Path $gateFull -Value "gate" -Encoding UTF8 + + # Pass 2: deps met and condition now true → conditioned task becomes eligible. + $p2 = Get-NextWorkflowTask -BotRoot $botDir569 -WorkflowName 'sched569-cond' + Assert-True -Name "#569 bug4: conditioned task runs once producer done + condition met" ` + -Condition ($p2.success -and $p2.task -and $p2.task['id'] -eq 'cond-569') ` + -Message "Expected cond-569, got: $($p2 | ConvertTo-Json -Compress)" + + # ── Bug 5: barrier waits for its dependency's spawned children ── + $barRun = Join-Path $botDir569 "workspace\tasks\workflow-runs\run-569b" + New-Item -ItemType Directory -Path $barRun -Force | Out-Null + function New-BarrierFixture { + param([string]$TaskId,[string]$Status,[string]$Type='prompt',[int]$Priority=50,[string[]]$Dependencies=@(),[string]$GeneratedBy) + $task = [ordered]@{ + id=$TaskId; name="Fixture $TaskId"; description="barrier fixture"; status=$Status; type=$Type + priority=$Priority; dependencies=@($Dependencies) + provenance=[ordered]@{ workflow='sched569-barrier'; run_id='run-569b' }; updated_at="2026-01-01T00:00:00Z" + } + if ($GeneratedBy) { $task['extensions'] = [ordered]@{ runner = [ordered]@{ generated_by = $GeneratedBy } } } + $task | ConvertTo-Json -Depth 10 | Set-Content -Path (Join-Path $barRun "$TaskId.json") -Encoding UTF8 + } + New-BarrierFixture -TaskId "gen-569" -Status "done" -Type "task_gen" -Priority 90 + New-BarrierFixture -TaskId "child-569" -Status "todo" -Type "prompt" -Priority 10 -GeneratedBy "gen-569" + New-BarrierFixture -TaskId "barr-569" -Status "todo" -Type "barrier" -Priority 80 -Dependencies @("gen-569") + + # Barrier has higher priority than the child and its explicit dep (gen) is done, + # so without the fix it would be selected first. It must instead stay blocked + # while the generated child is non-terminal → the child is selected. + $b1 = Get-NextWorkflowTask -BotRoot $botDir569 -WorkflowName 'sched569-barrier' + Assert-True -Name "#569 bug5: barrier blocked while generated child is todo (child selected)" ` + -Condition ($b1.success -and $b1.task -and $b1.task['id'] -eq 'child-569') ` + -Message "Expected child-569 (barrier must wait), got: $($b1 | ConvertTo-Json -Compress)" + + # Child completes → barrier's generated work is done → barrier becomes eligible. + $childDisk = Get-Content (Join-Path $barRun "child-569.json") -Raw | ConvertFrom-Json + $childDisk.status = "done" + $childDisk | ConvertTo-Json -Depth 10 | Set-Content -Path (Join-Path $barRun "child-569.json") -Encoding UTF8 + + $b2 = Get-NextWorkflowTask -BotRoot $botDir569 -WorkflowName 'sched569-barrier' + Assert-True -Name "#569 bug5: barrier fires once generated children complete" ` + -Condition ($b2.success -and $b2.task -and $b2.task['id'] -eq 'barr-569') ` + -Message "Expected barr-569, got: $($b2 | ConvertTo-Json -Compress)" +} +finally { + $global:DotbotProjectRoot = $savedDotbotProjectRoot569 + Pop-Location -ErrorAction SilentlyContinue + if ($testProject569) { Remove-TestProject -Path $testProject569 } +} + # ─── task-get-next runtime condition evaluation ────────────────────────────── # task-get-next is a thin HTTP wrapper around GET /tasks/next. Condition # evaluation is covered by handler-level runtime tests. From bd6c0aa4ba582498760491bdfae1912c0b0f9f31 Mon Sep 17 00:00:00 2001 From: EnmaJim <34482837+EnmaJim@users.noreply.github.com> Date: Thu, 2 Jul 2026 06:54:47 -0400 Subject: [PATCH 17/50] fix(ci): PR-based bump-release and repair v4 release pipeline (#567) --- .github/workflows/bump-release.yml | 114 ++++++++++++++++++++-- .github/workflows/release.yml | 152 ++++++++++++++--------------- 2 files changed, 183 insertions(+), 83 deletions(-) diff --git a/.github/workflows/bump-release.yml b/.github/workflows/bump-release.yml index 8ca41d99..103066b0 100644 --- a/.github/workflows/bump-release.yml +++ b/.github/workflows/bump-release.yml @@ -13,14 +13,16 @@ on: permissions: contents: write + pull-requests: write jobs: bump: - name: Bump version and tag + name: Open version-bump PR runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: + fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - name: Install PowerShell @@ -29,8 +31,28 @@ jobs: sudo apt-get update && sudo apt-get install -y powershell fi + - name: Check for changes since last release + id: gate + shell: bash + run: | + LAST_TAG="$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true)" + if [ -z "$LAST_TAG" ]; then + echo "No prior version tag found — treating as releasable." + echo "release=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + COUNT="$(git rev-list "${LAST_TAG}..HEAD" --count)" + echo "Last tag: $LAST_TAG — $COUNT commit(s) since." + if [ "${{ github.event_name }}" = "schedule" ] && [ "$COUNT" -eq 0 ]; then + echo "Nothing merged since $LAST_TAG — nothing to release." + echo "release=false" >> "$GITHUB_OUTPUT" + else + echo "release=true" >> "$GITHUB_OUTPUT" + fi + - name: Bump version id: bump + if: steps.gate.outputs.release == 'true' shell: pwsh run: | $bump = "${{ github.event.inputs.bump || 'patch' }}" @@ -43,16 +65,94 @@ jobs: 'patch' { $parts[2] = [int]$parts[2] + 1 } } $new = $parts -join '.' + + $existingTag = git tag --list "v$new" + if ($existingTag) { + Write-Error "Tag v$new already exists — aborting to avoid a duplicate release." + exit 1 + } + $existingBranch = git ls-remote --heads origin "release/v$new" + if ($existingBranch) { + Write-Error "Branch release/v$new already exists on origin — aborting." + exit 1 + } + @{ version = $new } | ConvertTo-Json | Set-Content $versionFile Write-Host "Bumped $current -> $new ($bump)" "version=$new" >> $env:GITHUB_OUTPUT - - name: Commit and tag + - name: Promote CHANGELOG Unreleased block + id: changelog + if: steps.gate.outputs.release == 'true' + shell: pwsh + run: | + $version = "${{ steps.bump.outputs.version }}" + $date = (Get-Date).ToString('yyyy-MM-dd') + $path = "CHANGELOG.md" + $lines = Get-Content $path + + $anchor = ($lines | Select-String -Pattern '^## \[Unreleased\]' -SimpleMatch:$false | Select-Object -First 1) + if (-not $anchor) { + Write-Error "No '## [Unreleased]' section found in $path" + exit 1 + } + $idx = $anchor.LineNumber - 1 + $bodyStart = $idx + 1 + $next = $lines.Length + for ($i = $bodyStart; $i -lt $lines.Length; $i++) { + if ($lines[$i] -match '^## \[') { $next = $i; break } + } + + $notes = ($lines[$bodyStart..($next - 1)] -join "`n").Trim() + $hasContent = $notes -split "`n" | Where-Object { $_ -notmatch '^\s*(#{2,6}\s|$)' } + if (-not $hasContent) { + $notes = "Maintenance release." + } + Set-Content -Path "release-notes.md" -Value $notes + + $scaffold = @( + '## [Unreleased]', + '', + '### Added', + '', + '### Changed', + '', + '### Fixed', + '', + '### Removed', + '', + "## [$version] - $date" + ) + + $rebuilt = @() + $rebuilt += $lines[0..($idx - 1)] + $rebuilt += $scaffold + $rebuilt += $lines[$bodyStart..($lines.Length - 1)] + Set-Content -Path $path -Value $rebuilt + Write-Host "Promoted [Unreleased] -> [$version] - $date" + + - name: Create branch, commit, open PR + if: steps.gate.outputs.release == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash run: | + set -euo pipefail VERSION="${{ steps.bump.outputs.version }}" + BRANCH="release/v${VERSION}" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add version.json - git commit -m "Bump version to v${VERSION}" - git tag "v${VERSION}" - git push origin main --tags + git checkout -b "$BRANCH" + git add version.json CHANGELOG.md + git commit -m "chore(release): bump version to v${VERSION}" + git push -u origin "$BRANCH" + { + cat release-notes.md + printf '\n\n---\n_Merging this PR cuts v%s — **Release & Publish** runs automatically on merge._\n' "$VERSION" + } > pr-body.md + gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "chore(release): v${VERSION}" \ + --body-file pr-body.md \ + --label no-issue diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b9ad011..10530e0d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,9 @@ name: Release & Publish on: push: - tags: ['v*'] + branches: [main] + paths: ['version.json'] + workflow_dispatch: permissions: contents: write @@ -20,7 +22,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install PowerShell (Linux) if: runner.os == 'Linux' @@ -63,11 +65,6 @@ jobs: with: node-version: '20' - - name: Install dotbot globally - shell: pwsh - run: | - pwsh -NoProfile -ExecutionPolicy Bypass -File install.ps1 - - name: Run tests (layers 1-3) shell: pwsh run: | @@ -83,7 +80,7 @@ jobs: sha256_tar: ${{ steps.hashes.outputs.sha256_tar }} sha256_zip: ${{ steps.hashes.outputs.sha256_zip }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install PowerShell run: | @@ -91,49 +88,83 @@ jobs: sudo apt-get update && sudo apt-get install -y powershell fi - - name: Extract version from tag + - name: Resolve version from version.json id: version + shell: bash run: | - VERSION="${GITHUB_REF_NAME#v}" + VERSION="$(pwsh -NoProfile -Command '(Get-Content version.json | ConvertFrom-Json).version')" echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Releasing v$VERSION" + + - name: Skip if release already exists + id: exists + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${{ steps.version.outputs.version }}" + if gh release view "v${VERSION}" >/dev/null 2>&1 || git ls-remote --exit-code --tags origin "v${VERSION}" >/dev/null 2>&1; then + echo "Release/tag v${VERSION} already exists — skipping." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi - - name: Validate version matches version.json + - name: Extract release notes from CHANGELOG + if: steps.exists.outputs.skip != 'true' shell: pwsh run: | - $expected = "${{ steps.version.outputs.version }}" - $actual = (Get-Content version.json | ConvertFrom-Json).version - if ($expected -ne $actual) { - Write-Error "Tag version ($expected) does not match version.json ($actual)" - exit 1 + $version = "${{ steps.version.outputs.version }}" + $path = "CHANGELOG.md" + $notes = "" + if (Test-Path $path) { + $lines = Get-Content $path + $start = -1 + for ($i = 0; $i -lt $lines.Length; $i++) { + if ($lines[$i] -match "^## \[$([regex]::Escape($version))\]") { $start = $i + 1; break } + } + if ($start -ge 0) { + $end = $lines.Length + for ($i = $start; $i -lt $lines.Length; $i++) { + if ($lines[$i] -match '^## \[') { $end = $i; break } + } + $notes = ($lines[$start..($end - 1)] -join "`n").Trim() + $hasContent = $notes -split "`n" | Where-Object { $_ -notmatch '^\s*(#{2,6}\s|$)' } + if (-not $hasContent) { $notes = "Maintenance release." } + } } + Set-Content -Path "release-notes.md" -Value $notes - name: Setup Node.js + if: steps.exists.outputs.skip != 'true' uses: actions/setup-node@v4 with: node-version: '20' - name: Build Studio UI - working-directory: studio-ui + if: steps.exists.outputs.skip != 'true' + working-directory: src/studio-ui run: npm ci && npm run build - name: Build archives + if: steps.exists.outputs.skip != 'true' run: | VERSION="${{ steps.version.outputs.version }}" STAGING="${ARCHIVE_PREFIX}-${VERSION}" mkdir "$STAGING" - cp -r install.ps1 install-remote.ps1 scripts/ workflows/ stacks/ LICENSE README.md \ - version.json dotbot.psd1 dotbot.psm1 "$STAGING/" - # Include studio-ui runtime files (no Node source) - mkdir -p "$STAGING/studio-ui/" - cp studio-ui/server.ps1 studio-ui/StudioAPI.psm1 "$STAGING/studio-ui/" - cp -r studio-ui/static/ "$STAGING/studio-ui/static/" + cp -r bin src content bootstrap.ps1 version.json LICENSE README.md "$STAGING/" + + rm -rf "$STAGING/src/studio-ui" + mkdir -p "$STAGING/src/studio-ui" + cp src/studio-ui/server.ps1 src/studio-ui/StudioAPI.psm1 "$STAGING/src/studio-ui/" + cp -r src/studio-ui/static "$STAGING/src/studio-ui/" tar czf "${STAGING}.tar.gz" "$STAGING" zip -r "${STAGING}.zip" "$STAGING" - name: Compute SHA256 id: hashes + if: steps.exists.outputs.skip != 'true' run: | VERSION="${{ steps.version.outputs.version }}" echo "sha256_tar=$(sha256sum ${ARCHIVE_PREFIX}-${VERSION}.tar.gz | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" @@ -142,66 +173,35 @@ jobs: sha256sum ${ARCHIVE_PREFIX}-${VERSION}.zip > ${ARCHIVE_PREFIX}-${VERSION}.zip.sha256 - name: Create GitHub Release + if: steps.exists.outputs.skip != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | VERSION="${{ steps.version.outputs.version }}" + if grep -q '[^[:space:]]' release-notes.md 2>/dev/null; then + cat release-notes.md > final-notes.md + { + printf '\n## Install\n\n' + printf '```\n' + printf '# Homebrew (macOS / Linux)\n' + printf 'brew install andresharpe/dotbot/dotbot\n\n' + printf '# Scoop (Windows)\n' + printf 'scoop bucket add dotbot https://github.com/andresharpe/scoop-dotbot\n' + printf 'scoop install dotbot\n\n' + printf '# From source\n' + printf 'git clone https://github.com/andresharpe/dotbot ~/dotbot\n' + printf 'pwsh ~/dotbot/bootstrap.ps1\n' + printf '```\n' + } >> final-notes.md + NOTES_ARGS=(--notes-file final-notes.md) + else + NOTES_ARGS=(--generate-notes) + fi gh release create "v${VERSION}" \ --title "v${VERSION}" \ - --generate-notes \ + --target "${GITHUB_SHA}" \ + "${NOTES_ARGS[@]}" \ "${ARCHIVE_PREFIX}-${VERSION}.tar.gz" \ "${ARCHIVE_PREFIX}-${VERSION}.zip" \ "${ARCHIVE_PREFIX}-${VERSION}.tar.gz.sha256" \ "${ARCHIVE_PREFIX}-${VERSION}.zip.sha256" - - # ── Publish to PowerShell Gallery ────────────────────────── - publish-psgallery: - name: Publish to PSGallery - needs: release - runs-on: ubuntu-latest - if: vars.ENABLE_PSGALLERY == 'true' - steps: - - uses: actions/checkout@v4 - - - name: Install PowerShell - run: | - if ! command -v pwsh &> /dev/null; then - sudo apt-get update && sudo apt-get install -y powershell - fi - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Build Studio UI - working-directory: studio-ui - run: npm ci && npm run build - - - name: Prepare module - shell: pwsh - run: | - $version = "${{ needs.release.outputs.version }}" - $staging = "publish-staging/Dotbot" - New-Item -ItemType Directory -Path $staging -Force - Copy-Item dotbot.psd1, dotbot.psm1, install.ps1, install-remote.ps1, LICENSE, README.md -Destination $staging - Copy-Item -Recurse scripts, workflows, stacks -Destination $staging - # Include studio-ui runtime files - $studioStaging = Join-Path $staging 'studio-ui' - New-Item -ItemType Directory -Path $studioStaging -Force - Copy-Item studio-ui/server.ps1, studio-ui/StudioAPI.psm1 -Destination $studioStaging - Copy-Item -Recurse studio-ui/static -Destination (Join-Path $studioStaging 'static') - # Rename to PascalCase for PSGallery convention - Rename-Item "$staging/dotbot.psd1" "Dotbot.psd1" - Rename-Item "$staging/dotbot.psm1" "Dotbot.psm1" - $psd1 = Get-Content "$staging/Dotbot.psd1" -Raw - $psd1 = $psd1 -replace "RootModule = 'dotbot.psm1'", "RootModule = 'Dotbot.psm1'" - $psd1 = $psd1 -replace "ModuleVersion = '.*?'", "ModuleVersion = '$version'" - $psd1 | Set-Content "$staging/Dotbot.psd1" -NoNewline - - - name: Publish - shell: pwsh - env: - PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} - run: | - Publish-Module -Path "publish-staging/Dotbot" -NuGetApiKey $env:PSGALLERY_API_KEY -Verbose From 46bdc2680135f956bb727c2a55ed15115cb00d50 Mon Sep 17 00:00:00 2001 From: Ivan Bondarenko Date: Thu, 2 Jul 2026 18:28:47 +0300 Subject: [PATCH 18/50] fix(worktree): classify orphan unrelated-history merge conflicts (#570) --- .../Modules/Dotbot.Task/Dotbot.Task.psm1 | 19 +++++ .../Dotbot.Worktree/Dotbot.Worktree.psm1 | 32 ++++++-- tests/Test-Components.ps1 | 77 ++++++++++++++++++- 3 files changed, 120 insertions(+), 8 deletions(-) diff --git a/src/runtime/Modules/Dotbot.Task/Dotbot.Task.psm1 b/src/runtime/Modules/Dotbot.Task/Dotbot.Task.psm1 index bca86c86..763cda7f 100644 --- a/src/runtime/Modules/Dotbot.Task/Dotbot.Task.psm1 +++ b/src/runtime/Modules/Dotbot.Task/Dotbot.Task.psm1 @@ -1022,6 +1022,25 @@ function New-MergeFailurePendingQuestion { asked_at = $askedAt } } + 'unrelated_history' { + # Two task worktrees were both cut before the project had any commits, + # so they are orphan roots with no common ancestor. Both added the same + # file(s) with different content — there is no base to auto-merge + # against, so the operator must choose which version wins. + $conflictDetail = if ($ConflictFiles.Count -gt 0) { $ConflictFiles -join '; ' } else { '(none reported)' } + return @{ + id = "merge-unrelated-history" + question = "Unrelated-history merge conflict — this task predates the project's first commit" + context = "This task's worktree was created before the project had any commits, so it shares no history with main. It and an earlier task both produced these file(s): $conflictDetail. There is no common ancestor to auto-merge, so choose which version to keep. Worktree preserved at: $WorktreePath" + options = @( + @{ key = "A"; label = "Open the worktree and merge manually (recommended)"; rationale = "Inspect both versions at $WorktreePath, reconcile the file(s), then retry merge" } + @{ key = "B"; label = "Keep this task's version"; rationale = "Overwrite main's copy of the conflicting file(s) with this task's version" } + @{ key = "C"; label = "Keep main's version (discard this task's changes to those files)"; rationale = "Abandon this task's copy of the conflicting file(s) and keep what is already on main" } + ) + recommendation = "A" + asked_at = $askedAt + } + } 'branch_missing' { return @{ id = "branch-missing" diff --git a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 index 53537470..28fbec70 100644 --- a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 +++ b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 @@ -1252,6 +1252,11 @@ function Apply-TaskBranchPatch { [Parameter(Mandatory)][string]$BranchName ) + # When two task branches were both cut while the project had no commits, they + # are unrelated orphan roots with no merge-base. Track that so a resulting + # conflict can be reported as 'unrelated_history' (an operator-recoverable, + # no-common-ancestor collision) rather than the generic 'rebase_conflict'. + $unrelatedHistory = $false $mergeBase = (git -C $ProjectRoot merge-base $BaseBranch $BranchName 2>$null) if ($LASTEXITCODE -ne 0 -or -not $mergeBase) { git -C $ProjectRoot rev-parse --verify "$BranchName^{commit}" 2>$null | Out-Null @@ -1266,6 +1271,7 @@ function Apply-TaskBranchPatch { # task branches still have no merge-base; replay them as changes from # the empty tree so any task can be the first one completed. $mergeBase = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' + $unrelatedHistory = $true } $patchPath = [System.IO.Path]::GetTempFileName() @@ -1357,11 +1363,18 @@ function Apply-TaskBranchPatch { Remove-Item -LiteralPath $targetPath -Force -ErrorAction SilentlyContinue } + # An empty-tree (unrelated-history) base means the conflict is an + # add/add between two orphan branches with no common ancestor — there + # is no automatic correct merge, so surface it as its own kind so the + # operator gets an actionable choice instead of an opaque conflict. + $failureKind = if ($conflictFiles.Count -gt 0) { + if ($unrelatedHistory) { 'unrelated_history' } else { 'rebase_conflict' } + } else { $null } return @{ success = $false output = @($applyOutput | ForEach-Object { "$_" }) conflict_files = $conflictFiles - failure_kind = if ($conflictFiles.Count -gt 0) { 'rebase_conflict' } else { $null } + failure_kind = $failureKind } } @@ -1795,15 +1808,22 @@ function Complete-TaskWorktree { # conflict on " pending_question instead of a generic # "Squash-merge command failed". Falls back to merge_command_failed # for non-conflict apply failures (no patch context, etc.). - $applyConflicts = if ($mergeResult.PSObject.Properties['conflict_files']) { + # $mergeResult is a hashtable (Apply-TaskBranchPatch returns @{...}), and + # a hashtable's keys are NOT surfaced via .PSObject.Properties[...] — that + # check silently returned empty, dropping conflict_files for every merge + # failure routed through here (incl. rebase_conflict). Use key/member + # access, mirroring Move-TaskToMergeFailureNeedsInput's dual check. + $applyConflicts = if ($mergeResult -is [hashtable]) { + if ($mergeResult.ContainsKey('conflict_files')) { @($mergeResult['conflict_files'] | Where-Object { $_ }) } else { @() } + } elseif ($mergeResult -and $mergeResult.PSObject.Properties['conflict_files']) { @($mergeResult.conflict_files | Where-Object { $_ }) } else { @() } $applyKind = [string]$mergeResult.failure_kind $resolvedKind = if ($applyKind) { $applyKind } else { 'merge_command_failed' } - $resolvedMessage = if ($resolvedKind -eq 'rebase_conflict') { - "Merge conflict during squash-merge: $($applyConflicts -join ', ')" - } else { - "Task branch patch failed: $($mergeOutput -join ' ')" + $resolvedMessage = switch ($resolvedKind) { + 'rebase_conflict' { "Merge conflict during squash-merge: $($applyConflicts -join ', ')" } + 'unrelated_history' { "Unrelated-history merge conflict (task predates the project's first commit): $($applyConflicts -join ', ')" } + default { "Task branch patch failed: $($mergeOutput -join ' ')" } } return @{ success = $false diff --git a/tests/Test-Components.ps1 b/tests/Test-Components.ps1 index e3f1ede7..815a0e8e 100644 --- a/tests/Test-Components.ps1 +++ b/tests/Test-Components.ps1 @@ -154,7 +154,8 @@ if (Test-Path $worktreeManagerModule) { -Message "Genuinely divergent untracked local files must still block patch replay (guard intact), but the blob comparison must NOT use --no-filters: on Windows with core.autocrlf=true a raw hash of a CRLF working-tree leftover never matches the LF branch blob, falsely flagging EOL-only-stale artifacts as divergent and permanently blocking squash-merge retries (issue #517)." Assert-True -Name "Apply-TaskBranchPatch surfaces conflict_files + 'rebase_conflict' kind on 3-way apply failure" ` -Condition (($worktreeManagerSrc -match 'diff\s+--name-only\s+--diff-filter=U') -and - ($worktreeManagerSrc -match "failure_kind\s*=\s*if\s*\(\s*\`$conflictFiles\.Count\s*-gt\s*0\s*\)\s*\{\s*'rebase_conflict'") -and + ($worktreeManagerSrc -match "\`$conflictFiles\.Count\s*-gt\s*0") -and + ($worktreeManagerSrc -match "'rebase_conflict'") -and ($worktreeManagerSrc -match "Merge conflict during squash-merge")) ` -Message "An add/add conflict on a single file (e.g. .gitignore) must reach the operator as a 'rebase_conflict' pending_question naming the file, not a generic 'merge_command_failed' with empty conflict_files. See botdot task d954f7e7 incident on 2026-05-14." @@ -821,6 +822,77 @@ if (Test-Path $worktreeManagerModule) { } Remove-TestProject -Path $unbornRoot } + + # #570: two orphan tasks (both cut while main was unborn) that write the SAME + # file collide on merge with no common ancestor. The second merge must be + # classified 'unrelated_history' (an actionable operator prompt) rather than + # the opaque, unrecoverable 'rebase_conflict'. + $collRoot = Join-Path ([System.IO.Path]::GetTempPath()) "dotbot-test-orphan-collide-$([System.Guid]::NewGuid().ToString().Substring(0,8))" + $collBot = Join-Path $collRoot ".bot" + $collA = $null; $collB = $null + try { + New-Item -ItemType Directory -Path $collRoot -Force | Out-Null + & git -C $collRoot init --quiet 2>&1 | Out-Null + & git -C $collRoot config user.email "test@dotbot.dev" 2>&1 | Out-Null + & git -C $collRoot config user.name "Dotbot Test" 2>&1 | Out-Null + & git -C $collRoot symbolic-ref HEAD refs/heads/main 2>&1 | Out-Null + New-Item -ItemType Directory -Path (Join-Path $collBot ".control") -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $collBot "workspace/tasks") -Force | Out-Null + ".control/`n" | Set-Content -Path (Join-Path $collBot ".gitignore") -Encoding UTF8 + + # Task B is cut first (earlier orphan), then task A — both while main is unborn. + $collB = New-TaskWorktree -TaskId "t_collide0" -TaskName "earlier orphan writes shared file" -ProjectRoot $collRoot -BotRoot $collBot + $collA = New-TaskWorktree -TaskId "t_collide2" -TaskName "later orphan writes shared file" -ProjectRoot $collRoot -BotRoot $collBot + + if ($collA -and $collA.success -and $collB -and $collB.success) { + $collRun = Join-Path $collBot "workspace/tasks/workflow-runs/2026-07-02-collide" + New-Item -ItemType Directory -Force -Path $collRun | Out-Null + @{ id = "wr_collide1"; workflow = "start-from-prompt"; status = "running" } | + ConvertTo-Json -Depth 10 | Set-Content -Path (Join-Path $collRun "run.json") -Encoding UTF8 + @{ id = "t_collide2"; name = "Later orphan"; status = "done"; completed_at = "2026-07-02T12:10:00Z" + provenance = @{ workflow = "start-from-prompt"; run_id = "wr_collide1"; definition_name = "Later orphan"; expanded_by = $null } } | + ConvertTo-Json -Depth 20 | Set-Content -Path (Join-Path $collRun "t_collide2.json") -Encoding UTF8 + @{ id = "t_collide0"; name = "Earlier orphan"; status = "in-progress" + provenance = @{ workflow = "start-from-prompt"; run_id = "wr_collide1"; definition_name = "Earlier orphan"; expanded_by = $null } } | + ConvertTo-Json -Depth 20 | Set-Content -Path (Join-Path $collRun "t_collide0.json") -Encoding UTF8 + + # BOTH tasks write the same path with different content. + "content from later task A" | Set-Content -Path (Join-Path $collA.worktree_path "shared-artifact.txt") -Encoding UTF8 + "content from earlier task B" | Set-Content -Path (Join-Path $collB.worktree_path "shared-artifact.txt") -Encoding UTF8 + + # Merge A first — initializes unborn main with A's shared-artifact.txt. + $collMergeA = Complete-TaskWorktree -TaskId "t_collide2" -ProjectRoot $collRoot -BotRoot $collBot + Assert-True -Name "#570 orphan-collision: first orphan merges (initializes main)" ` + -Condition ($collMergeA.success -eq $true) ` + -Message "Expected success, got: $($collMergeA | ConvertTo-Json -Depth 8 -Compress)" + + # Merge B — same file, no common ancestor -> unrelated-history collision. + $collTaskB = Join-Path $collRun "t_collide0.json" + $tb = Get-Content -LiteralPath $collTaskB -Raw | ConvertFrom-Json + $tb.status = "done"; $tb | ConvertTo-Json -Depth 20 | Set-Content -Path $collTaskB -Encoding UTF8 + + $collMergeB = Complete-TaskWorktree -TaskId "t_collide0" -ProjectRoot $collRoot -BotRoot $collBot + Assert-True -Name "#570 orphan-collision: second orphan same-file merge fails (not silent success)" ` + -Condition ($collMergeB.success -eq $false) ` + -Message "Expected failure, got: $($collMergeB | ConvertTo-Json -Depth 8 -Compress)" + Assert-Equal -Name "#570 orphan-collision: classified unrelated_history (not opaque rebase_conflict)" ` + -Expected "unrelated_history" ` + -Actual "$($collMergeB.failure_kind)" + Assert-True -Name "#570 orphan-collision: conflict file reported" ` + -Condition (@($collMergeB.conflict_files | Where-Object { $_ -match 'shared-artifact\.txt' }).Count -gt 0) ` + -Message "Expected shared-artifact.txt in conflict_files, got: $($collMergeB.conflict_files -join ', ')" + } else { + Write-TestResult -Name "#570 orphan-collision: setup" -Status Fail -Message "worktree setup failed: A=$($collA | ConvertTo-Json -Compress) B=$($collB | ConvertTo-Json -Compress)" + } + } finally { + foreach ($r in @($collA, $collB)) { + if ($r -and $r.worktree_path -and (Test-Path $r.worktree_path)) { & git -C $collRoot worktree remove -f $r.worktree_path 2>&1 | Out-Null } + if ($r -and $r.branch_name) { & git -C $collRoot branch -D $r.branch_name 2>&1 | Out-Null } + } + $collWtRoot = Join-Path (Split-Path $collRoot -Parent) "worktrees/$(Split-Path $collRoot -Leaf)" + if (Test-Path $collWtRoot) { Remove-Item -Path $collWtRoot -Recurse -Force -ErrorAction SilentlyContinue } + Remove-TestProject -Path $collRoot + } } else { Write-TestResult -Name "Dotbot.Worktree module exists" -Status Fail -Message "Module not found at $worktreeManagerModule" } @@ -3287,6 +3359,7 @@ if (Test-Path $mergeEscModule) { # kind → template mapping. Test it directly: cheap, deterministic, no FS I/O. $kindCases = @( @{ Kind = 'rebase_conflict'; ExpectedId = 'merge-conflict'; ExpectedOptionCount = 3 } + @{ Kind = 'unrelated_history'; ExpectedId = 'merge-unrelated-history'; ExpectedOptionCount = 3 } @{ Kind = 'branch_missing'; ExpectedId = 'branch-missing'; ExpectedOptionCount = 2 } @{ Kind = 'merge_command_failed'; ExpectedId = 'merge-failed'; ExpectedOptionCount = 3 } @{ Kind = 'commit_failed'; ExpectedId = 'commit-failed'; ExpectedOptionCount = 2 } @@ -3320,7 +3393,7 @@ if (Test-Path $mergeEscModule) { # rebase_conflict puts file names in context (canonical "conflict files" # phrasing). All other kinds put the message + failure_detail in context # so the operator sees git output / exception text. - if ($kind -eq 'rebase_conflict') { + if ($kind -in @('rebase_conflict', 'unrelated_history')) { Assert-True -Name "Kind dispatch ($kind): context lists conflict files" ` -Condition ($pq.context -match 'src/foo\.cs' -and $pq.context -match 'src/bar\.cs') ` -Message "Expected conflict files in context" From 623b9657ad7797e08187af4591297c7c8cce065b Mon Sep 17 00:00:00 2001 From: aselim31 Date: Fri, 3 Jul 2026 11:06:04 +0300 Subject: [PATCH 19/50] chore(release): bump version to v4.0.1 (#577) Co-authored-by: github-actions[bot] --- CHANGELOG.md | 10 ++++++++++ version.json | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30de7e67..93e1bd40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to dotbot are documented in this file. The format follows [K ## [Unreleased] +### Added + +### Changed + +### Fixed + +### Removed + +## [4.0.1] - 2026-07-02 + ### Added - **`bootstrap.ps1`** at the repo root — the one-time install step. Drops the `bin/shim/dotbot*` PATH shim into `~/.local/bin` (Linux/macOS) or `%LOCALAPPDATA%\Microsoft\WindowsApps` (Windows). Refuses PowerShell 5.1; never sets `$env:DOTBOT_HOME` for the user (design decision D4). Honours `-ShimDir` and `-Force`. - **`dotbot status`** subcommand reporting resolved `DOTBOT_HOME`, framework branch + short SHA + dirty flag, version, user-settings path, and the active project's workflow / provider / stacks. `--json` emits a stable shape for CI scripts and the dashboard. diff --git a/version.json b/version.json index b999b9fe..5d0fb8b8 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "4.0.0" + "version": "4.0.1" } From 79e1a4dee392c769aea5bdfa43d6304db4083b36 Mon Sep 17 00:00:00 2001 From: Ivan Bondarenko Date: Fri, 3 Jul 2026 12:11:48 +0300 Subject: [PATCH 20/50] fix(runtime): verify-hook-blocked done escalates to needs-input (#571) --- .../Dotbot.Runtime/Private/HttpServer.psm1 | 20 ++++++++++++++ .../Scripts/Invoke-WorkflowProcess.ps1 | 26 +++++++++++++++++++ tests/Test-Hooks.ps1 | 18 +++++++++++++ 3 files changed, 64 insertions(+) diff --git a/src/runtime/Modules/Dotbot.Runtime/Private/HttpServer.psm1 b/src/runtime/Modules/Dotbot.Runtime/Private/HttpServer.psm1 index 60314571..7535f66e 100644 --- a/src/runtime/Modules/Dotbot.Runtime/Private/HttpServer.psm1 +++ b/src/runtime/Modules/Dotbot.Runtime/Private/HttpServer.psm1 @@ -1232,6 +1232,14 @@ function Invoke-TaskStatusHandler { } } + # A transition that is about to proceed resolves any prior hook-blocked-done + # breadcrumb (set on the abort-revert path below). Clearing it here means a + # stale marker never lingers once the task makes forward progress. + if ($task['extensions'] -and ($task['extensions']['runner'] -is [System.Collections.IDictionary]) -and + $task['extensions']['runner'].ContainsKey('done_transition_block')) { + [void]$task['extensions']['runner'].Remove('done_transition_block') + } + try { Assert-TaskInstance -Task $task } catch { @@ -1287,6 +1295,18 @@ function Invoke-TaskStatusHandler { } else { $task['completed_at'] = $null } + # Breadcrumb the task-runner reads to escalate a hook-blocked done to + # needs-input instead of skipped(max-retries). Under extensions.runner + # (schema rejects unknown top-level fields); cleared on next success. + if ($to -eq 'done') { + if (-not $task['extensions']) { $task['extensions'] = @{} } + if (-not $task['extensions']['runner']) { $task['extensions']['runner'] = @{} } + $task['extensions']['runner']['done_transition_block'] = @{ + hook = [string]$hookResult.failing_hook + message = [string]$hookResult.failing_message + at = $task['updated_at'] + } + } _Write-TaskFileAtomic -Path $path -Content $task Write-ActivityEvent ` diff --git a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 index 3794cbfc..a773d0a0 100644 --- a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 +++ b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 @@ -2015,12 +2015,19 @@ Work on this task autonomously. When complete, ensure you call ``task_set_status # Task not completed - log diagnostic to help distinguish failure modes. $stillInProgress = $false $nowNeedsInput = $false + $doneHookBlock = $null try { $currentTask = Get-WorkflowTaskContent -Task $task -RunDir $runDir if ($currentTask -and $currentTask.Content) { $currentStatus = [string]$currentTask.Content.status $stillInProgress = ($currentStatus -eq 'in-progress') $nowNeedsInput = ($currentStatus -eq 'needs-input') + # Breadcrumb left by the transition layer when a verify hook + # aborted this task's done-transition (e.g. privacy scan). + $ext = $currentTask.Content.extensions + if ($ext -and $ext.runner -and $ext.runner.done_transition_block) { + $doneHookBlock = $ext.runner.done_transition_block + } } } catch { Write-BotLog -Level Debug -Message "Failed to parse data" -Exception $_ } @@ -2034,6 +2041,25 @@ Work on this task autonomously. When complete, ensure you call ``task_set_status break } + # A verify hook (e.g. privacy scan) blocked the done-transition: park to + # needs-input so the operator fixes the flagged content, instead of + # burning retries and mislabeling a done task skipped(max-retries). + # Checked before the harness-error classification so this definitive + # on-disk signal isn't pre-empted by heuristic text matching. + if ($doneHookBlock) { + $hookName = [string]$doneHookBlock.hook + $hookMsg = [string]$doneHookBlock.message + $blockContext = "The done-transition was blocked by verify hook '$hookName': $hookMsg`nThe task's declared work may already be complete — resolve the flagged content (e.g. redact absolute local paths from the committed artifact), then re-run or approve. The retry budget was not consumed." + Write-Status "Done-transition blocked by verify hook — parking for operator: $($task.name)" -Type Warn + Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Task '$($task.name)' parked (needs-input): verify hook '$hookName' blocked the done-transition" + Set-WorkflowTaskNeedsInput -Task $task -RunDir $runDir ` + -QuestionId "verify-hook-block-$($task.id)" ` + -Question "Verify hook blocked completion of '$($task.name)'" ` + -Context $blockContext | Out-Null + $taskParked = $true + break + } + if ($stillInProgress) { Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Completion check failed (attempt $attemptNumber): '$($task.name)' still has status in-progress. Check activity log: if a 'task_set_status(done) blocked' entry exists, verification failed; otherwise task_set_status(done) was likely never called." } else { diff --git a/tests/Test-Hooks.ps1 b/tests/Test-Hooks.ps1 index 6d20d399..1e643088 100644 --- a/tests/Test-Hooks.ps1 +++ b/tests/Test-Hooks.ps1 @@ -372,6 +372,17 @@ try { Assert-Equal -Name "After abort: GET returns 200" -Expected 200 -Actual $r.status_code Assert-Equal -Name "After abort: status reverted to in-progress" -Expected 'in-progress' -Actual $r.body.task.status + # #571: a done-transition abort leaves a breadcrumb under extensions.runner so + # the task-runner can escalate to needs-input instead of burning retries. + $blockAfterAbort = $null + try { $blockAfterAbort = $r.body.task.extensions.runner.done_transition_block } catch { $blockAfterAbort = $null } + Assert-True -Name "#571 After abort: done_transition_block marker present" ` + -Condition ($null -ne $blockAfterAbort) ` + -Message "Expected extensions.runner.done_transition_block, got task: $($r.body.task | ConvertTo-Json -Depth 8 -Compress)" + Assert-Equal -Name "#571 After abort: marker names the failing hook" -Expected 'enter-done' -Actual "$($blockAfterAbort.hook)" + Assert-True -Name "#571 After abort: marker carries the failing message" ` + -Condition ("$($blockAfterAbort.message)" -match 'verify says no') + # Activity log carries hook_failed. $logPath = Get-ActivityLogPath -BotRoot $bot $hookFailedLines = 0 @@ -392,6 +403,13 @@ try { Assert-Equal -Name "With non-aborting hook: in-progress → done → 200" -Expected 200 -Actual $r.status_code Assert-Equal -Name "Task status now done" -Expected 'done' -Actual $r.body.task.status Assert-True -Name "Response includes hook_results" -Condition ($r.body.hook_results.Count -ge 1) + + # #571: a successful transition clears the stale hook-block breadcrumb. + $blockAfterDone = $null + try { $blockAfterDone = $r.body.task.extensions.runner.done_transition_block } catch { $blockAfterDone = $null } + Assert-True -Name "#571 After successful done: done_transition_block marker cleared" ` + -Condition ($null -eq $blockAfterDone) ` + -Message "Expected marker cleared, got: $($blockAfterDone | ConvertTo-Json -Compress)" } finally { if ($start) { Stop-DotbotRuntime -BotRoot $bot -Listener $start.listener -ErrorAction SilentlyContinue } try { Remove-Item -Recurse -Force (Split-Path -Parent $bot) } catch { } From 2702d2e4b812a7a3c5883dc32cd72ef9c50b73f3 Mon Sep 17 00:00:00 2001 From: Ivan Bondarenko Date: Fri, 3 Jul 2026 13:07:27 +0300 Subject: [PATCH 21/50] docs(worktree): note unrelated_history in conflict_files comment (copilot review) --- src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 index 28fbec70..bd5f9f8a 100644 --- a/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 +++ b/src/runtime/Modules/Dotbot.Worktree/Dotbot.Worktree.psm1 @@ -1808,11 +1808,9 @@ function Complete-TaskWorktree { # conflict on " pending_question instead of a generic # "Squash-merge command failed". Falls back to merge_command_failed # for non-conflict apply failures (no patch context, etc.). - # $mergeResult is a hashtable (Apply-TaskBranchPatch returns @{...}), and - # a hashtable's keys are NOT surfaced via .PSObject.Properties[...] — that - # check silently returned empty, dropping conflict_files for every merge - # failure routed through here (incl. rebase_conflict). Use key/member - # access, mirroring Move-TaskToMergeFailureNeedsInput's dual check. + # $mergeResult is a hashtable — its keys aren't surfaced via + # .PSObject.Properties[...], which silently dropped conflict_files for + # every merge failure (rebase_conflict and unrelated_history). Use key access. $applyConflicts = if ($mergeResult -is [hashtable]) { if ($mergeResult.ContainsKey('conflict_files')) { @($mergeResult['conflict_files'] | Where-Object { $_ }) } else { @() } } elseif ($mergeResult -and $mergeResult.PSObject.Properties['conflict_files']) { From ac578995c4b5551448115783313a5477ebed9e2b Mon Sep 17 00:00:00 2001 From: Ivan Bondarenko Date: Fri, 3 Jul 2026 13:16:11 +0300 Subject: [PATCH 22/50] fix(runtime): type-guard breadcrumb write + truncate hook message (copilot review) --- .../Modules/Dotbot.Runtime/Private/HttpServer.psm1 | 12 +++++++++--- src/runtime/Scripts/Invoke-WorkflowProcess.ps1 | 3 +++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/runtime/Modules/Dotbot.Runtime/Private/HttpServer.psm1 b/src/runtime/Modules/Dotbot.Runtime/Private/HttpServer.psm1 index 7535f66e..a4f3d79e 100644 --- a/src/runtime/Modules/Dotbot.Runtime/Private/HttpServer.psm1 +++ b/src/runtime/Modules/Dotbot.Runtime/Private/HttpServer.psm1 @@ -1299,11 +1299,17 @@ function Invoke-TaskStatusHandler { # needs-input instead of skipped(max-retries). Under extensions.runner # (schema rejects unknown top-level fields); cleared on next success. if ($to -eq 'done') { - if (-not $task['extensions']) { $task['extensions'] = @{} } - if (-not $task['extensions']['runner']) { $task['extensions']['runner'] = @{} } + # Type-guard both levels (extensions content is not schema-validated): + # a non-dict extensions/runner would make the index-assign below throw. + if ($task['extensions'] -isnot [System.Collections.IDictionary]) { $task['extensions'] = @{} } + if ($task['extensions']['runner'] -isnot [System.Collections.IDictionary]) { $task['extensions']['runner'] = @{} } + # Truncate the persisted message — hook output is unbounded; the full + # text stays in the 422 response + activity log (matches AuthError cap). + $failingMsg = [string]$hookResult.failing_message + if ($failingMsg.Length -gt 1000) { $failingMsg = $failingMsg.Substring(0, 1000) + " … [truncated, showing 1000 of $($failingMsg.Length) chars]" } $task['extensions']['runner']['done_transition_block'] = @{ hook = [string]$hookResult.failing_hook - message = [string]$hookResult.failing_message + message = $failingMsg at = $task['updated_at'] } } diff --git a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 index a773d0a0..b0b2148f 100644 --- a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 +++ b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 @@ -2049,6 +2049,9 @@ Work on this task autonomously. When complete, ensure you call ``task_set_status if ($doneHookBlock) { $hookName = [string]$doneHookBlock.hook $hookMsg = [string]$doneHookBlock.message + # Defensive cap (breadcrumb is already truncated at the source) so an + # oversized hook message can't bloat the task record / handoff — mirrors AuthError. + if ($hookMsg.Length -gt 1000) { $hookMsg = $hookMsg.Substring(0, 1000) + " … [truncated, showing 1000 of $($hookMsg.Length) chars]" } $blockContext = "The done-transition was blocked by verify hook '$hookName': $hookMsg`nThe task's declared work may already be complete — resolve the flagged content (e.g. redact absolute local paths from the committed artifact), then re-run or approve. The retry budget was not consumed." Write-Status "Done-transition blocked by verify hook — parking for operator: $($task.name)" -Type Warn Write-ProcessActivity -Id $procId -ActivityType "text" -Message "Task '$($task.name)' parked (needs-input): verify hook '$hookName' blocked the done-transition" From ebf2c7ef0a4f2f28b22e4bb5bf67fe40fd63f9ee Mon Sep 17 00:00:00 2001 From: aselim31 Date: Mon, 6 Jul 2026 17:23:42 +0300 Subject: [PATCH 23/50] docs: update README lifecycle docs for single-session task execution Update README lifecycle documentation for single-session task execution. Closes #458 --- README.md | 12 +++++---- src/README.md | 67 +++++++++++++++++++++--------------------------- src/ui/README.md | 13 +++++----- 3 files changed, 42 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 6fcdad19..5c3b3400 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,12 @@ dotbot wraps AI-assisted coding in a managed, transparent workflow where every s ### Multi-workflow platform - **Workflow-driven pipelines** - Define multi-step pipelines in `workflow.json` manifests with tasks, dependencies, form configuration, MCP servers, and environment requirements. A project can have multiple workflows installed simultaneously, each run, re-run, and stopped independently. -- **Typed task system** - Tasks can be `prompt` (AI-executed), `script` (PowerShell, no LLM), `mcp` (tool call), `task_gen` (generates sub-tasks dynamically), or `prompt_template` (AI with a workflow-specific prompt). Script, MCP, and task_gen tasks bypass the AI entirely - they auto-promote past analysis, skip worktree isolation, and skip verification hooks. This enables deterministic pipeline stages within AI-orchestrated workflows. +- **Typed task system** - Tasks can be `prompt` (AI-executed), `script` (PowerShell, no LLM), `mcp` (tool call), `task_gen` (generates sub-tasks dynamically), or `prompt_template` (AI with a workflow-specific prompt). Script, MCP, and task_gen tasks bypass the provider session but still execute in the task worktree and complete through the normal task status transition, including verification hooks. This enables deterministic pipeline stages within AI-orchestrated workflows. - **Enterprise registries** - Teams publish workflows, stacks, tools, and skills in git-hosted or local registries. `dotbot registry add` links a registry (private or public); `dotbot init -Workflow registry:name` installs from it. Registries are validated against a `registry.json` manifest with version compatibility checks and auth-failure hints for GitHub, Azure DevOps, and GitLab. - **Workflows and stacks** - **Workflows** (e.g. `start-from-jira`) define operational pipelines - what dotbot does. **Stacks** (e.g. `dotnet`, `dotnet-blazor`) add tech-specific skills, hooks, and MCP tools - what tech the project uses. Stacks compose additively with `extends` chains. Settings deep-merge across `default -> workflows -> stacks`. ### Execution engine -- **Two-phase execution** - Analysis resolves ambiguity, identifies files, and builds a context package. Implementation consumes that package and writes code. Tasks flow: `todo -> analysing -> analysed -> in-progress -> done`. +- **Single-session execution** - Prompt tasks run discovery, planning, implementation, verification, and completion inside one provider session using `100-single-session-task.md`. The core flow is `todo -> in-progress -> done`, with side paths for `needs-input`, `needs-review`, `failed`, `skipped`, and `cancelled`. - **Per-task git worktree isolation** - Each task runs in its own worktree on an isolated branch, squash-merged back to main on completion. - **Per-task model selection** - Tasks can specify a model (e.g. Sonnet for simple tasks, Opus for complex ones) that overrides the process-level default. Use cheaper models where they suffice to reduce token spend. - **Multi-slot concurrent execution** - The workflow engine runs multiple tasks from the same workflow in parallel with slot-aware locking, shortening wall-clock time for large task queues. @@ -222,9 +222,9 @@ The runtime resolves framework content lazily: `/content/// ## MCP Tools -The dotbot MCP server exposes 33 tools, auto-discovered from `systems/mcp/tools/`: +The dotbot MCP server exposes 31 tools, auto-discovered from `src/mcp/tools/`: -**Task Management** (15): `task_create`, `task_create_bulk`, `task_get_next`, `task_get_context`, `task_list`, `task_get_stats`, `task_mark_todo`, `task_mark_analysing`, `task_mark_analysed`, `task_mark_in_progress`, `task_mark_done`, `task_mark_needs_input`, `task_mark_skipped`, `task_answer_question`, `task_approve_split` +**Task Management** (10): `task_create`, `task_create_bulk`, `task_get`, `task_get_context`, `task_get_next`, `task_list`, `task_mark_needs_review`, `task_set_status`, `task_submit_review`, `task_update` **Decision Tracking** (7): `decision_create`, `decision_get`, `decision_list`, `decision_update`, `decision_mark_accepted`, `decision_mark_deprecated`, `decision_mark_superseded` @@ -232,6 +232,8 @@ The dotbot MCP server exposes 33 tools, auto-discovered from `systems/mcp/tools/ **Plans** (3): `plan_create`, `plan_get`, `plan_update` +**Workflow** (3): `workflow_get`, `workflow_list`, `workflow_start` + **Steering**: `steering_heartbeat` **Development**: `dev_start`, `dev_stop` @@ -248,7 +250,7 @@ Four-layer test pyramid with ~500 assertions: |-------|---------------|-------------| | 1 - Structure | Syntax validation, module exports, workflow manifest parsing, task creation, condition evaluation, multi-workflow isolation | None | | 2 - Components | MCP tool lifecycle, task types, decision tracking, provider CLI, notification client, workflow integration, UI server startup | None | -| 3 - Mock Provider | Analysis/execution flows with mock Claude CLI and stream parsing | None | +| 3 - Mock Provider | Workflow execution flows with mock Claude CLI and stream parsing | None | | 4 - E2E | Full end-to-end with real AI provider API | API key | ```powershell diff --git a/src/README.md b/src/README.md index a5ebb4c2..c04da2fb 100644 --- a/src/README.md +++ b/src/README.md @@ -1,6 +1,6 @@ # dotbot - Autonomous Development Framework -A project-agnostic framework for autonomous software development across Claude Code, Codex, and Antigravity CLIs. Provides task management, two-phase execution (analysis + implementation), per-task git worktree isolation, a web dashboard, and a PowerShell MCP server. +A project-agnostic framework for autonomous software development across Claude Code, Codex, and Antigravity CLIs. Provides task management, single-session task execution, per-task git worktree isolation, a web dashboard, and a PowerShell MCP server. ## Installation @@ -45,7 +45,7 @@ dotbot go ├── recipes/ │ ├── agents/ # Agent personas (implementer, planner, reviewer, tester) │ ├── skills/ # Technical guidance (status, verify, write-test-plan, write-unit-tests) -│ ├── prompts/ # Numbered step-by-step processes (analysis, implementation, etc.) +│ ├── prompts/ # Numbered workflow and task prompt templates │ ├── includes/ # Shared prompt fragments │ └── research/ # Research templates ├── systems/ @@ -59,42 +59,29 @@ dotbot go └── workspace/ ├── product/ # Product docs (mission.md, entity-model.md, tech-stack.md) ├── decisions/ # Architecture decision records - └── tasks/ # Task queue (todo/, analysed/, in-progress/, done/) + └── tasks/ + ├── workflow-runs/ # Per-run directories with run.json + task JSON files + └── standalone/ # Standalone task JSON files ``` -## How It Works - -### Two-Phase Task Execution +Task status is stored in each task JSON file; task files do not move between per-status directories. -Every task goes through two phases, each run by a separate provider CLI instance: +## How It Works -**Phase 1 - Analysis** (`98-analyse-task.md`): -- Identifies affected entities and files -- Maps applicable coding standards -- Validates dependencies -- Produces concrete implementation guidance (insertion points, pattern snippets, field mappings) -- Asks clarifying questions or proposes task splits if needed +### Single-Session Task Execution -**Phase 2 - Execution** (`99-autonomous-task.md`): -- Receives pre-packaged analysis context (no re-exploration needed) -- Implements changes following the analysis guidance -- Runs verification scripts (privacy scan, git clean, build, format) -- Commits with task ID references +Prompt tasks run in one provider CLI session using `content/prompts/100-single-session-task.md`. That session does discovery, planning, implementation, verification, and completion together, so there is no separate pre-flight analysis process or intermediate handoff state. If the provider needs a human answer, it can pause the task as `needs-input`; after an answer or retry, the task returns to `todo` for another same-task session attempt. ### Per-Task Git Worktrees Each task runs in an isolated git worktree: ``` -Analysis picks up task +Task runner picks up task → Creates branch: task/{short-id}-{slug} → Creates worktree: ../worktrees/{repo}/task-{short-id}-{slug}/ - → Sets up junctions for shared .bot/ directories - → Copies essential gitignored files (e.g., .env) - -Execution picks up analysed task - → Looks up existing worktree - → Provider CLI implements and commits to task branch + → Materialises provider folders and MCP configuration in the worktree + → Provider CLI discovers, implements, verifies, and commits to task branch On completion → Rebases task branch onto main @@ -107,10 +94,14 @@ This provides full isolation between tasks — a failed task never leaves dirty ### Task Lifecycle ``` -todo → analysing → analysed → in-progress → done - │ │ - ├→ needs-input (question/split) └→ squash-merged to main - └→ skipped (non-recoverable) +todo → in-progress → done + │ │ │ + │ ├→ needs-input (human answer required, then retry from todo) + │ ├→ needs-review (completed work awaiting human review) + │ ├→ failed + │ ├→ skipped + │ └→ cancelled + └→ skipped / cancelled ``` ### Process Launcher @@ -119,8 +110,7 @@ todo → analysing → analysed → in-progress → done | Type | Purpose | |------|---------| -| `analysis` | Pre-flight task analysis | -| `execution` | Task implementation | +| `task-runner` | Workflow task execution loop | | `planning` | Roadmap generation | | `commit` | Git operations | | `task-creation` | Bulk task creation | @@ -131,14 +121,14 @@ Each process gets a registry entry for tracking and is managed through the web d The PowerShell MCP server (`dotbot-mcp.ps1`) exposes tools via stdio transport: -- **Task tools**: create, create-bulk, list, get-next, get-context, get-stats, answer-question, approve-split, mark-todo, mark-analysing, mark-analysed, mark-in-progress, mark-done, mark-needs-input, mark-skipped +- **Task tools**: create, create-bulk, get, list, get-next, get-context, update, set-status, mark-needs-review, submit-review - **Decision tools**: create, get, list, update, mark-accepted, mark-deprecated, mark-superseded - **Session tools**: initialize, update, get-state, get-stats, increment-completed - **Plan tools**: create, get, update - **Dev tools**: start, stop - **Steering**: heartbeat with whisper channel for operator interrupts -Tools are auto-discovered from `.bot/systems/mcp/tools/{tool-name}/` — each tool is a folder with `metadata.json` (schema) and `script.ps1` (implementation). +Tools are auto-discovered from `src/mcp/tools/{tool-name}/` — each tool is a folder with `metadata.json` (schema) and `script.ps1` (implementation). ## Usage @@ -150,7 +140,7 @@ dotbot go Opens the web UI on a random port in the IANA dynamic range (49152–65535) where you can: - View and manage tasks -- Start analysis and execution processes +- Start and stop workflow task runners - Monitor running processes - Kick off product planning @@ -163,11 +153,12 @@ From the dashboard, use the start-from-prompt workflow to: ### Autonomous Execution -Start analysis and execution processes from the dashboard. They run in a loop: +Start a workflow task runner from the dashboard or CLI. It runs in a loop: -1. **Analysis process** picks up `todo` tasks, analyses them, marks them `analysed` -2. **Execution process** picks up `analysed` tasks, implements them, marks them `done` -3. Completed tasks are squash-merged to main automatically +1. Picks the next eligible `todo` task from the workflow run +2. Runs the task in a single provider session, moving it to `in-progress` +3. Marks completed work `done`, or parks it as `needs-input`, `needs-review`, `failed`, `skipped`, or `cancelled` +4. Squash-merges completed task branches to main automatically ### Manual Task Management diff --git a/src/ui/README.md b/src/ui/README.md index 6604a019..1d0e3a5f 100644 --- a/src/ui/README.md +++ b/src/ui/README.md @@ -6,7 +6,7 @@ A minimal, dependency-free PowerShell web server for monitoring and controlling - **CP/M-inspired terminal aesthetic** with Axiome Design amber accents - **Real-time monitoring** via auto-polling (3-5 second intervals) -- **Task queue visualization** (TODO/Analysing/Analysed/In-Progress/Done) +- **Task queue visualization** for workflow-run and standalone tasks, including Needs Review and legacy analysis buckets when present - **Process management** - launch, stop, kill, and whisper to tracked processes - **Localhost-only** - no authentication needed - **Zero dependencies** - pure PowerShell + vanilla HTML/CSS/JS @@ -56,13 +56,12 @@ All processes are tracked via JSON files in `.bot/.control/processes/`: │ └── activity.jsonl # Global activity log └── workspace/ └── tasks/ - ├── todo/ - ├── analysing/ - ├── analysed/ - ├── in-progress/ - └── done/ + ├── workflow-runs/ # Per-run directories with run.json + task JSON files + └── standalone/ # Standalone task JSON files ``` +Task status is stored in each task JSON file; task files do not move between per-status directories. + ## API Endpoints ### `GET /api/state` @@ -75,7 +74,7 @@ Returns all tracked processes with status. Launch a new process: ```json { - "type": "analysis", + "type": "task-runner", "continue": true, "model": "best" } From ea9c9ee8ccd2ff67fb110dc465b5318d43925395 Mon Sep 17 00:00:00 2001 From: aselim31 Date: Mon, 6 Jul 2026 17:25:10 +0300 Subject: [PATCH 24/50] docs: add release notes for v4.0.1 (closes #580) (#583) --- docs/release-notes/v4.0.1.md | 159 +++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 docs/release-notes/v4.0.1.md diff --git a/docs/release-notes/v4.0.1.md b/docs/release-notes/v4.0.1.md new file mode 100644 index 00000000..254adbd6 --- /dev/null +++ b/docs/release-notes/v4.0.1.md @@ -0,0 +1,159 @@ +# dotbot v4.0.1 — Release Notes + +**Released:** July 3, 2026 +**Previous version:** v4.0.0 (June 4, 2026) +**Type:** Patch release + +> 27 bugs fixed · 14 enhancements · 41 issues closed + +--- + +## Highlights + +**Worktree concurrency hardened** — [#536](https://github.com/andresharpe/dotbot/issues/536) [#537](https://github.com/andresharpe/dotbot/issues/537) [#520](https://github.com/andresharpe/dotbot/issues/520) [#516](https://github.com/andresharpe/dotbot/issues/516) [#517](https://github.com/andresharpe/dotbot/issues/517) [#514](https://github.com/andresharpe/dotbot/issues/514) [#515](https://github.com/andresharpe/dotbot/issues/515) +Parallel workloads are now safe. Duplicate worktree creation, mass-deletion of active worktrees, and filesystem corruption on orphan cleanup are all resolved. + +**Task resume is reliable** — [#470](https://github.com/andresharpe/dotbot/issues/470) [#493](https://github.com/andresharpe/dotbot/issues/493) [#494](https://github.com/andresharpe/dotbot/issues/494) [#518](https://github.com/andresharpe/dotbot/issues/518) +Runner no longer exits silently on resume. Handoff manifests survive reopen. Output delta validation no longer fails on re-approval. + +**Question & Response API live** — [#451](https://github.com/andresharpe/dotbot/issues/451) [#445](https://github.com/andresharpe/dotbot/issues/445) [#29](https://github.com/andresharpe/dotbot/issues/29) +Mothership and outpost now share a structured Q&A envelope contract. Approvals, document review, and roles unified under one API surface. + +**"Revise" — give feedback without losing your work** — [#465](https://github.com/andresharpe/dotbot/issues/465) +New Revise button on needs-review tasks lets reviewers inject feedback and trigger partial regeneration in place. Worktree preserved — no full restart, no lost context. + +**Workflow inheritance** — [#427](https://github.com/andresharpe/dotbot/issues/427) +`extends:` in `workflow.yaml` lets workflows inherit from a base definition, at parity with stack inheritance. + +**New install model** +`bootstrap.ps1` replaces the old copy-based installer. One `git clone` + `pwsh bootstrap.ps1` is the full setup. `dotbot status` surfaces framework state at any time. + +**Needs-review tasks visible in the pipeline** — [#500](https://github.com/andresharpe/dotbot/issues/500) +Tasks waiting for review now appear in the Needs Input pipeline section — no more hunting for pending work. + +--- + +## Stabilization — 27 bugs fixed + +### Worktree & Concurrency + +| Issue | Fix | +|---|---| +| [#536](https://github.com/andresharpe/dotbot/issues/536) | Non-atomic task claim was creating duplicate worktrees under concurrent runs | +| [#537](https://github.com/andresharpe/dotbot/issues/537) | Orphan worktree partial setup was corrupting the filesystem on retry | +| [#520](https://github.com/andresharpe/dotbot/issues/520) | `Remove-OrphanWorktrees` mass-deleted all active worktrees due to task schema mismatch post-v4 | +| [#516](https://github.com/andresharpe/dotbot/issues/516) | `interview-answers.json` caused squash-merge conflicts when parallel tasks wrote simultaneously | +| [#517](https://github.com/andresharpe/dotbot/issues/517) | Untracked task files blocked squash-merge retry after first attempt failed | +| [#514](https://github.com/andresharpe/dotbot/issues/514) | `Test-DotbotMcpReadiness` crashed on task retry due to stale worktree junction | +| [#515](https://github.com/andresharpe/dotbot/issues/515) | Same stale junction crash — separate code path, both now resolved | + +### Task Lifecycle & Runner + +| Issue | Fix | +|---|---| +| [#470](https://github.com/andresharpe/dotbot/issues/470) | Task stuck in-progress indefinitely when runner process was killed mid-execution | +| [#493](https://github.com/andresharpe/dotbot/issues/493) | Runner exited silently on resume when a needs-input task blocked the queue | +| [#494](https://github.com/andresharpe/dotbot/issues/494) | Handoff manifest deleted on runner reopen — answer submission failed with "manifest not found" | +| [#518](https://github.com/andresharpe/dotbot/issues/518) | Output delta validation failed on resume-after-approval when artifact already existed | + +### Dashboard & UI + +| Issue | Fix | +|---|---| +| [#471](https://github.com/andresharpe/dotbot/issues/471) | STOP button gave no feedback — tooltip and notification now wired; hard kill available via Processes tab | +| [#519](https://github.com/andresharpe/dotbot/issues/519) | Products page blank for reviewers — task artifacts were hidden before approval | +| [#453](https://github.com/andresharpe/dotbot/issues/453) | Workflow task showing wrong phase during execution | +| [#454](https://github.com/andresharpe/dotbot/issues/454) | Roadmap "Done" section not showing all completed tasks | +| [#463](https://github.com/andresharpe/dotbot/issues/463) | Roadmap page dropdown missing workflows | +| [#477](https://github.com/andresharpe/dotbot/issues/477) | `escapeHtml` misused in HTML attribute context — XSS surface in actions.js resolved | + +### Auth & Notifications + +| Issue | Fix | +|---|---| +| [#467](https://github.com/andresharpe/dotbot/issues/467) | Auth expiry was dead code — tasks never re-authenticated after token expiry; `AuthError` now wired to needs-input re-auth | +| [#468](https://github.com/andresharpe/dotbot/issues/468) | Entering `needs-review` emitted no notification — reviewers were never alerted | + +### CI & Release Pipeline + +| Issue | Fix | +|---|---| +| [#460](https://github.com/andresharpe/dotbot/issues/460) | CI broken post-v4 restructure — .yml extensions stripped from `.github/workflows/` | +| [#475](https://github.com/andresharpe/dotbot/issues/475) | Bump-release workflow failing — `dotbot.psd1` missing after installer removal | +| [#487](https://github.com/andresharpe/dotbot/issues/487) | Discord release notification silently skipped for fork PRs | +| [#393](https://github.com/andresharpe/dotbot/issues/393) | Bump-and-release blocked by protected branch preventing direct push to main | +| [#476](https://github.com/andresharpe/dotbot/issues/476) | Layer 2 integration test was launching a real Claude process instead of a mock | +| [#474](https://github.com/andresharpe/dotbot/issues/474) | macOS CI Layer 1–3 tests had timing flakes | +| [#484](https://github.com/andresharpe/dotbot/issues/484) | Flaky Layer 5 UI E2E tests — Playwright retries now enabled | +| [#482](https://github.com/andresharpe/dotbot/issues/482) | Issue template chooser broken by JSON refactor | + +--- + +## Enhancements — 14 items + +### Install & Operations + +| | Enhancement | +|---|---| +| NEW | **`bootstrap.ps1`** — one-step install replacing the old copy-based installer; works on Windows, macOS, and Linux | +| NEW | **`dotbot status`** — surfaces resolved `DOTBOT_HOME`, framework branch/SHA, active workflow, provider and stacks; `--json` for CI scripts | +| NEW | **`MIGRATING.md`** — v3 → v4 migration guide covering shim install, `.bot/` rewrite, `.mcp.json` repointing, and retired entry points | + +### Task Execution + +| Issue | Enhancement | +|---|---| +| [#465](https://github.com/andresharpe/dotbot/issues/465) | **"Revise" review verb** — reviewer feedback injected in place; only affected section regenerates, worktree preserved | +| [#466](https://github.com/andresharpe/dotbot/issues/466) | Per-run integration branch — configurable base branch, provider-agnostic | +| [#521](https://github.com/andresharpe/dotbot/issues/521) | `MCP_TIMEOUT` / `MCP_TOOL_TIMEOUT` injected per-task so `claude.exe` survives slow MCP cold-starts | + +### Workflow Engine + +| Issue | Enhancement | +|---|---| +| [#427](https://github.com/andresharpe/dotbot/issues/427) | **`extends:` in `workflow.yaml`** — workflow inheritance from a base definition, at parity with stack inheritance | + +### Approvals & Q&A + +| Issue | Enhancement | +|---|---| +| [#451](https://github.com/andresharpe/dotbot/issues/451) | **Question & Response API** — structured Q&A envelope contract on mothership and outpost | +| [#445](https://github.com/andresharpe/dotbot/issues/445) | Merge approval and document review consolidated into one unified approval type | +| [#500](https://github.com/andresharpe/dotbot/issues/500) | Needs-review tasks now surfaced in the Needs Input pipeline section | +| [#29](https://github.com/andresharpe/dotbot/issues/29) | Expanded QuestionService — artifact approvals, roles, and new question types | + +### Dashboard & Integrations + +| Issue | Enhancement | +|---|---| +| [#469](https://github.com/andresharpe/dotbot/issues/469) | Resizable table columns and draggable panel separators — layout persisted across sessions | +| [#435](https://github.com/andresharpe/dotbot/issues/435) | Azurite blob storage emulator support for local mothership server testing | + +### AP Registry & Workflows + +| Issue | Enhancement | +|---|---| +| [#506](https://github.com/andresharpe/dotbot/issues/506) | QA-specific workflow definitions added to registry | +| [#507](https://github.com/andresharpe/dotbot/issues/507) / [#508](https://github.com/andresharpe/dotbot/issues/508) | AP Registry v4 compatibility check + classified registry restructured (default / client / role) | + +--- + +## Get this release + +```bash +# Homebrew (macOS / Linux) +brew upgrade dotbot + +# Scoop (Windows) +scoop update dotbot + +# From source +git pull && pwsh bootstrap.ps1 +``` + +Run `dotbot status` after updating to confirm you're on v4.0.1. + +--- + +*Next patch release: ~July 10, 2026* +*Release notes template: [`docs/release-notes/`](../release-notes/)* From 9504137872346392ac88a4c1092d0819e7555543 Mon Sep 17 00:00:00 2001 From: IBondarenko-iwg Date: Mon, 6 Jul 2026 17:43:49 +0300 Subject: [PATCH 25/50] fix(workflow): recipe resolver + output path normalize (#568) Closes #568 Refs #557 --- .../Scripts/Invoke-WorkflowProcess.ps1 | 47 +++++++++++++++---- tests/Test-WorkflowManifest.ps1 | 44 +++++++++++++++++ 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 index 3794cbfc..66f11ea3 100644 --- a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 +++ b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 @@ -710,8 +710,32 @@ function Test-TaskOutput { $taskOutputsDir = if ($Task -is [System.Collections.IDictionary]) { $Task['required_outputs_dir'] } else { $Task.required_outputs_dir } } if ($taskOutputs) { + # Output entries may be a bare filename ("x.md"), a repo-rooted path + # (".bot/workspace/product/x.md" — e.g. emitted by the deep-research + # generator), or an absolute path. $ProductDir is already + # /workspace/product, so naively Join-Path'ing a rooted entry + # double-joins (PowerShell only defers to the 2nd arg when it is + # drive-absolute) and validation always fails. Normalise first. + $projectRoot = Split-Path -Parent $BotRoot foreach ($f in $taskOutputs) { - if (-not (Test-Path (Join-Path $ProductDir $f))) { + $entry = ([string]$f -replace '\\', '/').Trim() + if ([string]::IsNullOrWhiteSpace($entry)) { continue } + # IsPathFullyQualified is the cross-platform "truly absolute" test: + # on Windows "C:/x" and "//host/x" are fully qualified but a bare "/x" + # is only drive-relative (correctly treated as relative here); on + # Linux/macOS "/tmp/x" is fully qualified. This avoids [IO.Path]:: + # IsPathRooted, which treats a bare leading "/" as rooted on Windows. + $target = if ([System.IO.Path]::IsPathFullyQualified($entry)) { + $entry + } elseif ($entry -match '^\.bot/') { + Join-Path $projectRoot $entry + } else { + Join-Path $ProductDir ($entry.TrimStart('/')) + } + # -LiteralPath: task-authored entries are untrusted; without it a + # filename containing [ ] would be treated as a wildcard (mis-validated), + # and it avoids a wildcard/existence probe on a crafted entry. + if (-not (Test-Path -LiteralPath $target)) { return "Task output not produced: $f" } } @@ -949,15 +973,20 @@ function Invoke-TaskClarificationLoopIfPresent { Set-Content -Path $summaryPath -Value $newSummary -NoNewline } - # Forward slashes for cross-platform Join-Path safety (PostScriptRunner.psm1 - # uses the same normalisation — Windows accepts either separator, Unix does not). - $adjustPromptPath = Join-Path $BotRoot "recipes/includes/adjust-after-answers.md" - if (-not (Test-Path $adjustPromptPath)) { - # Escalate via the postScriptFailed path so the worktree merge is - # blocked. Without the adjust prompt the answers cannot be applied - # to artifacts; merging would be incorrect. + # Resolve the adjust-after-answers recipe through the canonical content + # resolver (project .bot/content/recipes -> user -> $DOTBOT_HOME/content + # /recipes). A hardcoded "$BotRoot/recipes/..." never resolves: content + # install never populates a .bot/recipes tree (the worktree recipe-link is + # gated on that same non-existent dir), so inside a task worktree the file + # was always missing and the apply-answers step wrongly escalated. + $adjustPromptPath = Resolve-DotbotContent -BotRoot $BotRoot -Type recipes -Name 'includes/adjust-after-answers.md' + if (-not $adjustPromptPath) { + # Only reachable when the recipe is absent from every content tier — + # i.e. a broken framework install, which no operator "needs-input" + # answer can fix. Fail loud with a clear message rather than parking + # the task in needs-input (and never silently skip the adjust pass). Reset-ClarificationState -PD $ProcessData -Id $ProcId -TaskName $Task.name - return "Adjust prompt not found at $adjustPromptPath — cannot apply clarification answers" + return "Adjust-after-answers recipe not found in project or framework content (content/recipes/includes/adjust-after-answers.md) — framework install may be broken." } $adjustContent = Get-Content $adjustPromptPath -Raw $adjustPrompt = @" diff --git a/tests/Test-WorkflowManifest.ps1 b/tests/Test-WorkflowManifest.ps1 index 12d59145..5da14994 100644 --- a/tests/Test-WorkflowManifest.ps1 +++ b/tests/Test-WorkflowManifest.ps1 @@ -1516,6 +1516,50 @@ Assert-True -Name "Test-TaskOutput supports outputs_dir + min_output_count" ` Assert-True -Name "Test-TaskOutput falls back to absolute count for non-tasks/ on zero delta" ` -Condition ($workflowSrc -match 'if\s*\(\$isTasksOutput\s+-or\s+\$fileCount\s+-lt\s+\$minCount\)') ` -Message "Resume-after-approval would fail when delta is 0 and the artifact already exists unless non-tasks/ outputs fall back to the absolute file count." +# #568 bug 3 (behavioral): an output declared as a repo-rooted path +# (".bot/workspace/product/x.md") must resolve to the SAME file as a bare "x.md", +# not double-join onto $ProductDir (which already ends in workspace/product). +# Extract just Test-TaskOutput from the script and exercise it against a temp tree. +$ttoAst = [System.Management.Automation.Language.Parser]::ParseFile($workflowProcessPath, [ref]$null, [ref]$null) +$ttoFn = $ttoAst.Find({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Test-TaskOutput' }, $true) +Assert-True -Name "Test-TaskOutput function is extractable" -Condition ([bool]$ttoFn) +if ($ttoFn) { + . ([ScriptBlock]::Create($ttoFn.Extent.Text)) + $ttoRoot = Join-Path ([System.IO.Path]::GetTempPath()) "dotbot-tto-$([System.Guid]::NewGuid().ToString().Substring(0,8))" + $ttoBot = Join-Path $ttoRoot ".bot" + $ttoProd = Join-Path $ttoBot "workspace/product" + New-Item -ItemType Directory -Force -Path (Join-Path $ttoProd "briefing/repos") | Out-Null + Set-Content -Path (Join-Path $ttoProd "research-repos.md") -Value "x" + Set-Content -Path (Join-Path $ttoProd "briefing/repos/Foo.md") -Value "x" + try { + Assert-True -Name "Test-TaskOutput: bare filename validates" ` + -Condition ($null -eq (Test-TaskOutput -Task @{ outputs = @('research-repos.md') } -BotRoot $ttoBot -ProductDir $ttoProd)) + Assert-True -Name "Test-TaskOutput: rooted .bot/ path validates (no double-join)" ` + -Condition ($null -eq (Test-TaskOutput -Task @{ outputs = @('.bot/workspace/product/research-repos.md') } -BotRoot $ttoBot -ProductDir $ttoProd)) ` + -Message "A repo-rooted output entry must resolve under ProductDir, not double-join to .bot/workspace/product/.bot/workspace/product/..." + Assert-True -Name "Test-TaskOutput: nested rooted .bot/ path validates" ` + -Condition ($null -eq (Test-TaskOutput -Task @{ outputs = @('.bot/workspace/product/briefing/repos/Foo.md') } -BotRoot $ttoBot -ProductDir $ttoProd)) + # A fully-qualified absolute path (platform-native: C:\... on Windows, + # /tmp/... on Linux/macOS) must be used as-is, not trimmed and re-joined + # under ProductDir. Build a real absolute path to an existing fixture file. + $absOut = (Resolve-Path (Join-Path $ttoProd "research-repos.md")).Path + Assert-True -Name "Test-TaskOutput: fully-qualified absolute path validates" ` + -Condition ($null -eq (Test-TaskOutput -Task @{ outputs = @($absOut) } -BotRoot $ttoBot -ProductDir $ttoProd)) ` + -Message "An absolute output path must be tested as-is (regression: POSIX /tmp/... was treated as relative and joined under ProductDir)." + Assert-True -Name "Test-TaskOutput: genuinely missing output is reported" ` + -Condition ((Test-TaskOutput -Task @{ outputs = @('does-not-exist.md') } -BotRoot $ttoBot -ProductDir $ttoProd) -match 'not produced') + } finally { + Remove-Item $ttoRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + +# #568 bug 1: the clarification apply-answers recipe must resolve through the content +# resolver (project -> user -> framework), not a hardcoded $BotRoot/recipes path that +# dotbot never materialises (which made the file always missing inside a worktree). +Assert-True -Name "Clarification apply-answers resolves recipe via Resolve-DotbotContent" ` + -Condition (($workflowSrc -match "Resolve-DotbotContent\s+-BotRoot\s+\`$BotRoot\s+-Type\s+recipes\s+-Name\s+'includes/adjust-after-answers\.md'") -and + (-not ($workflowSrc -match 'Join-Path\s+\$BotRoot\s+"recipes/includes/adjust-after-answers\.md"'))) ` + -Message "adjust-after-answers.md must be resolved via Resolve-DotbotContent, not a hardcoded .bot/recipes path that is never materialised." Assert-True -Name "Measure-TaskFile counts workflow-run task files" ` -Condition (($workflowSrc -match 'Get-ChildItem\s+-LiteralPath\s+\$tasksRoot\s+-Recurse\s+-Filter\s+''\*\.json''') -and ($workflowSrc -match "\$_.Name\s+-ne\s+'run\.json'")) ` From ab11eb6614347a6a80a21e0b110057684059abe8 Mon Sep 17 00:00:00 2001 From: aselim31 Date: Mon, 6 Jul 2026 17:44:20 +0300 Subject: [PATCH 26/50] docs: update ROADMAP-RELEASES.md to reflect v4.0.1 deliverables docs: update ROADMAP-RELEASES.md to reflect v4.0.1 deliverables --- docs/ROADMAP-RELEASES.md | 252 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 docs/ROADMAP-RELEASES.md diff --git a/docs/ROADMAP-RELEASES.md b/docs/ROADMAP-RELEASES.md new file mode 100644 index 00000000..135c1011 --- /dev/null +++ b/docs/ROADMAP-RELEASES.md @@ -0,0 +1,252 @@ +# dotbot v4 — Release Roadmap + +> Three focused releases. Each one unlocks the next. + +--- + +## Delivery History + +| Version | Released | Issues Closed | +|---|---|---| +| v4.0.0 | June 4, 2026 | Initial v4 launch | +| [v4.0.1](release-notes/v4.0.1.md) | July 3, 2026 | 41 issues — 27 fixes, 14 enhancements | + +--- + +## Release Summary + +| Release | Version | Theme | Focus | Issues | +|---|---|---|---|---| +| R1 | v4.1 | **Stable · Enterprise · Drone** | Harden the runtime, deliver questionnaire & approval layer, land fleet registration & Drone | 13 | +| R2 | v4.2 | **Fleet · Workflow · Nice-to-have** | Fleet dashboard & auth, complete the workflow engine, ship deferred enterprise & improvement items | 15 | +| R3 | v4.3 | **Intelligence · Improvements** | AI self-improvement, knowledge injection, Aether conduits, remaining improvement backlog | 9 | +| — | ongoing | **UI / UX** | Design system delivered incrementally across all releases | 5 | + +--- + +## R1 — v4.1 · Stable · Enterprise · Drone + +> Harden the runtime. Deliver questionnaire & approval layer. Land fleet registration & Drone. + +**Why first:** Stabilization items unblock clean development. The Event Bus (#93) is a hard blocker for fleet, drone, and enterprise features. Enterprise questionnaire and team/roles form the approval layer that downstream automation depends on. + +### Stabilization (2 remaining) + +> 8 of 10 stabilization items shipped in v4.0.1. + +| Issue | Title | +|---|---| +| [#509](https://github.com/andresharpe/dotbot/issues/509) | Preflight content-aware checks | +| [#458](https://github.com/andresharpe/dotbot/issues/458) | Update README docs | + +
+Closed in v4.0.1 (8 issues) + +| Issue | Title | +|---|---| +| ~~[#537](https://github.com/andresharpe/dotbot/issues/537)~~ | ~~Orphan worktree corrupts filesystem~~ | +| ~~[#536](https://github.com/andresharpe/dotbot/issues/536)~~ | ~~Non-atomic task claim under concurrency~~ | +| ~~[#518](https://github.com/andresharpe/dotbot/issues/518)~~ | ~~Output delta validation on resume~~ | +| ~~[#516](https://github.com/andresharpe/dotbot/issues/516)~~ | ~~interview-answers.json merge conflicts~~ | +| ~~[#519](https://github.com/andresharpe/dotbot/issues/519)~~ | ~~Products page missing artifacts~~ | +| ~~[#393](https://github.com/andresharpe/dotbot/issues/393)~~ | ~~CI Bump & Release fails~~ | +| ~~[#504](https://github.com/andresharpe/dotbot/issues/504)~~ | ~~Cannot find Claude and Git~~ | +| ~~[#511](https://github.com/andresharpe/dotbot/issues/511)~~ | ~~Manage dotbot versioning~~ | + +
+ +### Enterprise (3 remaining) + +> 2 of 5 enterprise items shipped in v4.0.1. + +| Issue | Title | +|---|---| +| [#93](https://github.com/andresharpe/dotbot/issues/93) | Event bus — critical blocker | +| [#545](https://github.com/andresharpe/dotbot/issues/545) | Q&A web provider & channel prefs | +| [#98](https://github.com/andresharpe/dotbot/issues/98) | Project team & roles | + +
+Closed in v4.0.1 (2 issues) + +| Issue | Title | +|---|---| +| ~~[#29](https://github.com/andresharpe/dotbot/issues/29)~~ | ~~Expand QuestionService & approvals~~ | +| ~~[#30](https://github.com/andresharpe/dotbot/issues/30)~~ | ~~Jira as an approval channel~~ | + +
+ +### Mothership & Fleet (6 issues) + +| Issue | Title | +|---|---| +| [#544](https://github.com/andresharpe/dotbot/issues/544) | Fleet server: registration & heartbeat | +| [#95](https://github.com/andresharpe/dotbot/issues/95) | Mothership fleet coordination | +| [#96](https://github.com/andresharpe/dotbot/issues/96) | Drone agent — remote task execution | +| [#576](https://github.com/andresharpe/dotbot/issues/576) | Mothership deployment & setup (Dockerfile, docker-compose, env reference) | +| [#574](https://github.com/andresharpe/dotbot/issues/574) | Drone credential store (PAT-in-URL fix, http.extraHeader auth) | +| [#575](https://github.com/andresharpe/dotbot/issues/575) | Outpost-to-Drone task delegation (execution: local\|drone\|auto) | + +**Gate to R2:** Event Bus emitting events end-to-end · all stabilization items closed · fleet registration working · Drone agent spawning + +--- + +## R2 — v4.2 · Fleet · Workflow · Nice-to-have + +> Fleet dashboard & auth. Complete the workflow engine. Ship deferred enterprise & improvement items. + +**Why second:** Fleet dashboard and OIDC/RBAC build on the fleet registration layer from R1. Workflow Builder completion depends on the process refactor and event-driven triggers landed in R1. Enterprise and Mothership nice-to-haves are deferred here to keep R1 lean. + +### Enterprise (2 issues) + +| Issue | Title | +|---|---| +| [#38](https://github.com/andresharpe/dotbot/issues/38) | OpenClaw channels for orchestration | +| [#39](https://github.com/andresharpe/dotbot/issues/39) | Jira-initiated project launch | + +### Mothership & Fleet (2 issues) + +| Issue | Title | +|---|---| +| [#547](https://github.com/andresharpe/dotbot/issues/547) | Fleet dashboard: instance cards & metrics | +| [#548](https://github.com/andresharpe/dotbot/issues/548) | Auth layer: OIDC, IAuthProvider & RBAC | + +### Workflow Builder (5 issues) + +> `#522` removed — delivered as [#427](https://github.com/andresharpe/dotbot/issues/427) in v4.0.1. + +| Issue | Title | +|---|---| +| [#129](https://github.com/andresharpe/dotbot/issues/129) | GitHub Workflow Family | +| [#102](https://github.com/andresharpe/dotbot/issues/102) | User-level workflow editor | +| [#380](https://github.com/andresharpe/dotbot/issues/380) | Skill Builder Feature | +| [#542](https://github.com/andresharpe/dotbot/issues/542) | Process isolation: InterviewLoop & IPolicyEvaluator | +| [#543](https://github.com/andresharpe/dotbot/issues/543) | HealthAPI, ConfigValidator & idempotent init | + +### Improvements & Dev Exp (6 issues) + +| Issue | Title | +|---|---| +| [#512](https://github.com/andresharpe/dotbot/issues/512) | Selective Workflow Re-run | +| [#510](https://github.com/andresharpe/dotbot/issues/510) | Task Output Contract & execution gates | +| [#503](https://github.com/andresharpe/dotbot/issues/503) | On-Demand External Task Trigger | +| [#416](https://github.com/andresharpe/dotbot/issues/416) | Promote inbound decisions to ADRs | +| [#546](https://github.com/andresharpe/dotbot/issues/546) | Telemetry: OTel SDK & sinks | +| [#550](https://github.com/andresharpe/dotbot/issues/550) | Registry: remove, namespace:stack & auto-update | + +**Gate to R3:** Fleet dashboard live · OIDC/RBAC enforced · Workflow Builder feature-complete · all improvement items closed + +--- + +## R3 — v4.3 · Intelligence · Improvements + +> AI self-improvement, knowledge injection, Aether conduits, and the remaining improvement backlog. + +**Why third:** Self-improvement loop and Aether conduits depend on a stable Event Bus (R1) and a working fleet/drone layer (R2). Knowledge provider and shared memory require the workflow engine to be complete (R2). + +### Improvements & Dev Exp (5 issues) + +| Issue | Title | +|---|---| +| [#97](https://github.com/andresharpe/dotbot/issues/97) | Self-improvement loop | +| [#99](https://github.com/andresharpe/dotbot/issues/99) | Aether conduit plugin architecture | +| [#549](https://github.com/andresharpe/dotbot/issues/549) | IKnowledgeProvider: vector DB & ontology | +| [#76](https://github.com/andresharpe/dotbot/issues/76) | Built-in Shared Memory System | +| [#505](https://github.com/andresharpe/dotbot/issues/505) | Filter & Sort Decisions per Workflow | + +### Workflow Builder (4 issues) + +Remaining Workflow Builder items to be scheduled into R3 as R2 closes. Candidates from the gap analysis: +- `workflow-status`, `pause` & `resume` MCP tools (Ph3/Ph7 tail) +- Workflow tab UI & task lifecycle visualisation (Ph3 tail) +- Mothership registry sync & auto-update trigger (Ph11 tail) +- Additional workflow policy and retry items identified during R2 + +**Gate to ship:** Self-improvement loop running · at least 2 Aether conduit types bonding to events · Workflow Builder fully closed + +--- + +## UI / UX — Ongoing Across All Releases + +Design system delivered incrementally. Not gated to a single release. + +| Issue | Title | +|---|---| +| [#32](https://github.com/andresharpe/dotbot/issues/32) | Workflow tab UI & task lifecycle viz | +| [#551](https://github.com/andresharpe/dotbot/issues/551) | Navigation shell redesign | +| [#552](https://github.com/andresharpe/dotbot/issues/552) | Visual design system | +| [#553](https://github.com/andresharpe/dotbot/issues/553) | ⌘K command palette | +| [#554](https://github.com/andresharpe/dotbot/issues/554) | Accessibility & ADR integration | + +--- + +## Dependency Chain + +``` +R1: Stabilization ──► Event Bus (#93) ──────────────────────────┐ + │ │ + ├─► Enterprise Q&A (#545, #98) │ + ├─► Fleet registration (#544, #95) │ + ├─► Drone agent (#96) │ + ├─► Mothership setup (#576) │ + ├─► Drone credential store (#574) │ + └─► Task delegation (#575) │ + │ +R2: Fleet dashboard (#547, #548) ◄── R1 fleet layer │ + Workflow Builder (#129, #102, #380, #542, #543) │ + Improvements (#512, #510, #503, #416, #546, #550) │ + Enterprise nice-to-have (#38, #39) │ + │ +R3: Self-improvement (#97) ◄── drones (R1) + workflow (R2) │ + Aether conduits (#99) ◄──────────────────────────────────────┘ + Knowledge provider (#549), Shared memory (#76) + Remaining Workflow Builder (4 items) + +UI/UX (#32, #551, #552, #553, #554) ── ongoing across R1 → R3 +``` + +--- + +## All Issues by Release + +| Issue | Title | Release | Area | +|---|---|---|---| +| [#509](https://github.com/andresharpe/dotbot/issues/509) | Preflight content-aware checks | R1 | V4-Stabilization | +| [#458](https://github.com/andresharpe/dotbot/issues/458) | Update README docs | R1 | V4-Stabilization | +| [#93](https://github.com/andresharpe/dotbot/issues/93) | Event bus — critical blocker | R1 | Enterprise Features | +| [#545](https://github.com/andresharpe/dotbot/issues/545) | Q&A web provider & channel prefs | R1 | Enterprise Features | +| [#98](https://github.com/andresharpe/dotbot/issues/98) | Project team & roles | R1 | Enterprise Features | +| [#544](https://github.com/andresharpe/dotbot/issues/544) | Fleet server: registration & heartbeat | R1 | Mothership & Fleet | +| [#95](https://github.com/andresharpe/dotbot/issues/95) | Mothership fleet coordination | R1 | Mothership & Fleet | +| [#96](https://github.com/andresharpe/dotbot/issues/96) | Drone agent — remote task execution | R1 | Mothership & Fleet | +| [#576](https://github.com/andresharpe/dotbot/issues/576) | Mothership deployment & setup | R1 | Mothership & Fleet | +| [#574](https://github.com/andresharpe/dotbot/issues/574) | Drone credential store | R1 | Mothership & Fleet | +| [#575](https://github.com/andresharpe/dotbot/issues/575) | Outpost-to-Drone task delegation | R1 | Mothership & Fleet | +| [#38](https://github.com/andresharpe/dotbot/issues/38) | OpenClaw channels for orchestration | R2 | Enterprise Features | +| [#39](https://github.com/andresharpe/dotbot/issues/39) | Jira-initiated project launch | R2 | Enterprise Features | +| [#547](https://github.com/andresharpe/dotbot/issues/547) | Fleet dashboard: instance cards & metrics | R2 | Mothership & Fleet | +| [#548](https://github.com/andresharpe/dotbot/issues/548) | Auth layer: OIDC, IAuthProvider & RBAC | R2 | Mothership & Fleet | +| [#129](https://github.com/andresharpe/dotbot/issues/129) | GitHub Workflow Family | R2 | Workflow Builder | +| [#102](https://github.com/andresharpe/dotbot/issues/102) | User-level workflow editor | R2 | Workflow Builder | +| [#380](https://github.com/andresharpe/dotbot/issues/380) | Skill Builder Feature | R2 | Workflow Builder | +| [#542](https://github.com/andresharpe/dotbot/issues/542) | Process isolation: InterviewLoop & IPolicyEvaluator | R2 | Workflow Builder | +| [#543](https://github.com/andresharpe/dotbot/issues/543) | HealthAPI, ConfigValidator & idempotent init | R2 | Workflow Builder | +| [#512](https://github.com/andresharpe/dotbot/issues/512) | Selective Workflow Re-run | R2 | Improvements & Developer Experience | +| [#510](https://github.com/andresharpe/dotbot/issues/510) | Task Output Contract & execution gates | R2 | Improvements & Developer Experience | +| [#503](https://github.com/andresharpe/dotbot/issues/503) | On-Demand External Task Trigger | R2 | Improvements & Developer Experience | +| [#416](https://github.com/andresharpe/dotbot/issues/416) | Promote inbound decisions to ADRs | R2 | Improvements & Developer Experience | +| [#546](https://github.com/andresharpe/dotbot/issues/546) | Telemetry: OTel SDK & sinks | R2 | Improvements & Developer Experience | +| [#550](https://github.com/andresharpe/dotbot/issues/550) | Registry: remove, namespace:stack & auto-update | R2 | Improvements & Developer Experience | +| [#97](https://github.com/andresharpe/dotbot/issues/97) | Self-improvement loop | R3 | Improvements & Developer Experience | +| [#99](https://github.com/andresharpe/dotbot/issues/99) | Aether conduit plugin architecture | R3 | Improvements & Developer Experience | +| [#549](https://github.com/andresharpe/dotbot/issues/549) | IKnowledgeProvider: vector DB & ontology | R3 | Improvements & Developer Experience | +| [#76](https://github.com/andresharpe/dotbot/issues/76) | Built-in Shared Memory System | R3 | Improvements & Developer Experience | +| [#505](https://github.com/andresharpe/dotbot/issues/505) | Filter & Sort Decisions per Workflow | R3 | Improvements & Developer Experience | +| [#32](https://github.com/andresharpe/dotbot/issues/32) | Workflow tab UI & task lifecycle viz | ongoing | UI/UX | +| [#551](https://github.com/andresharpe/dotbot/issues/551) | Navigation shell redesign | ongoing | UI/UX | +| [#552](https://github.com/andresharpe/dotbot/issues/552) | Visual design system | ongoing | UI/UX | +| [#553](https://github.com/andresharpe/dotbot/issues/553) | ⌘K command palette | ongoing | UI/UX | +| [#554](https://github.com/andresharpe/dotbot/issues/554) | Accessibility & ADR integration | ongoing | UI/UX | + +--- + +*Last updated: 2026-07-03* From 3213ce90380bb2216f02dae018fb6728dcf40629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ognjen=20Gligori=C4=87?= <87246330+OgnjenGligoric@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:43:35 +0200 Subject: [PATCH 27/50] feat(registry): registry remove command and auto-update Implements registry remove, RegistryManager helpers, and registry auto-update on init/run. Validated with PowerShell 7.6.3: - tests/Test-RegistryCLI.ps1 - tests/Run-Tests.ps1 layers 1-3 --- bin/dotbot.ps1 | 10 ++ src/cli/RegistryManager.psm1 | 132 ++++++++++++++ src/cli/registry-remove.ps1 | 135 ++++++++++++++ tests/Run-Tests.ps1 | 3 +- tests/Test-RegistryCLI.ps1 | 338 +++++++++++++++++++++++++++++++++++ 5 files changed, 617 insertions(+), 1 deletion(-) create mode 100644 src/cli/RegistryManager.psm1 create mode 100644 src/cli/registry-remove.ps1 create mode 100644 tests/Test-RegistryCLI.ps1 diff --git a/bin/dotbot.ps1 b/bin/dotbot.ps1 index 2e8fef8a..8ad99f83 100755 --- a/bin/dotbot.ps1 +++ b/bin/dotbot.ps1 @@ -205,6 +205,11 @@ function Get-RequestedDashboardPort { } function Invoke-Init { + $registryManagerModule = Join-Path $ScriptsDir "RegistryManager.psm1" + if (Test-Path $registryManagerModule) { + Import-Module $registryManagerModule -Force -DisableNameChecking + Update-StaleRegistries -DotbotBase $DotbotBase + } $initScript = Join-Path $ScriptsDir "init-project.ps1" if (Test-Path $initScript) { if ($SplatArgs.Count -gt 0) { @@ -509,6 +514,11 @@ function Invoke-Install { } function Invoke-Run { + $registryManagerModule = Join-Path $ScriptsDir "RegistryManager.psm1" + if (Test-Path $registryManagerModule) { + Import-Module $registryManagerModule -Force -DisableNameChecking + Update-StaleRegistries -DotbotBase $DotbotBase + } $runScript = Join-Path $ScriptsDir 'workflow-run.ps1' $invocation = Get-WorkflowRunInvocation -RunArgs $SubArgs if ($invocation.WorkflowName -and (Test-Path $runScript)) { diff --git a/src/cli/RegistryManager.psm1 b/src/cli/RegistryManager.psm1 new file mode 100644 index 00000000..8514de4e --- /dev/null +++ b/src/cli/RegistryManager.psm1 @@ -0,0 +1,132 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Registry state management helpers for dotbot. + +.DESCRIPTION + Provides functions for reading registry metadata and auto-updating stale + git-based registries. Consumed by dotbot init and dotbot run. +#> + +$ErrorActionPreference = "Stop" + +function Get-DotbotRegistries { + <# + .SYNOPSIS + Returns all registered registries from registries.json as an array. + Returns an empty array if the file does not exist or has no entries. + #> + param( + [Parameter(Mandatory)][string]$DotbotBase + ) + $configPath = Join-Path $DotbotBase "registries.json" + if (-not (Test-Path $configPath)) { return @() } + try { + $config = Get-Content $configPath -Raw | ConvertFrom-Json + if ($config.registries) { return @($config.registries) } + } catch { + Write-DotbotWarning "Failed to parse registries.json — skipping registry auto-update: $($_.Exception.Message)" + } + return @() +} + +function Update-StaleRegistries { + <# + .SYNOPSIS + Pulls the latest commits for all git-based registries that have + auto_update set to true. Silently skips local (symlink) registries. + + .DESCRIPTION + Called automatically by dotbot init and dotbot run. Failures are + non-fatal: a warning is emitted and the stale local copy is used. + + .PARAMETER DotbotBase + Resolved dotbot install path (DOTBOT_HOME). + + .PARAMETER MaxAgeSecs + Only update registries whose last update is older than this many + seconds. Defaults to 3600 (1 hour) to avoid hammering git on every + run. Pass 0 to force-update all eligible registries. + #> + param( + [Parameter(Mandatory)][string]$DotbotBase, + [int]$MaxAgeSecs = 3600 + ) + + $configPath = Join-Path $DotbotBase "registries.json" + $registries = Get-DotbotRegistries -DotbotBase $DotbotBase + if ($registries.Count -eq 0) { return } + + $config = Get-Content $configPath -Raw | ConvertFrom-Json + $changed = $false + + foreach ($entry in $registries) { + if (-not $entry.auto_update) { continue } + if ($entry.type -ne "git") { continue } + + # Validate name and ensure resolved path stays under the registries root + $rawName = [string]$entry.name + $safeName = [System.IO.Path]::GetFileName($rawName) + if ($safeName -ne $rawName -or $safeName -in @('.', '..') -or $safeName -notmatch '^[A-Za-z0-9._-]+$') { + Write-DotbotWarning "Registry name '$rawName' is invalid — skipping auto-update" + continue + } + $registriesRoot = [System.IO.Path]::GetFullPath((Join-Path $DotbotBase "registries")) + $registryPath = [System.IO.Path]::GetFullPath((Join-Path $registriesRoot $safeName)) + $rootWithSep = $registriesRoot.TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar + ) + [System.IO.Path]::DirectorySeparatorChar + if (-not $registryPath.StartsWith($rootWithSep, [System.StringComparison]::OrdinalIgnoreCase)) { + Write-DotbotWarning "Registry '$safeName' resolves outside registries directory — skipping auto-update" + continue + } + + if (-not (Test-Path -LiteralPath $registryPath -PathType Container)) { + Write-DotbotWarning "Registry '$safeName' directory missing — skipping auto-update" + continue + } + + # Honour MaxAgeSecs: skip if updated recently + if ($MaxAgeSecs -gt 0 -and $entry.updated_at) { + try { + $lastUpdate = [datetime]::Parse($entry.updated_at) + $ageSecs = ([datetime]::UtcNow - $lastUpdate.ToUniversalTime()).TotalSeconds + if ($ageSecs -lt $MaxAgeSecs) { continue } + } catch { } + } + + # git fetch + fast-forward merge + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-DotbotWarning "git not found on PATH — skipping registry auto-update" + return + } + $branch = if ($entry.branch) { $entry.branch } else { "main" } + $null = & git -C $registryPath fetch --quiet origin $branch 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-DotbotWarning "Auto-update failed for registry '$safeName' (fetch error) — using cached copy" + continue + } + + $null = & git -C $registryPath merge --ff-only "origin/$branch" 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-DotbotWarning "Auto-update failed for registry '$safeName' (cannot fast-forward) — using cached copy" + continue + } + + # Record updated_at timestamp + $config.registries = @($config.registries | ForEach-Object { + if ($_.name -eq $entry.name) { + $_ | Add-Member -NotePropertyName 'updated_at' -NotePropertyValue ((Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')) -Force + $changed = $true + } + $_ + }) + } + + if ($changed) { + $config | ConvertTo-Json -Depth 5 | Set-Content $configPath + } +} + +Export-ModuleMember -Function Get-DotbotRegistries, Update-StaleRegistries diff --git a/src/cli/registry-remove.ps1 b/src/cli/registry-remove.ps1 new file mode 100644 index 00000000..b0e2ee7d --- /dev/null +++ b/src/cli/registry-remove.ps1 @@ -0,0 +1,135 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Remove a registered dotbot extension registry. + +.DESCRIPTION + Removes a registry by name: deletes the local registry directory (cloned + repo or junction/symlink) and removes the entry from registries.json. + +.PARAMETER Name + Registry namespace to remove (e.g., "myorg"). + +.PARAMETER Force + Skip confirmation prompt. + +.EXAMPLE + registry-remove.ps1 -Name myorg + registry-remove.ps1 -Name myorg -Force +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$Name, + [switch]$Force +) + +$ErrorActionPreference = "Stop" + +Import-Module (Join-Path $PSScriptRoot ".." "runtime" "Modules" "Dotbot.Core" "Dotbot.Core.psm1") -Force -DisableNameChecking +$DotbotBase = Get-DotbotInstallPath +$RegistriesDir = Join-Path $DotbotBase "registries" +$ConfigPath = Join-Path $DotbotBase "registries.json" + +# Import platform functions (required for theme helpers) +$PlatformFunctionsModule = Join-Path $PSScriptRoot "Platform-Functions.psm1" +if (-not (Test-Path $PlatformFunctionsModule)) { + Write-Error "Required module not found: $PlatformFunctionsModule — run 'dotbot update' to repair" + exit 1 +} +Import-Module $PlatformFunctionsModule -Force -ErrorAction Stop +Import-Module (Join-Path (Get-DotbotInstallPath) "src" "runtime" "Modules" "Dotbot.Theme" "Dotbot.Theme.psd1") -Force -DisableNameChecking + +Write-DotbotBanner -Title "D O T B O T" -Subtitle "Registry: Remove" + +# Validate registry name to prevent path traversal +$safeName = [System.IO.Path]::GetFileName($Name) +if ($safeName -ne $Name -or $safeName -in @('.', '..') -or $safeName -notmatch '^[A-Za-z0-9._-]+$') { + Write-DotbotError "Invalid registry name '$Name'" + exit 1 +} +$RegistryPath = [System.IO.Path]::GetFullPath((Join-Path $RegistriesDir $Name)) +$rootWithSep = [System.IO.Path]::GetFullPath($RegistriesDir).TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar +) + [System.IO.Path]::DirectorySeparatorChar +if (-not $RegistryPath.StartsWith($rootWithSep, [System.StringComparison]::OrdinalIgnoreCase)) { + Write-DotbotError "Invalid registry path '$RegistryPath'" + exit 1 +} + +# --------------------------------------------------------------------------- +# 1. Check registry exists in registries.json +# --------------------------------------------------------------------------- +if (-not (Test-Path $ConfigPath)) { + Write-DotbotError "No registries.json found — no registries have been added" + exit 1 +} + +$config = $null +try { + $config = Get-Content $ConfigPath -Raw | ConvertFrom-Json +} catch { + Write-DotbotError "Failed to parse registries.json: $_" + exit 1 +} + +if (-not $config.registries) { + Write-DotbotError "registries.json contains no registries" + exit 1 +} + +$entry = $config.registries | Where-Object { $_.name -eq $Name } +if (-not $entry) { + Write-DotbotError "Registry '$Name' is not registered" + Write-DotbotCommand "Run 'dotbot registry list' to see registered registries" + exit 1 +} + +# --------------------------------------------------------------------------- +# 2. Confirm removal +# --------------------------------------------------------------------------- +if (-not $Force) { + Write-DotbotWarning "This will remove registry '$Name' and delete its local files" + Write-DotbotLabel -Label "Source" -Value "$($entry.source)" + Write-DotbotLabel -Label "Type " -Value "$($entry.type)" + Write-BlankLine + if (-not (Read-DotbotConfirmation -Message "Remove registry '$Name'?" -Default $false)) { + Write-DotbotWarning "Aborted" + exit 0 + } +} + +# --------------------------------------------------------------------------- +# 3. Delete the local registry directory / symlink / junction +# --------------------------------------------------------------------------- +if (Test-Path $RegistryPath) { + $item = Get-Item -LiteralPath $RegistryPath -Force + # Junctions and symlinks must be removed without -Recurse to avoid + # deleting the target contents + $isJunctionOrSymlink = ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 + if ($isJunctionOrSymlink) { + Write-Status "Removing symlink/junction: $RegistryPath" + $item.Delete() + } else { + Write-Status "Removing registry directory: $RegistryPath" + Remove-Item -Path $RegistryPath -Recurse -Force + } + Write-Success "Removed local registry files" +} else { + Write-DotbotWarning "Registry directory not found at $RegistryPath — skipping file removal" +} + +# --------------------------------------------------------------------------- +# 4. Remove entry from registries.json +# --------------------------------------------------------------------------- +$config.registries = @($config.registries | Where-Object { $_.name -ne $Name }) +$config | ConvertTo-Json -Depth 5 | Set-Content $ConfigPath +Write-Success "Removed '$Name' from registries.json" + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +Write-BlankLine +Write-DotbotBanner -Title "Registry '$Name' removed" +Write-BlankLine diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index 63337667..ba723dd2 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -163,8 +163,9 @@ if (2 -in $layersToRun) { $studioAPICode = Invoke-TestFile -Layer '2' -FileName 'Test-StudioAPI.ps1' $toolLocalCode = Invoke-TestFile -Layer '2' -FileName 'Test-ToolLocal.ps1' $mcpHandshakeCode = Invoke-TestFile -Layer '2' -FileName 'Test-MCPHandshake.ps1' + $registryCLICode = Invoke-TestFile -Layer '2' -FileName 'Test-RegistryCLI.ps1' - $exitCode = if ($componentsCode -ne 0 -or $taskActionsCode -ne 0 -or $serverStartupCode -ne 0 -or $workflowIntegrationCode -ne 0 -or $processRegistryCode -ne 0 -or $processDispatchCode -ne 0 -or $studioAPICode -ne 0 -or $toolLocalCode -ne 0 -or $mcpHandshakeCode -ne 0) { 1 } else { 0 } + $exitCode = if ($componentsCode -ne 0 -or $taskActionsCode -ne 0 -or $serverStartupCode -ne 0 -or $workflowIntegrationCode -ne 0 -or $processRegistryCode -ne 0 -or $processDispatchCode -ne 0 -or $studioAPICode -ne 0 -or $toolLocalCode -ne 0 -or $mcpHandshakeCode -ne 0 -or $registryCLICode -ne 0) { 1 } else { 0 } $layerResults["2"] = ($exitCode -eq 0) if ($exitCode -ne 0) { $overallFailed = $true } } diff --git a/tests/Test-RegistryCLI.ps1 b/tests/Test-RegistryCLI.ps1 new file mode 100644 index 00000000..9f763fc5 --- /dev/null +++ b/tests/Test-RegistryCLI.ps1 @@ -0,0 +1,338 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Layer 2: Tests for registry-remove.ps1 and RegistryManager.psm1 (PR #564). +.DESCRIPTION + Exercises registry remove (path traversal guard, confirmation skip, file + deletion, registries.json cleanup) and RegistryManager helpers + (Get-DotbotRegistries, Update-StaleRegistries name validation). +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" + +Import-Module "$PSScriptRoot\Test-Helpers.psm1" -Force + +$dotbotDir = Get-DotbotInstallDir + +Write-Host "" +Write-Host "-----------------------------------------------------------" -ForegroundColor Blue +Write-Host " Layer 2: Registry CLI Tests (PR #564)" -ForegroundColor Blue +Write-Host "-----------------------------------------------------------" -ForegroundColor Blue +Write-Host "" + +Reset-TestResults + +# --------------------------------------------------------------------------- +# Prerequisites +# --------------------------------------------------------------------------- + +$dotbotInstalled = Test-Path (Join-Path $dotbotDir "src") +if (-not $dotbotInstalled) { + Write-TestResult -Name "Layer 2 prerequisites" -Status Fail -Message "dotbot not installed — set DOTBOT_HOME to a dotbot checkout" + Write-TestSummary -LayerName "Layer 2: Registry CLI" + exit 1 +} + +$registryManagerPath = Join-Path $dotbotDir "src/cli/RegistryManager.psm1" +$registryRemovePath = Join-Path $dotbotDir "src/cli/registry-remove.ps1" + +# =================================================================== +# MODULE LOADING +# =================================================================== + +Write-Host " MODULE LOADING" -ForegroundColor Cyan +Write-Host " --------------------------------------------" -ForegroundColor DarkGray + +Assert-True -Name "RegistryManager.psm1 exists on disk" ` + -Condition (Test-Path $registryManagerPath) ` + -Message "Not found: $registryManagerPath" + +Assert-True -Name "registry-remove.ps1 exists on disk" ` + -Condition (Test-Path $registryRemovePath) ` + -Message "Not found: $registryRemovePath" + +# Load a platform-functions stub so Write-DotbotWarning doesn't break module load +$platformFunctions = Join-Path $dotbotDir "src/cli/Platform-Functions.psm1" +if (Test-Path $platformFunctions) { + try { + Import-Module $platformFunctions -Force -DisableNameChecking + } catch { } +} + +try { + Import-Module $registryManagerPath -Force -DisableNameChecking + Write-TestResult -Name "RegistryManager.psm1 imports without error" -Status Pass +} catch { + Write-TestResult -Name "RegistryManager.psm1 imports without error" -Status Fail -Message $_.Exception.Message + Write-TestSummary -LayerName "Layer 2: Registry CLI" + exit 1 +} + +# =================================================================== +# SETUP: isolated temp home dir that mimics DOTBOT_HOME +# =================================================================== + +$testHome = Join-Path ([System.IO.Path]::GetTempPath()) "dotbot-test-regcli-$(Get-Random)" +$registriesDir = Join-Path $testHome "registries" +New-Item -Path $registriesDir -ItemType Directory -Force | Out-Null + +function Write-TestRegistriesJson { + param([array]$Entries) + $obj = [pscustomobject]@{ registries = $Entries } + $obj | ConvertTo-Json -Depth 5 | Set-Content (Join-Path $testHome "registries.json") -Encoding utf8NoBOM +} + +# =================================================================== +# Get-DotbotRegistries +# =================================================================== + +Write-Host "" +Write-Host " GET-DOTBOTREGISTRIES" -ForegroundColor Cyan +Write-Host " --------------------------------------------" -ForegroundColor DarkGray + +# No registries.json yet +$result = Get-DotbotRegistries -DotbotBase $testHome +Assert-True -Name "Get-DotbotRegistries returns empty array when no registries.json" ` + -Condition ($result.Count -eq 0) ` + -Message "Expected 0 entries, got $($result.Count)" + +# Empty registries array +Write-TestRegistriesJson -Entries @() +$result = Get-DotbotRegistries -DotbotBase $testHome +Assert-True -Name "Get-DotbotRegistries returns empty array for empty registries list" ` + -Condition ($result.Count -eq 0) ` + -Message "Expected 0 entries, got $($result.Count)" + +# Two entries +Write-TestRegistriesJson -Entries @( + [pscustomobject]@{ name = "alpha"; type = "git"; source = "https://example.com/alpha.git"; auto_update = $true } + [pscustomobject]@{ name = "beta"; type = "local"; source = "C:/repos/beta"; auto_update = $false } +) +$result = Get-DotbotRegistries -DotbotBase $testHome +Assert-True -Name "Get-DotbotRegistries returns correct count" ` + -Condition ($result.Count -eq 2) ` + -Message "Expected 2 entries, got $($result.Count)" + +Assert-True -Name "Get-DotbotRegistries first entry has correct name" ` + -Condition ($result[0].name -eq "alpha") ` + -Message "Expected 'alpha', got '$($result[0].name)'" + +# Corrupt JSON — should return empty, not throw +"not valid json {{{{" | Set-Content (Join-Path $testHome "registries.json") -Encoding utf8NoBOM +$result = Get-DotbotRegistries -DotbotBase $testHome +Assert-True -Name "Get-DotbotRegistries returns empty array on corrupt registries.json" ` + -Condition ($result.Count -eq 0) ` + -Message "Expected 0 entries on parse failure, got $($result.Count)" + +# =================================================================== +# Update-StaleRegistries — name validation (path traversal guard) +# =================================================================== + +Write-Host "" +Write-Host " UPDATE-STALEREGISTRIES (name validation)" -ForegroundColor Cyan +Write-Host " --------------------------------------------" -ForegroundColor DarkGray + +# Registry with a path-traversal name — should be skipped, not throw +$evilEntries = @( + [pscustomobject]@{ name = "../evil"; type = "git"; source = "https://example.com/evil.git"; auto_update = $true; branch = "main" } +) +Write-TestRegistriesJson -Entries $evilEntries + +try { + Update-StaleRegistries -DotbotBase $testHome -MaxAgeSecs 0 + Write-TestResult -Name "Update-StaleRegistries does not throw on path-traversal name" -Status Pass +} catch { + Write-TestResult -Name "Update-StaleRegistries does not throw on path-traversal name" -Status Fail -Message $_.Exception.Message +} + +# Registry with invalid characters in name — should be skipped, not throw +$badNameEntries = @( + [pscustomobject]@{ name = "reg; rm -rf /"; type = "git"; source = "https://example.com/x.git"; auto_update = $true; branch = "main" } +) +Write-TestRegistriesJson -Entries $badNameEntries + +try { + Update-StaleRegistries -DotbotBase $testHome -MaxAgeSecs 0 + Write-TestResult -Name "Update-StaleRegistries does not throw on name with shell metacharacters" -Status Pass +} catch { + Write-TestResult -Name "Update-StaleRegistries does not throw on name with shell metacharacters" -Status Fail -Message $_.Exception.Message +} + +# Valid name but directory missing — should be skipped, not throw +Write-TestRegistriesJson -Entries @( + [pscustomobject]@{ name = "myorg"; type = "git"; source = "https://example.com/myorg.git"; auto_update = $true; branch = "main" } +) +try { + Update-StaleRegistries -DotbotBase $testHome -MaxAgeSecs 0 + Write-TestResult -Name "Update-StaleRegistries does not throw when registry directory is missing" -Status Pass +} catch { + Write-TestResult -Name "Update-StaleRegistries does not throw when registry directory is missing" -Status Fail -Message $_.Exception.Message +} + +# MaxAgeSecs skips recently-updated registry (updated_at = now) +$nowUtc = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") +$recentDir = Join-Path $registriesDir "recent" +New-Item -Path $recentDir -ItemType Directory -Force | Out-Null +Write-TestRegistriesJson -Entries @( + [pscustomobject]@{ name = "recent"; type = "git"; source = "https://example.com/r.git"; auto_update = $true; branch = "main"; updated_at = $nowUtc } +) +# Spy: if it tried to run git it would fail (no .git dir) — but MaxAgeSecs should skip it +$configBefore = Get-Content (Join-Path $testHome "registries.json") -Raw +Update-StaleRegistries -DotbotBase $testHome -MaxAgeSecs 3600 +$configAfter = Get-Content (Join-Path $testHome "registries.json") -Raw +Assert-True -Name "Update-StaleRegistries skips registry updated within MaxAgeSecs" ` + -Condition ($configBefore -eq $configAfter) ` + -Message "registries.json changed — registry should have been skipped as recently updated" + +# Local (non-git) registry is always skipped +$localDir = Join-Path $registriesDir "localreg" +New-Item -Path $localDir -ItemType Directory -Force | Out-Null +Write-TestRegistriesJson -Entries @( + [pscustomobject]@{ name = "localreg"; type = "local"; source = "C:/repos/localreg"; auto_update = $false } +) +try { + Update-StaleRegistries -DotbotBase $testHome -MaxAgeSecs 0 + Write-TestResult -Name "Update-StaleRegistries skips local registries without error" -Status Pass +} catch { + Write-TestResult -Name "Update-StaleRegistries skips local registries without error" -Status Fail -Message $_.Exception.Message +} + +# =================================================================== +# registry-remove.ps1 — subprocess tests +# Use real DOTBOT_HOME so modules (Dotbot.Theme etc.) resolve correctly. +# Registry dirs and registries.json are created inside the real +# registries directory and cleaned up after each test. +# =================================================================== + +$realDotbotHome = $dotbotDir +$realRegistries = Join-Path $realDotbotHome "registries" +$realConfigPath = Join-Path $realDotbotHome "registries.json" + +if (-not (Test-Path -LiteralPath $realRegistries)) { + New-Item -Path $realRegistries -ItemType Directory -Force | Out-Null +} + +# Backup existing registries.json so we can restore it after tests +$configBackup = $null +if (Test-Path $realConfigPath) { + $configBackup = Get-Content $realConfigPath -Raw +} + +function Write-RealRegistriesJson { + param([array]$Entries) + $obj = [pscustomobject]@{ registries = $Entries } + $obj | ConvertTo-Json -Depth 5 | Set-Content $realConfigPath -Encoding utf8NoBOM +} + +function Restore-RegistriesJson { + if ($null -ne $configBackup) { + $configBackup | Set-Content $realConfigPath -Encoding utf8NoBOM + } elseif (Test-Path $realConfigPath) { + Remove-Item $realConfigPath -Force + } +} + +# =================================================================== +# registry-remove.ps1 — path traversal guard +# =================================================================== + +Write-Host "" +Write-Host " REGISTRY-REMOVE (path traversal guard)" -ForegroundColor Cyan +Write-Host " --------------------------------------------" -ForegroundColor DarkGray + +Write-RealRegistriesJson -Entries @( + [pscustomobject]@{ name = "testorg-traversal"; type = "local"; source = "C:/repos/x"; auto_update = $false } +) + +$null = & pwsh -NoProfile -NonInteractive -Command ` + "& '$registryRemovePath' -Name '../evil' -Force" 2>&1 +Assert-True -Name "registry-remove.ps1 exits non-zero for path-traversal name" ` + -Condition ($LASTEXITCODE -ne 0) ` + -Message "Expected non-zero exit for '../evil', got $LASTEXITCODE" + +Restore-RegistriesJson + +# =================================================================== +# registry-remove.ps1 — removes registry dir and registries.json entry +# =================================================================== + +Write-Host "" +Write-Host " REGISTRY-REMOVE (happy path)" -ForegroundColor Cyan +Write-Host " --------------------------------------------" -ForegroundColor DarkGray + +$testRegName = "dotbot-test-reg-$(Get-Random)" +$testRegDir = Join-Path $realRegistries $testRegName +New-Item -Path $testRegDir -ItemType Directory -Force | Out-Null + +Write-RealRegistriesJson -Entries @( + [pscustomobject]@{ name = $testRegName; type = "local"; source = "C:/repos/x"; auto_update = $false } + [pscustomobject]@{ name = "other-keep"; type = "git"; source = "https://example.com/o.git"; auto_update = $true } +) + +$removeOut = & pwsh -NoProfile -NonInteractive -Command ` + "& '$registryRemovePath' -Name '$testRegName' -Force" 2>&1 +$removeExitCode = $LASTEXITCODE + +Assert-True -Name "registry-remove.ps1 exits 0 for valid registry" ` + -Condition ($removeExitCode -eq 0) ` + -Message "Exit code: $removeExitCode. Output: $($removeOut | Out-String)" + +Assert-True -Name "registry-remove.ps1 deletes registry directory" ` + -Condition (-not (Test-Path $testRegDir)) ` + -Message "Directory still exists: $testRegDir" + +$configAfterRemove = Get-Content $realConfigPath -Raw | ConvertFrom-Json +$remaining = @($configAfterRemove.registries | Where-Object { $_.name -eq $testRegName }) +Assert-True -Name "registry-remove.ps1 removes entry from registries.json" ` + -Condition ($remaining.Count -eq 0) ` + -Message "Entry '$testRegName' still present in registries.json" + +$otherRemaining = @($configAfterRemove.registries | Where-Object { $_.name -eq "other-keep" }) +Assert-True -Name "registry-remove.ps1 leaves other entries intact in registries.json" ` + -Condition ($otherRemaining.Count -eq 1) ` + -Message "Entry 'other-keep' was unexpectedly removed" + +Restore-RegistriesJson + +# =================================================================== +# registry-remove.ps1 — exits non-zero for unknown registry +# =================================================================== + +Write-Host "" +Write-Host " REGISTRY-REMOVE (error cases)" -ForegroundColor Cyan +Write-Host " --------------------------------------------" -ForegroundColor DarkGray + +Write-RealRegistriesJson -Entries @( + [pscustomobject]@{ name = "someorg"; type = "local"; source = "C:/repos/x"; auto_update = $false } +) + +$null = & pwsh -NoProfile -NonInteractive -Command ` + "& '$registryRemovePath' -Name 'doesnotexist' -Force" 2>&1 +Assert-True -Name "registry-remove.ps1 exits non-zero for unregistered name" ` + -Condition ($LASTEXITCODE -ne 0) ` + -Message "Expected non-zero exit for unknown registry, got $LASTEXITCODE" + +Restore-RegistriesJson + +# =================================================================== +# CLEANUP +# =================================================================== + +try { + Remove-Item $testHome -Recurse -Force -ErrorAction SilentlyContinue +} catch { } + +Write-Host "" + +# =================================================================== +# SUMMARY +# =================================================================== + +$allPassed = Write-TestSummary -LayerName "Layer 2: Registry CLI" + +if (-not $allPassed) { + exit 1 +} From 5469202b5a1c94a1758bd28319668f8f43ceef93 Mon Sep 17 00:00:00 2001 From: IBondarenko-iwg Date: Tue, 7 Jul 2026 18:04:15 +0300 Subject: [PATCH 28/50] fix(repo-clone): stop leaking ADO PAT + tolerant Jira-key parser (#566) fix(repo-clone): stop leaking ADO PAT + tolerant Jira-key parser (#566) Closes #566 Refs #557 --- .../systems/mcp/tools/repo-clone/script.ps1 | 122 ++++++++++++++++-- .../systems/mcp/tools/repo-clone/test.ps1 | 121 +++++++++++++++++ 2 files changed, 229 insertions(+), 14 deletions(-) create mode 100644 content/workflows/start-from-jira/systems/mcp/tools/repo-clone/test.ps1 diff --git a/content/workflows/start-from-jira/systems/mcp/tools/repo-clone/script.ps1 b/content/workflows/start-from-jira/systems/mcp/tools/repo-clone/script.ps1 index 4d25d4eb..a0096860 100644 --- a/content/workflows/start-from-jira/systems/mcp/tools/repo-clone/script.ps1 +++ b/content/workflows/start-from-jira/systems/mcp/tools/repo-clone/script.ps1 @@ -1,3 +1,48 @@ +# Extract a Jira/ADO work-item key from a jira-context.md document. The +# Fetch-Jira-Context step is supposed to emit a canonical `| Jira Key | KEY |` +# row, but agents have been observed free-forming the metadata table with other +# labels (`Primary Jira Keys`, `Parent Epic`, ...). Try the canonical row first, +# then known label variants, then the H1 title, then any key anywhere -- so a +# minor table-format drift no longer kills the entire code-execution phase. +# Matching is case-SENSITIVE (-cmatch): real keys are upper-case, and an +# insensitive match would treat tokens like `utf-8` / `sha-1` as keys. +function Get-RepoCloneJiraKey { + param([AllowEmptyString()][string]$Content) + + if ([string]::IsNullOrWhiteSpace($Content)) { return $null } + $key = '[A-Z]{2,10}-\d+' + + # (a) canonical row + if ($Content -cmatch "\|\s*Jira Key\s*\|\s*($key)") { return $matches[1] } + # (b) known label variants (first key in the cell) + if ($Content -cmatch "\|\s*(?:Primary Jira Keys?|Parent Epic|Programme[^|]*)\s*\|\s*($key)") { return $matches[1] } + # (c) the H1 title (e.g. "# Jira Context: ENHANCE-9851 ...") + foreach ($line in ($Content -split "`n")) { + if ($line -match '^\s*#\s' -and $line -cmatch "($key)") { return $matches[1] } + } + # (d) last resort: first key-shaped token anywhere + if ($Content -cmatch "($key)") { return $matches[1] } + + return $null +} + +# A directory that merely exists is not a usable clone: a leftover empty gitlink +# (a 160000 tree entry with no working tree) or a dangling .git pointer leaves a +# dir that must be re-cloned. Treat a clone as complete when it is a real work +# tree with an origin remote -- which covers both populated clones and a valid +# clone of an empty (commitless) remote, without requiring a resolvable HEAD or +# tracked files (an empty-but-valid clone has neither yet, and must not be +# wrongly reclaimed). +function Test-RepoCloneComplete { + param([Parameter(Mandatory)][string]$ClonePath) + + if (-not (Test-Path (Join-Path $ClonePath '.git'))) { return $false } + $null = & git -C $ClonePath rev-parse --is-inside-work-tree 2>$null + if ($LASTEXITCODE -ne 0) { return $false } + $remote = & git -C $ClonePath config --get remote.origin.url 2>$null + return [bool]$remote +} + function Invoke-RepoClone { param([hashtable]$Arguments) @@ -7,6 +52,17 @@ function Invoke-RepoClone { if (-not $project) { throw "project is required" } if (-not $repo) { throw "repo is required" } + # $project/$repo are caller-supplied (MCP tool input). $repo becomes a + # filesystem path ($clonePath) that is later force-deleted on a re-clone, so + # reject anything that could escape the repos/ directory: path separators, + # '..' traversal, or a bare '.'/'..'. (Spaces and other chars are allowed -- + # ADO names permit them -- only traversal is blocked.) + foreach ($seg in @(@{ name = 'repo'; value = $repo }, @{ name = 'project'; value = $project })) { + if ($seg.value -match '[\\/]' -or $seg.value -match '\.\.' -or $seg.value -in @('.', '..')) { + throw "Invalid $($seg.name) name (path traversal not allowed): '$($seg.value)'" + } + } + # --------------------------------------------------------------------------- # Load .env.local for credentials # --------------------------------------------------------------------------- @@ -34,10 +90,7 @@ function Invoke-RepoClone { $initiativePath = Join-Path $global:DotbotProjectRoot ".bot/workspace/product/briefing/jira-context.md" $jiraKey = $null if (Test-Path $initiativePath) { - $content = Get-Content $initiativePath -Raw - if ($content -match '\|\s*Jira Key\s*\|\s*([A-Z]{2,10}-\d+)') { - $jiraKey = $matches[1] - } + $jiraKey = Get-RepoCloneJiraKey -Content (Get-Content $initiativePath -Raw) } # Read branch prefix from the merged settings chain (defaults + ~/dotbot + .control) @@ -66,25 +119,60 @@ function Invoke-RepoClone { } if (Test-Path $clonePath) { - return @{ - success = $true - path = $clonePath - default_branch = (git -C $clonePath symbolic-ref refs/remotes/origin/HEAD 2>$null) -replace 'refs/remotes/origin/', '' - working_branch = $workingBranch - message = "Repository already cloned at $clonePath" - already_cloned = $true + if (Test-RepoCloneComplete -ClonePath $clonePath) { + return @{ + success = $true + path = $clonePath + default_branch = (git -C $clonePath symbolic-ref refs/remotes/origin/HEAD 2>$null) -replace 'refs/remotes/origin/', '' + working_branch = $workingBranch + message = "Repository already cloned at $clonePath" + already_cloned = $true + } + } + # Path exists but is not a usable clone. Only reclaim it when it is + # genuinely empty (the observed failure: a leftover empty gitlink dir with + # no working tree). A non-empty directory might be a real repo that merely + # failed a transient git check, so refuse to force-delete it. + $hasContent = @(Get-ChildItem -LiteralPath $clonePath -Force -ErrorAction SilentlyContinue).Count -gt 0 + if ($hasContent) { + return @{ + success = $false + error_type = "incomplete_clone" + message = "Path '$clonePath' exists but is not a complete clone (no resolvable HEAD / tracked files). Remove or repair it, then retry." + path = $clonePath + } } + Remove-Item -LiteralPath $clonePath -Recurse -Force -ErrorAction SilentlyContinue } - # Build clone URL with PAT authentication - $orgHost = ($adoOrgUrl -replace 'https?://', '') - $cloneUrl = "https://$($adoPat)@$orgHost/$project/_git/$repo" + # Authenticate with a host-scoped http.extraHeader injected through the + # GIT_CONFIG_* environment (git >= 2.31). The PAT is never embedded in the + # clone URL, so it does not appear in process arguments and is not persisted + # to the cloned repo's .git/config (remote.origin.url stays credential-free). + $orgHost = ($adoOrgUrl -replace 'https?://', '').TrimEnd('/') + $cloneUrl = "https://$orgHost/$project/_git/$repo" + $basicToken = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes(":$adoPat")) + + # Append the auth header at the next free GIT_CONFIG_* slot, not slot 0: + # forcing GIT_CONFIG_COUNT=1 would make git ignore caller-injected config + # (e.g. corporate proxy / custom-CA) during the clone. Restored in finally. + $priorCount = $env:GIT_CONFIG_COUNT + $slot = if ($priorCount -match '^\d+$') { [int]$priorCount } else { 0 } + $keyVar = "GIT_CONFIG_KEY_$slot" + $valVar = "GIT_CONFIG_VALUE_$slot" + $priorKey = [Environment]::GetEnvironmentVariable($keyVar, 'Process') + $priorVal = [Environment]::GetEnvironmentVariable($valVar, 'Process') + + $env:GIT_CONFIG_COUNT = [string]($slot + 1) + [Environment]::SetEnvironmentVariable($keyVar, "http.https://$orgHost/.extraHeader", 'Process') + [Environment]::SetEnvironmentVariable($valVar, "Authorization: Basic $basicToken", 'Process') try { $cloneOutput = & git clone $cloneUrl $clonePath 2>&1 if ($LASTEXITCODE -ne 0) { $errorMsg = ($cloneOutput | Out-String).Trim() $errorMsg = $errorMsg -replace [regex]::Escape($adoPat), '***' + $errorMsg = $errorMsg -replace [regex]::Escape($basicToken), '***' $errorType = if ($errorMsg -match 'Authentication failed|401|403') { "authentication_failed" } elseif ($errorMsg -match 'not found|does not exist|404') { "repo_not_found" } @@ -105,6 +193,12 @@ function Invoke-RepoClone { message = "Failed to clone $repo from $project`: $_" path = $null } + } finally { + # Restore the original count + the slot we appended into, verbatim + # (including unset → $null, which removes the entry). + [Environment]::SetEnvironmentVariable('GIT_CONFIG_COUNT', $priorCount, 'Process') + [Environment]::SetEnvironmentVariable($keyVar, $priorKey, 'Process') + [Environment]::SetEnvironmentVariable($valVar, $priorVal, 'Process') } # Detect default branch diff --git a/content/workflows/start-from-jira/systems/mcp/tools/repo-clone/test.ps1 b/content/workflows/start-from-jira/systems/mcp/tools/repo-clone/test.ps1 new file mode 100644 index 00000000..a9ab4fee --- /dev/null +++ b/content/workflows/start-from-jira/systems/mcp/tools/repo-clone/test.ps1 @@ -0,0 +1,121 @@ +# Test repo-clone tool helpers (Jira-key extraction + clone-completeness guard). +# The clone itself needs live ADO credentials, so it is not exercised here; these +# tests cover the pure parsing and the local git-state guard. + +Import-Module $env:DOTBOT_TEST_HELPERS -Force +. "$PSScriptRoot\script.ps1" + +Reset-TestResults + +# --- Get-RepoCloneJiraKey ----------------------------------------------------- + +$canonical = @" +# Jira Context: Some Initiative + +## Metadata + +| Field | Value | +|-------|-------| +| Jira Key | CP-94926 | +| Summary | Do the thing | +"@ +Assert-Equal -Name "jira-key: canonical row" -Expected "CP-94926" ` + -Actual (Get-RepoCloneJiraKey -Content $canonical) + +$variant = @" +# Jira Context: ENHANCE Programme + +| Field | Value | +|-------|-------| +| Primary Jira Keys | CP-94926, CP-94927, CP-94928 (stories in scope) | +| Parent Epic | CP-94925 -- ENHANCE-9851 | +"@ +Assert-Equal -Name "jira-key: Primary Jira Keys variant (first key)" -Expected "CP-94926" ` + -Actual (Get-RepoCloneJiraKey -Content $variant) + +$parentOnly = @" +| Field | Value | +|-------|-------| +| Parent Epic | CP-94925 -- desc | +"@ +Assert-Equal -Name "jira-key: Parent Epic variant" -Expected "CP-94925" ` + -Actual (Get-RepoCloneJiraKey -Content $parentOnly) + +$h1Only = "# Jira Context: ENHANCE-9851 -- big programme`n`nNo metadata table here." +Assert-Equal -Name "jira-key: H1 title fallback" -Expected "ENHANCE-9851" ` + -Actual (Get-RepoCloneJiraKey -Content $h1Only) + +Assert-True -Name "jira-key: null when no key present" ` + -Condition ($null -eq (Get-RepoCloneJiraKey -Content "# Title`n`nNothing key-shaped here.")) ` + -Message "Expected null for content with no key" + +Assert-True -Name "jira-key: null for empty content" ` + -Condition ($null -eq (Get-RepoCloneJiraKey -Content "")) ` + -Message "Expected null for empty content" + +# Case-sensitive: lower-case key-shaped tokens must NOT be treated as keys, +# otherwise tokens like `utf-8` / `sha-1` (or a stray lower-case key) would +# silently produce a wrong branch name. +Assert-True -Name "jira-key: lowercase key not matched (case-sensitive)" ` + -Condition ($null -eq (Get-RepoCloneJiraKey -Content "| Jira Key | cp-94926 |")) ` + -Message "Expected null for lowercase key" + +Assert-True -Name "jira-key: 'utf-8'-style token not matched" ` + -Condition ($null -eq (Get-RepoCloneJiraKey -Content "Encoded as utf-8 with sha-1 digest.")) ` + -Message "Expected null for non-key hyphenated tokens" + +# --- Test-RepoCloneComplete --------------------------------------------------- + +$testRoot = Join-Path ([System.IO.Path]::GetTempPath()) "dotbot-test-repo-clone-$([System.Guid]::NewGuid().ToString().Substring(0,8))" +New-Item -Path $testRoot -ItemType Directory -Force | Out-Null + +try { + $missing = Join-Path $testRoot "missing" + Assert-True -Name "clone-complete: false for non-existent path" ` + -Condition (-not (Test-RepoCloneComplete -ClonePath $missing)) ` + -Message "Expected false" + + $emptyDir = Join-Path $testRoot "empty" + New-Item -Path $emptyDir -ItemType Directory -Force | Out-Null + Assert-True -Name "clone-complete: false for empty dir (no .git)" ` + -Condition (-not (Test-RepoCloneComplete -ClonePath $emptyDir)) ` + -Message "Expected false" + + $noRemote = Join-Path $testRoot "noremote" + New-Item -Path $noRemote -ItemType Directory -Force | Out-Null + & git -C $noRemote init --quiet 2>&1 | Out-Null + Assert-True -Name "clone-complete: false for git repo with no origin remote" ` + -Condition (-not (Test-RepoCloneComplete -ClonePath $noRemote)) ` + -Message "Expected false" + + # A valid clone of an empty (commitless) remote has a work tree + origin but + # no HEAD and no tracked files -- it must still count as complete so the tool + # does not wedge re-clone attempts on empty repos. + $emptyRemoteClone = Join-Path $testRoot "emptyremote" + New-Item -Path $emptyRemoteClone -ItemType Directory -Force | Out-Null + & git -C $emptyRemoteClone init --quiet 2>&1 | Out-Null + & git -C $emptyRemoteClone remote add origin "https://example.invalid/x/_git/y" 2>&1 | Out-Null + Assert-True -Name "clone-complete: true for empty repo with origin remote" ` + -Condition (Test-RepoCloneComplete -ClonePath $emptyRemoteClone) ` + -Message "Expected true (empty but valid clone)" + + $good = Join-Path $testRoot "good" + New-Item -Path $good -ItemType Directory -Force | Out-Null + & git -C $good init --quiet 2>&1 | Out-Null + & git -C $good remote add origin "https://example.invalid/x/_git/y" 2>&1 | Out-Null + & git -C $good config user.email "test@test.com" 2>&1 | Out-Null + & git -C $good config user.name "Test" 2>&1 | Out-Null + "readme" | Set-Content (Join-Path $good "README.md") + & git -C $good add -A 2>&1 | Out-Null + & git -C $good commit -m "init" --quiet 2>&1 | Out-Null + Assert-True -Name "clone-complete: true for populated repo" ` + -Condition (Test-RepoCloneComplete -ClonePath $good) ` + -Message "Expected true" +} finally { + if (Test-Path $testRoot) { + Remove-Item $testRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + +$allPassed = Write-TestSummary -LayerName "repo-clone" +if (-not $allPassed) { exit 1 } From 54db91a19ac388b775922f3a299556408c86a044 Mon Sep 17 00:00:00 2001 From: aselim31 Date: Tue, 7 Jul 2026 18:35:00 +0300 Subject: [PATCH 29/50] docs: add dotbot v4 framework overview document Adds docs/DOTBOT-V4-FRAMEWORK.md as an overview of the v4 architecture, install model, commands, settings chain, workflow/stack model, and v3 migration path. Closes #581 --- docs/DOTBOT-V4-FRAMEWORK.md | 233 ++++++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 docs/DOTBOT-V4-FRAMEWORK.md diff --git a/docs/DOTBOT-V4-FRAMEWORK.md b/docs/DOTBOT-V4-FRAMEWORK.md new file mode 100644 index 00000000..2c21a0b2 --- /dev/null +++ b/docs/DOTBOT-V4-FRAMEWORK.md @@ -0,0 +1,233 @@ +# dotbot v4 — Framework Overview + +> dotbot v4 is a PowerShell 7+ AI-assisted development orchestration framework. It coordinates Claude-powered task agents, manages worktrees, routes approvals, and integrates with your existing toolchain — all from a single git checkout. + +--- + +## Contents + +1. [What changed in v4](#what-changed-in-v4) +2. [Install](#install) +3. [Core concepts](#core-concepts) +4. [Key commands](#key-commands) +5. [Architecture](#architecture) +6. [Settings chain](#settings-chain) +7. [Workflow & stack model](#workflow--stack-model) +8. [Migration from v3](#migration-from-v3) + +--- + +## What changed in v4 + +v4 replaces the copy-based installer with a live git checkout model. The framework is no longer copied into your project — it lives in a single location (`DOTBOT_HOME`) and is resolved lazily at runtime. + +| | v3 | v4 | +|---|---|---| +| Install | `irm install-remote.ps1 \| iex` copies framework into `~/dotbot` | `git clone` + `pwsh bootstrap.ps1` drops a PATH shim | +| Framework location | Copied into `~/dotbot`, updated via `dotbot update` | One git checkout, `DOTBOT_HOME` points at it | +| Project `.bot/` | Contains copies of `src/`, `content/`, `settings/`, `hooks/` | Contains only `workspace/` and `.gitignore` | +| Settings source | `/settings/settings.default.json` | `/content/settings/settings.default.json` | +| Entry point | `.bot\go.ps1` / `.bot\init.ps1` | `dotbot go` (runtime + UI) / `dotbot serve` (runtime only) | +| PowerShell Gallery | `Install-Module Dotbot` | Retired | + +--- + +## Install + +### Requirements + +- PowerShell 7.2+ (PowerShell 5.1 is rejected at install time) +- Git + +### One-time setup + +```powershell +# Clone the framework +git clone https://github.com/andresharpe/dotbot ~/dotbot + +# Install the PATH shim and set DOTBOT_HOME +pwsh ~/dotbot/bootstrap.ps1 + +# Confirm +dotbot status +``` + +`bootstrap.ps1` drops a shim into: +- **Windows:** `%LOCALAPPDATA%\Microsoft\WindowsApps\dotbot.ps1` +- **macOS / Linux:** `~/.local/bin/dotbot` + +It never writes `DOTBOT_HOME` to the machine environment — you set it in your shell profile or project init. + +### Package managers + +```bash +# Homebrew (macOS / Linux) +brew install andresharpe/dotbot/dotbot + +# Scoop (Windows) +scoop bucket add dotbot https://github.com/andresharpe/scoop-dotbot +scoop install dotbot +``` + +--- + +## Core concepts + +### DOTBOT_HOME + +The environment variable that points at your dotbot framework checkout. All runtime modules, content, and settings are resolved from this path. Package-managed installs and bootstrapped shims set this automatically — you only need to set it explicitly if you maintain a manual clone or multiple framework versions side by side. + +```powershell +$env:DOTBOT_HOME = "~/dotbot" # point at your clone +``` + +### Project `.bot/` + +A minimal directory committed to your project repo. In v4 it contains only: + +``` +.bot/ + .gitignore + workspace/ # task files, answers, outputs + .control/ # runtime state (settings.json, instance_id) + content/ # project-tier overrides only (created on demand) +``` + +Framework content (workflows, stacks, MCP tools) is resolved lazily from `DOTBOT_HOME` — never copied into `.bot/`. + +### Workflows + +A workflow defines the end-to-end process for a class of work (e.g. `start-from-jira`, `start-from-prompt`). Workflows live in `/content/workflows/` and can be extended with `extends` in `workflow.json`. + +```json +{ + "name": "my-workflow", + "extends": "start-from-prompt" +} +``` + +### Stacks + +A stack adds technology-specific content (MCP tools, hooks, settings) layered on top of the framework defaults. Active stacks are declared in `.bot/.control/settings.json` and resolved via the `ContentResolver`. + +### Providers + +A provider configures the AI model backend (Claude, Codex, Copilot, Gemini). Provider settings live in `/content/settings/providers/`. + +--- + +## Key commands + +| Command | What it does | +|---|---| +| `dotbot status` | Shows resolved `DOTBOT_HOME`, framework branch/SHA/dirty flag, version, active workflow, provider, stacks | +| `dotbot status --json` | Same, machine-readable — used by CI scripts and the dashboard banner | +| `dotbot init` | Bootstraps a project `.bot/` directory (sparse — no framework copies) | +| `dotbot init -Workflow start-from-jira` | Init with a specific workflow materialised | +| `dotbot go` | Starts the task runner and Studio UI for the current project | +| `dotbot serve` | Starts the task runner only (no UI) — for headless / CI use | +| `dotbot workflow add ` | Activates a workflow in `.control/settings.json` | +| `dotbot workflow remove ` | Deactivates a workflow | + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ Studio UI (src/studio-ui/) │ +│ Browser dashboard · task list · roadmap · approvals│ +└───────────────────┬─────────────────────────────────┘ + │ HTTP +┌───────────────────▼─────────────────────────────────┐ +│ Runtime (src/runtime/) │ +│ Task runner · worktree manager · MCP preflight │ +│ Inbox watcher · approval router · event emitter │ +└───────────────────┬─────────────────────────────────┘ + │ PowerShell modules +┌───────────────────▼─────────────────────────────────┐ +│ ContentResolver │ +│ Framework (DOTBOT_HOME) → Active stacks → Project │ +└───────────────────┬─────────────────────────────────┘ + │ +┌───────────────────▼─────────────────────────────────┐ +│ Claude (via MCP) │ +│ claude.exe · per-task worktree · MCP tool set │ +└─────────────────────────────────────────────────────┘ +``` + +### Content resolution order + +The `ContentResolver` folds content in priority order: + +1. **Project tier** — `.bot/content/` (project-specific overrides) +2. **Active stacks** — resolved via `extends` chain in `.control/settings.json` +3. **Framework** — `/content/` + +The first match wins. This means project overrides beat stack defaults, which beat framework defaults. + +--- + +## Settings chain + +Four layers merged in order (last wins): + +| Layer | Source | Purpose | +|---|---|---| +| 1 — Framework defaults | `/content/settings/settings.default.json` | Baseline for all projects | +| 2 — Project override | `.bot/content/settings/settings.default.json` | Project-specific defaults (tracked) | +| 3 — User settings | `~/.config/dotbot/user-settings.json` | Per-developer overrides (not tracked) | +| 4 — Control state | `.bot/.control/settings.json` | Runtime state: active workflow, stacks, instance_id | + +--- + +## Workflow & stack model + +### Adding a workflow + +```powershell +dotbot workflow add start-from-jira +``` + +This records the active workflow in `.control/settings.json`. The workflow's content is resolved lazily from `DOTBOT_HOME` — no files are copied unless the workflow ships an `overrides/` subtree. + +### Workflow inheritance + +```json +// .bot/content/workflows/my-workflow/workflow.json +{ + "name": "my-workflow", + "extends": "start-from-prompt", + "phases": [ + { + "name": "spec" + } + ] +} +``` + +### MCP tool discovery + +The runtime walks both `tools/` (v4 layout) and `systems/mcp/tools/` (legacy layout) under each workflow source, so existing v3-style registries continue to work. + +--- + +## Migration from v3 + +See [`MIGRATING.md`](../MIGRATING.md) at the repo root for the full step-by-step guide. The short version: + +1. Archive `~/dotbot` (your v3 copy) +2. Clone afresh: `git clone https://github.com/andresharpe/dotbot ~/dotbot` +3. Run `pwsh ~/dotbot/bootstrap.ps1` +4. Set `$env:DOTBOT_HOME = "~/dotbot"` in your shell profile +5. Per project: `git rm -r .bot/src .bot/content .bot/settings .bot/recipes .bot/hooks` +6. Remove `.bot/.manifest.json`, `.bot/go.ps1`, `.bot/init.ps1` +7. Run `dotbot init` to create the v4 `.bot/` structure +8. Run `dotbot status` to confirm + +The `~/dotbot/user-settings.json → ~/.config/dotbot/user-settings.json` move happens automatically on first run. + +--- + +*Last updated: 2026-07-07* +*See also: [MIGRATING.md](../MIGRATING.md) · [AGENTS.md](../AGENTS.md) · [Release Notes](release-notes/v4.0.1.md)* From 11c03a693b462da772aa226cb3583ac09ad8aa1f Mon Sep 17 00:00:00 2001 From: Ivan Bondarenko Date: Wed, 8 Jul 2026 15:12:54 +0300 Subject: [PATCH 30/50] fix(process): correct inverted priority sort in Get-NextWorkflowTask --- src/runtime/Modules/Dotbot.Process/Dotbot.Process.psm1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/Modules/Dotbot.Process/Dotbot.Process.psm1 b/src/runtime/Modules/Dotbot.Process/Dotbot.Process.psm1 index 2685b8d3..b307935a 100644 --- a/src/runtime/Modules/Dotbot.Process/Dotbot.Process.psm1 +++ b/src/runtime/Modules/Dotbot.Process/Dotbot.Process.psm1 @@ -743,7 +743,7 @@ function Get-NextWorkflowTask { } if ($eligible.Count -gt 0) { $next = $eligible | Sort-Object @( - @{ Expression = { if ($_.Content.priority -is [int] -or $_.Content.priority -is [long]) { -[int]$_.Content.priority } else { 0 } }; Ascending = $true } + @{ Expression = { if ($_.Content.priority -is [int] -or $_.Content.priority -is [long]) { [int]$_.Content.priority } else { [int]::MaxValue } }; Ascending = $true } @{ Expression = { [string]$_.Content.created_at }; Ascending = $true } ) | Select-Object -First 1 return @{ From 28047d1395b096a0cb8caedeb25fc5cb355eb1b8 Mon Sep 17 00:00:00 2001 From: Ivan Bondarenko Date: Wed, 8 Jul 2026 20:25:23 +0300 Subject: [PATCH 31/50] feat(ci): auto-close linked issues on release-branch merge --- .github/workflows/release-autoclose.yml | 62 +++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/workflows/release-autoclose.yml diff --git a/.github/workflows/release-autoclose.yml b/.github/workflows/release-autoclose.yml new file mode 100644 index 00000000..097a6064 --- /dev/null +++ b/.github/workflows/release-autoclose.yml @@ -0,0 +1,62 @@ +name: "Auto-close linked issues (release branches)" + +on: + # pull_request_target: token can close issues from fork PRs; no checkout. + pull_request_target: + types: [closed] + branches: + - 'releases/**' + +permissions: + issues: write + +jobs: + close-linked: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Close linked issues + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const base = pr.base.ref; + const body = pr.body || ''; + + // Same keywords as pr-link-check.yml; bare same-repo #N only. + const re = /(?:closes|fixes|resolves):?\s+#(\d+)/gi; + const numbers = [...new Set([...body.matchAll(re)].map(m => Number(m[1])))]; + + if (numbers.length === 0) { + core.info('No linked issues found in PR body.'); + return; + } + + for (const issue_number of numbers) { + try { + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number, + }); + if (issue.pull_request) { core.info(`#${issue_number} is a PR, skipping.`); continue; } + if (issue.state === 'closed') { core.info(`#${issue_number} already closed, skipping.`); continue; } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number, + body: `Closed via #${pr.number} (merged to \`${base}\`).`, + }); + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number, + state: 'closed', + state_reason: 'completed', + }); + core.info(`Closed #${issue_number}.`); + } catch (err) { + core.warning(`Failed to close #${issue_number}: ${err.message}`); + } + } From 4978f75004bb62b6ffba3b9ea1b0c1da8bc3572d Mon Sep 17 00:00:00 2001 From: aselim31 Date: Fri, 10 Jul 2026 10:57:54 +0300 Subject: [PATCH 32/50] chore(release): bump version to v4.0.2 (#626) * chore(release): bump version to v4.0.2 * docs: add Mothership & Fleet Layer PRD * revert: remove PRD added to wrong branch --------- Co-authored-by: github-actions[bot] --- CHANGELOG.md | 10 ++++++++++ version.json | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93e1bd40..ef457d92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ All notable changes to dotbot are documented in this file. The format follows [K ### Removed +## [4.0.2] - 2026-07-09 + +### Added + +### Changed + +### Fixed + +### Removed + ## [4.0.1] - 2026-07-02 ### Added diff --git a/version.json b/version.json index 5d0fb8b8..aa9f2ccc 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "4.0.1" + "version": "4.0.2" } From 43e717475d811c7b235f6f06b3c7e48fe7ae483b Mon Sep 17 00:00:00 2001 From: aselim31 Date: Fri, 10 Jul 2026 14:00:28 +0300 Subject: [PATCH 33/50] docs: add release notes for v4.0.2 (closes #627) --- docs/release-notes/v4.0.2.md | 82 ++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/release-notes/v4.0.2.md diff --git a/docs/release-notes/v4.0.2.md b/docs/release-notes/v4.0.2.md new file mode 100644 index 00000000..6c5fb9c6 --- /dev/null +++ b/docs/release-notes/v4.0.2.md @@ -0,0 +1,82 @@ +# dotbot v4.0.2 — Release Notes + +**Released:** July 10, 2026 +**Previous version:** v4.0.1 (July 3, 2026) +**Type:** Patch release + +> 6 bugs fixed · 2 enhancements · 1 CI fix · 13 issues closed + +--- + +## Highlights + +**Post-task pipeline and repo-clone hardened** — [#566](https://github.com/andresharpe/dotbot/issues/566) [#568](https://github.com/andresharpe/dotbot/issues/568) [#569](https://github.com/andresharpe/dotbot/issues/569) [#570](https://github.com/andresharpe/dotbot/issues/570) [#571](https://github.com/andresharpe/dotbot/issues/571) +Five of the eight bugs tracked in the [#557](https://github.com/andresharpe/dotbot/issues/557) omnibus are resolved. ADO PAT no longer leaks in clone URLs. Recipe links and output paths resolve correctly. Condition-skip cascades and parallel barriers behave as designed. Orphan worktrees with unborn master no longer break squash-merge. Privacy scan no longer mislabels successful tasks as skipped. + +**Priority sort fixed** — [#613](https://github.com/andresharpe/dotbot/issues/613) [#614](https://github.com/andresharpe/dotbot/issues/614) +`Get-NextWorkflowTask` was sorting eligible tasks in reverse priority order — higher priority numbers were winning instead of lower. Tasks now execute in the intended order. + +**Registry remove command** — [#550](https://github.com/andresharpe/dotbot/issues/550) +`dotbot registry remove ` removes a registered stack or workflow. RegistryManager now auto-updates the registry on `init` and `run`, keeping registrations in sync without a manual step. + +**Linked issues auto-close on release branch merge** — [#617](https://github.com/andresharpe/dotbot/issues/617) +CI now processes `Closes #NNN` keywords when a PR merges into a `release/*` or `releases/*` branch and closes the linked issues automatically. Previously this only worked for PRs targeting `main`. + +--- + +## Stabilization — 6 bugs fixed + +### Post-task pipeline & repo-clone + +| Issue | PR | Fix | +|---|---|---| +| [#566](https://github.com/andresharpe/dotbot/issues/566) | [#565](https://github.com/andresharpe/dotbot/pull/565) | ADO PAT leaked into the clone URL logged to the activity log; Jira-key parser rejected all clone paths that included a period in the project key | +| [#568](https://github.com/andresharpe/dotbot/issues/568) | [#572](https://github.com/andresharpe/dotbot/pull/572) | `Invoke-WorkflowProcess` recipe resolver produced a broken link; output path join double-nested the outputs directory | +| [#569](https://github.com/andresharpe/dotbot/issues/569) | [#573](https://github.com/andresharpe/dotbot/pull/573) | Condition-skip on a dep-gated step propagated a false skip through all downstream steps; parallel barriers did not wait for all spawned children before advancing | +| [#570](https://github.com/andresharpe/dotbot/issues/570) | [#578](https://github.com/andresharpe/dotbot/pull/578) | Orphan worktrees cloned from repos with no commits (`unborn master`) failed squash-merge with an unrelated-history error and were not cleaned up | +| [#571](https://github.com/andresharpe/dotbot/issues/571) | [#579](https://github.com/andresharpe/dotbot/pull/579) | Privacy scan hook returned `done` when it should have escalated to `needs-input`; the verify-hook-blocked path now correctly escalates | + +### Task scheduling + +| Issue | PR | Fix | +|---|---|---| +| [#613](https://github.com/andresharpe/dotbot/issues/613) / [#614](https://github.com/andresharpe/dotbot/issues/614) | [#616](https://github.com/andresharpe/dotbot/pull/616) | `Get-NextWorkflowTask` negated the priority integer (`-[int]$priority`) before sorting ascending — inverting the order so higher numbers won. Negation removed; priority-less tasks now sort last via `[int]::MaxValue` sentinel | + +--- + +## Enhancements — 2 items + +| Issue | PR | Enhancement | +|---|---|---| +| [#550](https://github.com/andresharpe/dotbot/issues/550) | [#564](https://github.com/andresharpe/dotbot/pull/564) | **Registry remove command** — `dotbot registry remove ` removes a stack or workflow registration. New `RegistryManager` module; namespace:stack resolution; auto-update on `init` and `run` | +| [#617](https://github.com/andresharpe/dotbot/issues/617) | [#621](https://github.com/andresharpe/dotbot/pull/621) | **CI auto-close on release branch** — `Closes #NNN` keywords in PR bodies now trigger issue close when the PR merges into any `release/*` or `releases/*` branch, not only `main` | + +--- + +## CI & release pipeline fix + +| PR | Fix | +|---|---| +| [#567](https://github.com/andresharpe/dotbot/pull/567) | Bump-release workflow was failing after the v4 restructure. Switched to a PR-based flow; repaired the `dotbot.psd1` path; confirmed end-to-end on v4.0.1 | + +--- + +## Get this release + +```bash +# Homebrew (macOS / Linux) +brew upgrade dotbot + +# Scoop (Windows) +scoop update dotbot + +# From source +git pull && pwsh bootstrap.ps1 +``` + +Run `dotbot status` after updating to confirm you're on v4.0.2. + +--- + +*Next patch release: ~July 17, 2026* +*Release notes: [`docs/release-notes/`](../release-notes/)* From ae2e50e3a5cc06f61e5bfc2425d7599d3ca86133 Mon Sep 17 00:00:00 2001 From: emrahzunicaplab Date: Sat, 11 Jul 2026 02:24:02 +0200 Subject: [PATCH 34/50] fix(process): pin process-state writes to canonical BotRoot, not worktree (#637) Write-ProcessFile and Test-ProcessStopSignal resolved BotRoot via $PWD whenever no -BotRoot was passed. During task execution, Invoke-WorkflowProcess.ps1 Push-Location's into the task's worktree, whose .bot/.control is a junction back to the canonical .control dir. Worktree teardown (Complete-TaskWorktree, Reset-TaskWorktree, Remove-OrphanWorktrees) removes that junction before removing the worktree itself, so a heartbeat write racing the teardown resolves to a path that no longer exists and silently drops after exhausting retries. Pin every process-registry write inside the worktree-scoped execution window to $botRoot, the canonical root captured once at process start and never reassigned. Thread the same canonical root through Invoke-TaskClarificationLoopIfPresent via a new -ProcessBotRoot parameter, kept separate from its existing worktree-scoped -BotRoot (used for the answers-file path). Also add a processes/ directory guard to Write-ProcessFile, mirroring Write-ProcessActivity, so a missing directory self-heals instead of failing outright. Closes #612 --- .../Dotbot.Process/Dotbot.Process.psm1 | 3 +++ .../Scripts/Invoke-WorkflowProcess.ps1 | 24 ++++++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/runtime/Modules/Dotbot.Process/Dotbot.Process.psm1 b/src/runtime/Modules/Dotbot.Process/Dotbot.Process.psm1 index b307935a..db57b676 100644 --- a/src/runtime/Modules/Dotbot.Process/Dotbot.Process.psm1 +++ b/src/runtime/Modules/Dotbot.Process/Dotbot.Process.psm1 @@ -106,6 +106,9 @@ function Write-ProcessFile { [string]$BotRoot ) $processesDir = Get-ProcessesDir -BotRoot $BotRoot + if (-not (Test-Path $processesDir)) { + New-Item -Path $processesDir -ItemType Directory -Force | Out-Null + } $filePath = Join-Path $processesDir "$Id.json" $tempFile = "$filePath.tmp" $retry = Get-ProcessRetryConfig -BotRoot $BotRoot diff --git a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 index aab98f27..2694a5dd 100644 --- a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 +++ b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 @@ -837,7 +837,12 @@ function Invoke-TaskClarificationLoopIfPresent { [string]$ModelName, [bool]$ShowDebug, [bool]$ShowVerbose, - [string]$PermissionMode + [string]$PermissionMode, + # Canonical project .bot root for process-registry writes (proc-*.json). + # Distinct from $BotRoot above, which is worktree-scoped (used for the + # answers-file path) — process state must never resolve through the + # worktree's transient .control junction (#612). + [string]$ProcessBotRoot ) $questionsPath = Join-Path $ProductDir "clarification-questions.json" if (-not (Test-Path $questionsPath)) { return $null } @@ -858,7 +863,7 @@ function Invoke-TaskClarificationLoopIfPresent { $PD.status = 'running' $PD.pending_questions = $null $PD.heartbeat_status = "Running task: $TaskName" - Write-ProcessFile -Id $Id -Data $PD + Write-ProcessFile -Id $Id -Data $PD -BotRoot $ProcessBotRoot } $questionsData = $null @@ -899,14 +904,14 @@ function Invoke-TaskClarificationLoopIfPresent { $ProcessData.product_dir = $ProductDir $ProcessData.answers_path = $answersPath $ProcessData.heartbeat_status = "Waiting for answers (task: $($Task.name))" - Write-ProcessFile -Id $ProcId -Data $ProcessData + Write-ProcessFile -Id $ProcId -Data $ProcessData -BotRoot $ProcessBotRoot while (-not (Test-Path $answersPath)) { - if (Test-ProcessStopSignal -Id $ProcId) { + if (Test-ProcessStopSignal -Id $ProcId -BotRoot $ProcessBotRoot) { $ProcessData.status = 'stopped' $ProcessData.failed_at = (Get-Date).ToUniversalTime().ToString("o") $ProcessData.pending_questions = $null - Write-ProcessFile -Id $ProcId -Data $ProcessData + Write-ProcessFile -Id $ProcId -Data $ProcessData -BotRoot $ProcessBotRoot return "Process stopped by user during clarification wait" } Start-Sleep -Seconds 2 @@ -1963,10 +1968,10 @@ Work on this task autonomously. When complete, ensure you call ``task_set_status if ($attemptNumber -gt 1) { Write-Status "Retry attempt $attemptNumber of $maxRetriesPerTask" -Type Warn } - if (Test-ProcessStopSignal -Id $procId) { + if (Test-ProcessStopSignal -Id $procId -BotRoot $botRoot) { $processData.status = 'stopped' $processData.failed_at = (Get-Date).ToUniversalTime().ToString("o") - Write-ProcessFile -Id $procId -Data $processData + Write-ProcessFile -Id $procId -Data $processData -BotRoot $botRoot break } @@ -2015,7 +2020,7 @@ Work on this task autonomously. When complete, ensure you call ``task_set_status # Update heartbeat $processData.last_heartbeat = (Get-Date).ToUniversalTime().ToString("o") - Write-ProcessFile -Id $procId -Data $processData + Write-ProcessFile -Id $procId -Data $processData -BotRoot $botRoot # Check completion $completionCheck = Test-TaskCompletion -TaskId $task.id @@ -2170,7 +2175,8 @@ Work on this task autonomously. When complete, ensure you call ``task_set_status -ProductDir $executionProductDir -ProcessData $processData -ProcId $procId ` -ProjectRoot $worktreePath ` -ModelName $modelTier -ShowDebug $ShowDebug ` - -ShowVerbose $ShowVerbose -PermissionMode $permissionMode + -ShowVerbose $ShowVerbose -PermissionMode $permissionMode ` + -ProcessBotRoot $botRoot if ($clarErr) { $taskSuccess = $false $postScriptFailed = $true From 95420271ac7df390442d5e797d19227eede2637a Mon Sep 17 00:00:00 2001 From: IBondarenko-iwg Date: Wed, 8 Jul 2026 15:06:42 +0300 Subject: [PATCH 35/50] feat(ui): navigation shell CSS, review harness & shared-CSS serving (#604) (#615) * feat(ui): add v4 navigation shell stylesheet + review harness * feat(ui): serve shared CSS from Outpost and Mothership * refactor(server): source _Layout tokens from shared stylesheet * ci: watch shared CSS and release branches in studio-ui workflow --- .github/workflows/studio-ui.yml | 6 +- .../src/Dotbot.Server/Dotbot.Server.csproj | 9 + .../Dotbot.Server/Pages/Shared/_Layout.cshtml | 40 +-- .../src/Dotbot.Server/Program.cs | 22 ++ src/shared/css/dotbot-shell.css | 333 ++++++++++++++++++ src/shared/css/harness.html | 232 ++++++++++++ src/studio-ui/src/client/styles/globals.css | 3 + src/ui/server.ps1 | 20 ++ 8 files changed, 627 insertions(+), 38 deletions(-) create mode 100644 src/shared/css/dotbot-shell.css create mode 100644 src/shared/css/harness.html diff --git a/.github/workflows/studio-ui.yml b/.github/workflows/studio-ui.yml index f4ad3611..c7ba3ee4 100644 --- a/.github/workflows/studio-ui.yml +++ b/.github/workflows/studio-ui.yml @@ -2,13 +2,15 @@ name: Studio UI Build & Validate on: push: - branches: [main] + branches: [main, 'releases/**'] paths: - 'src/studio-ui/**' + - 'src/shared/css/**' pull_request: - branches: [main] + branches: [main, 'releases/**'] paths: - 'src/studio-ui/**' + - 'src/shared/css/**' workflow_dispatch: jobs: diff --git a/src/server-dotnet/src/Dotbot.Server/Dotbot.Server.csproj b/src/server-dotnet/src/Dotbot.Server/Dotbot.Server.csproj index 7088a7a0..f0529b6c 100644 --- a/src/server-dotnet/src/Dotbot.Server/Dotbot.Server.csproj +++ b/src/server-dotnet/src/Dotbot.Server/Dotbot.Server.csproj @@ -30,4 +30,13 @@ + + + + + diff --git a/src/server-dotnet/src/Dotbot.Server/Pages/Shared/_Layout.cshtml b/src/server-dotnet/src/Dotbot.Server/Pages/Shared/_Layout.cshtml index 55eba38b..35e65386 100644 --- a/src/server-dotnet/src/Dotbot.Server/Pages/Shared/_Layout.cshtml +++ b/src/server-dotnet/src/Dotbot.Server/Pages/Shared/_Layout.cshtml @@ -7,43 +7,11 @@ + @* Design tokens now come from the shared stylesheet (single source of truth, + #551/#604). Values are identical to the inline block this replaces; the + remaining inline component styles below move to the shared shell in #605. *@ + + + + +
+ + + + reduced motion: toggle via OS / devtools emulation — transitions must stop +
+ +
+
+ +
+ + + +
+ + + +
+
+ + + +
+
+

SHELL HARNESS

+

+ Content area. Grid texture at token opacity, no scanlines here — + chrome regions (top bar, rail, ticker) carry them instead. +

+
+
Mock module panel
+
+
42
+
Phosphor-glow stat — check glow follows preset
+
+
+
+
+ +
+ DISPATCH · 03 · task T-042 moved to analysed · process P-09 started · all systems nominal + +
+ +
+
+ + + + + diff --git a/src/studio-ui/src/client/styles/globals.css b/src/studio-ui/src/client/styles/globals.css index 1a2b206f..34b0a001 100644 --- a/src/studio-ui/src/client/styles/globals.css +++ b/src/studio-ui/src/client/styles/globals.css @@ -4,6 +4,9 @@ @import '@shared/css/dotbot-tokens.css'; @import '@shared/css/dotbot-crt.css'; +/* v4 navigation shell (#551/#604) — no .shell markup in Studio yet; importing + * keeps the stylesheet build-checked and ready for the R2 consolidation. */ +@import '@shared/css/dotbot-shell.css'; /* === App Layout === */ #root { diff --git a/src/ui/server.ps1 b/src/ui/server.ps1 index ef5c9569..883a6d2e 100644 --- a/src/ui/server.ps1 +++ b/src/ui/server.ps1 @@ -2827,6 +2827,26 @@ $docContext break } + # Shared shell/token stylesheets live outside the static root (src/shared/css). + # Explicit route with a traversal guard; .css only — nothing else in that + # directory is servable (e.g. harness.html is dev-only). + { $_ -like '/shared/css/*' } { + $sharedCssRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..' 'shared' 'css')) + $requested = $url.Substring('/shared/css/'.Length) + $candidate = [System.IO.Path]::GetFullPath((Join-Path $sharedCssRoot $requested)) + $rootWithSep = $sharedCssRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar + if ($candidate.StartsWith($rootWithSep, [System.StringComparison]::OrdinalIgnoreCase) -and + [System.IO.Path]::GetExtension($candidate) -eq '.css' -and + (Test-Path -LiteralPath $candidate -PathType Leaf)) { + $contentType = 'text/css; charset=utf-8' + $content = Get-Content -LiteralPath $candidate -Raw + } else { + $statusCode = 404 + $content = "Not found: $url" + } + break + } + default { # Serve static files $filePath = Join-Path $staticRoot $url.TrimStart('/') From 2718364b67fd128f4f7edcdda1633fcd0f24c90c Mon Sep 17 00:00:00 2001 From: Enmanuel Jimenez <34482837+EnmaJim@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:15:18 +0000 Subject: [PATCH 36/50] feat(events): add Publish-DotBotEvent with extensible event-type registry --- .../Dotbot.Runtime/Dotbot.Runtime.psd1 | 6 + .../Dotbot.Runtime/Private/ActivityLog.psm1 | 214 ++++++++++++++++-- 2 files changed, 199 insertions(+), 21 deletions(-) diff --git a/src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1 b/src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1 index a6bdc918..00020e9c 100644 --- a/src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1 +++ b/src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1 @@ -43,6 +43,12 @@ 'Get-ActivityLogPath' 'Get-DotbotProjectId' + # Event bus (publish side) + 'Publish-DotBotEvent' + 'Register-DotBotEventType' + 'Get-DotBotEventTypeRegistry' + 'Test-DotBotEventTypeRegistered' + # Control plane 'Get-ControlPlaneSettings' 'Start-ControlPlaneRegistration' diff --git a/src/runtime/Modules/Dotbot.Runtime/Private/ActivityLog.psm1 b/src/runtime/Modules/Dotbot.Runtime/Private/ActivityLog.psm1 index 16fd256a..141b6f3c 100644 --- a/src/runtime/Modules/Dotbot.Runtime/Private/ActivityLog.psm1 +++ b/src/runtime/Modules/Dotbot.Runtime/Private/ActivityLog.psm1 @@ -43,6 +43,86 @@ function _Get-ActivityLogLock { } return $lock } + +# --------------------------------------------------------------------------- +# Event-type registry (extensible) +# +# Publish-DotBotEvent validates a dotted event type (e.g. 'task.completed') +# against this registry. Entries are wildcard patterns, so a family like +# 'task.*' registers a whole namespace. An unregistered type is LOGGED and +# STILL DELIVERED — never rejected — so new families (e.g. 'nudge.*') need no +# publisher change; they just append to the registry when their producer loads. +# +# Stored on the AppDomain (like the writer lock) so a registration made in one +# runspace is visible to publishers in the per-request runspaces. +# --------------------------------------------------------------------------- +$script:DotbotEventTypeRegistryKey = 'Dotbot.Runtime.EventTypeRegistry' +$script:DotbotDefaultEventTypes = @( + 'task.*' + 'workflow.*' +) + +function _Get-EventTypeRegistry { + $registry = [System.AppDomain]::CurrentDomain.GetData($script:DotbotEventTypeRegistryKey) + if ($null -eq $registry) { + $registry = [System.Collections.Generic.List[string]]::new() + foreach ($t in $script:DotbotDefaultEventTypes) { $registry.Add($t) } + [System.AppDomain]::CurrentDomain.SetData($script:DotbotEventTypeRegistryKey, $registry) + } + # Unary comma prevents PowerShell from unrolling the List on return, so + # callers get the live List object (needed for .Add / .ToArray), not its + # enumerated elements. + return ,$registry +} + +function Register-DotBotEventType { + <# + .SYNOPSIS + Register an event type (or a wildcard family such as 'nudge.*') so that + Publish-DotBotEvent treats it as a known type. + + .DESCRIPTION + Idempotent: registering the same pattern twice is a no-op. Registration is + process-wide (AppDomain-backed) so producers loaded in any runspace share + one registry. + #> + [CmdletBinding()] + param([Parameter(Mandatory)] [string]$Type) + + $registry = _Get-EventTypeRegistry + if (-not ($registry -contains $Type)) { + $registry.Add($Type) + } +} + +function Get-DotBotEventTypeRegistry { + <# + .SYNOPSIS + Return the current event-type registry (array of patterns). For tests and + diagnostics. + #> + return ,@((_Get-EventTypeRegistry).ToArray()) +} + +function Test-DotBotEventTypeRegistered { + <# + .SYNOPSIS + Return $true when the given concrete type matches any registered pattern. + + .DESCRIPTION + Registry entries are treated as wildcard patterns, so 'task.completed' + matches the registered family 'task.*', and an exactly-registered concrete + type matches itself. + #> + [CmdletBinding()] + param([Parameter(Mandatory)] [string]$Type) + + foreach ($pattern in (_Get-EventTypeRegistry)) { + if ($Type -like $pattern) { return $true } + } + return $false +} + $script:DotbotActivityLogEventTypes = @( 'task_created' 'task_updated' @@ -124,6 +204,114 @@ function Get-ActivityLogEventTypes { return ,@($script:DotbotActivityLogEventTypes) } +function _New-DotBotEventId { + # Reuse the runtime's nanoid generator (imported globally via Dotbot.Task) + # so event ids share the house 'prefix_ + 8 chars' convention (t_, wr_, p_). + if (-not (Get-Command New-DotbotNanoId -ErrorAction SilentlyContinue)) { + throw "Publish-DotBotEvent requires New-DotbotNanoId (Dotbot.Task IdGen) — module not loaded." + } + return 'evt_' + (New-DotbotNanoId) +} + +function _Append-ActivityLogLine { + <# + .SYNOPSIS + Append one already-serialized JSON line to /.control/activity.jsonl + under the process-wide writer lock. Shared by Write-ActivityEvent and + Publish-DotBotEvent so both use the SAME SemaphoreSlim and can never + interleave bytes with each other. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$BotRoot, + [Parameter(Mandatory)] [string]$Line + ) + + $path = Get-ActivityLogPath -BotRoot $BotRoot + $dir = Split-Path -Parent $path + if (-not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + + $lock = _Get-ActivityLogLock + $lock.Wait() + try { + # AppendAllText opens / appends / closes per call. On POSIX this is + # atomic for sub-PIPE_BUF writes (any line we produce here). On NTFS + # it's atomic for sub-4KB writes. The +newline keeps lines separated. + [System.IO.File]::AppendAllText( + $path, + $Line + [System.Environment]::NewLine, + [System.Text.UTF8Encoding]::new($false) + ) + } finally { + [void]$lock.Release() + } +} + +function Publish-DotBotEvent { + <# + .SYNOPSIS + Publish a typed event onto the event bus. + + .DESCRIPTION + Stamps a well-formed envelope — id, type, timestamp, source, data, plus the + project id and actor — and appends it as one JSON line to + /.control/activity.jsonl. Publishing to the activity log means the + /api/activity/tail byte-cursor endpoint carries bus events to the browser + with no new transport. + + The Type is validated against the extensible event-type registry. An + UNREGISTERED type is logged and STILL DELIVERED (never rejected), so new + event families need no change to this publisher. + + .PARAMETER Type + The dotted event type, e.g. 'task.completed' or 'workflow.run_failed'. + + .PARAMETER Source + Where the event originated, e.g. 'runtime'. + + .PARAMETER Data + Arbitrary event payload; serialized as the envelope's nested 'data' object. + + .OUTPUTS + The envelope hashtable that was written (so callers/tests can inspect the id). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$BotRoot, + [Parameter(Mandatory)] [string]$Type, + [Parameter(Mandatory)] [string]$Source, + [hashtable]$Data, + [string]$Actor = 'system' + ) + + if (-not (Test-DotBotEventTypeRegistered -Type $Type)) { + # Logged, not rejected — the registry is extensible by design. Debug + # level: Write-BotLog only mirrors Info+ into activity.jsonl, so this + # note never pollutes the bus itself. Guarded so the module stays + # usable when Dotbot.Logging isn't loaded. + if (Get-Command Write-BotLog -ErrorAction SilentlyContinue) { + Write-BotLog -Level Debug -Message "Publish-DotBotEvent: unregistered event type '$Type' — delivering anyway." + } + } + + $envelope = [ordered]@{ + id = _New-DotBotEventId + type = $Type + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + source = $Source + data = if ($null -ne $Data) { $Data } else { @{} } + project_id = Get-DotbotProjectId -BotRoot $BotRoot + actor = $Actor + } + + $line = $envelope | ConvertTo-Json -Depth 10 -Compress + _Append-ActivityLogLine -BotRoot $BotRoot -Line $line + + return $envelope +} + function Write-ActivityEvent { <# .SYNOPSIS @@ -174,31 +362,15 @@ function Write-ActivityEvent { # one entry = one physical line, which is what the UI's FileWatcher # consumer assumes. $line = $event | ConvertTo-Json -Depth 6 -Compress - - $path = Get-ActivityLogPath -BotRoot $BotRoot - $dir = Split-Path -Parent $path - if (-not (Test-Path -LiteralPath $dir)) { - New-Item -ItemType Directory -Path $dir -Force | Out-Null - } - - $lock = _Get-ActivityLogLock - $lock.Wait() - try { - # AppendAllText opens / appends / closes per call. On POSIX this is - # atomic for sub-PIPE_BUF writes (any line we produce here). On NTFS - # it's atomic for sub-4KB writes. The +newline keeps lines separated. - [System.IO.File]::AppendAllText( - $path, - $line + [System.Environment]::NewLine, - [System.Text.UTF8Encoding]::new($false) - ) - } finally { - [void]$lock.Release() - } + _Append-ActivityLogLine -BotRoot $BotRoot -Line $line } Export-ModuleMember -Function @( 'Write-ActivityEvent' + 'Publish-DotBotEvent' + 'Register-DotBotEventType' + 'Get-DotBotEventTypeRegistry' + 'Test-DotBotEventTypeRegistered' 'Get-ActivityLogPath' 'Get-DotbotProjectId' 'Get-ActivityLogEventTypes' From 2b7f8dcc386f199e42c98860ece983400354a45a Mon Sep 17 00:00:00 2001 From: Enmanuel Jimenez <34482837+EnmaJim@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:45:05 +0000 Subject: [PATCH 37/50] feat(events): surface task.* and workflow.* lifecycle as dotted bus events --- .../Dotbot.Runtime/Private/ActivityLog.psm1 | 30 +++++++++++-------- .../Dotbot.Runtime/Private/HttpServer.psm1 | 12 ++++---- tests/Test-Hooks.ps1 | 4 +-- tests/Test-Runtime.ps1 | 20 ++++++------- 4 files changed, 36 insertions(+), 30 deletions(-) diff --git a/src/runtime/Modules/Dotbot.Runtime/Private/ActivityLog.psm1 b/src/runtime/Modules/Dotbot.Runtime/Private/ActivityLog.psm1 index 141b6f3c..30461178 100644 --- a/src/runtime/Modules/Dotbot.Runtime/Private/ActivityLog.psm1 +++ b/src/runtime/Modules/Dotbot.Runtime/Private/ActivityLog.psm1 @@ -8,11 +8,14 @@ line — the runtime is the sole writer, and a per-process SemaphoreSlim guards the writer so two listener threads on the same runtime can't interleave bytes. -Event shape: +Event shape (dotted type so each line is a bus event matching task.* / +workflow.* sink globs): { + "id": "evt_xxxxxxxx", + "type": "task.created" | "task.status_changed" | "workflow.run_started" | ... "timestamp": "2026-05-18T10:00:00Z", + "source": "runtime", "project_id": "p_AbCd1234", - "type": "task_created" | "task_status_changed" | ... "task_id": "t_xxxxxxxx", // when relevant "run_id": "wr_xxxxxxxx", // when relevant "from": "in-progress", // on transitions @@ -124,14 +127,14 @@ function Test-DotBotEventTypeRegistered { } $script:DotbotActivityLogEventTypes = @( - 'task_created' - 'task_updated' - 'task_status_changed' - 'workflow_run_started' - 'workflow_run_completed' - 'workflow_run_failed' - 'workflow_run_cancelled' - 'hook_failed' + 'task.created' + 'task.updated' + 'task.status_changed' + 'workflow.run_started' + 'workflow.run_completed' + 'workflow.run_failed' + 'workflow.run_cancelled' + 'hook.failed' ) function Get-ActivityLogPath { @@ -343,13 +346,16 @@ function Write-ActivityEvent { [string]$From, [string]$To, [string]$Actor = 'system', - [string]$Reason + [string]$Reason, + [string]$Source = 'runtime' ) $event = [ordered]@{ + id = _New-DotBotEventId + type = $Type timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + source = $Source project_id = Get-DotbotProjectId -BotRoot $BotRoot - type = $Type } if ($TaskId) { $event['task_id'] = $TaskId } if ($RunId) { $event['run_id'] = $RunId } diff --git a/src/runtime/Modules/Dotbot.Runtime/Private/HttpServer.psm1 b/src/runtime/Modules/Dotbot.Runtime/Private/HttpServer.psm1 index a4f3d79e..dc2b2a07 100644 --- a/src/runtime/Modules/Dotbot.Runtime/Private/HttpServer.psm1 +++ b/src/runtime/Modules/Dotbot.Runtime/Private/HttpServer.psm1 @@ -1034,7 +1034,7 @@ function Invoke-CreateTaskHandler { Lock-TaskMutex -TaskId $task.id | Out-Null try { _Write-TaskFileAtomic -Path $filePath -Content $task - Write-ActivityEvent -BotRoot $BotRoot -Type 'task_created' -TaskId $task.id -Actor $actor + Write-ActivityEvent -BotRoot $BotRoot -Type 'task.created' -TaskId $task.id -Actor $actor } finally { Unlock-TaskMutex -TaskId $task.id } @@ -1139,7 +1139,7 @@ function Invoke-PatchTaskHandler { } _Write-TaskFileAtomic -Path $path -Content $task - Write-ActivityEvent -BotRoot $BotRoot -Type 'task_updated' -TaskId $task.id -Actor $actor + Write-ActivityEvent -BotRoot $BotRoot -Type 'task.updated' -TaskId $task.id -Actor $actor } finally { Unlock-TaskMutex -TaskId $taskId } @@ -1250,7 +1250,7 @@ function Invoke-TaskStatusHandler { # Write the new status FIRST so hooks observe a consistent on-disk # view. _Write-TaskFileAtomic -Path $path -Content $task - Write-ActivityEvent -BotRoot $BotRoot -Type 'task_status_changed' -TaskId $task.id -From $from -To $to -Actor $actor -Reason $reason + Write-ActivityEvent -BotRoot $BotRoot -Type 'task.status_changed' -TaskId $task.id -From $from -To $to -Actor $actor -Reason $reason # Transition-hook dispatch. Hooks run synchronously, inline with # this handler, inside the task mutex. A failing hook with @@ -1317,7 +1317,7 @@ function Invoke-TaskStatusHandler { Write-ActivityEvent ` -BotRoot $BotRoot ` - -Type 'hook_failed' ` + -Type 'hook.failed' ` -TaskId $task.id ` -From $to ` -To $from ` @@ -1345,7 +1345,7 @@ function Invoke-TaskStatusHandler { # response is honest about the half-finished state. Write-ActivityEvent ` -BotRoot $BotRoot ` - -Type 'hook_failed' ` + -Type 'hook.failed' ` -TaskId $task.id ` -To $to ` -Actor $actor ` @@ -1533,7 +1533,7 @@ function Invoke-CreateRunHandler { _Write-TaskFileAtomic -Path $layout.run_record_path -Content $record _Write-TaskFileAtomic -Path $layout.live_status_path -Content $status - Write-ActivityEvent -BotRoot $BotRoot -Type 'workflow_run_started' -RunId $runId -Actor $startedBy + Write-ActivityEvent -BotRoot $BotRoot -Type 'workflow.run_started' -RunId $runId -Actor $startedBy } finally { Unlock-RunMutex -RunId $runId } diff --git a/tests/Test-Hooks.ps1 b/tests/Test-Hooks.ps1 index 1e643088..ec0d7ed8 100644 --- a/tests/Test-Hooks.ps1 +++ b/tests/Test-Hooks.ps1 @@ -389,10 +389,10 @@ try { Get-Content -LiteralPath $logPath | ForEach-Object { try { $obj = $_ | ConvertFrom-Json -ErrorAction Stop - if ($obj.type -eq 'hook_failed' -and $obj.task_id -eq $tid) { $hookFailedLines++ } + if ($obj.type -eq 'hook.failed' -and $obj.task_id -eq $tid) { $hookFailedLines++ } } catch { } } - Assert-True -Name "activity.jsonl contains hook_failed for the task" -Condition ($hookFailedLines -ge 1) + Assert-True -Name "activity.jsonl contains hook.failed for the task" -Condition ($hookFailedLines -ge 1) # Replace the aborter with an advisory hook; transition should now succeed. Remove-Item -LiteralPath (Join-Path $projectHookDir 'enter-done') -Recurse -Force diff --git a/tests/Test-Runtime.ps1 b/tests/Test-Runtime.ps1 index a5a5f96c..0c6d5e0c 100644 --- a/tests/Test-Runtime.ps1 +++ b/tests/Test-Runtime.ps1 @@ -538,15 +538,15 @@ try { $hasCreated = $false; $hasUpdated = $false; $hasStatus = $false; $hasRunStarted = $false foreach ($l in $lines) { $obj = $l | ConvertFrom-Json - if ($obj.type -eq 'task_created') { $hasCreated = $true } - if ($obj.type -eq 'task_updated') { $hasUpdated = $true } - if ($obj.type -eq 'task_status_changed') { $hasStatus = $true } - if ($obj.type -eq 'workflow_run_started') { $hasRunStarted = $true } + if ($obj.type -eq 'task.created') { $hasCreated = $true } + if ($obj.type -eq 'task.updated') { $hasUpdated = $true } + if ($obj.type -eq 'task.status_changed') { $hasStatus = $true } + if ($obj.type -eq 'workflow.run_started') { $hasRunStarted = $true } } - Assert-True -Name "activity.jsonl contains task_created" -Condition $hasCreated - Assert-True -Name "activity.jsonl contains task_updated" -Condition $hasUpdated - Assert-True -Name "activity.jsonl contains task_status_changed" -Condition $hasStatus - Assert-True -Name "activity.jsonl contains workflow_run_started" -Condition $hasRunStarted + Assert-True -Name "activity.jsonl contains task.created" -Condition $hasCreated + Assert-True -Name "activity.jsonl contains task.updated" -Condition $hasUpdated + Assert-True -Name "activity.jsonl contains task.status_changed" -Condition $hasStatus + Assert-True -Name "activity.jsonl contains workflow.run_started" -Condition $hasRunStarted # ───── Invoke-RuntimeRequest (client helper) ───── $oldUrl = $env:DOTBOT_RUNTIME_URL @@ -633,10 +633,10 @@ try { Get-Content -LiteralPath $logPath | ForEach-Object { try { $obj = $_ | ConvertFrom-Json -ErrorAction Stop - if ($obj.type -eq 'task_updated' -and $obj.task_id -eq $tid) { $updatedLines++ } + if ($obj.type -eq 'task.updated' -and $obj.task_id -eq $tid) { $updatedLines++ } } catch { } } - Assert-Equal -Name "Activity log contains exactly 10 task_updated lines for the target task" -Expected 10 -Actual $updatedLines + Assert-Equal -Name "Activity log contains exactly 10 task.updated lines for the target task" -Expected 10 -Actual $updatedLines } finally { if ($start) { Stop-DotbotRuntime -BotRoot $bot -Listener $start.listener -ErrorAction SilentlyContinue } From 05a468967e3a35f6f9cb0c013ad5dc4472e1e98e Mon Sep 17 00:00:00 2001 From: Enmanuel Jimenez <34482837+EnmaJim@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:20:53 +0000 Subject: [PATCH 38/50] feat(events): emit workflow.run terminal events on the bus --- .../Dotbot.Runtime/Dotbot.Runtime.psd1 | 1 + .../Scripts/Invoke-WorkflowProcess.ps1 | 9 ++++++ src/ui/server.ps1 | 7 ++++- tests/Test-ProcessDispatch.ps1 | 18 ++++++++++++ tests/Test-Runtime.ps1 | 29 +++++++++++++++++++ 5 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1 b/src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1 index 00020e9c..8ced422d 100644 --- a/src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1 +++ b/src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1 @@ -42,6 +42,7 @@ 'Write-ActivityEvent' 'Get-ActivityLogPath' 'Get-DotbotProjectId' + 'Get-ActivityLogEventTypes' # Event bus (publish side) 'Publish-DotBotEvent' diff --git a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 index 2694a5dd..fa6756e1 100644 --- a/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 +++ b/src/runtime/Scripts/Invoke-WorkflowProcess.ps1 @@ -2539,6 +2539,7 @@ Work on this task autonomously. When complete, ensure you call ``task_set_status $processData.completed_at = (Get-Date).ToUniversalTime().ToString("o") } if ($RunId) { + $runStatus = 'running' try { $runStatus = switch ([string]$processData.status) { 'completed' { 'completed' } @@ -2550,6 +2551,14 @@ Work on this task autonomously. When complete, ensure you call ``task_set_status } catch { Write-BotLog -Level Warn -Message "Failed to update WorkflowRun live status for $RunId" -Exception $_ } + + if ($runStatus -in @('completed', 'failed', 'cancelled')) { + try { + Write-ActivityEvent -BotRoot $botRoot -Type "workflow.run_$runStatus" -RunId $RunId -From 'running' -To $runStatus -Actor 'system' -Reason $processData.error + } catch { + Write-BotLog -Level Warn -Message "Failed to emit workflow.run_$runStatus event for $RunId" -Exception $_ + } + } } if ($integrationBranch) { diff --git a/src/ui/server.ps1 b/src/ui/server.ps1 index 883a6d2e..c9c0bade 100644 --- a/src/ui/server.ps1 +++ b/src/ui/server.ps1 @@ -2577,7 +2577,12 @@ $docContext @{ run_id = $run.run_id; status = 'failed'; completed_at = $failTs; last_heartbeat = $failTs; error = $_.Exception.Message } } $failStatus | ConvertTo-Json -Depth 20 | Set-Content -Path $run.live_status_path -Encoding utf8NoBOM - } catch { Write-BotLog -Level Debug -Message "Failed to mark orphaned run failed" -Exception $_ } + $orphanFailReason = $_.Exception.Message + if (-not (Get-Command Write-ActivityEvent -ErrorAction SilentlyContinue)) { + Import-Module (Join-Path $PSScriptRoot ".." "runtime" "Modules" "Dotbot.Runtime" "Dotbot.Runtime.psd1") -DisableNameChecking -Global -ErrorAction Stop + } + Write-ActivityEvent -BotRoot $botRoot -Type 'workflow.run_failed' -RunId $run.run_id -From 'running' -To 'failed' -Actor 'system' -Reason $orphanFailReason + } catch { Write-BotLog -Level Debug -Message "Failed to mark orphaned run failed or emit event" -Exception $_ } } $statusCode = 500 $content = @{ success = $false; error = "Failed to run workflow: $($_.Exception.Message)" } | ConvertTo-Json -Compress diff --git a/tests/Test-ProcessDispatch.ps1 b/tests/Test-ProcessDispatch.ps1 index e11c645d..94e520ab 100644 --- a/tests/Test-ProcessDispatch.ps1 +++ b/tests/Test-ProcessDispatch.ps1 @@ -192,6 +192,24 @@ Assert-True -Name "Task-runner finalizes WorkflowRun live status" ` -Condition ($workflowProcessContent -match 'Set-WorkflowRunLiveStatus' -and $workflowProcessContent -match 'New-WorkflowRunStatus') ` -Message "Workflow runner should update .control/workflow-runs/.json when it exits" +Assert-True -Name "Task-runner emits workflow.run terminal events on the bus" ` + -Condition ($workflowProcessContent -match 'Write-ActivityEvent' -and $workflowProcessContent -match 'workflow\.run_\$runStatus') ` + -Message "Invoke-WorkflowProcess should emit workflow.run_ via Write-ActivityEvent when a run ends" + +Assert-True -Name "Task-runner guards the terminal-event emit to terminal states" ` + -Condition ($workflowProcessContent -match "@\('completed',\s*'failed',\s*'cancelled'\)") ` + -Message "The workflow.run_* emit must only fire for terminal run statuses, not 'running'" + +$uiServerFile = Join-Path $dotbotDir "src/ui/server.ps1" +if (Test-Path $uiServerFile) { + $uiServerContent = Get-Content $uiServerFile -Raw + Assert-True -Name "UI orphan-fail path emits workflow.run_failed on the bus" ` + -Condition ($uiServerContent -match 'Write-ActivityEvent' -and $uiServerContent -match "workflow\.run_failed") ` + -Message "ui/server.ps1 should emit workflow.run_failed when a run fails to launch before the runner starts" +} else { + Write-TestResult -Name "UI orphan-fail path emits workflow.run_failed on the bus" -Status Skip -Message "ui/server.ps1 not found at $uiServerFile" +} + Assert-True -Name "Task-runner uses legal executor status transitions" ` -Condition ($workflowProcessContent -match 'Invoke-TaskMarkInProgress' -and $workflowProcessContent -match 'Cannot dispatch non-prompt task' -and diff --git a/tests/Test-Runtime.ps1 b/tests/Test-Runtime.ps1 index 0c6d5e0c..db67b1f8 100644 --- a/tests/Test-Runtime.ps1 +++ b/tests/Test-Runtime.ps1 @@ -548,6 +548,35 @@ try { Assert-True -Name "activity.jsonl contains task.status_changed" -Condition $hasStatus Assert-True -Name "activity.jsonl contains workflow.run_started" -Condition $hasRunStarted + $termBot = Join-Path ([System.IO.Path]::GetTempPath()) ("rt-term-" + [guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path (Join-Path $termBot '.control') -Force | Out-Null + try { + $knownTypes = Get-ActivityLogEventTypes + foreach ($t in @('workflow.run_completed','workflow.run_failed','workflow.run_cancelled')) { + Assert-True -Name "vocabulary includes $t" -Condition ($knownTypes -contains $t) + } + + $terminalMap = @{ completed = 'workflow.run_completed'; failed = 'workflow.run_failed'; cancelled = 'workflow.run_cancelled' } + foreach ($runStatus in $terminalMap.Keys) { + $rid = 'wr_' + (New-DotbotNanoId) + Write-ActivityEvent -BotRoot $termBot -Type "workflow.run_$runStatus" -RunId $rid -From 'running' -To $runStatus -Actor 'system' + } + + $termLines = Get-Content -LiteralPath (Get-ActivityLogPath -BotRoot $termBot) | ForEach-Object { $_ | ConvertFrom-Json } + foreach ($runStatus in $terminalMap.Keys) { + $expectedType = $terminalMap[$runStatus] + $evt = $termLines | Where-Object { $_.type -eq $expectedType } | Select-Object -First 1 + Assert-True -Name "activity.jsonl contains $expectedType" -Condition ($null -ne $evt) + Assert-Equal -Name "$expectedType carries source=runtime" -Expected 'runtime' -Actual $evt.source + Assert-Equal -Name "$expectedType carries to=$runStatus" -Expected $runStatus -Actual $evt.to + Assert-Equal -Name "$expectedType carries from=running" -Expected 'running' -Actual $evt.from + Assert-True -Name "$expectedType has a well-formed event id" -Condition ($evt.id -match '^evt_[A-Za-z0-9]{8}$') + Assert-True -Name "$expectedType matches the workflow.* sink glob" -Condition ($evt.type -like 'workflow.*') + } + } finally { + Remove-Item -Recurse -Force $termBot -ErrorAction SilentlyContinue + } + # ───── Invoke-RuntimeRequest (client helper) ───── $oldUrl = $env:DOTBOT_RUNTIME_URL $oldToken = $env:DOTBOT_RUNTIME_TOKEN From 0fd029f9c8f04025def2702c0985566c28237260 Mon Sep 17 00:00:00 2001 From: Enmanuel Jimenez <34482837+EnmaJim@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:28:31 +0000 Subject: [PATCH 39/50] feat(events): add events settings section with sink flags and webhooks --- content/settings/settings.default.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/content/settings/settings.default.json b/content/settings/settings.default.json index 47b19e7b..ca2f39af 100644 --- a/content/settings/settings.default.json +++ b/content/settings/settings.default.json @@ -104,5 +104,21 @@ "description": "New document in product briefing" } ] + }, + "events": { + "enabled": true, + "webhooks": { + "enabled": false, + "endpoints": [ + { + "url": "https://hooks.example.com/dotbot", + "events": ["task.*", "workflow.*"], + "secret": "" + } + ] + }, + "mothership": { + "enabled": false + } } } From 79c32f834dd0dfd69c7da709c34fd692488f7bbb Mon Sep 17 00:00:00 2001 From: Enmanuel Jimenez <34482837+EnmaJim@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:42:38 +0000 Subject: [PATCH 40/50] feat(events): add Dotbot.Events module with folder-per-plugin sink discovery --- .../Modules/Dotbot.Events/Dotbot.Events.psd1 | 27 ++ .../Modules/Dotbot.Events/Dotbot.Events.psm1 | 12 + .../Dotbot.Events/Private/Discovery.psm1 | 250 ++++++++++++++++++ tests/Run-Tests.ps1 | 3 +- tests/Test-Events.ps1 | 198 ++++++++++++++ 5 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 src/runtime/Modules/Dotbot.Events/Dotbot.Events.psd1 create mode 100644 src/runtime/Modules/Dotbot.Events/Dotbot.Events.psm1 create mode 100644 src/runtime/Modules/Dotbot.Events/Private/Discovery.psm1 create mode 100644 tests/Test-Events.ps1 diff --git a/src/runtime/Modules/Dotbot.Events/Dotbot.Events.psd1 b/src/runtime/Modules/Dotbot.Events/Dotbot.Events.psd1 new file mode 100644 index 00000000..11af9bd1 --- /dev/null +++ b/src/runtime/Modules/Dotbot.Events/Dotbot.Events.psd1 @@ -0,0 +1,27 @@ +@{ + RootModule = 'Dotbot.Events.psm1' + ModuleVersion = '1.0.0' + GUID = 'c4e1f8a2-3b6d-4e9c-8a17-2f5b9d3c7e04' + Author = 'dotbot contributors' + Description = 'Event-bus sinks. Folder-discovered subscribers that react to bus events on the activity log. Discovery scans /Plugins/Events/Sinks/*/metadata.json; Dispatch runs Invoke-Sink in a time-boxed child runspace, non-aborting and out-of-band; a background consumer tails activity.jsonl by persisted byte cursor.' + PowerShellVersion = '7.0' + + # Concerns live as nested modules so each is findable in isolation. + # Dispatch (child-runspace execution) and the background Consumer are added + # in later steps. + NestedModules = @( + 'Private/Discovery.psm1' + ) + + FunctionsToExport = @( + # Discovery + 'Get-DefaultSinksDirectory' + 'Read-SinkMetadata' + 'Get-SinkRegistry' + 'Get-SinksForEvent' + ) + + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() +} diff --git a/src/runtime/Modules/Dotbot.Events/Dotbot.Events.psm1 b/src/runtime/Modules/Dotbot.Events/Dotbot.Events.psm1 new file mode 100644 index 00000000..639c0621 --- /dev/null +++ b/src/runtime/Modules/Dotbot.Events/Dotbot.Events.psm1 @@ -0,0 +1,12 @@ +<# +.SYNOPSIS +Dotbot.Events root module — event-bus sinks. + +The actual surface lives under Private/*.psm1 (Discovery today; Dispatch and +the background Consumer land in later steps). This root file exists so the psd1 +has a RootModule to point at; it deliberately exports nothing of its own. The +plugin contract and shipped sinks live on disk under +/Plugins/Events/Sinks/* and are picked up at dispatch time. +#> + +# Intentionally empty: nested modules carry the surface. diff --git a/src/runtime/Modules/Dotbot.Events/Private/Discovery.psm1 b/src/runtime/Modules/Dotbot.Events/Private/Discovery.psm1 new file mode 100644 index 00000000..3cd5967c --- /dev/null +++ b/src/runtime/Modules/Dotbot.Events/Private/Discovery.psm1 @@ -0,0 +1,250 @@ +<# +.SYNOPSIS +Discovery for event-bus sinks. + +Sinks live one-folder-per-sink under a stable directory. Each folder must +contain metadata.json + script.ps1. Discovery scans the folder, parses each +metadata.json, and returns a list of sink records sorted alphabetically by +folder name — dispatch order is the declaration order in the directory listing. + +A malformed metadata.json is reported as an error rather than silently +skipped, so a fixture directory with valid sinks + one malformed produces a +startup error (parity with the Dotbot.Hook engine). + +Sinks differ from transition hooks in two ways enforced elsewhere (Dispatch): +dispatch is non-aborting and out-of-band. Consequently a sink's metadata has +no `abort_on_failure` field — every sink is non-aborting by contract. +#> + +# ─── Configurable schema ──────────────────────────────────────────────────── + +$script:DotbotSinkMetadataRequiredFields = @( + 'name', + 'subscribed_events', + 'max_duration' +) + +# ─── Default sinks directory resolution ───────────────────────────────────── + +function Get-DefaultSinksDirectory { + <# + .SYNOPSIS + Resolve the canonical "where do sinks live" path for a project. + + .DESCRIPTION + Sinks live under runtime/Plugins/Events/Sinks/. + After dotbot init, this is /src/runtime/Plugins/Events/Sinks/. + When running against an uninstalled source tree (dev tests), the sinks + live next to this module in /src/runtime/Plugins/Events/Sinks/. + + Resolution order: + 1. /src/runtime/Plugins/Events/Sinks/ ← per-project framework copy + 2. /../../Plugins/Events/Sinks/ ← dev/repo fallback + + Returns $null if neither exists. Callers can pass an explicit -SinksDir + to Get-SinkRegistry to override. + #> + [CmdletBinding()] + param( + [string]$BotRoot + ) + + if ($BotRoot) { + $projectCopy = Join-Path $BotRoot (Join-Path 'src' (Join-Path 'runtime' (Join-Path 'Plugins' (Join-Path 'Events' 'Sinks')))) + if (Test-Path -LiteralPath $projectCopy -PathType Container) { + return $projectCopy + } + } + + # Dev fallback: this file lives at + # /src/runtime/Modules/Dotbot.Events/Private/Discovery.psm1, + # so the sinks sit at /src/runtime/Plugins/Events/Sinks/. + $repoCopy = Join-Path (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))) (Join-Path 'Plugins' (Join-Path 'Events' 'Sinks')) + if (Test-Path -LiteralPath $repoCopy -PathType Container) { + return $repoCopy + } + + return $null +} + +# ─── Metadata parsing ─────────────────────────────────────────────────────── + +function _Parse-SinkMetadataJson { + <# + .SYNOPSIS + Parse a metadata.json string into a hashtable. + #> + param([Parameter(Mandatory)] [string]$Content) + + try { + return ($Content | ConvertFrom-Json -AsHashtable) + } catch { + throw "Invalid metadata.json: $($_.Exception.Message)" + } +} + +function Read-SinkMetadata { + <# + .SYNOPSIS + Parse and validate a single sink's metadata.json. + + .OUTPUTS + Hashtable record: + @{ + name = 'webhooks' + description = '...' + subscribed_events = @('task.*', 'workflow.*') + max_duration = 10 + metadata_path = '/path/to/metadata.json' + script_path = '/path/to/script.ps1' + dir = '/path/to/webhooks' + } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$SinkDir + ) + + if (-not (Test-Path -LiteralPath $SinkDir -PathType Container)) { + throw "Read-SinkMetadata: sink directory not found: $SinkDir" + } + + $metaPath = Join-Path $SinkDir 'metadata.json' + $scriptPath = Join-Path $SinkDir 'script.ps1' + + if (-not (Test-Path -LiteralPath $metaPath -PathType Leaf)) { + throw "Read-SinkMetadata: '$SinkDir' is missing metadata.json." + } + if (-not (Test-Path -LiteralPath $scriptPath -PathType Leaf)) { + throw "Read-SinkMetadata: '$SinkDir' is missing script.ps1." + } + + $raw = Get-Content -LiteralPath $metaPath -Raw + $parsed = _Parse-SinkMetadataJson -Content $raw + if (-not $parsed -or $parsed.Count -eq 0) { + throw "Read-SinkMetadata: '$metaPath' did not parse to any fields." + } + + foreach ($req in $script:DotbotSinkMetadataRequiredFields) { + if (-not $parsed.ContainsKey($req)) { + throw "Read-SinkMetadata: '$metaPath' is missing required field '$req'." + } + } + + # Normalise subscribed_events → string[]. Entries are glob patterns matched + # against a concrete dotted event type (e.g. 'task.*' matches 'task.created'). + # No closed vocabulary: the bus is extensible, so any non-empty pattern is + # legal here — an unknown family just never matches until it is published. + $events = @($parsed['subscribed_events']) + if ($events.Count -eq 0) { + throw "Read-SinkMetadata: '$metaPath' has empty subscribed_events (must list at least one glob pattern)." + } + foreach ($e in $events) { + if ([string]::IsNullOrWhiteSpace([string]$e)) { + throw "Read-SinkMetadata: '$metaPath' has a blank entry in subscribed_events." + } + } + + # Normalise max_duration → int seconds + $maxDur = [int]$parsed['max_duration'] + if ($maxDur -le 0) { + throw "Read-SinkMetadata: '$metaPath' has non-positive max_duration ($($parsed['max_duration']))." + } + + $description = if ($parsed.ContainsKey('description')) { [string]$parsed['description'] } else { '' } + + return [ordered]@{ + name = [string]$parsed['name'] + description = $description + subscribed_events = [string[]]$events + max_duration = $maxDur + metadata_path = $metaPath + script_path = $scriptPath + dir = $SinkDir + } +} + +# ─── Registry assembly ────────────────────────────────────────────────────── + +function Get-SinkRegistry { + <# + .SYNOPSIS + Scan the sinks directory, parse each sink's metadata, return a sorted + list of sink records. + + .DESCRIPTION + Discovery is deterministic and reproducible. Order is alphabetical by + directory name. A malformed sink (bad/missing metadata, missing script.ps1) + throws — discovery is "either all parse correctly or fail loudly" so a typo + at startup is impossible to miss. + + .PARAMETER SinksDir + Override the sinks root. When omitted, Get-DefaultSinksDirectory chooses. + + .PARAMETER BotRoot + Project bot root, used by Get-DefaultSinksDirectory to find the per-project + framework copy. + + .OUTPUTS + @(, ...). Empty array if no sinks dir exists. + #> + [CmdletBinding()] + param( + [string]$SinksDir, + [string]$BotRoot + ) + + if (-not $SinksDir) { + $SinksDir = Get-DefaultSinksDirectory -BotRoot $BotRoot + } + if (-not $SinksDir) { return @() } + if (-not (Test-Path -LiteralPath $SinksDir -PathType Container)) { return @() } + + # Collect with a foreach statement (not ForEach-Object) so accumulation + # stays in this scope, and emit the elements normally — callers wrap the + # result in @() so 0/1/many sinks all read back as an array. + $registry = @() + foreach ($dir in (Get-ChildItem -LiteralPath $SinksDir -Directory -ErrorAction SilentlyContinue | Sort-Object -Property Name)) { + # Read-SinkMetadata throws on malformed entries; let it propagate. + $registry += (Read-SinkMetadata -SinkDir $dir.FullName) + } + return $registry +} + +function Get-SinksForEvent { + <# + .SYNOPSIS + Filter a sink registry down to sinks subscribed to a concrete event type. + + .DESCRIPTION + A sink matches when any of its subscribed_events glob patterns matches the + given event type (PowerShell -like), so a sink subscribed to 'task.*' + matches 'task.created', and one subscribed to the exact 'workflow.run_completed' + matches only that type. + + .PARAMETER Registry + The output of Get-SinkRegistry. + + .PARAMETER EventType + The concrete dotted event type of a published event (e.g. 'task.created'). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Registry, + [Parameter(Mandatory)] [string]$EventType + ) + $out = @() + foreach ($s in $Registry) { + foreach ($pattern in $s.subscribed_events) { + if ($EventType -like $pattern) { $out += $s; break } + } + } + return $out +} + +Export-ModuleMember -Function @( + 'Get-DefaultSinksDirectory' + 'Read-SinkMetadata' + 'Get-SinkRegistry' + 'Get-SinksForEvent' +) diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index ba723dd2..9687cea7 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -138,6 +138,7 @@ if (1 -in $layersToRun) { $worktreeCode = Invoke-TestFile -Layer '1' -FileName 'Test-Worktree.ps1' $executorCode = Invoke-TestFile -Layer '1' -FileName 'Test-Executor.ps1' $hooksCode = Invoke-TestFile -Layer '1' -FileName 'Test-Hooks.ps1' + $eventsCode = Invoke-TestFile -Layer '1' -FileName 'Test-Events.ps1' $mdRefsCode = Invoke-TestFile -Layer '1' -FileName 'Test-MdRefs.ps1' $legacyVocabularyCode = Invoke-TestFile -Layer '1' -FileName 'Test-NoLegacyVocabulary.ps1' $backslashPathsCode = Invoke-TestFile -Layer '1' -FileName 'Test-NoBackslashPaths.ps1' @@ -147,7 +148,7 @@ if (1 -in $layersToRun) { $pathSanitizerCode = Invoke-TestFile -Layer '1' -FileName 'Test-PathSanitizer.ps1' $mcpSurfaceCode = Invoke-TestFile -Layer '1' -FileName 'Test-McpSurface.ps1' - $exitCode = if ($structureCode -ne 0 -or $compilationCode -ne 0 -or $workflowManifestCode -ne 0 -or $dataModelCode -ne 0 -or $runtimeCode -ne 0 -or $worktreeCode -ne 0 -or $executorCode -ne 0 -or $hooksCode -ne 0 -or $mdRefsCode -ne 0 -or $legacyVocabularyCode -ne 0 -or $backslashPathsCode -ne 0 -or $clarificationCode -ne 0 -or $activityLogCode -ne 0 -or $privacyScanCode -ne 0 -or $pathSanitizerCode -ne 0 -or $mcpSurfaceCode -ne 0) { 1 } else { 0 } + $exitCode = if ($structureCode -ne 0 -or $compilationCode -ne 0 -or $workflowManifestCode -ne 0 -or $dataModelCode -ne 0 -or $runtimeCode -ne 0 -or $worktreeCode -ne 0 -or $executorCode -ne 0 -or $hooksCode -ne 0 -or $eventsCode -ne 0 -or $mdRefsCode -ne 0 -or $legacyVocabularyCode -ne 0 -or $backslashPathsCode -ne 0 -or $clarificationCode -ne 0 -or $activityLogCode -ne 0 -or $privacyScanCode -ne 0 -or $pathSanitizerCode -ne 0 -or $mcpSurfaceCode -ne 0) { 1 } else { 0 } $layerResults["1"] = ($exitCode -eq 0) if ($exitCode -ne 0) { $overallFailed = $true } } diff --git a/tests/Test-Events.ps1 b/tests/Test-Events.ps1 new file mode 100644 index 00000000..71458449 --- /dev/null +++ b/tests/Test-Events.ps1 @@ -0,0 +1,198 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Layer 1: Dotbot.Events sink-discovery tests. +.DESCRIPTION + Covers the discovery surface of Dotbot.Events (parity with the Dotbot.Hook + engine): + + - Folder-per-sink discovery: metadata.json + script.ps1, sorted by folder + name, records carry the expected shape. + - Event routing: Get-SinksForEvent matches a concrete dotted event type + against each sink's subscribed_events glob patterns. + - Fail-loud validation: malformed sink metadata throws rather than being + silently skipped, so one bad sink among valid ones fails the whole + registry scan at startup. + + No installed dotbot needed (module is imported directly from src/). +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" + +Import-Module "$PSScriptRoot\Test-Helpers.psm1" -Force + +$repoRoot = Get-RepoRoot + +Write-Host "" +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Blue +Write-Host " Dotbot.Events — Sink Discovery" -ForegroundColor Blue +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Blue +Write-Host "" + +Reset-TestResults + +Import-Module (Join-Path $repoRoot "src/runtime/Modules/Dotbot.Events/Dotbot.Events.psd1") -Force -DisableNameChecking -Global + +# Small helper: assert a scriptblock throws and (optionally) message matches a pattern. +function Assert-Throws { + param( + [Parameter(Mandatory)] [string]$Name, + [Parameter(Mandatory)] [scriptblock]$Action, + [string]$Pattern + ) + $threw = $false + $msg = '' + try { & $Action } catch { $threw = $true; $msg = $_.Exception.Message } + if (-not $threw) { + Write-TestResult -Name $Name -Status Fail -Message "Expected an exception, got none." + return + } + if ($Pattern -and ($msg -notmatch $Pattern)) { + Write-TestResult -Name $Name -Status Fail -Message "Exception '$msg' did not match pattern '$Pattern'." + return + } + Write-TestResult -Name $Name -Status Pass +} + +# ─────────────────────────────────────────────────────────────────────────── +# Fixture helpers +# ─────────────────────────────────────────────────────────────────────────── + +function New-SinksRoot { + $dir = Join-Path ([System.IO.Path]::GetTempPath()) ("dotbot-sinks-" + [guid]::NewGuid().ToString('N').Substring(0,8)) + New-Item -ItemType Directory -Path $dir -Force | Out-Null + return $dir +} + +function New-SinkFixture { + param( + [Parameter(Mandatory)] [string]$Root, + [Parameter(Mandatory)] [string]$Name, + [object]$Metadata, # hashtable to serialize, or a raw string for malformed-JSON cases + [switch]$NoMetadata, + [switch]$NoScript, + [string]$RawMetadata + ) + $sinkDir = Join-Path $Root $Name + New-Item -ItemType Directory -Path $sinkDir -Force | Out-Null + + if (-not $NoMetadata) { + $metaPath = Join-Path $sinkDir 'metadata.json' + if ($PSBoundParameters.ContainsKey('RawMetadata')) { + Set-Content -LiteralPath $metaPath -Value $RawMetadata -Encoding utf8NoBOM + } else { + ($Metadata | ConvertTo-Json -Depth 6) | Set-Content -LiteralPath $metaPath -Encoding utf8NoBOM + } + } + if (-not $NoScript) { + $scriptPath = Join-Path $sinkDir 'script.ps1' + Set-Content -LiteralPath $scriptPath -Encoding utf8NoBOM -Value @' +function Invoke-Sink { param($Event) } +Export-ModuleMember -Function Invoke-Sink +'@ + } + return $sinkDir +} + +# ═══════════════════════════════════════════════════════════════════════════ +# Happy-path discovery +# ═══════════════════════════════════════════════════════════════════════════ + +Write-Host " Discovery" -ForegroundColor Cyan +Write-Host " ──────────────────────────────────────────────────" -ForegroundColor DarkGray + +$root = New-SinksRoot +try { + New-SinkFixture -Root $root -Name 'alpha' -Metadata @{ name = 'alpha'; description = 'A sink'; subscribed_events = @('task.*'); max_duration = 10 } | Out-Null + New-SinkFixture -Root $root -Name 'zeta' -Metadata @{ name = 'zeta'; subscribed_events = @('workflow.run_completed', 'task.created'); max_duration = 5 } | Out-Null + + $registry = @(Get-SinkRegistry -SinksDir $root) + Assert-Equal -Name "registry discovers both sinks" -Expected 2 -Actual $registry.Count + Assert-Equal -Name "registry is sorted by folder name (alpha first)" -Expected 'alpha' -Actual $registry[0].name + Assert-Equal -Name "registry is sorted by folder name (zeta second)" -Expected 'zeta' -Actual $registry[1].name + + $alpha = $registry[0] + Assert-Equal -Name "alpha carries description" -Expected 'A sink' -Actual $alpha.description + Assert-Equal -Name "alpha carries max_duration as int" -Expected 10 -Actual $alpha.max_duration + Assert-True -Name "alpha subscribed_events has task.*" -Condition ($alpha.subscribed_events -contains 'task.*') + Assert-True -Name "alpha record points at its script.ps1" -Condition (Test-Path -LiteralPath $alpha.script_path) + Assert-True -Name "alpha record points at its metadata.json" -Condition (Test-Path -LiteralPath $alpha.metadata_path) + + # ── Event routing (glob match) ── + $forTaskCreated = @(Get-SinksForEvent -Registry $registry -EventType 'task.created') + Assert-Equal -Name "task.created routes to both (alpha via task.*, zeta via exact)" -Expected 2 -Actual $forTaskCreated.Count + + $forTaskUpdated = @(Get-SinksForEvent -Registry $registry -EventType 'task.updated') + Assert-Equal -Name "task.updated routes to alpha only (task.* glob)" -Expected 1 -Actual $forTaskUpdated.Count + Assert-Equal -Name "task.updated matched sink is alpha" -Expected 'alpha' -Actual $forTaskUpdated[0].name + + $forRunCompleted = @(Get-SinksForEvent -Registry $registry -EventType 'workflow.run_completed') + Assert-Equal -Name "workflow.run_completed routes to zeta only" -Expected 1 -Actual $forRunCompleted.Count + Assert-Equal -Name "workflow.run_completed matched sink is zeta" -Expected 'zeta' -Actual $forRunCompleted[0].name + + $forDecision = @(Get-SinksForEvent -Registry $registry -EventType 'decision.created') + Assert-Equal -Name "unsubscribed event routes to no sinks" -Expected 0 -Actual $forDecision.Count +} finally { + Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue +} + +# ── Empty / missing sinks dir is not an error ── +$missing = Join-Path ([System.IO.Path]::GetTempPath()) ("dotbot-sinks-none-" + [guid]::NewGuid().ToString('N').Substring(0,8)) +Assert-Equal -Name "missing sinks dir yields empty registry (no throw)" -Expected 0 -Actual (@(Get-SinkRegistry -SinksDir $missing)).Count + +# ═══════════════════════════════════════════════════════════════════════════ +# Fail-loud validation +# ═══════════════════════════════════════════════════════════════════════════ + +Write-Host "" +Write-Host " Fail-loud validation" -ForegroundColor Cyan +Write-Host " ──────────────────────────────────────────────────" -ForegroundColor DarkGray + +$bad = New-SinksRoot +try { + $d = New-SinkFixture -Root $bad -Name 'no-meta' -NoMetadata + Assert-Throws -Name "missing metadata.json throws" -Action { Read-SinkMetadata -SinkDir $d } -Pattern 'missing metadata.json' + + $d = New-SinkFixture -Root $bad -Name 'no-script' -Metadata @{ name = 'x'; subscribed_events = @('task.*'); max_duration = 5 } -NoScript + Assert-Throws -Name "missing script.ps1 throws" -Action { Read-SinkMetadata -SinkDir $d } -Pattern 'missing script.ps1' + + $d = New-SinkFixture -Root $bad -Name 'bad-json' -RawMetadata '{ not valid json ]' + Assert-Throws -Name "invalid JSON throws" -Action { Read-SinkMetadata -SinkDir $d } -Pattern 'Invalid metadata.json' + + $d = New-SinkFixture -Root $bad -Name 'no-max' -Metadata @{ name = 'x'; subscribed_events = @('task.*') } + Assert-Throws -Name "missing required field throws" -Action { Read-SinkMetadata -SinkDir $d } -Pattern "missing required field 'max_duration'" + + $d = New-SinkFixture -Root $bad -Name 'empty-events' -Metadata @{ name = 'x'; subscribed_events = @(); max_duration = 5 } + Assert-Throws -Name "empty subscribed_events throws" -Action { Read-SinkMetadata -SinkDir $d } -Pattern 'empty subscribed_events' + + $d = New-SinkFixture -Root $bad -Name 'blank-event' -Metadata @{ name = 'x'; subscribed_events = @('task.*', ''); max_duration = 5 } + Assert-Throws -Name "blank subscribed_events entry throws" -Action { Read-SinkMetadata -SinkDir $d } -Pattern 'blank entry in subscribed_events' + + $d = New-SinkFixture -Root $bad -Name 'zero-dur' -Metadata @{ name = 'x'; subscribed_events = @('task.*'); max_duration = 0 } + Assert-Throws -Name "non-positive max_duration throws" -Action { Read-SinkMetadata -SinkDir $d } -Pattern 'non-positive max_duration' +} finally { + Remove-Item -Recurse -Force $bad -ErrorAction SilentlyContinue +} + +# ── One malformed sink among valid ones fails the whole registry scan ── +$mixed = New-SinksRoot +try { + New-SinkFixture -Root $mixed -Name 'good' -Metadata @{ name = 'good'; subscribed_events = @('task.*'); max_duration = 5 } | Out-Null + New-SinkFixture -Root $mixed -Name 'broken' -Metadata @{ name = 'broken'; subscribed_events = @('task.*') } | Out-Null # missing max_duration + Assert-Throws -Name "Get-SinkRegistry fails loudly when any sink is malformed" -Action { Get-SinkRegistry -SinksDir $mixed } -Pattern "missing required field" +} finally { + Remove-Item -Recurse -Force $mixed -ErrorAction SilentlyContinue +} + +# ═══════════════════════════════════════════════════════════════════════════ +# SUMMARY +# ═══════════════════════════════════════════════════════════════════════════ + +$allPassed = Write-TestSummary -LayerName "Layer 1: Dotbot.Events Sink Discovery" + +if (-not $allPassed) { + exit 1 +} From c33d72451ce602184adf1dab27b3b634fcad517a Mon Sep 17 00:00:00 2001 From: Enmanuel Jimenez <34482837+EnmaJim@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:49:50 +0000 Subject: [PATCH 41/50] feat(events): add non-aborting time-boxed sink dispatch --- .../Modules/Dotbot.Events/Dotbot.Events.psd1 | 10 +- .../Dotbot.Events/Private/Dispatch.psm1 | 230 ++++++++++++++++++ tests/Test-Events.ps1 | 97 +++++++- 3 files changed, 328 insertions(+), 9 deletions(-) create mode 100644 src/runtime/Modules/Dotbot.Events/Private/Dispatch.psm1 diff --git a/src/runtime/Modules/Dotbot.Events/Dotbot.Events.psd1 b/src/runtime/Modules/Dotbot.Events/Dotbot.Events.psd1 index 11af9bd1..296e2ec3 100644 --- a/src/runtime/Modules/Dotbot.Events/Dotbot.Events.psd1 +++ b/src/runtime/Modules/Dotbot.Events/Dotbot.Events.psd1 @@ -7,10 +7,10 @@ PowerShellVersion = '7.0' # Concerns live as nested modules so each is findable in isolation. - # Dispatch (child-runspace execution) and the background Consumer are added - # in later steps. + # The background Consumer is added in a later step. NestedModules = @( - 'Private/Discovery.psm1' + 'Private/Discovery.psm1', + 'Private/Dispatch.psm1' ) FunctionsToExport = @( @@ -19,6 +19,10 @@ 'Read-SinkMetadata' 'Get-SinkRegistry' 'Get-SinksForEvent' + + # Dispatch + 'Invoke-SingleSink' + 'Invoke-EventSinks' ) CmdletsToExport = @() diff --git a/src/runtime/Modules/Dotbot.Events/Private/Dispatch.psm1 b/src/runtime/Modules/Dotbot.Events/Private/Dispatch.psm1 new file mode 100644 index 00000000..a80cac0b --- /dev/null +++ b/src/runtime/Modules/Dotbot.Events/Private/Dispatch.psm1 @@ -0,0 +1,230 @@ +<# +.SYNOPSIS +Dispatch for event-bus sinks. + +Runs each sink subscribed to a published event. Two rules distinguish sinks +from transition hooks: + + - NON-ABORTING: a sink that fails or times out is logged and skipped. It + never rolls back a task and never stops the other sinks for the same event. + - OUT-OF-BAND: dispatch happens AFTER the event is durably logged, driven by + the background consumer (a later step) — never inside a task's + state-transition path. + +Each sink runs in a child runspace so max_duration can be enforced via Stop(). + +The Invoke-Sink contract from each sink's script.ps1: a single function taking +$Event (the event envelope) and optionally returning a hashtable with +Success/Message. A sink that returns nothing is treated as success — its work +is the side effect (POST a webhook, forward to the mothership, …). + +Loading note: a bare .ps1 with a top-level Export-ModuleMember isn't a real +module. We turn it into one at dispatch time via New-Module against a +ScriptBlock built from the file contents, matching the Dotbot.Hook engine. +#> + +function Invoke-SingleSink { + <# + .SYNOPSIS + Run one sink's Invoke-Sink function under a timeout. Catches all faults and + normalises the return to a single hashtable. Never throws. + + .OUTPUTS + @{ + name = '' + success = $true|$false + message = '' + duration = + timed_out = $true|$false + } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Sink, # one element from Get-SinkRegistry + [Parameter(Mandatory)] $Event # the event envelope (hashtable or pscustomobject) + ) + + $name = [string]$Sink.name + $maxDuration = [int]$Sink.max_duration + + # Read the script once; pass the contents into the child runspace rather + # than re-reading from disk inside it (avoids coupling to a working dir). + $scriptContent = $null + try { + $scriptContent = Get-Content -LiteralPath $Sink.script_path -Raw -ErrorAction Stop + } catch { + return @{ + name = $name + success = $false + message = "Sink '$name': could not read script.ps1 — $($_.Exception.Message)" + duration = [TimeSpan]::Zero + timed_out = $false + } + } + + $runner = { + param([string]$Content, [string]$SinkName, $Event) + + $sw = [System.Diagnostics.Stopwatch]::StartNew() + try { + $sb = [ScriptBlock]::Create($Content) + # New-Module against the script block produces a real dynamic + # module — Export-ModuleMember inside the script works, and we can + # invoke its function via `& $mod `. + $mod = New-Module -Name ("DotbotSink_" + $SinkName) -ScriptBlock $sb + $sinkResult = & $mod Invoke-Sink -Event $Event + } catch { + $sw.Stop() + return @{ + success = $false + message = $_.Exception.Message + duration = $sw.Elapsed + } + } + $sw.Stop() + + # A sink that returns nothing is a success (the work is the side + # effect). If it returns a hashtable, honour Success/Message (PascalCase + # per contract, lowercase tolerated). + $success = $true + $message = '' + $sinkDuration = $sw.Elapsed + if ($sinkResult -is [hashtable]) { + if ($sinkResult.ContainsKey('Success')) { $success = [bool]$sinkResult['Success'] } + elseif ($sinkResult.ContainsKey('success')) { $success = [bool]$sinkResult['success'] } + if ($sinkResult.ContainsKey('Message')) { $message = [string]$sinkResult['Message'] } + elseif ($sinkResult.ContainsKey('message')) { $message = [string]$sinkResult['message'] } + } + return @{ + success = $success + message = $message + duration = $sinkDuration + } + } + + $ps = [PowerShell]::Create() + $null = $ps.AddScript($runner) + $null = $ps.AddArgument($scriptContent) + $null = $ps.AddArgument($name) + $null = $ps.AddArgument($Event) + + $outerSw = [System.Diagnostics.Stopwatch]::StartNew() + $async = $ps.BeginInvoke() + + $completed = $async.AsyncWaitHandle.WaitOne([TimeSpan]::FromSeconds($maxDuration)) + $outerSw.Stop() + + if (-not $completed) { + # Timeout — forcibly stop the runspace. Non-aborting: the sink is + # marked failed/timed-out and the caller moves on to the next sink. + try { $ps.Stop() } catch { $null = $_ } + try { $ps.Dispose() } catch { $null = $_ } + return @{ + name = $name + success = $false + message = "Sink '$name' exceeded max_duration of ${maxDuration}s and was stopped." + duration = $outerSw.Elapsed + timed_out = $true + } + } + + $result = $null + try { + $result = $ps.EndInvoke($async) | Select-Object -First 1 + } catch { + $result = @{ success = $false; message = $_.Exception.Message; duration = $outerSw.Elapsed } + } finally { + try { $ps.Dispose() } catch { $null = $_ } + } + + if ($null -eq $result) { + $result = @{ success = $false; message = "Sink '$name' produced no result."; duration = $outerSw.Elapsed } + } + + return @{ + name = $name + success = [bool]$result.success + message = [string]$result.message + duration = if ($result.duration -is [TimeSpan]) { $result.duration } else { $outerSw.Elapsed } + timed_out = $false + } +} + +function Invoke-EventSinks { + <# + .SYNOPSIS + Dispatch every sink subscribed to a single event's type. Non-aborting: + every matching sink runs regardless of what the others do. + + .DESCRIPTION + Extracts the event's dotted `type`, finds the subscribed sinks, and runs + each in its own time-boxed child runspace. A sink failure or timeout is + captured in the results and never stops the remaining sinks — nor does it + ever propagate back to the caller. + + .PARAMETER Event + The event envelope — a hashtable (as published) or a pscustomobject (as + parsed from the activity log by the consumer). + + .PARAMETER Registry + A pre-discovered sink registry (from Get-SinkRegistry). When omitted, the + registry is discovered from -SinksDir / -BotRoot. The consumer discovers + once at startup and passes it here per event to avoid re-scanning disk. + + .OUTPUTS + @{ + event_type = '' | $null + dispatched = + results = @( , ... ) + } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Event, + $Registry, + [string]$BotRoot, + [string]$SinksDir + ) + + $eventType = if ($Event -is [hashtable]) { [string]$Event['type'] } else { [string]$Event.type } + if ([string]::IsNullOrWhiteSpace($eventType)) { + return @{ event_type = $null; dispatched = 0; results = @() } + } + + if ($null -eq $Registry) { + $Registry = @(Get-SinkRegistry -SinksDir $SinksDir -BotRoot $BotRoot) + } + + $matching = @(Get-SinksForEvent -Registry $Registry -EventType $eventType) + + $results = @() + foreach ($s in $matching) { + # Non-aborting belt-and-braces: Invoke-SingleSink already swallows sink + # faults, but guard the dispatch call itself too so one bad sink can + # never stop the others. + $r = $null + try { + $r = Invoke-SingleSink -Sink $s -Event $Event + } catch { + $r = @{ + name = [string]$s.name + success = $false + message = "Sink dispatch error: $($_.Exception.Message)" + duration = [TimeSpan]::Zero + timed_out = $false + } + } + $results += ,$r + } + + return @{ + event_type = $eventType + dispatched = $matching.Count + results = $results + } +} + +Export-ModuleMember -Function @( + 'Invoke-SingleSink' + 'Invoke-EventSinks' +) diff --git a/tests/Test-Events.ps1 b/tests/Test-Events.ps1 index 71458449..7d1ed6d3 100644 --- a/tests/Test-Events.ps1 +++ b/tests/Test-Events.ps1 @@ -74,7 +74,8 @@ function New-SinkFixture { [object]$Metadata, # hashtable to serialize, or a raw string for malformed-JSON cases [switch]$NoMetadata, [switch]$NoScript, - [string]$RawMetadata + [string]$RawMetadata, + [string]$ScriptBody # override the default Invoke-Sink body ) $sinkDir = Join-Path $Root $Name New-Item -ItemType Directory -Path $sinkDir -Force | Out-Null @@ -89,10 +90,12 @@ function New-SinkFixture { } if (-not $NoScript) { $scriptPath = Join-Path $sinkDir 'script.ps1' - Set-Content -LiteralPath $scriptPath -Encoding utf8NoBOM -Value @' -function Invoke-Sink { param($Event) } -Export-ModuleMember -Function Invoke-Sink -'@ + $body = if ($PSBoundParameters.ContainsKey('ScriptBody')) { + $ScriptBody + } else { + "function Invoke-Sink { param(`$Event) }`nExport-ModuleMember -Function Invoke-Sink" + } + Set-Content -LiteralPath $scriptPath -Value $body -Encoding utf8NoBOM } return $sinkDir } @@ -187,11 +190,93 @@ try { Remove-Item -Recurse -Force $mixed -ErrorAction SilentlyContinue } +# ═══════════════════════════════════════════════════════════════════════════ +# Dispatch (time-boxed child runspace, non-aborting) +# ═══════════════════════════════════════════════════════════════════════════ + +Write-Host "" +Write-Host " Dispatch" -ForegroundColor Cyan +Write-Host " ──────────────────────────────────────────────────" -ForegroundColor DarkGray + +$disp = New-SinksRoot +try { + # A sink that records the event it received, to prove the payload reaches + # the sink runspace (type + nested data round-trip). + $markerBody = @' +function Invoke-Sink { + param($Event) + Set-Content -LiteralPath $Event.data.marker -Value $Event.type -Encoding utf8NoBOM +} +Export-ModuleMember -Function Invoke-Sink +'@ + $markerSink = New-SinkFixture -Root $disp -Name 'marker' -Metadata @{ name = 'marker'; subscribed_events = @('task.*'); max_duration = 10 } -ScriptBody $markerBody + $markerRec = Read-SinkMetadata -SinkDir $markerSink + + $markerFile = Join-Path $disp 'marker.out' + $evt = @{ type = 'task.created'; data = @{ marker = $markerFile } } + $res = Invoke-SingleSink -Sink $markerRec -Event $evt + Assert-True -Name "successful sink reports success" -Condition ([bool]$res.success) + Assert-True -Name "successful sink is not timed_out" -Condition (-not $res.timed_out) + Assert-True -Name "sink actually ran (marker file written)" -Condition (Test-Path -LiteralPath $markerFile) + Assert-Equal -Name "sink received the event payload (type via data.marker)" -Expected 'task.created' -Actual (Get-Content -LiteralPath $markerFile -Raw).Trim() + + # A sink that throws → captured as failure, never rethrown. + $throwSink = New-SinkFixture -Root $disp -Name 'boom' -Metadata @{ name = 'boom'; subscribed_events = @('task.*'); max_duration = 10 } -ScriptBody "function Invoke-Sink { param(`$Event) throw 'kaboom' }`nExport-ModuleMember -Function Invoke-Sink" + $throwRec = Read-SinkMetadata -SinkDir $throwSink + $throwRes = Invoke-SingleSink -Sink $throwRec -Event $evt + Assert-True -Name "throwing sink reports failure (not rethrown)" -Condition (-not [bool]$throwRes.success) + Assert-True -Name "throwing sink message carries the error" -Condition ($throwRes.message -match 'kaboom') + + # A slow sink → forcibly stopped at max_duration, marked timed_out. + $slowSink = New-SinkFixture -Root $disp -Name 'slow' -Metadata @{ name = 'slow'; subscribed_events = @('task.*'); max_duration = 1 } -ScriptBody "function Invoke-Sink { param(`$Event) Start-Sleep -Seconds 5 }`nExport-ModuleMember -Function Invoke-Sink" + $slowRec = Read-SinkMetadata -SinkDir $slowSink + $slowRes = Invoke-SingleSink -Sink $slowRec -Event $evt + Assert-True -Name "slow sink is marked timed_out" -Condition ([bool]$slowRes.timed_out) + Assert-True -Name "slow sink reports failure" -Condition (-not [bool]$slowRes.success) + Assert-True -Name "slow sink stopped near max_duration (< 4s)" -Condition ($slowRes.duration.TotalSeconds -lt 4) +} finally { + Remove-Item -Recurse -Force $disp -ErrorAction SilentlyContinue +} + +# ── Non-aborting fan-out: a failing sink must not stop the others ── +# 'aaa-boom' sorts first and throws; 'bbb-good' sorts second and writes a +# marker. If dispatch aborted on failure, the marker would never appear. +$fan = New-SinksRoot +try { + New-SinkFixture -Root $fan -Name 'aaa-boom' -Metadata @{ name = 'aaa-boom'; subscribed_events = @('task.*'); max_duration = 10 } -ScriptBody "function Invoke-Sink { param(`$Event) throw 'first sink fails' }`nExport-ModuleMember -Function Invoke-Sink" | Out-Null + $goodBody = @' +function Invoke-Sink { + param($Event) + Set-Content -LiteralPath $Event.data.marker -Value 'ran' -Encoding utf8NoBOM +} +Export-ModuleMember -Function Invoke-Sink +'@ + New-SinkFixture -Root $fan -Name 'bbb-good' -Metadata @{ name = 'bbb-good'; subscribed_events = @('task.*'); max_duration = 10 } -ScriptBody $goodBody | Out-Null + + $registry = @(Get-SinkRegistry -SinksDir $fan) + $fanMarker = Join-Path $fan 'good.out' + $dispatch = Invoke-EventSinks -Event @{ type = 'task.created'; data = @{ marker = $fanMarker } } -Registry $registry + + Assert-Equal -Name "Invoke-EventSinks dispatched to both matching sinks" -Expected 2 -Actual $dispatch.dispatched + Assert-Equal -Name "Invoke-EventSinks reports event_type" -Expected 'task.created' -Actual $dispatch.event_type + Assert-True -Name "non-aborting: good sink ran despite the earlier sink failing" -Condition (Test-Path -LiteralPath $fanMarker) + $boomResult = @($dispatch.results | Where-Object { $_.name -eq 'aaa-boom' })[0] + $goodResult = @($dispatch.results | Where-Object { $_.name -eq 'bbb-good' })[0] + Assert-True -Name "failing sink recorded as failure in results" -Condition (-not [bool]$boomResult.success) + Assert-True -Name "good sink recorded as success in results" -Condition ([bool]$goodResult.success) + + # Routing: an event no sink subscribes to dispatches to nothing (no error). + $none = Invoke-EventSinks -Event @{ type = 'decision.created'; data = @{} } -Registry $registry + Assert-Equal -Name "unsubscribed event dispatches to zero sinks" -Expected 0 -Actual $none.dispatched +} finally { + Remove-Item -Recurse -Force $fan -ErrorAction SilentlyContinue +} + # ═══════════════════════════════════════════════════════════════════════════ # SUMMARY # ═══════════════════════════════════════════════════════════════════════════ -$allPassed = Write-TestSummary -LayerName "Layer 1: Dotbot.Events Sink Discovery" +$allPassed = Write-TestSummary -LayerName "Layer 1: Dotbot.Events Sink Discovery + Dispatch" if (-not $allPassed) { exit 1 From 0c412908b943aad78bd50b0da73ec8a6af6b1ca5 Mon Sep 17 00:00:00 2001 From: Enmanuel Jimenez <34482837+EnmaJim@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:00:18 +0000 Subject: [PATCH 42/50] feat(events): add background consumer with persisted byte cursor --- .../Modules/Dotbot.Events/Dotbot.Events.psd1 | 14 +- .../Dotbot.Events/Private/Consumer.psm1 | 341 ++++++++++++++++++ tests/Test-Events.ps1 | 127 ++++++- 3 files changed, 479 insertions(+), 3 deletions(-) create mode 100644 src/runtime/Modules/Dotbot.Events/Private/Consumer.psm1 diff --git a/src/runtime/Modules/Dotbot.Events/Dotbot.Events.psd1 b/src/runtime/Modules/Dotbot.Events/Dotbot.Events.psd1 index 296e2ec3..f738ba7c 100644 --- a/src/runtime/Modules/Dotbot.Events/Dotbot.Events.psd1 +++ b/src/runtime/Modules/Dotbot.Events/Dotbot.Events.psd1 @@ -7,10 +7,10 @@ PowerShellVersion = '7.0' # Concerns live as nested modules so each is findable in isolation. - # The background Consumer is added in a later step. NestedModules = @( 'Private/Discovery.psm1', - 'Private/Dispatch.psm1' + 'Private/Dispatch.psm1', + 'Private/Consumer.psm1' ) FunctionsToExport = @( @@ -23,6 +23,16 @@ # Dispatch 'Invoke-SingleSink' 'Invoke-EventSinks' + + # Consumer + 'Get-EventCursorPath' + 'Read-EventCursor' + 'Save-EventCursor' + 'Initialize-EventConsumerCursor' + 'Read-EventBatch' + 'Invoke-EventConsumerTick' + 'Start-EventConsumer' + 'Stop-EventConsumer' ) CmdletsToExport = @() diff --git a/src/runtime/Modules/Dotbot.Events/Private/Consumer.psm1 b/src/runtime/Modules/Dotbot.Events/Private/Consumer.psm1 new file mode 100644 index 00000000..9b45b46e --- /dev/null +++ b/src/runtime/Modules/Dotbot.Events/Private/Consumer.psm1 @@ -0,0 +1,341 @@ +<# +.SYNOPSIS +Background consumer for the event bus. + +Tails /.control/activity.jsonl by BYTE CURSOR — the same read +technique the /api/activity/tail endpoint uses — and dispatches each event to +its matching sinks (out-of-band: only after the event is durably on disk). + +Delivery is AT-LEAST-ONCE: the persisted cursor advances only AFTER a batch has +been dispatched, so a crash mid-batch re-delivers that batch on the next tick +rather than dropping it. The cursor lives at /.control/events-cursor.json +and survives restarts, so events appended while no runtime was running (e.g. a +detached CLI `tasks run`) are delivered from the persisted offset when the next +runtime starts — delayed, never dropped. + +This module is self-contained: it computes the activity-log path itself rather +than importing Dotbot.Runtime, so the dependency stays one-directional +(Dotbot.Runtime hosts Dotbot.Events, never the reverse). + +The runspace lifecycle (Start/Stop-EventConsumer) mirrors the runtime's +ControlPlaneClient: a cooperative stop flag + explicit Stop()/Dispose(). +#> + +# ─── Paths ────────────────────────────────────────────────────────────────── + +function _Get-EventActivityLogPath { + # Duplicated (not imported from Dotbot.Runtime) to keep the dependency + # one-directional. Must stay in sync with Get-ActivityLogPath. + param([Parameter(Mandatory)] [string]$BotRoot) + return Join-Path $BotRoot (Join-Path '.control' 'activity.jsonl') +} + +function Get-EventCursorPath { + <# + .SYNOPSIS + Resolve /.control/events-cursor.json (the persisted byte offset). + #> + param([Parameter(Mandatory)] [string]$BotRoot) + return Join-Path $BotRoot (Join-Path '.control' 'events-cursor.json') +} + +# ─── Cursor persistence ───────────────────────────────────────────────────── + +function Read-EventCursor { + <# + .SYNOPSIS + Return the persisted byte offset, or $null when no cursor exists yet. + #> + [CmdletBinding()] + param([Parameter(Mandatory)] [string]$BotRoot) + + $path = Get-EventCursorPath -BotRoot $BotRoot + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null } + try { + $obj = Get-Content -LiteralPath $path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + if ($null -ne $obj -and $null -ne $obj.offset) { return [long]$obj.offset } + } catch { + # Corrupt cursor → treat as fresh; the caller re-initialises. + } + return $null +} + +function Save-EventCursor { + <# + .SYNOPSIS + Persist the byte offset. Atomic (temp + move) so a crash can't leave a + half-written cursor. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$BotRoot, + [Parameter(Mandatory)] [long]$Offset + ) + + $path = Get-EventCursorPath -BotRoot $BotRoot + $dir = Split-Path -Parent $path + if (-not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + $json = ([ordered]@{ offset = $Offset } | ConvertTo-Json -Compress) + $tmp = "$path.tmp" + [System.IO.File]::WriteAllText($tmp, $json, [System.Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $tmp -Destination $path -Force +} + +function Initialize-EventConsumerCursor { + <# + .SYNOPSIS + Ensure a cursor exists. On the very first start (no cursor) seed it to the + current end of the activity log so historical events are NOT replayed to + sinks (a webhook re-POSTing the whole history would be surprising). From + then on the persisted cursor carries forward across restarts. + + .OUTPUTS + The byte offset the consumer will start reading from. + #> + [CmdletBinding()] + param([Parameter(Mandatory)] [string]$BotRoot) + + $existing = Read-EventCursor -BotRoot $BotRoot + if ($null -ne $existing) { return $existing } + + $logPath = _Get-EventActivityLogPath -BotRoot $BotRoot + $eof = 0L + if (Test-Path -LiteralPath $logPath -PathType Leaf) { + $eof = [long]((Get-Item -LiteralPath $logPath).Length) + } + Save-EventCursor -BotRoot $BotRoot -Offset $eof + return $eof +} + +# ─── Byte-cursor read ─────────────────────────────────────────────────────── + +function Read-EventBatch { + <# + .SYNOPSIS + Read all complete JSON lines from $Offset to EOF and report the new byte + position. Mirrors the /api/activity/tail streaming read (FileStream opened + ReadWrite-shared, Seek to the offset, read to EOF, report Position). + + .OUTPUTS + @{ events = @(, ...); position = } + + Malformed lines are skipped. A cursor past EOF (log truncated/rotated) + restarts from 0. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$LogPath, + [Parameter(Mandatory)] [long]$Offset + ) + + if (-not (Test-Path -LiteralPath $LogPath -PathType Leaf)) { + return @{ events = @(); position = $Offset } + } + + $events = @() + $newPos = $Offset + $fs = $null + $reader = $null + try { + $fs = [System.IO.FileStream]::new( + $LogPath, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::ReadWrite) + + $start = if ($Offset -gt $fs.Length) { 0L } else { $Offset } + [void]$fs.Seek($start, [System.IO.SeekOrigin]::Begin) + + $reader = [System.IO.StreamReader]::new($fs, [System.Text.UTF8Encoding]::new($false)) + $text = $reader.ReadToEnd() + $newPos = $fs.Position + + foreach ($line in ($text -split "`n")) { + $trimmed = $line.Trim() + if (-not $trimmed) { continue } + try { $events += ($trimmed | ConvertFrom-Json -ErrorAction Stop) } catch { } + } + } finally { + # Disposing the reader disposes the underlying stream too. + if ($reader) { $reader.Dispose() } elseif ($fs) { $fs.Dispose() } + } + + return @{ events = @($events); position = $newPos } +} + +# ─── One delivery tick ────────────────────────────────────────────────────── + +function Invoke-EventConsumerTick { + <# + .SYNOPSIS + Read the batch since the persisted cursor, dispatch each event to its + sinks, then advance the cursor. At-least-once: the cursor is saved only + after dispatch. + + .PARAMETER Registry + Pre-discovered sink registry (Get-SinkRegistry). Passed in so the tick does + not re-scan disk on every cycle. + + .OUTPUTS + @{ processed = ; dispatched = ; position = } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$BotRoot, + $Registry, + [string]$LogPath + ) + + if (-not $LogPath) { $LogPath = _Get-EventActivityLogPath -BotRoot $BotRoot } + + $offset = Read-EventCursor -BotRoot $BotRoot + if ($null -eq $offset) { $offset = 0L } + + $batch = Read-EventBatch -LogPath $LogPath -Offset $offset + + $dispatchedTotal = 0 + foreach ($evt in $batch.events) { + # Invoke-EventSinks is non-aborting and never throws, but guard anyway + # so a single event can never wedge the cursor. + try { + $r = Invoke-EventSinks -Event $evt -Registry $Registry -BotRoot $BotRoot + $dispatchedTotal += [int]$r.dispatched + } catch { + $null = $_ + } + } + + # Advance the cursor only after dispatch (at-least-once). + Save-EventCursor -BotRoot $BotRoot -Offset $batch.position + + return @{ + processed = @($batch.events).Count + dispatched = $dispatchedTotal + position = $batch.position + } +} + +# ─── Runspace lifecycle (mirrors ControlPlaneClient) ──────────────────────── + +function _Test-EventConsumerEnabled { + # Master kill-switch: events.enabled. Defaults to on when settings are + # unavailable (e.g. isolated unit tests import only Dotbot.Events). + param([Parameter(Mandatory)] [string]$BotRoot) + + if (-not (Get-Command Get-MergedSettings -ErrorAction SilentlyContinue)) { return $true } + try { + $settings = Get-MergedSettings -BotRoot $BotRoot + if ($null -ne $settings -and $null -ne $settings.events -and $settings.events.enabled -eq $false) { + return $false + } + } catch { + $null = $_ + } + return $true +} + +function Start-EventConsumer { + <# + .SYNOPSIS + Start the background consumer on a dedicated runspace. Returns a handle for + Stop-EventConsumer, or $null when the bus is disabled. + + .DESCRIPTION + Discovers sinks once, seeds the cursor to EOF on first-ever start, then + loops Invoke-EventConsumerTick on the runspace until the stop flag is set. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$BotRoot, + [double]$IntervalSeconds = 2 + ) + + if (-not (_Test-EventConsumerEnabled -BotRoot $BotRoot)) { + return $null + } + + # Discover sinks once in the parent (fail-loud happens here at startup). + $registry = @(Get-SinkRegistry -BotRoot $BotRoot) + + # Seed the cursor so we never replay history to sinks on first-ever start. + Initialize-EventConsumerCursor -BotRoot $BotRoot | Out-Null + + $modulePath = Join-Path (Split-Path -Parent $PSScriptRoot) 'Dotbot.Events.psd1' + + # Single-element bool array passed by reference into the runspace as the + # cooperative stop signal. + $stopFlag = [bool[]]::new(1) + + $loop = { + param([string]$BotRoot, $Registry, [double]$IntervalSeconds, [string]$ModulePath, [bool[]]$StopFlag) + + Import-Module $ModulePath -DisableNameChecking -Global -ErrorAction SilentlyContinue + + while (-not $StopFlag[0]) { + try { + Invoke-EventConsumerTick -BotRoot $BotRoot -Registry $Registry | Out-Null + } catch { + $null = $_ + } + # Chunked sleep so a stop request is honoured promptly. + $slept = 0.0 + while ($slept -lt $IntervalSeconds -and -not $StopFlag[0]) { + Start-Sleep -Milliseconds 200 + $slept += 0.2 + } + } + } + + $runspace = [runspacefactory]::CreateRunspace() + $runspace.Open() + $ps = [powershell]::Create() + $ps.Runspace = $runspace + $null = $ps.AddScript($loop) + $null = $ps.AddArgument($BotRoot) + $null = $ps.AddArgument($registry) + $null = $ps.AddArgument($IntervalSeconds) + $null = $ps.AddArgument($modulePath) + $null = $ps.AddArgument($stopFlag) + $async = $ps.BeginInvoke() + + return @{ + enabled = $true + stop_flag = $stopFlag + ps = $ps + runspace = $runspace + async = $async + registry = $registry + bot_root = $BotRoot + } +} + +function Stop-EventConsumer { + <# + .SYNOPSIS + Signal the consumer loop to stop and dispose its runspace. Idempotent. + #> + [CmdletBinding()] + param($Consumer) + + if ($null -eq $Consumer) { return } + + # Use `$null -ne` explicitly: a [bool[]] of length 1 evaluates to its single + # element in a boolean context, so `if ($Consumer.stop_flag)` would be + # $false when the flag is still down and never set it. + try { if ($null -ne $Consumer.stop_flag) { $Consumer.stop_flag[0] = $true } } catch { $null = $_ } + try { if ($null -ne $Consumer.ps) { $Consumer.ps.Stop(); $Consumer.ps.Dispose() } } catch { $null = $_ } + try { if ($null -ne $Consumer.runspace) { $Consumer.runspace.Close(); $Consumer.runspace.Dispose() } } catch { $null = $_ } +} + +Export-ModuleMember -Function @( + 'Get-EventCursorPath' + 'Read-EventCursor' + 'Save-EventCursor' + 'Initialize-EventConsumerCursor' + 'Read-EventBatch' + 'Invoke-EventConsumerTick' + 'Start-EventConsumer' + 'Stop-EventConsumer' +) diff --git a/tests/Test-Events.ps1 b/tests/Test-Events.ps1 index 7d1ed6d3..f6e2920d 100644 --- a/tests/Test-Events.ps1 +++ b/tests/Test-Events.ps1 @@ -100,6 +100,20 @@ function New-SinkFixture { return $sinkDir } +function Add-EventLine { + # Append one activity.jsonl event line (hand-written so this test needn't + # import Dotbot.Runtime/Dotbot.Task). data.marker points a sink at its + # output file. + param( + [Parameter(Mandatory)] [string]$LogPath, + [Parameter(Mandatory)] [string]$Id, + [Parameter(Mandatory)] [string]$Type, + [string]$Marker + ) + $line = @{ id = $Id; type = $Type; source = 'runtime'; data = @{ marker = $Marker } } | ConvertTo-Json -Compress + Add-Content -LiteralPath $LogPath -Value $line -Encoding utf8NoBOM +} + # ═══════════════════════════════════════════════════════════════════════════ # Happy-path discovery # ═══════════════════════════════════════════════════════════════════════════ @@ -272,11 +286,122 @@ Export-ModuleMember -Function Invoke-Sink Remove-Item -Recurse -Force $fan -ErrorAction SilentlyContinue } +# ═══════════════════════════════════════════════════════════════════════════ +# Consumer (byte cursor, at-least-once, replay) +# ═══════════════════════════════════════════════════════════════════════════ + +Write-Host "" +Write-Host " Consumer" -ForegroundColor Cyan +Write-Host " ──────────────────────────────────────────────────" -ForegroundColor DarkGray + +$cbot = Join-Path ([System.IO.Path]::GetTempPath()) ("dotbot-consumer-" + [guid]::NewGuid().ToString('N').Substring(0,8)) +New-Item -ItemType Directory -Path (Join-Path $cbot '.control') -Force | Out-Null +$csinks = New-SinksRoot +try { + # A sink that APPENDS each received event id to a file → line count = number + # of deliveries (lets us prove at-least-once / no-drop precisely). + $markerBody = @' +function Invoke-Sink { + param($Event) + Add-Content -LiteralPath $Event.data.marker -Value $Event.id -Encoding utf8NoBOM +} +Export-ModuleMember -Function Invoke-Sink +'@ + New-SinkFixture -Root $csinks -Name 'recorder' -Metadata @{ name = 'recorder'; subscribed_events = @('task.*'); max_duration = 10 } -ScriptBody $markerBody | Out-Null + $reg = @(Get-SinkRegistry -SinksDir $csinks) + + $log = Join-Path $cbot '.control/activity.jsonl' + $marker = Join-Path $cbot 'deliveries.out' + + # ── Cursor round-trip ── + Save-EventCursor -BotRoot $cbot -Offset 42 + Assert-Equal -Name "cursor round-trips through disk" -Expected 42 -Actual (Read-EventCursor -BotRoot $cbot) + + # ── Read-EventBatch by offset ── + Add-EventLine -LogPath $log -Id 'evt_1' -Type 'task.created' -Marker $marker + Add-EventLine -LogPath $log -Id 'evt_2' -Type 'task.updated' -Marker $marker + Add-EventLine -LogPath $log -Id 'evt_3' -Type 'workflow.run_started' -Marker $marker + + $b0 = Read-EventBatch -LogPath $log -Offset 0 + Assert-Equal -Name "batch from 0 reads all 3 lines" -Expected 3 -Actual (@($b0.events).Count) + Assert-True -Name "batch position advances to EOF" -Condition ($b0.position -gt 0) + + $b1 = Read-EventBatch -LogPath $log -Offset $b0.position + Assert-Equal -Name "batch from EOF reads nothing" -Expected 0 -Actual (@($b1.events).Count) + Assert-Equal -Name "batch from EOF keeps position" -Expected $b0.position -Actual $b1.position + + # ── Tick: dispatch task.* only, advance cursor ── + Save-EventCursor -BotRoot $cbot -Offset 0 + $t1 = Invoke-EventConsumerTick -BotRoot $cbot -Registry $reg -LogPath $log + Assert-Equal -Name "tick processes every event in the batch" -Expected 3 -Actual $t1.processed + Assert-Equal -Name "tick dispatches only the 2 task.* events" -Expected 2 -Actual $t1.dispatched + Assert-Equal -Name "recorder delivered 2 events" -Expected 2 -Actual (@(Get-Content -LiteralPath $marker)).Count + Assert-Equal -Name "cursor advanced to batch position" -Expected $t1.position -Actual (Read-EventCursor -BotRoot $cbot) + + # ── Second tick with nothing new → no-op ── + $t2 = Invoke-EventConsumerTick -BotRoot $cbot -Registry $reg -LogPath $log + Assert-Equal -Name "idle tick processes nothing" -Expected 0 -Actual $t2.processed + Assert-Equal -Name "idle tick delivers nothing new" -Expected 2 -Actual (@(Get-Content -LiteralPath $marker)).Count + + # ── Resume from persisted cursor: a new event is delivered, old ones aren't ── + Add-EventLine -LogPath $log -Id 'evt_4' -Type 'task.status_changed' -Marker $marker + $t3 = Invoke-EventConsumerTick -BotRoot $cbot -Registry $reg -LogPath $log + Assert-Equal -Name "resume tick processes only the new event" -Expected 1 -Actual $t3.processed + Assert-Equal -Name "resume tick delivers only the new task.* event" -Expected 3 -Actual (@(Get-Content -LiteralPath $marker)).Count + + # ── At-least-once / replay: rewinding the cursor re-delivers ── + Save-EventCursor -BotRoot $cbot -Offset 0 + $t4 = Invoke-EventConsumerTick -BotRoot $cbot -Registry $reg -LogPath $log + Assert-Equal -Name "replay tick re-reads all 4 events" -Expected 4 -Actual $t4.processed + Assert-Equal -Name "replay re-delivers the 3 task.* events (3+3=6)" -Expected 6 -Actual (@(Get-Content -LiteralPath $marker)).Count +} finally { + Remove-Item -Recurse -Force $cbot -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force $csinks -ErrorAction SilentlyContinue +} + +# ── Runspace lifecycle: Start-EventConsumer delivers, Stop tears down ── +$rbot = Join-Path ([System.IO.Path]::GetTempPath()) ("dotbot-consumer-rs-" + [guid]::NewGuid().ToString('N').Substring(0,8)) +$rsinkDir = Join-Path $rbot 'src/runtime/Plugins/Events/Sinks/recorder' +New-Item -ItemType Directory -Path (Join-Path $rbot '.control') -Force | Out-Null +New-Item -ItemType Directory -Path $rsinkDir -Force | Out-Null +try { + @{ name = 'recorder'; subscribed_events = @('task.*'); max_duration = 10 } | ConvertTo-Json -Depth 6 | + Set-Content -LiteralPath (Join-Path $rsinkDir 'metadata.json') -Encoding utf8NoBOM + Set-Content -LiteralPath (Join-Path $rsinkDir 'script.ps1') -Encoding utf8NoBOM -Value @' +function Invoke-Sink { + param($Event) + Add-Content -LiteralPath $Event.data.marker -Value $Event.id -Encoding utf8NoBOM +} +Export-ModuleMember -Function Invoke-Sink +'@ + + $rlog = Join-Path $rbot '.control/activity.jsonl' + $rmarker = Join-Path $rbot 'deliveries.out' + + $consumer = Start-EventConsumer -BotRoot $rbot -IntervalSeconds 0.5 + Assert-True -Name "Start-EventConsumer returns a running handle" -Condition ($null -ne $consumer -and $consumer.enabled) + + # Append AFTER start (cursor was seeded to EOF), so the consumer must tail it. + Add-EventLine -LogPath $rlog -Id 'evt_rs1' -Type 'task.created' -Marker $rmarker + + $delivered = $false + for ($i = 0; $i -lt 60; $i++) { + if (Test-Path -LiteralPath $rmarker) { $delivered = $true; break } + Start-Sleep -Milliseconds 100 + } + Assert-True -Name "background consumer tails and delivers an appended event" -Condition $delivered + + Stop-EventConsumer -Consumer $consumer + Assert-True -Name "Stop-EventConsumer signals the stop flag" -Condition ([bool]$consumer.stop_flag[0]) +} finally { + Remove-Item -Recurse -Force $rbot -ErrorAction SilentlyContinue +} + # ═══════════════════════════════════════════════════════════════════════════ # SUMMARY # ═══════════════════════════════════════════════════════════════════════════ -$allPassed = Write-TestSummary -LayerName "Layer 1: Dotbot.Events Sink Discovery + Dispatch" +$allPassed = Write-TestSummary -LayerName "Layer 1: Dotbot.Events Sink Discovery + Dispatch + Consumer" if (-not $allPassed) { exit 1 From b94652ebea3c3f9033f7c493f8c54ac8440d89a7 Mon Sep 17 00:00:00 2001 From: Enmanuel Jimenez <34482837+EnmaJim@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:11:28 +0000 Subject: [PATCH 43/50] feat(events): host the event consumer in the runtime lifecycle --- bin/dotbot.ps1 | 2 +- src/cli/serve.ps1 | 2 +- .../Dotbot.Runtime/Private/Imports.ps1 | 1 + .../Dotbot.Runtime/Private/Lifecycle.psm1 | 21 +++++++- tests/Test-Runtime.ps1 | 52 +++++++++++++++++-- 5 files changed, 71 insertions(+), 7 deletions(-) diff --git a/bin/dotbot.ps1 b/bin/dotbot.ps1 index 8ad99f83..a1ec3384 100755 --- a/bin/dotbot.ps1 +++ b/bin/dotbot.ps1 @@ -667,7 +667,7 @@ function Invoke-Go { } finally { Pop-Location if ($runtimeStartedHere -and $runtimeStart -and $runtimeStart.listener) { - Stop-DotbotRuntime -BotRoot $botDir -Listener $runtimeStart.listener -ErrorAction SilentlyContinue + Stop-DotbotRuntime -BotRoot $botDir -Listener $runtimeStart.listener -ControlPlaneRegistration $runtimeStart.control_plane -EventConsumer $runtimeStart.events_consumer -ErrorAction SilentlyContinue } } } diff --git a/src/cli/serve.ps1 b/src/cli/serve.ps1 index 236f0327..fcf04d7e 100644 --- a/src/cli/serve.ps1 +++ b/src/cli/serve.ps1 @@ -89,7 +89,7 @@ $cleanupRan = $false $cleanup = { if ($script:cleanupRan) { return } $script:cleanupRan = $true - try { Stop-DotbotRuntime -BotRoot $botRoot -Listener $start.listener -ControlPlaneRegistration $start.control_plane -ErrorAction SilentlyContinue } catch { $null = $_ } + try { Stop-DotbotRuntime -BotRoot $botRoot -Listener $start.listener -ControlPlaneRegistration $start.control_plane -EventConsumer $start.events_consumer -ErrorAction SilentlyContinue } catch { $null = $_ } } try { [Console]::CancelKeyPress.Add({ param($s, $e) $e.Cancel = $true; & $cleanup }) diff --git a/src/runtime/Modules/Dotbot.Runtime/Private/Imports.ps1 b/src/runtime/Modules/Dotbot.Runtime/Private/Imports.ps1 index d79a906c..3ca95b57 100644 --- a/src/runtime/Modules/Dotbot.Runtime/Private/Imports.ps1 +++ b/src/runtime/Modules/Dotbot.Runtime/Private/Imports.ps1 @@ -8,3 +8,4 @@ Import-Module (Join-Path $runtimeModules 'Dotbot.Process' 'Dotbot.Process.psd1') Import-Module (Join-Path $runtimeModules 'Dotbot.Hook' 'Dotbot.Hook.psd1') -DisableNameChecking -Global Import-Module (Join-Path $runtimeModules 'Dotbot.Settings' 'Dotbot.Settings.psd1') -DisableNameChecking -Global Import-Module (Join-Path $runtimeModules 'Dotbot.Handoff' 'Dotbot.Handoff.psd1') -DisableNameChecking -Global +Import-Module (Join-Path $runtimeModules 'Dotbot.Events' 'Dotbot.Events.psd1') -DisableNameChecking -Global diff --git a/src/runtime/Modules/Dotbot.Runtime/Private/Lifecycle.psm1 b/src/runtime/Modules/Dotbot.Runtime/Private/Lifecycle.psm1 index 9d5556a2..4d6ed151 100644 --- a/src/runtime/Modules/Dotbot.Runtime/Private/Lifecycle.psm1 +++ b/src/runtime/Modules/Dotbot.Runtime/Private/Lifecycle.psm1 @@ -204,6 +204,7 @@ function Start-DotbotRuntime { attached = $true listener = $null control_plane = $null + events_consumer = $null } } @@ -242,6 +243,7 @@ function Start-DotbotRuntime { attached = $false listener = $listener control_plane = $null + events_consumer = $null } $runtimeRegistration = @{ @@ -257,6 +259,16 @@ function Start-DotbotRuntime { $result.control_plane = [ordered]@{ enabled = $true; registered = $false; error = $_.Exception.Message } } + # Event-bus consumer — hosted here so there is exactly one per project in + # every mode (both `dotbot go` and `dotbot serve` launch the runtime + # server; the UI server never hosts it). Start-EventConsumer returns $null + # when the bus is disabled. + try { + $result.events_consumer = Start-EventConsumer -BotRoot $BotRoot + } catch { + $result.events_consumer = [ordered]@{ enabled = $true; started = $false; error = $_.Exception.Message } + } + if ($Foreground) { # The listener loop runs on a background ThreadPool job. In foreground # mode we block in this caller until someone signals shutdown — that @@ -268,7 +280,7 @@ function Start-DotbotRuntime { Start-Sleep -Milliseconds 250 } } finally { - Stop-DotbotRuntime -BotRoot $BotRoot -Listener $listener -ControlPlaneRegistration $result.control_plane -ErrorAction SilentlyContinue + Stop-DotbotRuntime -BotRoot $BotRoot -Listener $listener -ControlPlaneRegistration $result.control_plane -EventConsumer $result.events_consumer -ErrorAction SilentlyContinue } } @@ -290,7 +302,8 @@ function Stop-DotbotRuntime { param( [Parameter(Mandatory)] [string]$BotRoot, [System.Net.HttpListener]$Listener, - [object]$ControlPlaneRegistration + [object]$ControlPlaneRegistration, + [object]$EventConsumer ) if ($Listener) { @@ -305,6 +318,10 @@ function Stop-DotbotRuntime { try { Stop-ControlPlaneRegistration -BotRoot $BotRoot -Registration $ControlPlaneRegistration } catch { $null = $_ } } + if ($EventConsumer) { + try { Stop-EventConsumer -Consumer $EventConsumer } catch { $null = $_ } + } + Remove-RuntimeConnectionFile -BotRoot $BotRoot Clear-RuntimeMutexPool } diff --git a/tests/Test-Runtime.ps1 b/tests/Test-Runtime.ps1 index db67b1f8..c22775c6 100644 --- a/tests/Test-Runtime.ps1 +++ b/tests/Test-Runtime.ps1 @@ -270,7 +270,7 @@ try { Assert-Equal -Name "Second Start-DotbotRuntime reuses the URL" -Expected $startResult.url -Actual $second.url Assert-Equal -Name "Second Start-DotbotRuntime reuses the token" -Expected $startResult.token -Actual $second.token } finally { - Stop-DotbotRuntime -BotRoot $bot -Listener $startResult.listener -ErrorAction SilentlyContinue + Stop-DotbotRuntime -BotRoot $bot -Listener $startResult.listener -ControlPlaneRegistration $startResult.control_plane -EventConsumer $startResult.events_consumer -ErrorAction SilentlyContinue } Assert-True -Name "Stop-DotbotRuntime removes runtime.json" -Condition (-not (Test-Path (Get-RuntimeConnectionFilePath -BotRoot $bot))) @@ -597,7 +597,7 @@ try { } } finally { - if ($start) { Stop-DotbotRuntime -BotRoot $bot -Listener $start.listener -ErrorAction SilentlyContinue } + if ($start) { Stop-DotbotRuntime -BotRoot $bot -Listener $start.listener -ControlPlaneRegistration $start.control_plane -EventConsumer $start.events_consumer -ErrorAction SilentlyContinue } Remove-TestBotRoot -BotRoot $bot } @@ -668,10 +668,56 @@ try { Assert-Equal -Name "Activity log contains exactly 10 task.updated lines for the target task" -Expected 10 -Actual $updatedLines } finally { - if ($start) { Stop-DotbotRuntime -BotRoot $bot -Listener $start.listener -ErrorAction SilentlyContinue } + if ($start) { Stop-DotbotRuntime -BotRoot $bot -Listener $start.listener -ControlPlaneRegistration $start.control_plane -EventConsumer $start.events_consumer -ErrorAction SilentlyContinue } Remove-TestBotRoot -BotRoot $bot } +# ═══════════════════════════════════════════════════════════════════════════ +# Event-bus consumer hosted by the runtime (Step 7 / AC#6) +# ═══════════════════════════════════════════════════════════════════════════ + +Write-Host "" +Write-Host " Event-bus consumer hosted by the runtime" -ForegroundColor Cyan +Write-Host " ──────────────────────────────────────────────────" -ForegroundColor DarkGray + +$ebot = New-TestBotRoot +$esinkDir = Join-Path $ebot 'src/runtime/Plugins/Events/Sinks/recorder' +New-Item -ItemType Directory -Path $esinkDir -Force | Out-Null +$emarker = Join-Path $ebot 'delivered.out' +@{ name = 'recorder'; subscribed_events = @('task.*'); max_duration = 10 } | ConvertTo-Json -Depth 6 | + Set-Content -LiteralPath (Join-Path $esinkDir 'metadata.json') -Encoding utf8NoBOM +# Sink writes each matching event's type to a fixed marker path (baked literal). +Set-Content -LiteralPath (Join-Path $esinkDir 'script.ps1') -Encoding utf8NoBOM -Value @" +function Invoke-Sink { + param(`$Event) + Add-Content -LiteralPath '$emarker' -Value `$Event.type -Encoding utf8NoBOM +} +Export-ModuleMember -Function Invoke-Sink +"@ + +$estart = Start-DotbotRuntime -BotRoot $ebot +try { + Assert-True -Name "Start-DotbotRuntime hosts the event consumer" ` + -Condition ($null -ne $estart.events_consumer -and [bool]$estart.events_consumer.enabled) + + # A producer appends a bus event after the runtime is up (the cursor was + # seeded to EOF at start, so this must be tailed and delivered). + $eLog = Join-Path $ebot '.control/activity.jsonl' + (@{ id = 'evt_rt1'; type = 'task.created'; source = 'runtime'; data = @{} } | ConvertTo-Json -Compress) | + Add-Content -LiteralPath $eLog -Encoding utf8NoBOM + + $delivered = $false + for ($i = 0; $i -lt 60; $i++) { + if (Test-Path -LiteralPath $emarker) { $delivered = $true; break } + Start-Sleep -Milliseconds 100 + } + Assert-True -Name "runtime-hosted consumer tails and delivers a bus event to a sink" -Condition $delivered +} finally { + Stop-DotbotRuntime -BotRoot $ebot -Listener $estart.listener -ControlPlaneRegistration $estart.control_plane -EventConsumer $estart.events_consumer -ErrorAction SilentlyContinue +} +Assert-True -Name "Stop-DotbotRuntime tears down the event consumer" -Condition ([bool]$estart.events_consumer.stop_flag[0]) +Remove-TestBotRoot -BotRoot $ebot + # ═══════════════════════════════════════════════════════════════════════════ # Mutex: deterministic acquire order for multi-task ops # ═══════════════════════════════════════════════════════════════════════════ From fbd2ccd30874c9f2763a370fcffb4cf30066f7c4 Mon Sep 17 00:00:00 2001 From: Enmanuel Jimenez <34482837+EnmaJim@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:26:40 +0000 Subject: [PATCH 44/50] feat(events): add webhooks sink with HTTPS, HMAC and SSRF guard --- .../Dotbot.Events/Private/Consumer.psm1 | 29 ++- .../Dotbot.Events/Private/Dispatch.psm1 | 30 ++- .../Events/Sinks/webhooks/metadata.json | 6 + .../Plugins/Events/Sinks/webhooks/script.ps1 | 215 ++++++++++++++++++ tests/Test-Events.ps1 | 86 ++++++- tests/Test-Runtime.ps1 | 2 +- 6 files changed, 347 insertions(+), 21 deletions(-) create mode 100644 src/runtime/Plugins/Events/Sinks/webhooks/metadata.json create mode 100644 src/runtime/Plugins/Events/Sinks/webhooks/script.ps1 diff --git a/src/runtime/Modules/Dotbot.Events/Private/Consumer.psm1 b/src/runtime/Modules/Dotbot.Events/Private/Consumer.psm1 index 9b45b46e..d895d93e 100644 --- a/src/runtime/Modules/Dotbot.Events/Private/Consumer.psm1 +++ b/src/runtime/Modules/Dotbot.Events/Private/Consumer.psm1 @@ -167,6 +167,21 @@ function Read-EventBatch { # ─── One delivery tick ────────────────────────────────────────────────────── +function _Get-EventsSettingsSection { + # Resolve the `events` settings section for the sink Context. Guarded so + # the tick still works when Dotbot.Settings isn't loaded (isolated tests). + param([Parameter(Mandatory)] [string]$BotRoot) + + if (-not (Get-Command Get-MergedSettings -ErrorAction SilentlyContinue)) { return $null } + try { + $settings = Get-MergedSettings -BotRoot $BotRoot + if ($null -ne $settings) { return $settings.events } + } catch { + $null = $_ + } + return $null +} + function Invoke-EventConsumerTick { <# .SYNOPSIS @@ -195,12 +210,16 @@ function Invoke-EventConsumerTick { $batch = Read-EventBatch -LogPath $LogPath -Offset $offset + # Resolve the sink Context once per tick (fresh settings each cycle so an + # operator's config edit takes effect without a restart). + $context = @{ BotRoot = $BotRoot; Events = (_Get-EventsSettingsSection -BotRoot $BotRoot) } + $dispatchedTotal = 0 foreach ($evt in $batch.events) { # Invoke-EventSinks is non-aborting and never throws, but guard anyway # so a single event can never wedge the cursor. try { - $r = Invoke-EventSinks -Event $evt -Registry $Registry -BotRoot $BotRoot + $r = Invoke-EventSinks -Event $evt -Registry $Registry -BotRoot $BotRoot -Context $context $dispatchedTotal += [int]$r.dispatched } catch { $null = $_ @@ -263,15 +282,20 @@ function Start-EventConsumer { Initialize-EventConsumerCursor -BotRoot $BotRoot | Out-Null $modulePath = Join-Path (Split-Path -Parent $PSScriptRoot) 'Dotbot.Events.psd1' + # Dotbot.Settings sits alongside Dotbot.Events under Modules/. Import it in + # the loop so Invoke-EventConsumerTick can resolve the sink Context's + # `events` config each cycle. + $settingsPath = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) (Join-Path 'Dotbot.Settings' 'Dotbot.Settings.psd1') # Single-element bool array passed by reference into the runspace as the # cooperative stop signal. $stopFlag = [bool[]]::new(1) $loop = { - param([string]$BotRoot, $Registry, [double]$IntervalSeconds, [string]$ModulePath, [bool[]]$StopFlag) + param([string]$BotRoot, $Registry, [double]$IntervalSeconds, [string]$ModulePath, [string]$SettingsPath, [bool[]]$StopFlag) Import-Module $ModulePath -DisableNameChecking -Global -ErrorAction SilentlyContinue + Import-Module $SettingsPath -DisableNameChecking -Global -ErrorAction SilentlyContinue while (-not $StopFlag[0]) { try { @@ -297,6 +321,7 @@ function Start-EventConsumer { $null = $ps.AddArgument($registry) $null = $ps.AddArgument($IntervalSeconds) $null = $ps.AddArgument($modulePath) + $null = $ps.AddArgument($settingsPath) $null = $ps.AddArgument($stopFlag) $async = $ps.BeginInvoke() diff --git a/src/runtime/Modules/Dotbot.Events/Private/Dispatch.psm1 b/src/runtime/Modules/Dotbot.Events/Private/Dispatch.psm1 index a80cac0b..3f9a8777 100644 --- a/src/runtime/Modules/Dotbot.Events/Private/Dispatch.psm1 +++ b/src/runtime/Modules/Dotbot.Events/Private/Dispatch.psm1 @@ -13,10 +13,12 @@ from transition hooks: Each sink runs in a child runspace so max_duration can be enforced via Stop(). -The Invoke-Sink contract from each sink's script.ps1: a single function taking -$Event (the event envelope) and optionally returning a hashtable with -Success/Message. A sink that returns nothing is treated as success — its work -is the side effect (POST a webhook, forward to the mothership, …). +The Invoke-Sink contract from each sink's script.ps1: a function taking +$Event (the event envelope) and $Context (a hashtable with BotRoot and the +resolved `events` settings section — so a sink can read its own config without +importing anything into its isolated runspace). A sink may return a hashtable +with Success/Message; returning nothing is treated as success — its work is the +side effect (POST a webhook, forward to the mothership, …). Loading note: a bare .ps1 with a top-level Export-ModuleMember isn't a real module. We turn it into one at dispatch time via New-Module against a @@ -41,11 +43,13 @@ function Invoke-SingleSink { [CmdletBinding()] param( [Parameter(Mandatory)] $Sink, # one element from Get-SinkRegistry - [Parameter(Mandatory)] $Event # the event envelope (hashtable or pscustomobject) + [Parameter(Mandatory)] $Event, # the event envelope (hashtable or pscustomobject) + $Context # @{ BotRoot; Events } passed to the sink ) $name = [string]$Sink.name $maxDuration = [int]$Sink.max_duration + if ($null -eq $Context) { $Context = @{} } # Read the script once; pass the contents into the child runspace rather # than re-reading from disk inside it (avoids coupling to a working dir). @@ -63,7 +67,7 @@ function Invoke-SingleSink { } $runner = { - param([string]$Content, [string]$SinkName, $Event) + param([string]$Content, [string]$SinkName, $Event, $Context) $sw = [System.Diagnostics.Stopwatch]::StartNew() try { @@ -72,7 +76,7 @@ function Invoke-SingleSink { # module — Export-ModuleMember inside the script works, and we can # invoke its function via `& $mod `. $mod = New-Module -Name ("DotbotSink_" + $SinkName) -ScriptBlock $sb - $sinkResult = & $mod Invoke-Sink -Event $Event + $sinkResult = & $mod Invoke-Sink -Event $Event -Context $Context } catch { $sw.Stop() return @{ @@ -107,6 +111,7 @@ function Invoke-SingleSink { $null = $ps.AddArgument($scriptContent) $null = $ps.AddArgument($name) $null = $ps.AddArgument($Event) + $null = $ps.AddArgument($Context) $outerSw = [System.Diagnostics.Stopwatch]::StartNew() $async = $ps.BeginInvoke() @@ -171,6 +176,10 @@ function Invoke-EventSinks { registry is discovered from -SinksDir / -BotRoot. The consumer discovers once at startup and passes it here per event to avoid re-scanning disk. + .PARAMETER Context + The @{ BotRoot; Events } context handed to each sink. When omitted, a + minimal @{ BotRoot = $BotRoot } is built. + .OUTPUTS @{ event_type = '' | $null @@ -183,9 +192,12 @@ function Invoke-EventSinks { [Parameter(Mandatory)] $Event, $Registry, [string]$BotRoot, - [string]$SinksDir + [string]$SinksDir, + $Context ) + if ($null -eq $Context) { $Context = @{ BotRoot = $BotRoot } } + $eventType = if ($Event -is [hashtable]) { [string]$Event['type'] } else { [string]$Event.type } if ([string]::IsNullOrWhiteSpace($eventType)) { return @{ event_type = $null; dispatched = 0; results = @() } @@ -204,7 +216,7 @@ function Invoke-EventSinks { # never stop the others. $r = $null try { - $r = Invoke-SingleSink -Sink $s -Event $Event + $r = Invoke-SingleSink -Sink $s -Event $Event -Context $Context } catch { $r = @{ name = [string]$s.name diff --git a/src/runtime/Plugins/Events/Sinks/webhooks/metadata.json b/src/runtime/Plugins/Events/Sinks/webhooks/metadata.json new file mode 100644 index 00000000..3a32f260 --- /dev/null +++ b/src/runtime/Plugins/Events/Sinks/webhooks/metadata.json @@ -0,0 +1,6 @@ +{ + "name": "webhooks", + "description": "POST matching events to configured HTTPS endpoints, HMAC-signed per endpoint. HTTPS-only with SSRF URL validation; each endpoint honours its own event filter.", + "subscribed_events": ["*"], + "max_duration": 15 +} diff --git a/src/runtime/Plugins/Events/Sinks/webhooks/script.ps1 b/src/runtime/Plugins/Events/Sinks/webhooks/script.ps1 new file mode 100644 index 00000000..c220724c --- /dev/null +++ b/src/runtime/Plugins/Events/Sinks/webhooks/script.ps1 @@ -0,0 +1,215 @@ +<# +.SYNOPSIS +webhooks sink — POST matching bus events to configured HTTPS endpoints. + +Config (from settings' events.webhooks section, handed in via $Context.Events): + { + "enabled": true, + "endpoints": [ + { "url": "https://hooks.example.com/dotbot", "events": ["task.*"], "secret": "…" } + ] + } + +For each configured endpoint whose `events` filter matches the event type AND +whose URL passes HTTPS + SSRF validation, the event JSON is POSTed with an +HMAC-SHA256 signature (header X-DotBot-Signature: sha256=) derived from the +endpoint's `secret`. + +Helpers are exported alongside Invoke-Sink so they can be unit-tested without a +live endpoint (the actual POST is integration-only). +#> + +function Test-IpBlocked { + <# + .SYNOPSIS + $true when an IP is loopback / private / link-local / metadata / otherwise + not a safe public destination (SSRF guard). + #> + param([Parameter(Mandatory)] [System.Net.IPAddress]$IpAddress) + + $ip = $IpAddress + # Collapse IPv4-mapped IPv6 (::ffff:a.b.c.d) down to IPv4 for range checks. + if ($ip.IsIPv4MappedToIPv6) { $ip = $ip.MapToIPv4() } + + if ([System.Net.IPAddress]::IsLoopback($ip)) { return $true } + + if ($ip.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork) { + $b = $ip.GetAddressBytes() # 4 bytes + if ($b[0] -eq 0) { return $true } # 0.0.0.0/8 "this network" + if ($b[0] -eq 10) { return $true } # 10/8 private + if ($b[0] -eq 127) { return $true } # loopback + if ($b[0] -eq 169 -and $b[1] -eq 254) { return $true } # 169.254/16 link-local (incl. 169.254.169.254 metadata) + if ($b[0] -eq 172 -and $b[1] -ge 16 -and $b[1] -le 31) { return $true } # 172.16/12 private + if ($b[0] -eq 192 -and $b[1] -eq 168) { return $true } # 192.168/16 private + if ($b[0] -eq 100 -and $b[1] -ge 64 -and $b[1] -le 127) { return $true } # 100.64/10 CGNAT + if ($b[0] -ge 224) { return $true } # 224/4 multicast + 240/4 reserved + return $false + } + + if ($ip.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetworkV6) { + if ($ip.IsIPv6LinkLocal -or $ip.IsIPv6Multicast -or $ip.IsIPv6SiteLocal) { return $true } + if ($ip.Equals([System.Net.IPAddress]::IPv6Loopback)) { return $true } + if ($ip.Equals([System.Net.IPAddress]::IPv6Any)) { return $true } + # fc00::/7 unique-local + $bytes = $ip.GetAddressBytes() + if (($bytes[0] -band 0xFE) -eq 0xFC) { return $true } + return $false + } + + return $true # unknown address family → treat as unsafe +} + +function Test-WebhookUrlAllowed { + <# + .SYNOPSIS + Validate a webhook URL: HTTPS-only + SSRF guard. Returns + @{ allowed = $bool; reason = '' }. + #> + param([Parameter(Mandatory)] [string]$Url) + + $uri = $null + if (-not [System.Uri]::TryCreate($Url, [System.UriKind]::Absolute, [ref]$uri)) { + return @{ allowed = $false; reason = 'malformed_url' } + } + if ($uri.Scheme -ne 'https') { + return @{ allowed = $false; reason = 'not_https' } + } + + $hostName = $uri.DnsSafeHost + $lower = $hostName.ToLowerInvariant() + if ($lower -eq 'localhost' -or $lower.EndsWith('.localhost') -or + $lower.EndsWith('.local') -or $lower.EndsWith('.internal')) { + return @{ allowed = $false; reason = 'internal_hostname' } + } + + # IP-literal host → check ranges directly (no DNS). + $literal = $null + if ([System.Net.IPAddress]::TryParse($hostName, [ref]$literal)) { + if (Test-IpBlocked -IpAddress $literal) { + return @{ allowed = $false; reason = 'blocked_ip_range' } + } + return @{ allowed = $true; reason = 'ok' } + } + + # Hostname → resolve and check every resolved address. Fail closed if it + # can't be resolved (a webhook to an unresolvable host is undeliverable + # anyway, and failing closed avoids surprises). + try { + $addresses = [System.Net.Dns]::GetHostAddresses($hostName) + } catch { + return @{ allowed = $false; reason = 'dns_resolution_failed' } + } + if (-not $addresses -or $addresses.Count -eq 0) { + return @{ allowed = $false; reason = 'dns_no_addresses' } + } + foreach ($addr in $addresses) { + if (Test-IpBlocked -IpAddress $addr) { + return @{ allowed = $false; reason = 'resolves_to_blocked_ip' } + } + } + return @{ allowed = $true; reason = 'ok' } +} + +function New-WebhookSignature { + <# + .SYNOPSIS + HMAC-SHA256 of the body keyed by the endpoint secret, as 'sha256='. + #> + param( + [Parameter(Mandatory)] [string]$Body, + [Parameter(Mandatory)] [AllowEmptyString()] [string]$Secret + ) + $hmac = [System.Security.Cryptography.HMACSHA256]::new([System.Text.Encoding]::UTF8.GetBytes($Secret)) + try { + $hash = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Body)) + } finally { + $hmac.Dispose() + } + $hex = -join ($hash | ForEach-Object { $_.ToString('x2') }) + return "sha256=$hex" +} + +function Get-WebhookDeliveryPlan { + <# + .SYNOPSIS + Return the endpoints that WOULD receive this event: those whose `events` + filter matches the event type AND whose URL passes HTTPS/SSRF validation. + Pure decision logic (no network), so it is unit-testable. + #> + param( + [Parameter(Mandatory)] $Event, + [Parameter(Mandatory)] $Config + ) + + $type = if ($Event -is [hashtable]) { [string]$Event['type'] } else { [string]$Event.type } + $plan = @() + foreach ($ep in @($Config.endpoints)) { + if ($null -eq $ep) { continue } + $url = [string]$ep.url + if ([string]::IsNullOrWhiteSpace($url)) { continue } + + # Per-endpoint event filter. Empty/missing → match everything. + $filters = @($ep.events) + $matched = $filters.Count -eq 0 + foreach ($f in $filters) { + if ($type -like [string]$f) { $matched = $true; break } + } + if (-not $matched) { continue } + + $check = Test-WebhookUrlAllowed -Url $url + if (-not $check.allowed) { continue } + + $plan += ,([pscustomobject]@{ + url = $url + secret = [string]$ep.secret + events = $filters + }) + } + return $plan +} + +function Invoke-Sink { + param($Event, $Context) + + $cfg = $null + if ($null -ne $Context -and $null -ne $Context.Events) { $cfg = $Context.Events.webhooks } + if ($null -eq $cfg) { return @{ Success = $true; Message = 'webhooks: no config' } } + + $enabled = $false + try { $enabled = [bool]$cfg.enabled } catch { $enabled = $false } + if (-not $enabled) { return @{ Success = $true; Message = 'webhooks: disabled' } } + + $plan = @(Get-WebhookDeliveryPlan -Event $Event -Config $cfg) + if ($plan.Count -eq 0) { return @{ Success = $true; Message = 'webhooks: no matching endpoints' } } + + $type = if ($Event -is [hashtable]) { [string]$Event['type'] } else { [string]$Event.type } + $body = $Event | ConvertTo-Json -Depth 12 -Compress + + $sent = 0 + $failed = 0 + foreach ($ep in $plan) { + $sig = New-WebhookSignature -Body $body -Secret $ep.secret + try { + Invoke-WebRequest -Uri $ep.url -Method POST -Body $body ` + -ContentType 'application/json; charset=utf-8' ` + -Headers @{ 'X-DotBot-Event' = $type; 'X-DotBot-Signature' = $sig } ` + -TimeoutSec 10 -SkipHttpErrorCheck -UseBasicParsing | Out-Null + $sent++ + } catch { + $failed++ + } + } + + return @{ + Success = ($failed -eq 0) + Message = "webhooks: sent=$sent failed=$failed of $($plan.Count)" + } +} + +Export-ModuleMember -Function @( + 'Invoke-Sink' + 'Test-IpBlocked' + 'Test-WebhookUrlAllowed' + 'New-WebhookSignature' + 'Get-WebhookDeliveryPlan' +) diff --git a/tests/Test-Events.ps1 b/tests/Test-Events.ps1 index f6e2920d..5544ef10 100644 --- a/tests/Test-Events.ps1 +++ b/tests/Test-Events.ps1 @@ -93,7 +93,7 @@ function New-SinkFixture { $body = if ($PSBoundParameters.ContainsKey('ScriptBody')) { $ScriptBody } else { - "function Invoke-Sink { param(`$Event) }`nExport-ModuleMember -Function Invoke-Sink" + "function Invoke-Sink { param(`$Event, `$Context) }`nExport-ModuleMember -Function Invoke-Sink" } Set-Content -LiteralPath $scriptPath -Value $body -Encoding utf8NoBOM } @@ -218,7 +218,7 @@ try { # the sink runspace (type + nested data round-trip). $markerBody = @' function Invoke-Sink { - param($Event) + param($Event, $Context) Set-Content -LiteralPath $Event.data.marker -Value $Event.type -Encoding utf8NoBOM } Export-ModuleMember -Function Invoke-Sink @@ -235,14 +235,14 @@ Export-ModuleMember -Function Invoke-Sink Assert-Equal -Name "sink received the event payload (type via data.marker)" -Expected 'task.created' -Actual (Get-Content -LiteralPath $markerFile -Raw).Trim() # A sink that throws → captured as failure, never rethrown. - $throwSink = New-SinkFixture -Root $disp -Name 'boom' -Metadata @{ name = 'boom'; subscribed_events = @('task.*'); max_duration = 10 } -ScriptBody "function Invoke-Sink { param(`$Event) throw 'kaboom' }`nExport-ModuleMember -Function Invoke-Sink" + $throwSink = New-SinkFixture -Root $disp -Name 'boom' -Metadata @{ name = 'boom'; subscribed_events = @('task.*'); max_duration = 10 } -ScriptBody "function Invoke-Sink { param(`$Event, `$Context) throw 'kaboom' }`nExport-ModuleMember -Function Invoke-Sink" $throwRec = Read-SinkMetadata -SinkDir $throwSink $throwRes = Invoke-SingleSink -Sink $throwRec -Event $evt Assert-True -Name "throwing sink reports failure (not rethrown)" -Condition (-not [bool]$throwRes.success) Assert-True -Name "throwing sink message carries the error" -Condition ($throwRes.message -match 'kaboom') # A slow sink → forcibly stopped at max_duration, marked timed_out. - $slowSink = New-SinkFixture -Root $disp -Name 'slow' -Metadata @{ name = 'slow'; subscribed_events = @('task.*'); max_duration = 1 } -ScriptBody "function Invoke-Sink { param(`$Event) Start-Sleep -Seconds 5 }`nExport-ModuleMember -Function Invoke-Sink" + $slowSink = New-SinkFixture -Root $disp -Name 'slow' -Metadata @{ name = 'slow'; subscribed_events = @('task.*'); max_duration = 1 } -ScriptBody "function Invoke-Sink { param(`$Event, `$Context) Start-Sleep -Seconds 5 }`nExport-ModuleMember -Function Invoke-Sink" $slowRec = Read-SinkMetadata -SinkDir $slowSink $slowRes = Invoke-SingleSink -Sink $slowRec -Event $evt Assert-True -Name "slow sink is marked timed_out" -Condition ([bool]$slowRes.timed_out) @@ -257,10 +257,10 @@ Export-ModuleMember -Function Invoke-Sink # marker. If dispatch aborted on failure, the marker would never appear. $fan = New-SinksRoot try { - New-SinkFixture -Root $fan -Name 'aaa-boom' -Metadata @{ name = 'aaa-boom'; subscribed_events = @('task.*'); max_duration = 10 } -ScriptBody "function Invoke-Sink { param(`$Event) throw 'first sink fails' }`nExport-ModuleMember -Function Invoke-Sink" | Out-Null + New-SinkFixture -Root $fan -Name 'aaa-boom' -Metadata @{ name = 'aaa-boom'; subscribed_events = @('task.*'); max_duration = 10 } -ScriptBody "function Invoke-Sink { param(`$Event, `$Context) throw 'first sink fails' }`nExport-ModuleMember -Function Invoke-Sink" | Out-Null $goodBody = @' function Invoke-Sink { - param($Event) + param($Event, $Context) Set-Content -LiteralPath $Event.data.marker -Value 'ran' -Encoding utf8NoBOM } Export-ModuleMember -Function Invoke-Sink @@ -302,7 +302,7 @@ try { # of deliveries (lets us prove at-least-once / no-drop precisely). $markerBody = @' function Invoke-Sink { - param($Event) + param($Event, $Context) Add-Content -LiteralPath $Event.data.marker -Value $Event.id -Encoding utf8NoBOM } Export-ModuleMember -Function Invoke-Sink @@ -369,7 +369,7 @@ try { Set-Content -LiteralPath (Join-Path $rsinkDir 'metadata.json') -Encoding utf8NoBOM Set-Content -LiteralPath (Join-Path $rsinkDir 'script.ps1') -Encoding utf8NoBOM -Value @' function Invoke-Sink { - param($Event) + param($Event, $Context) Add-Content -LiteralPath $Event.data.marker -Value $Event.id -Encoding utf8NoBOM } Export-ModuleMember -Function Invoke-Sink @@ -397,11 +397,79 @@ Export-ModuleMember -Function Invoke-Sink Remove-Item -Recurse -Force $rbot -ErrorAction SilentlyContinue } +# ═══════════════════════════════════════════════════════════════════════════ +# webhooks sink (HTTPS-only + HMAC + SSRF guard + per-endpoint filter) +# ═══════════════════════════════════════════════════════════════════════════ + +Write-Host "" +Write-Host " webhooks sink" -ForegroundColor Cyan +Write-Host " ──────────────────────────────────────────────────" -ForegroundColor DarkGray + +$whScript = Join-Path $repoRoot 'src/runtime/Plugins/Events/Sinks/webhooks/script.ps1' +Assert-True -Name "webhooks sink ships script.ps1" -Condition (Test-Path -LiteralPath $whScript) +Assert-True -Name "webhooks sink ships metadata.json" -Condition (Test-Path -LiteralPath (Join-Path $repoRoot 'src/runtime/Plugins/Events/Sinks/webhooks/metadata.json')) + +# The shipped sink must pass the very discovery/validation it will face at runtime. +$whRec = Read-SinkMetadata -SinkDir (Join-Path $repoRoot 'src/runtime/Plugins/Events/Sinks/webhooks') +Assert-Equal -Name "webhooks metadata name is 'webhooks'" -Expected 'webhooks' -Actual $whRec.name + +$whMod = New-Module -Name 'DotbotWebhooksTest' -ScriptBlock ([ScriptBlock]::Create((Get-Content -LiteralPath $whScript -Raw))) + +# ── URL validation: HTTPS-only + SSRF (IP literals → no DNS needed) ── +$pubIp = '93.184.216.34' +Assert-True -Name "https public IP is allowed" -Condition (& $whMod Test-WebhookUrlAllowed -Url "https://$pubIp/hook").allowed +Assert-Equal -Name "http scheme is rejected" -Expected 'not_https' -Actual (& $whMod Test-WebhookUrlAllowed -Url "http://$pubIp/hook").reason +Assert-Equal -Name "loopback 127.0.0.1 is rejected" -Expected 'blocked_ip_range' -Actual (& $whMod Test-WebhookUrlAllowed -Url 'https://127.0.0.1/x').reason +Assert-Equal -Name "private 10/8 is rejected" -Expected 'blocked_ip_range' -Actual (& $whMod Test-WebhookUrlAllowed -Url 'https://10.1.2.3/x').reason +Assert-Equal -Name "private 192.168/16 is rejected" -Expected 'blocked_ip_range' -Actual (& $whMod Test-WebhookUrlAllowed -Url 'https://192.168.0.1/x').reason +Assert-Equal -Name "cloud metadata 169.254.169.254 blocked" -Expected 'blocked_ip_range' -Actual (& $whMod Test-WebhookUrlAllowed -Url 'https://169.254.169.254/latest').reason +Assert-Equal -Name "IPv6 loopback [::1] is rejected" -Expected 'blocked_ip_range' -Actual (& $whMod Test-WebhookUrlAllowed -Url 'https://[::1]/x').reason +Assert-Equal -Name "localhost hostname is rejected" -Expected 'internal_hostname' -Actual (& $whMod Test-WebhookUrlAllowed -Url 'https://localhost/x').reason +Assert-True -Name "172.32/x (outside 172.16/12) allowed" -Condition (& $whMod Test-WebhookUrlAllowed -Url 'https://172.32.0.1/x').allowed + +# ── HMAC signature ── +$sigA = & $whMod New-WebhookSignature -Body '{"a":1}' -Secret 'shh' +$sigB = & $whMod New-WebhookSignature -Body '{"a":1}' -Secret 'shh' +$sigC = & $whMod New-WebhookSignature -Body '{"a":1}' -Secret 'different' +Assert-True -Name "HMAC signature is well-formed (sha256=<64 hex>)" -Condition ($sigA -match '^sha256=[0-9a-f]{64}$') +Assert-Equal -Name "HMAC is deterministic for same body+secret" -Expected $sigA -Actual $sigB +Assert-True -Name "HMAC differs when the secret differs" -Condition ($sigA -ne $sigC) + +# ── Delivery plan: per-endpoint filter + SSRF pruning ── +$cfg = [pscustomobject]@{ + enabled = $true + endpoints = @( + [pscustomobject]@{ url = "https://$pubIp/tasks"; events = @('task.*'); secret = 's1' } + [pscustomobject]@{ url = 'https://127.0.0.1/blocked'; events = @('task.*'); secret = 's2' } # SSRF → pruned + [pscustomobject]@{ url = "https://$pubIp/wf"; events = @('workflow.*'); secret = 's3' } + ) +} +$planTask = @(& $whMod Get-WebhookDeliveryPlan -Event @{ type = 'task.created' } -Config $cfg) +Assert-Equal -Name "plan honours filter + SSRF: task.created → 1 endpoint" -Expected 1 -Actual $planTask.Count +Assert-Equal -Name "plan keeps the public task endpoint" -Expected "https://$pubIp/tasks" -Actual $planTask[0].url + +$planWf = @(& $whMod Get-WebhookDeliveryPlan -Event @{ type = 'workflow.run_completed' } -Config $cfg) +Assert-Equal -Name "plan honours filter: workflow event → workflow endpoint only" -Expected 1 -Actual $planWf.Count +Assert-Equal -Name "plan keeps the workflow endpoint" -Expected "https://$pubIp/wf" -Actual $planWf[0].url + +$planNone = @(& $whMod Get-WebhookDeliveryPlan -Event @{ type = 'decision.created' } -Config $cfg) +Assert-Equal -Name "plan is empty when no endpoint filter matches" -Expected 0 -Actual $planNone.Count + +# ── Invoke-Sink gating (no network) ── +$disabledCtx = @{ BotRoot = 'x'; Events = [pscustomobject]@{ webhooks = [pscustomobject]@{ enabled = $false; endpoints = @() } } } +$rDisabled = & $whMod Invoke-Sink -Event @{ type = 'task.created' } -Context $disabledCtx +Assert-True -Name "Invoke-Sink no-ops when webhooks disabled" -Condition ([bool]$rDisabled.Success) +Assert-True -Name "disabled message says disabled" -Condition ($rDisabled.Message -match 'disabled') + +$noCfgCtx = @{ BotRoot = 'x'; Events = $null } +$rNoCfg = & $whMod Invoke-Sink -Event @{ type = 'task.created' } -Context $noCfgCtx +Assert-True -Name "Invoke-Sink no-ops when there is no events config" -Condition ([bool]$rNoCfg.Success) + # ═══════════════════════════════════════════════════════════════════════════ # SUMMARY # ═══════════════════════════════════════════════════════════════════════════ -$allPassed = Write-TestSummary -LayerName "Layer 1: Dotbot.Events Sink Discovery + Dispatch + Consumer" +$allPassed = Write-TestSummary -LayerName "Layer 1: Dotbot.Events Sink Discovery + Dispatch + Consumer + webhooks" if (-not $allPassed) { exit 1 diff --git a/tests/Test-Runtime.ps1 b/tests/Test-Runtime.ps1 index c22775c6..3a25733c 100644 --- a/tests/Test-Runtime.ps1 +++ b/tests/Test-Runtime.ps1 @@ -689,7 +689,7 @@ $emarker = Join-Path $ebot 'delivered.out' # Sink writes each matching event's type to a fixed marker path (baked literal). Set-Content -LiteralPath (Join-Path $esinkDir 'script.ps1') -Encoding utf8NoBOM -Value @" function Invoke-Sink { - param(`$Event) + param(`$Event, `$Context) Add-Content -LiteralPath '$emarker' -Value `$Event.type -Encoding utf8NoBOM } Export-ModuleMember -Function Invoke-Sink From 08181c802c195fc1fa60c7752d3bf44331a66572 Mon Sep 17 00:00:00 2001 From: Enmanuel Jimenez <34482837+EnmaJim@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:33:10 +0000 Subject: [PATCH 45/50] feat(events): add gated no-op mothership sink and thread full settings context --- .../Dotbot.Events/Private/Consumer.psm1 | 15 ++-- .../Events/Sinks/mothership/metadata.json | 6 ++ .../Events/Sinks/mothership/script.ps1 | 83 +++++++++++++++++++ .../Plugins/Events/Sinks/webhooks/script.ps1 | 6 +- tests/Test-Events.ps1 | 51 +++++++++++- 5 files changed, 149 insertions(+), 12 deletions(-) create mode 100644 src/runtime/Plugins/Events/Sinks/mothership/metadata.json create mode 100644 src/runtime/Plugins/Events/Sinks/mothership/script.ps1 diff --git a/src/runtime/Modules/Dotbot.Events/Private/Consumer.psm1 b/src/runtime/Modules/Dotbot.Events/Private/Consumer.psm1 index d895d93e..a68784d8 100644 --- a/src/runtime/Modules/Dotbot.Events/Private/Consumer.psm1 +++ b/src/runtime/Modules/Dotbot.Events/Private/Consumer.psm1 @@ -167,15 +167,14 @@ function Read-EventBatch { # ─── One delivery tick ────────────────────────────────────────────────────── -function _Get-EventsSettingsSection { - # Resolve the `events` settings section for the sink Context. Guarded so - # the tick still works when Dotbot.Settings isn't loaded (isolated tests). +function _Get-MergedSettingsSafe { + # Resolve the full merged settings for the sink Context. Guarded so the + # tick still works when Dotbot.Settings isn't loaded (isolated tests). param([Parameter(Mandatory)] [string]$BotRoot) if (-not (Get-Command Get-MergedSettings -ErrorAction SilentlyContinue)) { return $null } try { - $settings = Get-MergedSettings -BotRoot $BotRoot - if ($null -ne $settings) { return $settings.events } + return Get-MergedSettings -BotRoot $BotRoot } catch { $null = $_ } @@ -211,8 +210,10 @@ function Invoke-EventConsumerTick { $batch = Read-EventBatch -LogPath $LogPath -Offset $offset # Resolve the sink Context once per tick (fresh settings each cycle so an - # operator's config edit takes effect without a restart). - $context = @{ BotRoot = $BotRoot; Events = (_Get-EventsSettingsSection -BotRoot $BotRoot) } + # operator's config edit takes effect without a restart). Sinks read the + # section they need: webhooks → Settings.events.webhooks, mothership → + # Settings.mothership + Settings.events.mothership. + $context = @{ BotRoot = $BotRoot; Settings = (_Get-MergedSettingsSafe -BotRoot $BotRoot) } $dispatchedTotal = 0 foreach ($evt in $batch.events) { diff --git a/src/runtime/Plugins/Events/Sinks/mothership/metadata.json b/src/runtime/Plugins/Events/Sinks/mothership/metadata.json new file mode 100644 index 00000000..01b0d822 --- /dev/null +++ b/src/runtime/Plugins/Events/Sinks/mothership/metadata.json @@ -0,0 +1,6 @@ +{ + "name": "mothership", + "description": "Forward selected events to the mothership fleet server. Gated on mothership + events.mothership settings and honours mothership.sync_events. Ships as a gated no-op until the server-side fleet events endpoint (#599/#544) exists.", + "subscribed_events": ["*"], + "max_duration": 10 +} diff --git a/src/runtime/Plugins/Events/Sinks/mothership/script.ps1 b/src/runtime/Plugins/Events/Sinks/mothership/script.ps1 new file mode 100644 index 00000000..d04f864b --- /dev/null +++ b/src/runtime/Plugins/Events/Sinks/mothership/script.ps1 @@ -0,0 +1,83 @@ +<# +.SYNOPSIS +mothership sink — forward selected bus events to the mothership fleet server. + +GATED NO-OP (by design, for now): the server-side fleet events endpoint +(POST /api/fleet/{instance_id}/events) is owned by #599/#544 and does not exist +yet. Until it lands this sink evaluates all its gates and then no-ops instead of +POSTing, so enabling it is safe. When the endpoint ships, the forward step wires +through Dotbot.Notification (the existing mothership client) — the decision +logic here (Test-MothershipShouldForward) does not change. + +Gates (all must pass to forward): + - mothership.enabled (the fleet connection is configured/on) + - events.mothership.enabled (the sink itself is turned on) + - mothership.server_url is set + - event type matches mothership.sync_events (glob list; empty → all) + +Config is handed in via $Context.Settings (full merged settings). +#> + +function Test-MothershipShouldForward { + <# + .SYNOPSIS + Decide whether an event should be forwarded to the mothership. Pure logic + (no network) so it is unit-testable. Returns @{ forward = $bool; reason = '...' }. + #> + param( + [Parameter(Mandatory)] $Event, + [Parameter(Mandatory)] $Settings + ) + + $ms = $null + $sink = $null + if ($null -ne $Settings) { + $ms = $Settings.mothership + if ($null -ne $Settings.events) { $sink = $Settings.events.mothership } + } + + $msEnabled = $false + try { $msEnabled = [bool]$ms.enabled } catch { $msEnabled = $false } + if (-not $msEnabled) { return @{ forward = $false; reason = 'mothership_disabled' } } + + $sinkEnabled = $false + try { $sinkEnabled = [bool]$sink.enabled } catch { $sinkEnabled = $false } + if (-not $sinkEnabled) { return @{ forward = $false; reason = 'sink_disabled' } } + + $serverUrl = '' + try { $serverUrl = [string]$ms.server_url } catch { $serverUrl = '' } + if ([string]::IsNullOrWhiteSpace($serverUrl)) { return @{ forward = $false; reason = 'no_server_url' } } + + $type = if ($Event -is [hashtable]) { [string]$Event['type'] } else { [string]$Event.type } + $sync = @($ms.sync_events) + $matched = $sync.Count -eq 0 + foreach ($s in $sync) { + if ($type -like [string]$s) { $matched = $true; break } + } + if (-not $matched) { return @{ forward = $false; reason = 'not_in_sync_events' } } + + return @{ forward = $true; reason = 'ok' } +} + +function Invoke-Sink { + param($Event, $Context) + + $settings = if ($null -ne $Context) { $Context.Settings } else { $null } + if ($null -eq $settings) { return @{ Success = $true; Message = 'mothership: no settings' } } + + $decision = Test-MothershipShouldForward -Event $Event -Settings $settings + if (-not $decision.forward) { + return @{ Success = $true; Message = "mothership: skipped ($($decision.reason))" } + } + + # GATED NO-OP: the fleet events endpoint (#599/#544) does not exist yet. + # When it does, forward here via Dotbot.Notification. Until then this is a + # deliberate no-op — the gates above still run so the behaviour is correct + # the moment the endpoint ships. + return @{ Success = $true; Message = 'mothership: would forward (fleet events endpoint pending #599/#544)' } +} + +Export-ModuleMember -Function @( + 'Invoke-Sink' + 'Test-MothershipShouldForward' +) diff --git a/src/runtime/Plugins/Events/Sinks/webhooks/script.ps1 b/src/runtime/Plugins/Events/Sinks/webhooks/script.ps1 index c220724c..e9f9d495 100644 --- a/src/runtime/Plugins/Events/Sinks/webhooks/script.ps1 +++ b/src/runtime/Plugins/Events/Sinks/webhooks/script.ps1 @@ -2,7 +2,7 @@ .SYNOPSIS webhooks sink — POST matching bus events to configured HTTPS endpoints. -Config (from settings' events.webhooks section, handed in via $Context.Events): +Config (from settings' events.webhooks section, handed in via $Context.Settings.events): { "enabled": true, "endpoints": [ @@ -172,7 +172,9 @@ function Invoke-Sink { param($Event, $Context) $cfg = $null - if ($null -ne $Context -and $null -ne $Context.Events) { $cfg = $Context.Events.webhooks } + if ($null -ne $Context -and $null -ne $Context.Settings -and $null -ne $Context.Settings.events) { + $cfg = $Context.Settings.events.webhooks + } if ($null -eq $cfg) { return @{ Success = $true; Message = 'webhooks: no config' } } $enabled = $false diff --git a/tests/Test-Events.ps1 b/tests/Test-Events.ps1 index 5544ef10..7b41355a 100644 --- a/tests/Test-Events.ps1 +++ b/tests/Test-Events.ps1 @@ -456,20 +456,65 @@ $planNone = @(& $whMod Get-WebhookDeliveryPlan -Event @{ type = 'decision.create Assert-Equal -Name "plan is empty when no endpoint filter matches" -Expected 0 -Actual $planNone.Count # ── Invoke-Sink gating (no network) ── -$disabledCtx = @{ BotRoot = 'x'; Events = [pscustomobject]@{ webhooks = [pscustomobject]@{ enabled = $false; endpoints = @() } } } +$disabledCtx = @{ BotRoot = 'x'; Settings = [pscustomobject]@{ events = [pscustomobject]@{ webhooks = [pscustomobject]@{ enabled = $false; endpoints = @() } } } } $rDisabled = & $whMod Invoke-Sink -Event @{ type = 'task.created' } -Context $disabledCtx Assert-True -Name "Invoke-Sink no-ops when webhooks disabled" -Condition ([bool]$rDisabled.Success) Assert-True -Name "disabled message says disabled" -Condition ($rDisabled.Message -match 'disabled') -$noCfgCtx = @{ BotRoot = 'x'; Events = $null } +$noCfgCtx = @{ BotRoot = 'x'; Settings = $null } $rNoCfg = & $whMod Invoke-Sink -Event @{ type = 'task.created' } -Context $noCfgCtx Assert-True -Name "Invoke-Sink no-ops when there is no events config" -Condition ([bool]$rNoCfg.Success) +# ═══════════════════════════════════════════════════════════════════════════ +# mothership sink (gated no-op until the fleet events endpoint exists) +# ═══════════════════════════════════════════════════════════════════════════ + +Write-Host "" +Write-Host " mothership sink" -ForegroundColor Cyan +Write-Host " ──────────────────────────────────────────────────" -ForegroundColor DarkGray + +$msDir = Join-Path $repoRoot 'src/runtime/Plugins/Events/Sinks/mothership' +Assert-True -Name "mothership sink ships script.ps1" -Condition (Test-Path -LiteralPath (Join-Path $msDir 'script.ps1')) +$msRec = Read-SinkMetadata -SinkDir $msDir +Assert-Equal -Name "mothership metadata name is 'mothership'" -Expected 'mothership' -Actual $msRec.name + +$msMod = New-Module -Name 'DotbotMothershipTest' -ScriptBlock ([ScriptBlock]::Create((Get-Content -LiteralPath (Join-Path $msDir 'script.ps1') -Raw))) + +function New-MsSettings { + param([bool]$MsEnabled, [bool]$SinkEnabled, [string]$ServerUrl = 'https://mothership.example', $Sync = @('task.*')) + return [pscustomobject]@{ + mothership = [pscustomobject]@{ enabled = $MsEnabled; server_url = $ServerUrl; sync_events = $Sync } + events = [pscustomobject]@{ mothership = [pscustomobject]@{ enabled = $SinkEnabled } } + } +} + +$evt = @{ type = 'task.created' } +Assert-Equal -Name "gate: mothership disabled → no forward" -Expected 'mothership_disabled' ` + -Actual (& $msMod Test-MothershipShouldForward -Event $evt -Settings (New-MsSettings -MsEnabled $false -SinkEnabled $true)).reason +Assert-Equal -Name "gate: sink disabled → no forward" -Expected 'sink_disabled' ` + -Actual (& $msMod Test-MothershipShouldForward -Event $evt -Settings (New-MsSettings -MsEnabled $true -SinkEnabled $false)).reason +Assert-Equal -Name "gate: missing server_url → no forward" -Expected 'no_server_url' ` + -Actual (& $msMod Test-MothershipShouldForward -Event $evt -Settings (New-MsSettings -MsEnabled $true -SinkEnabled $true -ServerUrl '')).reason +Assert-Equal -Name "gate: event not in sync_events → no forward" -Expected 'not_in_sync_events' ` + -Actual (& $msMod Test-MothershipShouldForward -Event @{ type = 'workflow.run_started' } -Settings (New-MsSettings -MsEnabled $true -SinkEnabled $true -Sync @('task.*'))).reason +Assert-True -Name "all gates pass → forward=true" ` + -Condition (& $msMod Test-MothershipShouldForward -Event $evt -Settings (New-MsSettings -MsEnabled $true -SinkEnabled $true -Sync @('task.*'))).forward + +# Invoke-Sink is a gated no-op: never throws, always Success, never POSTs. +$rSkip = & $msMod Invoke-Sink -Event $evt -Context @{ BotRoot = 'x'; Settings = (New-MsSettings -MsEnabled $false -SinkEnabled $false) } +Assert-True -Name "Invoke-Sink no-ops (skipped) when disabled" -Condition ([bool]$rSkip.Success -and $rSkip.Message -match 'skipped') + +$rFwd = & $msMod Invoke-Sink -Event $evt -Context @{ BotRoot = 'x'; Settings = (New-MsSettings -MsEnabled $true -SinkEnabled $true -Sync @('task.*')) } +Assert-True -Name "Invoke-Sink is a gated no-op even when all gates pass" -Condition ([bool]$rFwd.Success -and $rFwd.Message -match 'would forward') + +$rNoSettings = & $msMod Invoke-Sink -Event $evt -Context @{ BotRoot = 'x'; Settings = $null } +Assert-True -Name "Invoke-Sink no-ops when there are no settings" -Condition ([bool]$rNoSettings.Success) + # ═══════════════════════════════════════════════════════════════════════════ # SUMMARY # ═══════════════════════════════════════════════════════════════════════════ -$allPassed = Write-TestSummary -LayerName "Layer 1: Dotbot.Events Sink Discovery + Dispatch + Consumer + webhooks" +$allPassed = Write-TestSummary -LayerName "Layer 1: Dotbot.Events Discovery + Dispatch + Consumer + webhooks + mothership" if (-not $allPassed) { exit 1 From 2835a87e7a8e2b9da6f2f551cb6afa24a0611371 Mon Sep 17 00:00:00 2001 From: Enmanuel Jimenez <34482837+EnmaJim@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:41:48 +0000 Subject: [PATCH 46/50] feat(events): drive aether from task.* events, drop /api/state diffing --- src/ui/static/modules/aether.js | 117 +++++++++++++++---------------- src/ui/static/modules/polling.js | 6 +- tests/Run-Tests.ps1 | 3 +- tests/Test-Aether.ps1 | 63 +++++++++++++++++ 4 files changed, 123 insertions(+), 66 deletions(-) create mode 100644 tests/Test-Aether.ps1 diff --git a/src/ui/static/modules/aether.js b/src/ui/static/modules/aether.js index 76829614..98ca1c31 100644 --- a/src/ui/static/modules/aether.js +++ b/src/ui/static/modules/aether.js @@ -20,9 +20,6 @@ const Aether = (function() { const BOND_POLL_INTERVAL = 1000; // Poll every second during bonding // State tracking for event detection - let _lastTaskId = null; - let _lastFailures = 0; - let _initialized = false; let _linked = false; // Stats tracking @@ -334,72 +331,71 @@ const Aether = (function() { } /** - * Process state update from polling - detect events and trigger effects - * Oscilloscope effects work regardless of light connection + * Process an activity-tail event and trigger light / oscilloscope effects. + * + * Two families flow through here from /api/activity/tail: + * - Event-bus lifecycle events (task.* / workflow.*), which replace the + * old /api/state diffing: a task moving to in-progress / done / failed, + * or a workflow run completing / failing. + * - Harness tool activity (write / edit / bash / error / rate_limit / text). + * + * Oscilloscope effects work regardless of light connection. */ - function processState(state) { - const currentTaskId = state.tasks?.current?.id || null; - const failures = state.session?.consecutive_failures || 0; - - // First poll establishes baseline - don't fire events - if (!_initialized) { - _lastTaskId = currentTaskId; - _lastFailures = failures; - _initialized = true; - return; - } + function processActivity(event) { + const type = (event.type || '').toLowerCase(); - // Task start: new task ID appears (different from last seen) - if (currentTaskId && currentTaskId !== _lastTaskId) { - _stats.starts++; - _lastEvent = { type: 'START', time: new Date(), taskId: currentTaskId }; - updateStatsUI(); - if (_linked && _selectedNodes.length > 0) { - celebrateColors(); // Dramatic cycle through all theme colors - } - if (typeof activityScope !== 'undefined' && activityScope) { - activityScope.injectPulse(1.5, 40); - } - } + // ── Task & workflow lifecycle (event bus) ── + if (type === 'task.status_changed' || type === 'workflow.run_completed' || + type === 'workflow.run_failed' || type === 'workflow.run_cancelled') { + const to = (event.to || '').toLowerCase(); - // Task complete: task ID disappears without new failures - if (_lastTaskId && !currentTaskId && failures <= _lastFailures) { - _stats.completes++; - _lastEvent = { type: 'COMPLETE', time: new Date(), taskId: _lastTaskId }; - updateStatsUI(); - if (_linked && _selectedNodes.length > 0) { - celebrateColors(); // Dramatic cycle through all theme colors - } - if (typeof activityScope !== 'undefined' && activityScope) { - activityScope.injectSweep(1.0); + // Task moved into execution → START. + if (type === 'task.status_changed' && to === 'in-progress') { + _stats.starts++; + _lastEvent = { type: 'START', time: new Date(), taskId: event.task_id }; + updateStatsUI(); + if (_linked && _selectedNodes.length > 0) { + celebrateColors(); // Dramatic cycle through all theme colors + } + if (typeof activityScope !== 'undefined' && activityScope) { + activityScope.injectPulse(1.5, 40); + } + return; } - } - // Error: failures increased - if (failures > _lastFailures) { - _stats.errors++; - _lastEvent = { type: 'ERROR', time: new Date(), failures }; - updateStatsUI(); - if (_linked && _selectedNodes.length > 0) { - pulse('warning'); // Warning color from theme - } - if (typeof activityScope !== 'undefined' && activityScope) { - activityScope.injectNoise(0.8, 40); + // Task done, or a workflow run completed → COMPLETE. + if ((type === 'task.status_changed' && to === 'done') || type === 'workflow.run_completed') { + _stats.completes++; + _lastEvent = { type: 'COMPLETE', time: new Date(), taskId: event.task_id }; + updateStatsUI(); + if (_linked && _selectedNodes.length > 0) { + celebrateColors(); + } + if (typeof activityScope !== 'undefined' && activityScope) { + activityScope.injectSweep(1.0); + } + return; } - } - // Update tracking - _lastTaskId = currentTaskId; - _lastFailures = failures; - } + // Task failed, or a workflow run failed → ERROR. + if ((type === 'task.status_changed' && to === 'failed') || type === 'workflow.run_failed') { + _stats.errors++; + _lastEvent = { type: 'ERROR', time: new Date(), taskId: event.task_id }; + updateStatsUI(); + if (_linked && _selectedNodes.length > 0) { + pulse('warning'); // Warning color from theme + } + if (typeof activityScope !== 'undefined' && activityScope) { + activityScope.injectNoise(0.8, 40); + } + return; + } - /** - * Process activity event - respond to tool calls, writes, errors during tasks - */ - function processActivity(event) { - const type = (event.type || '').toLowerCase(); + // Other transitions (todo, needs-input, run_cancelled) → no effect. + return; + } - // Different effects for different activity types + // ── Harness tool activity ── switch (type) { case 'write': case 'edit': @@ -909,7 +905,6 @@ const Aether = (function() { startBonding, stopBonding, loadNodes, - processState, processActivity, pulse, pulseQuick, diff --git a/src/ui/static/modules/polling.js b/src/ui/static/modules/polling.js index 5db1e8f5..9ebe7df6 100644 --- a/src/ui/static/modules/polling.js +++ b/src/ui/static/modules/polling.js @@ -33,10 +33,8 @@ async function pollState() { setConnectionStatus('connected'); updateUI(state); - // Aether ambient feedback - if (typeof Aether !== 'undefined') { - Aether.processState(state); - } + // Aether ambient feedback is driven from the event bus via the activity + // tail (see Aether.processActivity in pollActivity), not from /api/state. // Update Overview side panel every poll (no extra fetch — uses state already in hand) updateOverviewWorkflowPanel(state); diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index 9687cea7..daa6c839 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -139,6 +139,7 @@ if (1 -in $layersToRun) { $executorCode = Invoke-TestFile -Layer '1' -FileName 'Test-Executor.ps1' $hooksCode = Invoke-TestFile -Layer '1' -FileName 'Test-Hooks.ps1' $eventsCode = Invoke-TestFile -Layer '1' -FileName 'Test-Events.ps1' + $aetherCode = Invoke-TestFile -Layer '1' -FileName 'Test-Aether.ps1' $mdRefsCode = Invoke-TestFile -Layer '1' -FileName 'Test-MdRefs.ps1' $legacyVocabularyCode = Invoke-TestFile -Layer '1' -FileName 'Test-NoLegacyVocabulary.ps1' $backslashPathsCode = Invoke-TestFile -Layer '1' -FileName 'Test-NoBackslashPaths.ps1' @@ -148,7 +149,7 @@ if (1 -in $layersToRun) { $pathSanitizerCode = Invoke-TestFile -Layer '1' -FileName 'Test-PathSanitizer.ps1' $mcpSurfaceCode = Invoke-TestFile -Layer '1' -FileName 'Test-McpSurface.ps1' - $exitCode = if ($structureCode -ne 0 -or $compilationCode -ne 0 -or $workflowManifestCode -ne 0 -or $dataModelCode -ne 0 -or $runtimeCode -ne 0 -or $worktreeCode -ne 0 -or $executorCode -ne 0 -or $hooksCode -ne 0 -or $eventsCode -ne 0 -or $mdRefsCode -ne 0 -or $legacyVocabularyCode -ne 0 -or $backslashPathsCode -ne 0 -or $clarificationCode -ne 0 -or $activityLogCode -ne 0 -or $privacyScanCode -ne 0 -or $pathSanitizerCode -ne 0 -or $mcpSurfaceCode -ne 0) { 1 } else { 0 } + $exitCode = if ($structureCode -ne 0 -or $compilationCode -ne 0 -or $workflowManifestCode -ne 0 -or $dataModelCode -ne 0 -or $runtimeCode -ne 0 -or $worktreeCode -ne 0 -or $executorCode -ne 0 -or $hooksCode -ne 0 -or $eventsCode -ne 0 -or $aetherCode -ne 0 -or $mdRefsCode -ne 0 -or $legacyVocabularyCode -ne 0 -or $backslashPathsCode -ne 0 -or $clarificationCode -ne 0 -or $activityLogCode -ne 0 -or $privacyScanCode -ne 0 -or $pathSanitizerCode -ne 0 -or $mcpSurfaceCode -ne 0) { 1 } else { 0 } $layerResults["1"] = ($exitCode -eq 0) if ($exitCode -ne 0) { $overallFailed = $true } } diff --git a/tests/Test-Aether.ps1 b/tests/Test-Aether.ps1 new file mode 100644 index 00000000..51fe766a --- /dev/null +++ b/tests/Test-Aether.ps1 @@ -0,0 +1,63 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Layer 1: Aether client-side event-bus wiring (PRD-031 AC#8 / AC#9). +.DESCRIPTION + Source-level assertions that the browser Aether module drives its + lights/oscilloscope from task.* / workflow.* events on the activity tail, + with the old /api/state diffing removed — and that the activity tail poll + itself is untouched. + + The frontend has no JS unit harness in this suite, so these are static + source checks (same approach as the other UI/source assertions). +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" + +Import-Module "$PSScriptRoot\Test-Helpers.psm1" -Force + +$repoRoot = Get-RepoRoot + +Write-Host "" +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Blue +Write-Host " Aether event-bus wiring" -ForegroundColor Blue +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Blue +Write-Host "" + +Reset-TestResults + +$aetherPath = Join-Path $repoRoot 'src/ui/static/modules/aether.js' +$pollingPath = Join-Path $repoRoot 'src/ui/static/modules/polling.js' +Assert-PathExists -Name "aether.js exists" -Path $aetherPath +Assert-PathExists -Name "polling.js exists" -Path $pollingPath + +$aether = Get-Content -LiteralPath $aetherPath -Raw +$polling = Get-Content -LiteralPath $pollingPath -Raw + +# ── AC#8: /api/state diffing removed ── +Assert-True -Name "aether.js no longer defines processState (diffing removed)" ` + -Condition (-not ($aether -match 'function\s+processState')) +Assert-True -Name "aether.js no longer references processState at all" ` + -Condition (-not ($aether -match 'processState')) +Assert-True -Name "polling.js no longer calls Aether.processState" ` + -Condition (-not ($polling -match 'Aether\.processState')) + +# ── AC#8: Aether drives from task.* / workflow.* events ── +Assert-True -Name "aether.js handles task.status_changed" -Condition ($aether -match 'task\.status_changed') +Assert-True -Name "aether.js handles workflow.run_completed" -Condition ($aether -match 'workflow\.run_completed') +Assert-True -Name "aether.js handles workflow.run_failed" -Condition ($aether -match 'workflow\.run_failed') +Assert-True -Name "aether.js reacts to the 'in-progress' task transition" -Condition ($aether -match "in-progress") + +# ── AC#9: the activity-tail poll is still wired (oscilloscope/tail untouched) ── +Assert-True -Name "aether.js still exports processActivity" -Condition ($aether -match '(?m)^\s*processActivity,') +Assert-True -Name "polling.js still feeds Aether.processActivity from the tail" -Condition ($polling -match 'Aether\.processActivity') +Assert-True -Name "polling.js still polls /api/activity/tail" -Condition ($polling -match '/api/activity/tail') + +$allPassed = Write-TestSummary -LayerName "Layer 1: Aether event-bus wiring" + +if (-not $allPassed) { + exit 1 +} From 2e12d62320d89950e5af6ac60f8eb7dd7f50bd52 Mon Sep 17 00:00:00 2001 From: Ivan Bondarenko Date: Thu, 9 Jul 2026 18:29:02 +0300 Subject: [PATCH 47/50] feat(ui): render v4 shell in Outpost control panel --- src/ui/static/app.js | 1 + src/ui/static/index.html | 91 ++++++++++++++++------------------ src/ui/static/modules/shell.js | 55 ++++++++++++++++++++ src/ui/static/modules/tabs.js | 3 ++ 4 files changed, 102 insertions(+), 48 deletions(-) create mode 100644 src/ui/static/modules/shell.js diff --git a/src/ui/static/app.js b/src/ui/static/app.js index 317d7fbc..7c5c94d6 100644 --- a/src/ui/static/app.js +++ b/src/ui/static/app.js @@ -30,6 +30,7 @@ document.addEventListener('DOMContentLoaded', async () => { // Initialize UI components initTabs(); + initShell(); initLogoClick(); initHamburgerMenu(); initSidebarCollapse(); diff --git a/src/ui/static/index.html b/src/ui/static/index.html index a9440596..b624a2fd 100644 --- a/src/ui/static/index.html +++ b/src/ui/static/index.html @@ -15,6 +15,7 @@ +