392 lines
14 KiB
Plaintext
392 lines
14 KiB
Plaintext
# reflection/nulib/logger.nu — JSONL action logger with config-driven rotation.
|
|
# Config source: .ontoref/config.ncl → log { level, path, rotation, compress, archive, max_files }
|
|
# Env override: ONTOREF_LOG_LEVEL overrides config level if set (for CI/scripts).
|
|
|
|
use ./shared.nu [project-root]
|
|
use ../modules/store.nu [daemon-export-safe]
|
|
|
|
def level-rank [level: string]: nothing -> int {
|
|
match $level {
|
|
"write" => 0,
|
|
"read" => 1,
|
|
"interactive" => 2,
|
|
_ => 1,
|
|
}
|
|
}
|
|
|
|
# Load log config from .ontoref/config.ncl, with defaults for missing/broken config.
|
|
def load-log-config [root: string]: nothing -> record {
|
|
let defaults = {
|
|
level: "none",
|
|
path: ".coder/actions.jsonl",
|
|
rotation: "none",
|
|
compress: false,
|
|
archive: ".coder/archive",
|
|
max_files: 10,
|
|
}
|
|
|
|
let config_file = $"($root)/.ontoref/config.ncl"
|
|
if not ($config_file | path exists) { return $defaults }
|
|
|
|
let cfg = (daemon-export-safe $config_file)
|
|
if $cfg == null { return $defaults }
|
|
let log = ($cfg.log? | default {})
|
|
{
|
|
level: ($log.level? | default $defaults.level),
|
|
path: ($log.path? | default $defaults.path),
|
|
rotation: ($log.rotation? | default $defaults.rotation),
|
|
compress: ($log.compress? | default $defaults.compress),
|
|
archive: ($log.archive? | default $defaults.archive),
|
|
max_files: ($log.max_files? | default $defaults.max_files),
|
|
}
|
|
}
|
|
|
|
# Determine rotation suffix from current date and policy.
|
|
def rotation-suffix [policy: string, ts: datetime]: nothing -> string {
|
|
match $policy {
|
|
"daily" => ($ts | format date "%Y-%m-%d"),
|
|
"weekly" => ($ts | format date "%Y-W%V"),
|
|
"monthly" => ($ts | format date "%Y-%m"),
|
|
_ => "",
|
|
}
|
|
}
|
|
|
|
# Check if the current log file needs rotation. Returns true if rotation boundary crossed.
|
|
def needs-rotation [log_file: string, policy: string]: nothing -> bool {
|
|
if $policy == "none" { return false }
|
|
if not ($log_file | path exists) { return false }
|
|
|
|
let stat = ls -l $log_file | first
|
|
let file_modified = $stat.modified
|
|
let now = (date now)
|
|
|
|
let file_suffix = (rotation-suffix $policy $file_modified)
|
|
let current_suffix = (rotation-suffix $policy $now)
|
|
|
|
$file_suffix != $current_suffix
|
|
}
|
|
|
|
# Rotate: move current log to archive with date suffix, optionally compress, prune old files.
|
|
def rotate-log [root: string, cfg: record] {
|
|
let log_file = $"($root)/($cfg.path)"
|
|
if not ($log_file | path exists) { return }
|
|
|
|
let archive_dir = $"($root)/($cfg.archive)"
|
|
if not ($archive_dir | path exists) { mkdir $archive_dir }
|
|
|
|
let basename = ($log_file | path parse | get stem)
|
|
let stat = ls -l $log_file | first
|
|
let suffix = (rotation-suffix $cfg.rotation $stat.modified)
|
|
let archive_name = $"($basename)-($suffix).jsonl"
|
|
let archive_path = $"($archive_dir)/($archive_name)"
|
|
|
|
mv $log_file $archive_path
|
|
|
|
if $cfg.compress {
|
|
let gz_result = do { ^gzip $archive_path } | complete
|
|
if $gz_result.exit_code != 0 {
|
|
let err_msg = ($gz_result.stderr | str trim)
|
|
print -e $" warn: gzip failed for ($archive_path): ($err_msg)"
|
|
}
|
|
}
|
|
|
|
# Prune: keep only max_files most recent archives
|
|
let pattern = $"($archive_dir)/($basename)-*"
|
|
let archives = (glob $pattern | sort -r)
|
|
if ($archives | length) > $cfg.max_files {
|
|
let to_remove = ($archives | skip $cfg.max_files)
|
|
for f in $to_remove { rm $f }
|
|
}
|
|
}
|
|
|
|
# ── Query commands (exported for dispatcher) ────────────────────────────────
|
|
|
|
# Show log config: path, resolved file, settings.
|
|
export def log-show-config [] {
|
|
let root = (project-root)
|
|
let cfg = (load-log-config $root)
|
|
let log_file = $"($root)/($cfg.path)"
|
|
let archive_dir = $"($root)/($cfg.archive)"
|
|
let file_exists = ($log_file | path exists)
|
|
let file_size = if $file_exists { (ls $log_file | first | get size) } else { 0 }
|
|
let line_count = if $file_exists { (open $log_file --raw | lines | where { $in | is-not-empty } | length) } else { 0 }
|
|
let archive_count = if ($archive_dir | path exists) {
|
|
let basename = ($log_file | path parse | get stem)
|
|
glob $"($archive_dir)/($basename)-*" | length
|
|
} else { 0 }
|
|
|
|
print ""
|
|
print $" (ansi white_bold)Log config(ansi reset) (ansi dark_gray)\(.ontoref/config.ncl → log\)(ansi reset)"
|
|
print ""
|
|
print $" (ansi cyan)level(ansi reset) ($cfg.level)"
|
|
print $" (ansi cyan)path(ansi reset) ($cfg.path)"
|
|
print $" (ansi cyan)rotation(ansi reset) ($cfg.rotation)"
|
|
print $" (ansi cyan)compress(ansi reset) ($cfg.compress)"
|
|
print $" (ansi cyan)archive(ansi reset) ($cfg.archive)"
|
|
print $" (ansi cyan)max_files(ansi reset) ($cfg.max_files)"
|
|
print ""
|
|
print $" (ansi white_bold)Levels(ansi reset) (ansi dark_gray)\(cumulative — each includes all below\)(ansi reset)"
|
|
print ""
|
|
let levels = [
|
|
["level" "logs" "rank"];
|
|
["none" "nothing" "—"]
|
|
["write" "mutations only (add, done, apply, accept...)" "0"]
|
|
["read" "mutations + queries (list, show, status, describe...)" "0+1"]
|
|
["all" "mutations + queries + interactive (help, selectors)" "0+1+2"]
|
|
]
|
|
let active_level = ($env.ONTOREF_LOG_LEVEL? | default $cfg.level)
|
|
for row in $levels {
|
|
let marker = if $row.level == $active_level { $"(ansi green_bold)●(ansi reset)" } else { $"(ansi dark_gray)○(ansi reset)" }
|
|
print $" ($marker) (ansi cyan)($row.level | fill -w 6)(ansi reset) ($row.logs)"
|
|
}
|
|
if ($env.ONTOREF_LOG_LEVEL? | is-not-empty) {
|
|
print ""
|
|
print $" (ansi dark_gray)env override: ONTOREF_LOG_LEVEL=($env.ONTOREF_LOG_LEVEL)(ansi reset)"
|
|
}
|
|
print ""
|
|
print $" (ansi white_bold)Current file(ansi reset)"
|
|
print $" (ansi cyan)path(ansi reset) ($log_file)"
|
|
print $" (ansi cyan)exists(ansi reset) ($file_exists)"
|
|
print $" (ansi cyan)size(ansi reset) ($file_size)"
|
|
print $" (ansi cyan)entries(ansi reset) ($line_count)"
|
|
print $" (ansi cyan)archived(ansi reset) ($archive_count) files"
|
|
print ""
|
|
print $" (ansi white_bold)Columns(ansi reset) ts | author | actor | level | action"
|
|
print ""
|
|
}
|
|
|
|
# Parse JSONL log entries from the active log file.
|
|
# Guards against malformed lines: from json is an internal command and cannot
|
|
# be captured with | complete, so we pre-filter to lines that look like objects.
|
|
def load-entries [root: string, cfg: record]: nothing -> list<record> {
|
|
let log_file = $"($root)/($cfg.path)"
|
|
if not ($log_file | path exists) { return [] }
|
|
open $log_file --raw
|
|
| lines
|
|
| where { $in | str trim | is-not-empty }
|
|
| where { ($in | str trim | str starts-with "{") and ($in | str trim | str ends-with "}") }
|
|
| each { |line| try { $line | str trim | from json } catch { null } }
|
|
| where { $in != null }
|
|
}
|
|
|
|
# Filter entries by --since/--until (ISO 8601 string comparison).
|
|
def filter-time-range [entries: list<record>, since: string, until: string]: nothing -> list<record> {
|
|
mut result = $entries
|
|
if ($since | is-not-empty) {
|
|
$result = ($result | where { |e| ($e.ts? | default "") >= $since })
|
|
}
|
|
if ($until | is-not-empty) {
|
|
$result = ($result | where { |e| ($e.ts? | default "") <= $until })
|
|
}
|
|
$result
|
|
}
|
|
|
|
# Apply column:match-text query filters. Multiple queries are AND-combined.
|
|
# Each query is "column:text" — matches if the column value contains text.
|
|
def filter-query [entries: list<record>, queries: list<string>]: nothing -> list<record> {
|
|
mut result = $entries
|
|
for q in $queries {
|
|
let parts = ($q | split row ":" | collect)
|
|
if ($parts | length) < 2 { continue }
|
|
let col = ($parts | first)
|
|
let pattern = ($parts | skip 1 | str join ":")
|
|
$result = ($result | where { |e|
|
|
let val = ($e | get -o $col | default "")
|
|
($val | str contains $pattern)
|
|
})
|
|
}
|
|
$result
|
|
}
|
|
|
|
# Format a single entry for terminal display.
|
|
def fmt-entry [entry: record, timestamps: bool]: nothing -> string {
|
|
let level_color = match ($entry.level? | default "read") {
|
|
"write" => (ansi yellow),
|
|
"read" => (ansi cyan),
|
|
"interactive" => (ansi magenta),
|
|
_ => (ansi default_dimmed),
|
|
}
|
|
let actor_str = $"(ansi dark_gray)($entry.actor? | default '?')(ansi reset)"
|
|
let action_str = $"($entry.action? | default '')"
|
|
if $timestamps {
|
|
let ts_str = $"(ansi dark_gray)($entry.ts? | default '')(ansi reset)"
|
|
$" ($ts_str) ($level_color)($entry.level? | default '?')(ansi reset) ($actor_str) ($action_str)"
|
|
} else {
|
|
$" ($level_color)($entry.level? | default '?')(ansi reset) ($actor_str) ($action_str)"
|
|
}
|
|
}
|
|
|
|
# Query log entries with filters. Used by `strat log`.
|
|
export def log-query [
|
|
--tail_n: int = -1,
|
|
--since: string = "",
|
|
--until: string = "",
|
|
--latest,
|
|
--timestamps (-t),
|
|
--level: string = "",
|
|
--actor: string = "",
|
|
--query (-q): list<string> = [],
|
|
--fmt (-f): string = "text",
|
|
] {
|
|
let root = (project-root)
|
|
let cfg = (load-log-config $root)
|
|
let log_file = $"($root)/($cfg.path)"
|
|
|
|
if not ($log_file | path exists) {
|
|
print " No log entries."
|
|
return
|
|
}
|
|
|
|
mut entries = (load-entries $root $cfg)
|
|
$entries = (filter-time-range $entries $since $until)
|
|
|
|
if ($level | is-not-empty) {
|
|
$entries = ($entries | where { |e| ($e.level? | default "") == $level })
|
|
}
|
|
if ($actor | is-not-empty) {
|
|
$entries = ($entries | where { |e| ($e.actor? | default "") == $actor })
|
|
}
|
|
if ($query | is-not-empty) {
|
|
$entries = (filter-query $entries $query)
|
|
}
|
|
|
|
if $latest {
|
|
$entries = if ($entries | is-empty) { [] } else { [($entries | last)] }
|
|
} else if $tail_n > 0 {
|
|
let total = ($entries | length)
|
|
if $total > $tail_n {
|
|
$entries = ($entries | skip ($total - $tail_n))
|
|
}
|
|
}
|
|
|
|
match $fmt {
|
|
"json" => { print ($entries | to json) },
|
|
"jsonl" => {
|
|
for e in $entries { print ($e | to json -r) }
|
|
},
|
|
"table" => { print ($entries | table --expand) },
|
|
_ => {
|
|
if ($entries | is-empty) {
|
|
print " No log entries."
|
|
return
|
|
}
|
|
for e in $entries {
|
|
print (fmt-entry $e $timestamps)
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
# Follow log output — polls for new entries every 2 seconds.
|
|
export def log-follow [--timestamps (-t), --query (-q): list<string> = []] {
|
|
let root = (project-root)
|
|
let cfg = (load-log-config $root)
|
|
let log_file = $"($root)/($cfg.path)"
|
|
let has_filter = ($query | is-not-empty)
|
|
|
|
let filter_label = if $has_filter { $" (ansi cyan)filter: ($query | str join ', ')(ansi reset)" } else { "" }
|
|
print $" (ansi dark_gray)Following ($cfg.path)(ansi reset)($filter_label)(ansi dark_gray) — Ctrl+C to stop(ansi reset)"
|
|
print ""
|
|
|
|
mut last_count = if ($log_file | path exists) {
|
|
(load-entries $root $cfg) | length
|
|
} else { 0 }
|
|
|
|
# Show last 10 entries as context (filtered if query given)
|
|
if $last_count > 0 {
|
|
mut entries = (load-entries $root $cfg)
|
|
if $has_filter { $entries = (filter-query $entries $query) }
|
|
let total = ($entries | length)
|
|
let start = if $total > 10 { $total - 10 } else { 0 }
|
|
let tail_entries = ($entries | skip $start)
|
|
for e in $tail_entries {
|
|
print (fmt-entry $e $timestamps)
|
|
}
|
|
}
|
|
|
|
loop {
|
|
sleep 2sec
|
|
let current_count = if ($log_file | path exists) {
|
|
(load-entries $root $cfg) | length
|
|
} else { 0 }
|
|
|
|
if $current_count < $last_count {
|
|
# File was rotated (replaced with a shorter one) — reset position.
|
|
$last_count = 0
|
|
}
|
|
if $current_count > $last_count {
|
|
let entries = (load-entries $root $cfg)
|
|
mut new_entries = ($entries | skip $last_count)
|
|
if $has_filter { $new_entries = (filter-query $new_entries $query) }
|
|
for e in $new_entries {
|
|
print (fmt-entry $e $timestamps)
|
|
}
|
|
$last_count = $current_count
|
|
}
|
|
}
|
|
}
|
|
|
|
# ── Write ────────────────────────────────────────────────────────────────────
|
|
|
|
# Manual record — for agents, hooks, scripts, and any external actor.
|
|
# Bypasses level filtering (always writes if log is not "none").
|
|
export def log-record [
|
|
action: string,
|
|
--level (-l): string = "write",
|
|
--author (-a): string = "",
|
|
--actor: string = "",
|
|
] {
|
|
let root = (project-root)
|
|
let cfg = (load-log-config $root)
|
|
|
|
let effective_level = ($env.ONTOREF_LOG_LEVEL? | default $cfg.level)
|
|
if $effective_level == "none" { return }
|
|
|
|
let log_file = $"($root)/($cfg.path)"
|
|
let dir = ($log_file | path dirname)
|
|
if not ($dir | path exists) { mkdir $dir }
|
|
|
|
if (needs-rotation $log_file $cfg.rotation) {
|
|
rotate-log $root $cfg
|
|
}
|
|
|
|
let ts = (date now | format date "%Y-%m-%dT%H:%M:%S%z")
|
|
let resolved_author = if ($author | is-not-empty) { $author } else { ($env.ONTOREF_AUTHOR? | default "unknown") }
|
|
let resolved_actor = if ($actor | is-not-empty) { $actor } else { ($env.ONTOREF_ACTOR? | default "developer") }
|
|
let entry = { ts: $ts, author: $resolved_author, actor: $resolved_actor, level: $level, action: $action }
|
|
let line = ($entry | to json --raw)
|
|
$line + "\n" | save -a $log_file
|
|
}
|
|
|
|
# Auto log — called internally by dispatcher on each canonical command.
|
|
export def log-action [action: string, level: string = "read"] {
|
|
let root = (project-root)
|
|
let cfg = (load-log-config $root)
|
|
|
|
# Env override: ONTOREF_LOG_LEVEL takes precedence over config
|
|
let effective_level = ($env.ONTOREF_LOG_LEVEL? | default $cfg.level)
|
|
if $effective_level == "none" { return }
|
|
|
|
# "all" maps to interactive (rank 2) — allows everything through
|
|
let threshold_key = if $effective_level == "all" { "interactive" } else { $effective_level }
|
|
let action_rank = (level-rank $level)
|
|
let threshold_rank = (level-rank $threshold_key)
|
|
if $action_rank > $threshold_rank { return }
|
|
|
|
let log_file = $"($root)/($cfg.path)"
|
|
let dir = ($log_file | path dirname)
|
|
if not ($dir | path exists) { mkdir $dir }
|
|
|
|
# Rotation check before write
|
|
if (needs-rotation $log_file $cfg.rotation) {
|
|
rotate-log $root $cfg
|
|
}
|
|
|
|
let ts = (date now | format date "%Y-%m-%dT%H:%M:%S%z")
|
|
let author = ($env.ONTOREF_AUTHOR? | default "unknown")
|
|
let actor = ($env.ONTOREF_ACTOR? | default "developer")
|
|
let entry = { ts: $ts, author: $author, actor: $actor, level: $level, action: $action }
|
|
let line = ($entry | to json --raw)
|
|
$line + "\n" | save -a $log_file
|
|
}
|