#!/usr/bin/env nu # reflection/modules/run.nu — step execution tracking and run lifecycle. # Provides: run start, step report, run status # # Storage: .coder//runs// # run.json — run metadata # steps.jsonl — one JSON record per reported step # current.json — pointer to active run (in .coder//runs/) use ../modules/store.nu [daemon-export-safe] use ../modules/describe.nu [nickel-import-path] def project-root []: nothing -> string { let pr = ($env.ONTOREF_PROJECT_ROOT? | default "") if ($pr | is-not-empty) and ($pr != $env.ONTOREF_ROOT) { $pr } else { $env.ONTOREF_ROOT } } def runs-dir []: nothing -> string { let root = (project-root) let actor = ($env.ONTOREF_ACTOR? | default "developer") $"($root)/.coder/($actor)/runs" } def current-run-file []: nothing -> string { $"(runs-dir)/current.json" } def load-current-run []: nothing -> record { let f = (current-run-file) if not ($f | path exists) { return {} } open $f } def load-run-steps [run_id: string]: nothing -> list { let f = $"(runs-dir)/($run_id)/steps.jsonl" if not ($f | path exists) { return [] } open $f | lines | where { |l| $l | str trim | is-not-empty } | each { |l| $l | from json } } def find-mode-file [mode: string]: nothing -> string { let root = (project-root) let project_file = $"($root)/reflection/modes/($mode).ncl" let ontoref_file = $"($env.ONTOREF_ROOT)/reflection/modes/($mode).ncl" if ($project_file | path exists) { $project_file } else if ($ontoref_file | path exists) { $ontoref_file } else { "" } } def load-mode-dag [mode: string]: nothing -> record { let root = (project-root) let mode_file = (find-mode-file $mode) if ($mode_file | is-empty) { error make { msg: $"Mode '($mode)' not found in project or ontoref reflection/modes/" } } let mode_root = if ($mode_file | str starts-with $root) and ($root != $env.ONTOREF_ROOT) { $root } else { $env.ONTOREF_ROOT } let ip = (nickel-import-path $mode_root) let result = (daemon-export-safe $mode_file --import-path $ip) if $result == null { error make { msg: $"Failed to export mode '($mode)' — check NCL syntax and import paths" } } $result } def now-iso []: nothing -> string { date now | format date "%Y-%m-%dT%H:%M:%SZ" } def run-id-for [mode: string, task: string]: nothing -> string { if ($task | is-not-empty) { $"($mode)-($task)" } else { let ts = (date now | format date "%Y%m%dT%H%M%S") $"($mode)-($ts)" } } # Start a new run for a mode. Establishes run context for subsequent `step report` calls. export def "run start" [ mode: string, # Mode ID (must exist in reflection/modes/) --task (-t): string = "", # Backlog task ID to associate with this run --fmt (-f): string = "", ]: nothing -> nothing { let actor = ($env.ONTOREF_ACTOR? | default "developer") let fmt = if ($fmt | is-not-empty) { $fmt } else if $actor == "agent" { "json" } else { "text" } let mode_data = (load-mode-dag $mode) let run_id = (run-id-for $mode $task) let dir = $"(runs-dir)/($run_id)" mkdir $dir let step_count = ($mode_data.steps? | default [] | length) let state = { run_id: $run_id, mode: $mode, task: $task, actor: $actor, started_at: (now-iso), steps_total: $step_count, } $state | to json | save --force $"($dir)/run.json" $state | to json | save --force (current-run-file) # Seed empty steps.jsonl so append works consistently "" | save --force $"($dir)/steps.jsonl" if $fmt == "json" { print ($state | to json) } else { print $"Run started: ($run_id)" print $" Mode: ($mode) ($step_count) steps" if ($task | is-not-empty) { print $" Task: ($task)" } print $" Dir: ($dir)" print $" Next: ontoref step report ($mode) --status pass|fail" } } # Report the result of a step. Validates step exists and dependencies are satisfied. export def "step report" [ mode: string, # Mode ID step_id: string, # Step ID to report --status (-s): string, # pass | fail | skip --exit-code (-e): int = 0, --artifacts (-a): list = [], --warnings (-w): int = 0, --run (-r): string = "", # Override run ID (default: active run) --fmt (-f): string = "", ]: nothing -> nothing { let actor = ($env.ONTOREF_ACTOR? | default "developer") let fmt = if ($fmt | is-not-empty) { $fmt } else if $actor == "agent" { "json" } else { "text" } if not ($status in ["pass", "fail", "skip"]) { error make { msg: $"Invalid status '($status)'. Must be: pass | fail | skip" } } # Resolve run context let current = (load-current-run) let run_id = if ($run | is-not-empty) { $run } else if ($current | is-not-empty) and ($current.mode? | default "") == $mode { $current.run_id? | default "" } else { error make { msg: $"No active run for mode '($mode)'. Start one with: ontoref run start ($mode)" } } let dir = $"(runs-dir)/($run_id)" if not ($dir | path exists) { error make { msg: $"Run directory not found: ($dir). Start with: ontoref run start ($mode)" } } let steps_file = $"($dir)/steps.jsonl" # Validate step exists in mode DAG let mode_data = (load-mode-dag $mode) # Validate guards — warn if any Block guard would fail (informational in step report context) let guards = ($mode_data.guards? | default []) let blocking_guards = ($guards | where { |g| ($g.severity? | default "Block") == "Block" }) for g in $blocking_guards { let result = (do { bash -c $g.cmd } | complete) if $result.exit_code != 0 { if $fmt != "json" { print $"(ansi yellow)WARN(ansi reset): guard '($g.id)' would block mode execution: ($g.reason)" } } } let matching_steps = ($mode_data.steps? | default [] | where { |s| $s.id == $step_id }) let step_def = if ($matching_steps | is-not-empty) { $matching_steps | first } else { null } if ($step_def | is-empty) { error make { msg: $"Step '($step_id)' not found in mode '($mode)'. Valid steps: ($mode_data.steps? | default [] | each { |s| $s.id } | str join ', ')" } } # Validate blocking dependencies are satisfied let completed = (load-run-steps $run_id) let blocking = ($step_def.depends_on? | default [] | where { |d| ($d.kind? | default "Always") in ["Always", "OnSuccess"] }) for dep in $blocking { let prev_list = ($completed | where { |r| $r.step == $dep.step }) let prev = if ($prev_list | is-not-empty) { $prev_list | first } else { null } if ($prev | is-empty) { error make { msg: $"Step '($step_id)' requires '($dep.step)' to be reported first" } } if ($dep.kind? | default "Always") == "OnSuccess" and ($prev.status? | default "") != "pass" { error make { msg: $"Step '($step_id)' depends on '($dep.step)' with OnSuccess, but it ($prev.status? | default 'unknown')" } } } let entry = { run_id: $run_id, mode: $mode, step: $step_id, status: $status, exit_code: $exit_code, warnings: $warnings, artifacts: $artifacts, actor: $actor, ts: (now-iso), } $"\n($entry | to json -r)" | save --append $steps_file # Emit side-effect on fail+Stop let on_error_strategy = ($step_def.on_error?.strategy? | default "Stop") if $status == "fail" and $on_error_strategy == "Stop" { if $fmt != "json" { print $"(ansi red)BLOCKED(ansi reset): step '($step_id)' failed with on_error=Stop" print $" Run '($run_id)' is now blocked. Resolve and re-run this step." } } if $fmt == "json" { print ($entry | to json) } else { let mark = if $status == "pass" { $"(ansi green)✓(ansi reset)" } else if $status == "fail" { $"(ansi red)✗(ansi reset)" } else { $"(ansi yellow)-(ansi reset)" } print $" ($mark) ($step_id) [($mode) / ($run_id)]" if $warnings > 0 { print $" ($warnings) warning(s)" } if ($artifacts | is-not-empty) { print $" artifacts: ($artifacts | str join ', ')" } } } # Show current run progress. export def "run status" [ --run (-r): string = "", # Specific run ID (default: active run) --fmt (-f): string = "", ]: nothing -> nothing { let actor = ($env.ONTOREF_ACTOR? | default "developer") let fmt = if ($fmt | is-not-empty) { $fmt } else if $actor == "agent" { "json" } else { "text" } let current = (load-current-run) let run_id = if ($run | is-not-empty) { $run } else if ($current | is-not-empty) { $current.run_id? | default "" } else { "" } if ($run_id | is-empty) { if $fmt == "json" { print "null" } else { print "No active run. Start with: ontoref run start " } return } let mode_id = ($current.mode? | default "") let mode_data = if ($mode_id | is-not-empty) { try { load-mode-dag $mode_id } catch { {} } } else { {} } let all_step_ids = ($mode_data.steps? | default [] | each { |s| $s.id }) let reported = (load-run-steps $run_id) let steps_view = ($all_step_ids | each { |id| let r_list = ($reported | where { |s| $s.step == $id }) let r = if ($r_list | is-not-empty) { $r_list | first } else { null } if ($r | is-not-empty) { { id: $id, status: $r.status, exit_code: ($r.exit_code? | default 0), ts: ($r.ts? | default ""), warnings: ($r.warnings? | default 0) } } else { { id: $id, status: "pending", exit_code: null, ts: "", warnings: 0 } } }) let data = { run_id: $run_id, mode: $mode_id, task: ($current.task? | default ""), started_at: ($current.started_at? | default ""), steps_total: ($all_step_ids | length), steps_reported: ($reported | length), steps: $steps_view, } if $fmt == "json" { print ($data | to json) } else { print "" print $"RUN: ($data.run_id)" print "══════════════════════════════════════════════════════════════════" if ($data.task | is-not-empty) { print $" Task: ($data.task)" } print $" Mode: ($data.mode)" print $" Started: ($data.started_at)" print $" Progress: ($data.steps_reported)/($data.steps_total) steps reported" print "" for s in $data.steps { let mark = match $s.status { "pass" => $"(ansi green)✓(ansi reset)", "fail" => $"(ansi red)✗(ansi reset)", "skip" => $"(ansi yellow)-(ansi reset)", "pending" => $"(ansi dark_gray)·(ansi reset)", _ => " ", } let warn_tag = if ($s.warnings? | default 0) > 0 { $" ⚠ ($s.warnings) warnings" } else { "" } print $" ($mark) ($s.id)($warn_tag)" } print "" } } # Verify a run is complete and emit side effects (backlog close, artifacts summary). # Fails with a specific message if any required step is missing or blocked. export def "mode complete" [ mode: string, # Mode ID --task (-t): string = "", # Backlog task ID to close on success --run (-r): string = "", # Override run ID (default: active run) --fmt (-f): string = "", ]: nothing -> nothing { let actor = ($env.ONTOREF_ACTOR? | default "developer") let fmt = if ($fmt | is-not-empty) { $fmt } else if $actor == "agent" { "json" } else { "text" } # Resolve run context let current = (load-current-run) let run_id = if ($run | is-not-empty) { $run } else if ($current | is-not-empty) and ($current.mode? | default "") == $mode { $current.run_id? | default "" } else { "" } if ($run_id | is-empty) { error make { msg: $"No active run for mode '($mode)'. Start one with: ontoref run start ($mode)" } } let dir = $"(runs-dir)/($run_id)" if not ($dir | path exists) { error make { msg: $"Run directory not found: ($dir)" } } # Load mode DAG and reported steps let mode_data = (load-mode-dag $mode) let all_steps = ($mode_data.steps? | default []) let reported = (load-run-steps $run_id) # Build verification results — one record per step let results = ($all_steps | each { |step_def| let rep_list = ($reported | where { |r| $r.step == $step_def.id }) let rep = if ($rep_list | is-not-empty) { $rep_list | first } else { null } let strategy = ($step_def.on_error?.strategy? | default "Stop") { id: $step_def.id, reported: ($rep | is-not-empty), status: (if ($rep | is-not-empty) { $rep.status } else { "pending" }), strategy: $strategy, # A step blocks completion if: not reported AND strategy=Stop, # OR reported as fail AND strategy=Stop blocks: ( (($rep | is-empty) and $strategy == "Stop") or (($rep | is-not-empty) and $rep.status == "fail" and $strategy == "Stop") ), } }) let blockers = ($results | where blocks == true) let warnings = ($results | where { |r| not $r.blocks and $r.status in ["fail", "pending"] }) let all_artifacts = ($reported | each { |r| $r.artifacts? | default [] } | flatten) let total_warnings = ($reported | each { |r| $r.warnings? | default 0 } | math sum) let resolved_task = if ($task | is-not-empty) { $task } else { $current.task? | default "" } let ok = ($blockers | is-empty) let result = { run_id: $run_id, mode: $mode, task: $resolved_task, ok: $ok, steps_total: ($all_steps | length), steps_reported: ($reported | length), blockers: ($blockers | each { |b| { id: $b.id, status: $b.status, strategy: $b.strategy } }), warnings: ($warnings | each { |w| { id: $w.id, status: $w.status } }), artifacts: $all_artifacts, total_warnings: $total_warnings, } if $ok { # Mark run as completed in run.json let run_meta = (open $"($dir)/run.json") $run_meta | merge { completed_at: (now-iso), ok: true } | to json | save --force $"($dir)/run.json" # Clear current.json — no active run "{}" | save --force (current-run-file) } if $fmt == "json" { print ($result | to json) } else if $ok { print "" print $"(ansi green)✓ RUN COMPLETE(ansi reset): ($run_id)" print "══════════════════════════════════════════════════════════════════" print $" Mode: ($mode)" if ($resolved_task | is-not-empty) { print $" Task: ($resolved_task)" } print $" Steps: ($result.steps_reported)/($result.steps_total) reported" if $total_warnings > 0 { print $" Warnings: ($total_warnings) advisory (non-blocking)" } if ($all_artifacts | is-not-empty) { print "" print " Artifacts:" for a in $all_artifacts { print $" ($a)" } } if ($resolved_task | is-not-empty) { print "" print $" Close backlog item with: ontoref backlog done ($resolved_task)" } print "" } else { print "" print $"(ansi red)✗ RUN INCOMPLETE(ansi reset): ($run_id)" print "══════════════════════════════════════════════════════════════════" print $" ($blockers | length) blocker(s):" for b in $blockers { let reason = if $b.status == "pending" { "not reported" } else { $"($b.status) with on_error=Stop" } print $" ($b.id) — ($reason)" } print "" } }