ff-status.sh 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. #!/usr/bin/env bash
  2. # ff-status.sh - emit a fleetflow run's live status as JSON (the data feed
  3. # behind assets/ff-monitor.html, and a machine-readable run summary on its own).
  4. #
  5. # Reads the run journal + artifacts; never modifies anything. Lane state is
  6. # derived from journal records (started-without-result = running), timings
  7. # from artifact mtimes, activity from lane commits (claude brains) or the
  8. # codex event stream (item.completed counts + last item).
  9. # stdout: the JSON document (data only). stderr: chatter.
  10. #
  11. # Exit codes: 0 ok | 2 usage
  12. set -u
  13. . "$(dirname "${BASH_SOURCE[0]}")/_env.sh"
  14. FF_VERSION="1.1.0"
  15. usage() {
  16. cat <<'EOF'
  17. Usage: ff-status.sh --run NAME [--repo PATH] [--out FILE] [--watch SECONDS]
  18. --run NAME run name under <repo>/.fleetflow/
  19. --repo PATH repo root (default: git toplevel of cwd)
  20. --out FILE write JSON to FILE instead of stdout
  21. --watch SECONDS loop forever, rewriting --out every SECONDS (requires --out)
  22. EXAMPLES
  23. ff-status.sh --run currency | jq '.lanes[] | {id, state, elapsed_s}'
  24. ff-status.sh --run currency --out .fleetflow/currency/status.json --watch 3
  25. EOF
  26. }
  27. err() { echo "ff-status: $*" >&2; }
  28. RUN="" REPO="" OUT="" WATCH=""
  29. while [ $# -gt 0 ]; do
  30. case "$1" in
  31. --run) RUN="${2:-}"; shift 2 ;;
  32. --repo) REPO="${2:-}"; shift 2 ;;
  33. --out) OUT="${2:-}"; shift 2 ;;
  34. --watch) WATCH="${2:-}"; shift 2 ;;
  35. -h|--help) usage; exit 0 ;;
  36. *) err "unknown argument: $1"; usage >&2; exit 2 ;;
  37. esac
  38. done
  39. [ -n "$RUN" ] || { err "--run required"; usage >&2; exit 2; }
  40. command -v jq >/dev/null || { err "jq required"; exit 2; }
  41. [ -z "$WATCH" ] || [ -n "$OUT" ] || { err "--watch requires --out"; exit 2; }
  42. [ -n "$REPO" ] || REPO="$(git rev-parse --show-toplevel 2>/dev/null)" || true
  43. RUNDIR="$REPO/.fleetflow/$RUN"
  44. [ -f "$RUNDIR/journal.jsonl" ] || { err "no journal at $RUNDIR"; exit 2; }
  45. mtime() { stat -c %Y "$1" 2>/dev/null || echo 0; }
  46. emit() {
  47. local now lanes id brain state started finished elapsed art commits last_c tools activity tokens etail rc
  48. now=$(date +%s)
  49. lanes="[]"
  50. for id in $(jq -r 'select(.type=="started") | .id' "$RUNDIR/journal.jsonl" | awk '!seen[$0]++'); do
  51. brain="$(jq -r --arg id "$id" 'select(.id==$id) | .brain' "$RUNDIR/journal.jsonl" | head -1)"
  52. phase="$(jq -r --arg id "$id" 'select(.type=="started" and .id==$id) | .phase // "build"' "$RUNDIR/journal.jsonl" | head -1)"
  53. # state derives from the LAST journal record for this id: a respawn appends a
  54. # fresh "started" AFTER an old "result", which means the lane is running again.
  55. # last-result-wins (tail -1 of result records) would wrongly show done/failed.
  56. last_type="$(jq -r --arg id "$id" 'select(.id==$id) | .type' "$RUNDIR/journal.jsonl" | tail -1)"
  57. rc="$(jq -r --arg id "$id" 'select(.type=="result" and .id==$id) | .rc' "$RUNDIR/journal.jsonl" | tail -1)"
  58. art="$(jq -r --arg id "$id" 'select(.type=="result" and .id==$id) | .artifact' "$RUNDIR/journal.jsonl" | tail -1)"
  59. if [ "$last_type" = "started" ]; then state="running"; finished=0
  60. elif [ -z "$rc" ]; then state="running"; finished=0
  61. elif [ "$rc" = "0" ]; then state="done"; finished=$(mtime "$art")
  62. else state="failed"; finished=$(mtime "$art"); fi
  63. started=$(mtime "$RUNDIR/$id.prompt.txt")
  64. if [ "$finished" -gt 0 ]; then elapsed=$((finished - started)); else elapsed=$((now - started)); fi
  65. [ "$elapsed" -ge 0 ] || elapsed=0
  66. commits=0; last_c=""
  67. if [ -d "$RUNDIR/wt-$id" ]; then
  68. commits="$(git -C "$RUNDIR/wt-$id" rev-list --count "main..HEAD" 2>/dev/null || echo 0)"
  69. last_c="$(git -C "$RUNDIR/wt-$id" log -1 --format=%s "main..HEAD" -- 2>/dev/null | head -c 90)"
  70. fi
  71. tools=0; activity=""; tokens=0
  72. if [ "$brain" = "codex" ] && [ -f "$RUNDIR/$id.events.jsonl" ]; then
  73. tools="$(jq -r 'select(.type=="item.completed" and .item.type=="command_execution") | 1' "$RUNDIR/$id.events.jsonl" 2>/dev/null | wc -l | tr -d ' ')"
  74. activity="$(jq -r 'select(.type=="item.completed") | .item | (.type + ": " + ((.command // .text // "") | gsub("\n";" ") | .[0:70]))' "$RUNDIR/$id.events.jsonl" 2>/dev/null | tail -1)"
  75. tokens="$(jq -r 'select(.usage != null) | .usage.total_tokens // (.usage.input_tokens + .usage.output_tokens) // 0' "$RUNDIR/$id.events.jsonl" 2>/dev/null | tail -1)"
  76. [ -n "$tokens" ] || tokens=0
  77. elif [ "$state" != "running" ] && [ -f "$RUNDIR/$id.result.json" ]; then
  78. tokens="$(jq -r '.usage.output_tokens // 0' "$RUNDIR/$id.result.json" 2>/dev/null | head -1)"
  79. tools="$(jq -r '.num_turns // 0' "$RUNDIR/$id.result.json" 2>/dev/null | head -1)"
  80. [ -n "$tokens" ] || tokens=0; [ -n "$tools" ] || tools=0
  81. elif [ "$state" = "running" ]; then
  82. # claude -p persists its session transcript inside the worker's isolated
  83. # config dir as it runs - the only live signal a claude-brain lane emits
  84. T="$(ls -t "${FLEETFLOW_CFG_BASE:-$HOME/.fleet-worker}/cfg-ff-$id/projects"/*/*.jsonl 2>/dev/null | head -1)"
  85. if [ -n "$T" ]; then
  86. tools="$(grep -c '"type":"tool_use"' "$T" 2>/dev/null | tr -d ' ')"
  87. last_tool="$(grep -o '"name":"[A-Za-z_]*"' "$T" 2>/dev/null | tail -1 | cut -d'"' -f4)"
  88. [ -z "$last_tool" ] || activity="live: $last_tool"
  89. tokens="$(grep -o '"output_tokens":[0-9]*' "$T" 2>/dev/null | awk -F: '{s+=$2} END {print s+0}')"
  90. fi
  91. fi
  92. [ -n "$activity" ] || activity="${last_c:-working}"
  93. etail="$(grep -v '^\s*$' "$RUNDIR/$id.err" 2>/dev/null | tail -1 | head -c 160)"
  94. lanes="$(jq -nc --argjson L "$lanes" \
  95. --arg id "$id" --arg brain "$brain" --arg state "$state" --arg activity "$activity" \
  96. --arg last_c "$last_c" --arg etail "$etail" --arg art "${art:-}" --arg phase "${phase:-build}" \
  97. --argjson started "$started" --argjson elapsed "$elapsed" \
  98. --argjson commits "${commits:-0}" --argjson tools "${tools:-0}" --argjson tokens "${tokens:-0}" \
  99. '$L + [{id:$id,brain:$brain,phase:$phase,state:$state,started:$started,elapsed_s:$elapsed,
  100. commits:$commits,tools:$tools,tokens:$tokens,activity:$activity,
  101. last_commit:$last_c,artifact:$art,err_tail:$etail}]')"
  102. done
  103. local manifest="null"
  104. if [ -f "$RUNDIR/manifest.json" ]; then
  105. manifest="$(jq -c '{packet_count:(.packets|length), phases:(.phases // [])}' "$RUNDIR/manifest.json" 2>/dev/null)"
  106. fi
  107. jq -nc --arg run "$RUN" --arg repo "$REPO" --argjson now "$now" --argjson lanes "$lanes" \
  108. --argjson manifest "${manifest:-null}" \
  109. '{run:$run,repo:$repo,generated_at:$now,lanes:$lanes,manifest:$manifest}'
  110. }
  111. if [ -n "$WATCH" ]; then
  112. err "watching every ${WATCH}s -> $OUT (ctrl-c to stop)"
  113. while :; do emit > "$OUT.tmp" && mv -f "$OUT.tmp" "$OUT"; sleep "$WATCH"; done
  114. elif [ -n "$OUT" ]; then
  115. emit > "$OUT"
  116. else
  117. emit
  118. fi
  119. exit 0