391 lines
15 KiB
Plaintext
391 lines
15 KiB
Plaintext
|
|
#!/usr/bin/env nu
|
||
|
|
# reflection/modules/run.nu — step execution tracking and run lifecycle.
|
||
|
|
# Provides: run start, step report, run status
|
||
|
|
#
|
||
|
|
# Storage: .coder/<actor>/runs/<run_id>/
|
||
|
|
# run.json — run metadata
|
||
|
|
# steps.jsonl — one JSON record per reported step
|
||
|
|
# current.json — pointer to active run (in .coder/<actor>/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<record> {
|
||
|
|
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) <step_id> --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<string> = [],
|
||
|
|
--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)
|
||
|
|
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 <mode>" }
|
||
|
|
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 ""
|
||
|
|
}
|
||
|
|
}
|