Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/mcp/modules/MothershipClient.psm1
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<#
.SYNOPSIS
Compatibility shim for runtime-owned notification/mothership helpers.

.DESCRIPTION
Mothership client logic lives in Dotbot.Notification. Existing MCP and UI
callers can import this module — it forwards to Dotbot.Notification via a
global import so the same function names remain available.
#>

$notifModule = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'runtime' 'Modules' 'Dotbot.Notification' 'Dotbot.Notification.psd1'
if (-not (Get-Module Dotbot.Notification)) {
Import-Module $notifModule -DisableNameChecking -Global
}
12 changes: 4 additions & 8 deletions src/mcp/modules/NotificationClient.psm1
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
<#
.SYNOPSIS
Compatibility shim for runtime-owned notification helpers.
Deprecated: use MothershipClient.psm1 instead.

.DESCRIPTION
Notification client logic now lives in Dotbot.Notification. Existing MCP and
UI callers can keep importing this module — it forwards to Dotbot.Notification
via a global import so the same function names remain available.
This shim is kept for backward compatibility. It forwards to MothershipClient.psm1.
New callers should import MothershipClient.psm1 directly.
#>

$notifModule = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'runtime' 'Modules' 'Dotbot.Notification' 'Dotbot.Notification.psd1'
if (-not (Get-Module Dotbot.Notification)) {
Import-Module $notifModule -DisableNameChecking -Global
}
Import-Module (Join-Path $PSScriptRoot 'MothershipClient.psm1') -Force -Global
4 changes: 2 additions & 2 deletions src/ui/modules/NotificationPoller.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function Initialize-NotificationPoller {
$script:pollerBotRoot = $BotRoot

# Import the notification client module
$notifModule = Join-Path $PSScriptRoot ".." ".." "mcp" "modules" "NotificationClient.psm1"
$notifModule = Join-Path $PSScriptRoot ".." ".." "mcp" "modules" "MothershipClient.psm1"
if (-not (Test-Path $notifModule)) {
return
}
Expand Down Expand Up @@ -141,7 +141,7 @@ function Invoke-NotificationPollTick {
if (-not (Test-Path $tasksBaseDir)) { return }

# Ensure notification client is loaded
$notifModule = Join-Path $PSScriptRoot ".." ".." "mcp" "modules" "NotificationClient.psm1"
$notifModule = Join-Path $PSScriptRoot ".." ".." "mcp" "modules" "MothershipClient.psm1"
if (-not (Test-Path $notifModule)) { return }
Import-Module $notifModule -Force

Expand Down
4 changes: 2 additions & 2 deletions src/ui/modules/SettingsAPI.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -1215,9 +1215,9 @@ function Set-MothershipConfig {
function Set-NotificationConfig { param([Parameter(Mandatory)] $Body) return Set-MothershipConfig -Body $Body }

function Test-MothershipServerFromUI {
$notifModule = Join-Path $PSScriptRoot ".." ".." "mcp" "modules" "NotificationClient.psm1"
$notifModule = Join-Path $PSScriptRoot ".." ".." "mcp" "modules" "MothershipClient.psm1"
if (-not (Test-Path $notifModule)) {
return @{ reachable = $false; error = "NotificationClient module not found" }
return @{ reachable = $false; error = "MothershipClient module not found" }
}

Comment thread
OgnjenGligoric marked this conversation as resolved.
Import-Module $notifModule -Force
Expand Down
195 changes: 195 additions & 0 deletions src/ui/modules/WorkQueueService.psm1
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
<#
.SYNOPSIS
Work queue service skeleton for fleet task dispatch (Phase 10 / Drone).

.DESCRIPTION
Provides a file-based work queue that the Mothership uses to dispatch tasks to
registered drone runtimes. Storage mirrors FleetAPI.psm1: JSON files under
fleet/queue/<RuntimeId>/.

This is a skeleton — function signatures are stable and importable by #96
(Drone agent), but full dispatch logic is deferred to the Drone phase.
#>

$script:QueueConfig = @{
ControlDir = $null
}

function Initialize-WorkQueueService {
<#
.SYNOPSIS
Sets up the work queue storage directory. Call once at server startup.
#>
param(
[Parameter(Mandatory)][string]$ControlDir
)
$script:QueueConfig.ControlDir = $ControlDir
$queueRoot = _Get-QueueRoot
if (-not (Test-Path -LiteralPath $queueRoot)) {
New-Item -ItemType Directory -Path $queueRoot -Force | Out-Null
}
}

function Enqueue-WorkItem {
<#
.SYNOPSIS
Adds a work item to a runtime's queue.
.PARAMETER RuntimeId
The target runtime that should process this item.
.PARAMETER TaskId
Comment thread
OgnjenGligoric marked this conversation as resolved.
The task ID to dispatch.
.PARAMETER Payload
Arbitrary hashtable of additional context (workflow name, run id, etc.).
.OUTPUTS
Hashtable with the new item's id and queued_at timestamp.
#>
param(
[Parameter(Mandatory)][string]$RuntimeId,
[Parameter(Mandatory)][string]$TaskId,
[hashtable]$Payload = @{}
)

$itemId = "wqi-$([guid]::NewGuid().ToString('N').Substring(0, 12))"
$item = [ordered]@{
id = $itemId
runtime_id = $RuntimeId
task_id = $TaskId
payload = $Payload
status = 'pending'
queued_at = (Get-Date).ToUniversalTime().ToString('o')
}

$dir = _Get-RuntimeQueueDir -RuntimeId $RuntimeId
if (-not (Test-Path -LiteralPath $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
_Write-QueueJson -Path (Join-Path $dir "$itemId.json") -Value $item

return @{ id = $itemId; queued_at = $item.queued_at }
}

function Dequeue-WorkItem {
<#
.SYNOPSIS
Pops the next pending work item for a runtime (FIFO by queued_at).
Marks the item as 'leased' so it is not returned again.
Returns $null when the queue is empty.
#>
param(
[Parameter(Mandatory)][string]$RuntimeId
)

$dir = _Get-RuntimeQueueDir -RuntimeId $RuntimeId
$files = @(Get-ChildItem -LiteralPath $dir -Filter '*.json' -File -ErrorAction SilentlyContinue)

$nextFile = $null
$nextItem = $null
$nextQueuedAt = [DateTime]::MaxValue

foreach ($file in $files) {
$item = _Read-QueueJson -Path $file.FullName
if (-not $item -or $item['status'] -ne 'pending') { continue }

try { $queuedAt = [DateTime]::Parse([string]$item['queued_at']).ToUniversalTime() } catch { $queuedAt = [DateTime]::MaxValue }
if ($queuedAt -lt $nextQueuedAt) {
$nextQueuedAt = $queuedAt
$nextFile = $file
$nextItem = $item
}
}

if (-not $nextItem) { return $null }

$nextItem['status'] = 'leased'
$nextItem['leased_at'] = (Get-Date).ToUniversalTime().ToString('o')
_Write-QueueJson -Path $nextFile.FullName -Value $nextItem

return [ordered]@{
id = $nextItem['id']
runtime_id = $nextItem['runtime_id']
task_id = $nextItem['task_id']
payload = $nextItem['payload']
queued_at = $nextItem['queued_at']
leased_at = $nextItem['leased_at']
}
}

function Get-WorkQueueDepth {
<#
.SYNOPSIS
Returns the count of pending (not yet leased) items for a runtime.
#>
param(
[Parameter(Mandatory)][string]$RuntimeId
)

$dir = _Get-RuntimeQueueDir -RuntimeId $RuntimeId
$count = 0
foreach ($file in Get-ChildItem -LiteralPath $dir -Filter '*.json' -File -ErrorAction SilentlyContinue) {
$item = _Read-QueueJson -Path $file.FullName
if ($item -and $item['status'] -eq 'pending') { $count++ }
}
return $count
}

function Complete-WorkItem {
<#
.SYNOPSIS
Marks a leased work item as completed. Called by the drone after finishing.
#>
param(
[Parameter(Mandatory)][string]$RuntimeId,
[Parameter(Mandatory)][string]$ItemId
)

$path = Join-Path (_Get-RuntimeQueueDir -RuntimeId $RuntimeId) "$ItemId.json"
$item = _Read-QueueJson -Path $path
if (-not $item) { return @{ success = $false; error = 'item not found' } }
if ($item['status'] -ne 'leased') {
return @{ success = $false; error = "item is not leased (status=$($item['status']))" }
}

$item['status'] = 'completed'
$item['completed_at'] = (Get-Date).ToUniversalTime().ToString('o')
_Write-QueueJson -Path $path -Value $item

return @{ success = $true }
}

# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------

function _Get-QueueRoot {
return Join-Path $script:QueueConfig.ControlDir 'fleet' 'queue'
}

function _Get-RuntimeQueueDir {
param([Parameter(Mandatory)][string]$RuntimeId)
$safe = $RuntimeId -replace '[^A-Za-z0-9_.-]', '_'
return Join-Path (_Get-QueueRoot) $safe
}

function _Read-QueueJson {
param([Parameter(Mandatory)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return $null }
try { return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -AsHashtable } catch { return $null }
}

function _Write-QueueJson {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][object]$Value
)
$tmp = "$Path.tmp"
[System.IO.File]::WriteAllText($tmp, ($Value | ConvertTo-Json -Depth 20), [System.Text.UTF8Encoding]::new($false))
Move-Item -LiteralPath $tmp -Destination $Path -Force
}

Export-ModuleMember -Function @(
'Initialize-WorkQueueService',
'Enqueue-WorkItem',
'Dequeue-WorkItem',
'Get-WorkQueueDepth',
'Complete-WorkItem'
)
2 changes: 2 additions & 0 deletions src/ui/server.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ Import-Module (Join-Path $PSScriptRoot "modules/NotificationPoller.psm1") -Force
Import-Module (Join-Path $PSScriptRoot "modules/DecisionAPI.psm1") -Force
Import-Module (Join-Path $PSScriptRoot "modules/InboxWatcher.psm1") -Force
Import-Module (Join-Path $PSScriptRoot "modules/FleetAPI.psm1") -Force
Import-Module (Join-Path $PSScriptRoot "modules/WorkQueueService.psm1") -Force
Comment thread
OgnjenGligoric marked this conversation as resolved.

# Import workflow manifest utilities (for installed workflows API).
# -Global so Test-ValidWorkflowDir / Read-WorkflowManifest stay visible to
Expand All @@ -206,6 +207,7 @@ Initialize-NotificationPoller -BotRoot $botRoot
Initialize-DecisionAPI -BotRoot $botRoot
Initialize-InboxWatcher -BotRoot $botRoot
Initialize-FleetAPI -ControlDir $controlDir -BotRoot $botRoot
Initialize-WorkQueueService -ControlDir $controlDir

# Request counter for single-line logging
$script:requestCount = 0
Expand Down
69 changes: 69 additions & 0 deletions tests/Test-Runtime.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Import-Module (Join-Path $repoRoot "src/runtime/Modules/Dotbot.Task/Dotbot.Task.
Import-Module (Join-Path $repoRoot "src/runtime/Modules/Dotbot.Workflow/Dotbot.Workflow.psd1") -Force -DisableNameChecking -Global
Import-Module (Join-Path $repoRoot "src/runtime/Modules/Dotbot.Runtime/Dotbot.Runtime.psd1") -Force -DisableNameChecking -Global
Import-Module (Join-Path $repoRoot "src/ui/modules/FleetAPI.psm1") -Force -DisableNameChecking -Global
Import-Module (Join-Path $repoRoot "src/ui/modules/WorkQueueService.psm1") -Force -DisableNameChecking -Global

# Small helper: assert a scriptblock throws and (optionally) message matches a pattern.
function Assert-Throws {
Expand Down Expand Up @@ -530,6 +531,74 @@ try {
$proxy = Invoke-FleetRuntimeProxy -RuntimeId 'rt-test-runtime' -Method GET -ApiPath '/api/info'
Assert-Equal -Name "FleetAPI proxies /api/info to runtime" -Expected 200 -Actual $proxy.status_code

# ───── WorkQueueService ─────
$wqControlDir = Join-Path ([System.IO.Path]::GetTempPath()) "dotbot-test-wqs-$(Get-Random)"
New-Item -Path $wqControlDir -ItemType Directory -Force | Out-Null

Initialize-WorkQueueService -ControlDir $wqControlDir
$queueRoot = Join-Path $wqControlDir 'fleet' 'queue'
Assert-True -Name "WorkQueueService creates fleet/queue directory on init" `
-Condition (Test-Path -LiteralPath $queueRoot)

# Empty queue returns null and depth 0
Assert-True -Name "WorkQueueService Dequeue-WorkItem returns null on empty queue" `
-Condition ($null -eq (Dequeue-WorkItem -RuntimeId 'drone-1'))
Assert-Equal -Name "WorkQueueService Get-WorkQueueDepth returns 0 on empty queue" `
-Expected 0 -Actual (Get-WorkQueueDepth -RuntimeId 'drone-1')

# Enqueue two items
$item1 = Enqueue-WorkItem -RuntimeId 'drone-1' -TaskId 't_aaa00001' -Payload @{ run_id = 'run-001' }
$item2 = Enqueue-WorkItem -RuntimeId 'drone-1' -TaskId 't_bbb00002'
Assert-True -Name "WorkQueueService Enqueue-WorkItem returns an id" `
-Condition ($item1.id -match '^wqi-')
Assert-Equal -Name "WorkQueueService Get-WorkQueueDepth reflects enqueued items" `
-Expected 2 -Actual (Get-WorkQueueDepth -RuntimeId 'drone-1')

# Dequeue respects FIFO order
$dequeued1 = Dequeue-WorkItem -RuntimeId 'drone-1'
Assert-Equal -Name "WorkQueueService Dequeue-WorkItem returns first item (FIFO)" `
-Expected $item1.id -Actual $dequeued1.id
Assert-Equal -Name "WorkQueueService dequeued item has correct task_id" `
-Expected 't_aaa00001' -Actual $dequeued1.task_id
Assert-Equal -Name "WorkQueueService dequeued item status is leased" `
-Expected 'leased' -Actual (
(Get-Content -LiteralPath (Join-Path $queueRoot "drone-1/$($item1.id).json") -Raw | ConvertFrom-Json).status
)

# Depth drops by 1 after lease (leased item no longer pending)
Assert-Equal -Name "WorkQueueService depth decreases after dequeue" `
-Expected 1 -Actual (Get-WorkQueueDepth -RuntimeId 'drone-1')

# Complete the first item
$completeResult = Complete-WorkItem -RuntimeId 'drone-1' -ItemId $item1.id
Assert-True -Name "WorkQueueService Complete-WorkItem returns success" `
-Condition ([bool]$completeResult.success)
Assert-Equal -Name "WorkQueueService completed item has status completed" `
-Expected 'completed' -Actual (
(Get-Content -LiteralPath (Join-Path $queueRoot "drone-1/$($item1.id).json") -Raw | ConvertFrom-Json).status
)

# Dequeue second item, then queue is empty
$dequeued2 = Dequeue-WorkItem -RuntimeId 'drone-1'
Assert-Equal -Name "WorkQueueService second dequeue returns second item" `
-Expected $item2.id -Actual $dequeued2.id
Assert-True -Name "WorkQueueService third dequeue on empty queue returns null" `
-Condition ($null -eq (Dequeue-WorkItem -RuntimeId 'drone-1'))

# Complete-WorkItem returns error for unknown item
$badComplete = Complete-WorkItem -RuntimeId 'drone-1' -ItemId 'wqi-doesnotexist'
Assert-True -Name "WorkQueueService Complete-WorkItem returns error for unknown item" `
-Condition (-not $badComplete.success)

# Multiple runtimes are isolated
$null = Enqueue-WorkItem -RuntimeId 'drone-2' -TaskId 't_ccc00003'
Assert-Equal -Name "WorkQueueService queues are isolated per runtime" `
-Expected 1 -Actual (Get-WorkQueueDepth -RuntimeId 'drone-2')
Assert-Equal -Name "WorkQueueService drone-1 unaffected by drone-2 enqueue" `
-Expected 0 -Actual (Get-WorkQueueDepth -RuntimeId 'drone-1')

Remove-Item $wqControlDir -Recurse -Force -ErrorAction SilentlyContinue

# ───── Activity log ─────
$logPath = Get-ActivityLogPath -BotRoot $bot
Assert-True -Name "activity.jsonl exists after mutations" -Condition (Test-Path -LiteralPath $logPath)
Expand Down
Loading