workflows/default/systems/runtime/modules/DotBotLog.psm1

<#
.SYNOPSIS
    Structured logging module for dotbot.

.DESCRIPTION
    Provides centralized, structured JSONL logging with levels (Debug, Info, Warn, Error, Fatal),
    automatic log rotation, and backward-compatible activity log integration.

    Output: .bot/.control/logs/dotbot-{date}.jsonl
    Each line: {ts, level, msg, correlation_id, process_id, task_id, phase, pid, error, stack}

    Info+ events are also written to activity.jsonl for UI oscilloscope backward compat.

    Zero external module dependencies — uses only .NET APIs and PowerShell built-ins.
#>


#region Module State

$script:LogDir          = $null
$script:ControlDir      = $null
$script:FileLevel       = 'Debug'
$script:ConsoleLevel    = 'Info'
$script:ConsoleEnabled  = $true
$script:RetentionDays   = 7
$script:MaxFileSizeMB   = 50
$script:Initialized     = $false
$script:ProjectRoot     = $null
$script:FileRetryCount  = 3
$script:FileRetryBaseMs = 50

$script:LevelOrder = @{
    'Debug' = 0
    'Info'  = 1
    'Warn'  = 2
    'Error' = 3
    'Fatal' = 4
}

$script:LevelToActivityType = @{
    'Info'  = 'info'
    'Warn'  = 'warning'
    'Error' = 'error'
    'Fatal' = 'fatal'
}

#endregion

#region Public Functions

function Initialize-DotBotLog {
    <#
    .SYNOPSIS
        Initializes the structured logging system with configuration.
    .DESCRIPTION
        Idempotent — can be called multiple times (e.g., first with defaults, then with settings).
        Also runs log rotation on each call.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$LogDir,

        [Parameter(Mandatory)]
        [string]$ControlDir,

        [string]$ProjectRoot,

        [ValidateSet('Debug','Info','Warn','Error','Fatal')]
        [string]$FileLevel = 'Debug',

        [ValidateSet('Debug','Info','Warn','Error','Fatal')]
        [string]$ConsoleLevel = 'Info',

        [bool]$ConsoleEnabled = $true,

        [int]$RetentionDays = 7,

        [int]$MaxFileSizeMB = 50,

        [int]$FileRetryCount = 3,

        [int]$FileRetryBaseMs = 50
    )

    $script:LogDir          = $LogDir
    $script:ControlDir      = $ControlDir
    $script:ProjectRoot     = $ProjectRoot
    $script:FileLevel       = $FileLevel
    $script:ConsoleLevel    = $ConsoleLevel
    $script:ConsoleEnabled  = $ConsoleEnabled
    $script:RetentionDays   = $RetentionDays
    $script:MaxFileSizeMB   = $MaxFileSizeMB
    $script:FileRetryCount  = $FileRetryCount
    $script:FileRetryBaseMs = $FileRetryBaseMs

    # Create log directory
    if (-not (Test-Path $script:LogDir)) {
        New-Item -Path $script:LogDir -ItemType Directory -Force | Out-Null
    }

    $script:Initialized = $true

    # Run rotation once per initialization
    Rotate-DotBotLog
}

function Write-BotLog {
    <#
    .SYNOPSIS
        Writes a structured log entry to the JSONL log file.
    .DESCRIPTION
        Core logging function. Writes to structured JSONL log, activity.jsonl (Info+),
        per-process activity log, and console (themed when DotBotTheme is loaded).
    .PARAMETER Level
        Log severity: Debug, Info, Warn, Error, Fatal.
    .PARAMETER Message
        The log message.
    .PARAMETER Context
        Optional hashtable of additional context fields merged into the log entry.
    .PARAMETER Exception
        Optional ErrorRecord to include error message and stack trace.
    .PARAMETER ProcessId
        Optional process ID override. Defaults to $env:DOTBOT_PROCESS_ID.
    .PARAMETER CorrelationId
        Optional correlation ID override. Defaults to $env:DOTBOT_CORRELATION_ID.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('Debug','Info','Warn','Error','Fatal')]
        [string]$Level,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Message,

        [hashtable]$Context,

        [System.Management.Automation.ErrorRecord]$Exception,

        [string]$ProcessId,

        [string]$CorrelationId,

        [switch]$ForceDisplay
    )

    # Auto-initialize if not yet initialized — discover log dir from module location
    if (-not $script:Initialized) {
        $autoControlDir = $null
        # Walk up from PSScriptRoot to find .control dir
        # DotBotLog lives at .bot/systems/runtime/modules/ — .control is at .bot/.control
        $botRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
        if ($botRoot) {
            $autoControlDir = Join-Path $botRoot ".control"
        }
        if ($autoControlDir -and (Test-Path (Split-Path -Parent $autoControlDir))) {
            $autoLogDir = Join-Path $autoControlDir "logs"
            $autoProjectRoot = Split-Path -Parent $botRoot
            Initialize-DotBotLog -LogDir $autoLogDir -ControlDir $autoControlDir -ProjectRoot $autoProjectRoot
        } else {
            # Cannot auto-initialize — silently return
            return
        }
    }

    # Three-way level gate: file, console, and activity (always Info+)
    $levelOrd = $script:LevelOrder[$Level]
    $meetsFileLevel    = $levelOrd -ge $script:LevelOrder[$script:FileLevel]
    $meetsConsoleLevel = $script:ConsoleEnabled -and ($ForceDisplay -or ($levelOrd -ge $script:LevelOrder[$script:ConsoleLevel]))
    $shouldWriteActivity = $levelOrd -ge $script:LevelOrder['Info']

    if (-not $meetsFileLevel -and -not $meetsConsoleLevel -and -not $shouldWriteActivity) {
        return
    }

    # Sanitize message — strip absolute paths (inline, no PathSanitizer dependency)
    $sanitizedMessage = $Message
    if ($script:ProjectRoot -and $script:ProjectRoot.Length -gt 0) {
        try {
            $sanitizedMessage = $Message -replace [regex]::Escape($script:ProjectRoot), '.'
        } catch {
            # Regex escape failed — use original message
        }
    }

    # Resolve process ID and correlation ID
    $effectiveProcessId = if ($ProcessId) { $ProcessId } else { $env:DOTBOT_PROCESS_ID }
    $effectiveCorrelationId = if ($CorrelationId) { $CorrelationId } else { $env:DOTBOT_CORRELATION_ID }

    # Build structured log entry
    $entry = [ordered]@{
        ts             = (Get-Date).ToUniversalTime().ToString("o")
        level          = $Level
        msg            = $sanitizedMessage
        correlation_id = $effectiveCorrelationId
        process_id     = $effectiveProcessId
        task_id        = $env:DOTBOT_CURRENT_TASK_ID
        phase          = $env:DOTBOT_CURRENT_PHASE
        pid            = $PID
    }

    # Add exception details
    if ($Exception) {
        $entry.error = $Exception.Exception.Message
        $entry.stack = $Exception.ScriptStackTrace
    }

    # Merge context (keys that don't collide with core fields)
    if ($Context) {
        foreach ($key in $Context.Keys) {
            if (-not $entry.Contains($key)) {
                $entry[$key] = $Context[$key]
            }
        }
    }

    $jsonLine = $entry | ConvertTo-Json -Compress

    # 1. Write to structured log file (with size-based rollover)
    if ($meetsFileLevel) {
        $logFilePath = Get-CurrentLogFilePath
        Write-JsonlLine -Path $logFilePath -Line $jsonLine
    }

    # 2. Activity log integration — always for Info+, regardless of file_level
    if ($shouldWriteActivity) {
        $activityType = if ($Context -and $Context.activity_type) {
            $Context.activity_type
        } else {
            $script:LevelToActivityType[$Level]
        }
        $effectivePhase = if ($Context -and $Context.phase_override) {
            $Context.phase_override
        } elseif ($env:DOTBOT_CURRENT_PHASE) {
            $env:DOTBOT_CURRENT_PHASE
        } else {
            $null
        }

        $activityEntry = @{
            timestamp      = $entry.ts
            type           = $activityType
            message        = $sanitizedMessage
            correlation_id = $effectiveCorrelationId
            task_id        = $env:DOTBOT_CURRENT_TASK_ID
            phase          = $effectivePhase
        } | ConvertTo-Json -Compress

        # Global activity.jsonl
        $activityPath = Join-Path $script:ControlDir "activity.jsonl"
        Write-JsonlLine -Path $activityPath -Line $activityEntry

        # Per-process activity log
        if ($effectiveProcessId) {
            $processActivityPath = Join-Path (Join-Path $script:ControlDir "processes") "$effectiveProcessId.activity.jsonl"
            Write-JsonlLine -Path $processActivityPath -Line $activityEntry
        }
    }

    # 3. Console output (themed when DotBotTheme is loaded)
    if ($meetsConsoleLevel) {
        Write-BotLogConsole -Level $Level -Message $sanitizedMessage -Exception $Exception
    }
}

function Rotate-DotBotLog {
    <#
    .SYNOPSIS
        Removes structured log files older than the configured retention period.
        Also cleans up legacy diag-*.log files.
    #>

    [CmdletBinding()]
    param()

    if (-not $script:Initialized -or -not $script:LogDir -or -not (Test-Path $script:LogDir)) {
        return
    }

    try {
        $cutoff = (Get-Date).AddDays(-$script:RetentionDays)

        # Clean structured log files
        Get-ChildItem -Path $script:LogDir -Filter "dotbot-*.jsonl" -ErrorAction SilentlyContinue |
            Where-Object { $_.LastWriteTime -lt $cutoff } |
            ForEach-Object {
                try { Remove-Item $_.FullName -Force } catch { }
            }

        # Clean legacy diag files in .control
        if ($script:ControlDir -and (Test-Path $script:ControlDir)) {
            Get-ChildItem -Path $script:ControlDir -Filter "diag-*.log" -ErrorAction SilentlyContinue |
                Where-Object { $_.LastWriteTime -lt $cutoff } |
                ForEach-Object {
                    try { Remove-Item $_.FullName -Force } catch { }
                }
        }
    } catch {
        # Rotation is best-effort — don't crash
    }
}

function Write-Diag {
    <#
    .SYNOPSIS
        Backward-compatible wrapper — delegates to Write-BotLog -Level Debug.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [string]$Msg
    )

    Write-BotLog -Level Debug -Message $Msg
}

#endregion

#region Private Functions

function Write-BotLogConsole {
    <#
    .SYNOPSIS
        Writes themed console output for a log entry.
    #>

    param(
        [string]$Level,
        [string]$Message,
        [System.Management.Automation.ErrorRecord]$Exception
    )

    $icons = @{
        Debug = '.'
        Info  = '›'
        Warn  = '⚠'
        Error = '✗'
        Fatal = '✗'
    }

    $icon = $icons[$Level]
    $exMsg = if ($Exception) { " — $($Exception.Exception.Message)" } else { '' }
    $text = " $icon $Message$exMsg"

    # Try to use DotBotTheme colors if loaded
    $theme = $null
    if (Get-Module DotBotTheme) {
        try { $theme = Get-DotBotTheme } catch { }
    }

    if ($theme) {
        $colorMap = @{
            Debug = $theme.Muted
            Info  = $theme.Cyan
            Warn  = $theme.Amber
            Error = $theme.Red
            Fatal = $theme.Red
        }
        $color = $colorMap[$Level]
        $reset = $theme.Reset
        if ($color -and $reset) {
            Write-Host "${color}${text}${reset}"
            return
        }
    }

    # Fallback: plain Write-Host with basic ForegroundColor
    $fgMap = @{
        Debug = 'DarkGray'
        Info  = 'Cyan'
        Warn  = 'Yellow'
        Error = 'Red'
        Fatal = 'Red'
    }
    Write-Host $text -ForegroundColor $fgMap[$Level]
}

function Get-CurrentLogFilePath {
    <#
    .SYNOPSIS
        Returns the current log file path, rolling over when max size is exceeded.
    #>

    $dateStamp = Get-Date -Format 'yyyy-MM-dd'
    $baseName = "dotbot-$dateStamp"
    $basePath = Join-Path $script:LogDir "$baseName.jsonl"

    $maxBytes = $script:MaxFileSizeMB * 1MB
    if ($maxBytes -le 0 -or -not (Test-Path $basePath) -or (Get-Item $basePath).Length -lt $maxBytes) {
        return $basePath
    }

    # Find the next available rollover suffix
    for ($i = 1; $i -lt 100; $i++) {
        $rollPath = Join-Path $script:LogDir "$baseName.$i.jsonl"
        if (-not (Test-Path $rollPath) -or (Get-Item $rollPath).Length -lt $maxBytes) {
            return $rollPath
        }
    }

    return $basePath
}

function Write-JsonlLine {
    <#
    .SYNOPSIS
        Appends a single line to a JSONL file with FileStream retry logic.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path,

        [Parameter(Mandatory)]
        [string]$Line
    )

    # Ensure parent directory exists
    $dir = Split-Path -Parent $Path
    if ($dir -and -not (Test-Path $dir)) {
        New-Item -Path $dir -ItemType Directory -Force | Out-Null
    }

    for ($r = 0; $r -lt $script:FileRetryCount; $r++) {
        try {
            $fs = [System.IO.FileStream]::new(
                $Path,
                [System.IO.FileMode]::Append,
                [System.IO.FileAccess]::Write,
                [System.IO.FileShare]::ReadWrite
            )
            $sw = [System.IO.StreamWriter]::new($fs, [System.Text.UTF8Encoding]::new($false))
            $sw.WriteLine($Line)
            $sw.Close()
            $fs.Close()
            return
        } catch {
            if ($r -lt ($script:FileRetryCount - 1)) {
                Start-Sleep -Milliseconds ($script:FileRetryBaseMs * ($r + 1))
            }
            # Final retry failure is silently ignored (non-critical logging)
        }
    }
}

#endregion

Export-ModuleMember -Function @(
    'Initialize-DotBotLog',
    'Write-BotLog',
    'Rotate-DotBotLog',
    'Write-Diag'
)