loki-mode 7.89.1 → 7.90.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/proof-check.sh +234 -0
- package/autonomy/lib/proof-pr.sh +251 -0
- package/autonomy/loki +62 -1
- package/autonomy/run.sh +216 -5
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +167 -2
- package/dashboard/static/index.html +243 -383
- package/dashboard/static/proofs.html +32 -3
- package/docs/INSTALLATION.md +2 -2
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
package/autonomy/run.sh
CHANGED
|
@@ -572,6 +572,25 @@ if [ -f "$GIT_PR_ADVISORY_LIB" ]; then
|
|
|
572
572
|
source "$GIT_PR_ADVISORY_LIB"
|
|
573
573
|
fi
|
|
574
574
|
|
|
575
|
+
# Proven PR (Loop 6): shared print-only Evidence Receipt renderer for PR bodies.
|
|
576
|
+
# render_evidence_receipt_md prints the run's honest headline + facts +
|
|
577
|
+
# verify-yourself block into the PR body. Pure, never pushes/PRs/mutates.
|
|
578
|
+
PROOF_PR_LIB="$SCRIPT_DIR/lib/proof-pr.sh"
|
|
579
|
+
if [ -f "$PROOF_PR_LIB" ]; then
|
|
580
|
+
# shellcheck source=lib/proof-pr.sh
|
|
581
|
+
source "$PROOF_PR_LIB"
|
|
582
|
+
fi
|
|
583
|
+
|
|
584
|
+
# Proven PR (Loop 6 / Slice B): optional advisory verified-completion check-run.
|
|
585
|
+
# Owned by Slice B (autonomy/lib/proof-check.sh); sourced guarded so this slice
|
|
586
|
+
# is correct whether or not the file is present in the tree, and the single call
|
|
587
|
+
# site is itself guarded on declare -f + LOKI_PROVEN_PR_CHECK.
|
|
588
|
+
PROOF_CHECK_LIB="$SCRIPT_DIR/lib/proof-check.sh"
|
|
589
|
+
if [ -f "$PROOF_CHECK_LIB" ]; then
|
|
590
|
+
# shellcheck source=lib/proof-check.sh
|
|
591
|
+
source "$PROOF_CHECK_LIB"
|
|
592
|
+
fi
|
|
593
|
+
|
|
575
594
|
# Completion Council (v5.25.0) - Multi-agent completion verification
|
|
576
595
|
# Source completion council module
|
|
577
596
|
COUNCIL_SCRIPT="$SCRIPT_DIR/completion-council.sh"
|
|
@@ -2330,6 +2349,19 @@ EOF
|
|
|
2330
2349
|
jq -r '.completed_tasks[]? | select(.github_issue) | "Closes #\(.github_issue)"' .loki/ledger.json >> "$pr_body" 2>/dev/null || true
|
|
2331
2350
|
fi
|
|
2332
2351
|
|
|
2352
|
+
# Proven PR (Loop 6): append the Evidence Receipt to the body file before
|
|
2353
|
+
# gh pr create --body-file. Default-on; LOKI_PROVEN_PR=0 -> body file bytes
|
|
2354
|
+
# byte-identical to before. Empty expected_head_sha by design (R-DET-1
|
|
2355
|
+
# run_id pointer is the anti-stale guard; the branch head is offset by the
|
|
2356
|
+
# session commit). Best-effort: a missing proof appends nothing extra here.
|
|
2357
|
+
if [ "${LOKI_PROVEN_PR:-1}" != "0" ] && declare -f render_evidence_receipt_md >/dev/null 2>&1; then
|
|
2358
|
+
local _bf_proof=""
|
|
2359
|
+
_bf_proof="$(_loki_proof_json_for_pr 2>/dev/null || true)"
|
|
2360
|
+
if [ -n "$_bf_proof" ]; then
|
|
2361
|
+
{ printf '\n'; render_evidence_receipt_md "$_bf_proof" "" "" 2>/dev/null; } >> "$pr_body" 2>/dev/null || true
|
|
2362
|
+
fi
|
|
2363
|
+
fi
|
|
2364
|
+
|
|
2333
2365
|
# Build PR create command
|
|
2334
2366
|
local pr_args=("pr" "create" "--repo" "$repo" "--title" "[Loki Mode] $feature_name" "--body-file" "$pr_body")
|
|
2335
2367
|
|
|
@@ -3184,7 +3216,30 @@ on_run_complete() {
|
|
|
3184
3216
|
log_info "LOKI_DELEGATE_PR=1: PR already exists for branch '$branch': $existing_pr (skipping create)."
|
|
3185
3217
|
return 0
|
|
3186
3218
|
fi
|
|
3187
|
-
|
|
3219
|
+
# Proven PR (Loop 6): append the Evidence Receipt to the inline body.
|
|
3220
|
+
# Default-on; LOKI_PROVEN_PR=0 -> body byte-identical to before. This path
|
|
3221
|
+
# runs gh from a `cd "${TARGET_DIR:-.}"` subshell, so resolve the proof
|
|
3222
|
+
# relative to TARGET_DIR (not the bare-relative helper). Empty
|
|
3223
|
+
# expected_head_sha by design (R-DET-1 run_id pointer is the anti-stale guard).
|
|
3224
|
+
local _del_body="Opened by Loki Mode (delegate mode). Review locally before merge."
|
|
3225
|
+
if [ "${LOKI_PROVEN_PR:-1}" != "0" ] && declare -f render_evidence_receipt_md >/dev/null 2>&1; then
|
|
3226
|
+
local _del_loki="${TARGET_DIR:-.}/.loki"
|
|
3227
|
+
local _del_idfile="$_del_loki/state/last-proof-id.txt"
|
|
3228
|
+
if [ -s "$_del_idfile" ]; then
|
|
3229
|
+
local _del_rid=""
|
|
3230
|
+
_del_rid="$(cat "$_del_idfile" 2>/dev/null || true)"
|
|
3231
|
+
if [ -n "$_del_rid" ] && [ -f "$_del_loki/proofs/$_del_rid/proof.json" ]; then
|
|
3232
|
+
local _del_receipt=""
|
|
3233
|
+
_del_receipt="$(render_evidence_receipt_md "$_del_loki/proofs/$_del_rid/proof.json" "" "" 2>/dev/null || true)"
|
|
3234
|
+
if [ -n "$_del_receipt" ]; then
|
|
3235
|
+
_del_body="${_del_body}
|
|
3236
|
+
|
|
3237
|
+
${_del_receipt}"
|
|
3238
|
+
fi
|
|
3239
|
+
fi
|
|
3240
|
+
fi
|
|
3241
|
+
fi
|
|
3242
|
+
pr_url="$( (cd "${TARGET_DIR:-.}" && _loki_net gh pr create --title "$pr_title" --body "$_del_body" --head "$branch") 2>/dev/null || true )"
|
|
3188
3243
|
if [ -n "$pr_url" ]; then
|
|
3189
3244
|
# Export so build_completion_summary folds the url into the summary.
|
|
3190
3245
|
_LOKI_DELEGATE_PR_URL="$pr_url"
|
|
@@ -5501,6 +5556,46 @@ generate_proof_of_run() {
|
|
|
5501
5556
|
local ver provider
|
|
5502
5557
|
ver="$(get_version 2>/dev/null || echo unknown)"
|
|
5503
5558
|
provider="${PROVIDER_NAME:-claude}"
|
|
5559
|
+
|
|
5560
|
+
# Proven PR (Loop 6 / Slice A2): resolve a deterministic run_id so the
|
|
5561
|
+
# proof + the PR Evidence Receipt agree, and persist a stable pointer the PR
|
|
5562
|
+
# sites read (never newest-by-mtime). The generator otherwise mints a fresh
|
|
5563
|
+
# _gen_run_id() when LOKI_SESSION_ID is unset (the `loki start ./prd.md`
|
|
5564
|
+
# case), which the later PR step could not know. Gated on the SAME flag as the
|
|
5565
|
+
# receipt so LOKI_PROVEN_PR=0 is a byte-identical no-op (no --run-id passed,
|
|
5566
|
+
# no pointer written): the pointer is only ever READ by the renderer, which
|
|
5567
|
+
# is itself off under that flag.
|
|
5568
|
+
if [ "${LOKI_PROVEN_PR:-1}" != "0" ]; then
|
|
5569
|
+
local _rid=""
|
|
5570
|
+
if declare -f _loki_trust_run_id >/dev/null 2>&1; then
|
|
5571
|
+
_rid="$(_loki_trust_run_id 2>/dev/null || true)"
|
|
5572
|
+
fi
|
|
5573
|
+
# Fall back to a locally minted id when no persisted per-run id exists.
|
|
5574
|
+
# Do NOT call _loki_trust_run_id --new here: that would clobber the
|
|
5575
|
+
# trust-events run-id file; this is a read-or-mint-local resolution.
|
|
5576
|
+
if [ -z "$_rid" ]; then
|
|
5577
|
+
_rid="proof-$(date -u +%Y%m%d%H%M%S 2>/dev/null || echo 0)-$$-${RANDOM:-0}"
|
|
5578
|
+
fi
|
|
5579
|
+
ITERATION_COUNT="${ITERATION_COUNT:-0}" \
|
|
5580
|
+
PROVIDER_NAME="$provider" \
|
|
5581
|
+
PRD_PATH="${prd_path:-}" \
|
|
5582
|
+
python3 "$gen" \
|
|
5583
|
+
--loki-dir "$loki_dir" \
|
|
5584
|
+
--loki-version "$ver" \
|
|
5585
|
+
--provider "$provider" \
|
|
5586
|
+
--run-id "$_rid" \
|
|
5587
|
+
--quiet >/dev/null 2>&1 || true
|
|
5588
|
+
# Persist the resolved run_id atomically (.tmp + mv) so the PR sites read
|
|
5589
|
+
# the exact run the generator wrote, never an mtime guess.
|
|
5590
|
+
local _id_dir="$loki_dir/state"
|
|
5591
|
+
local _id_file="$_id_dir/last-proof-id.txt"
|
|
5592
|
+
mkdir -p "$_id_dir" 2>/dev/null || true
|
|
5593
|
+
if printf '%s' "$_rid" > "${_id_file}.tmp" 2>/dev/null; then
|
|
5594
|
+
mv -f "${_id_file}.tmp" "$_id_file" 2>/dev/null || rm -f "${_id_file}.tmp" 2>/dev/null || true
|
|
5595
|
+
fi
|
|
5596
|
+
return 0
|
|
5597
|
+
fi
|
|
5598
|
+
|
|
5504
5599
|
ITERATION_COUNT="${ITERATION_COUNT:-0}" \
|
|
5505
5600
|
PROVIDER_NAME="$provider" \
|
|
5506
5601
|
PRD_PATH="${prd_path:-}" \
|
|
@@ -6327,6 +6422,25 @@ commit_session_changes() {
|
|
|
6327
6422
|
return 0
|
|
6328
6423
|
}
|
|
6329
6424
|
|
|
6425
|
+
# _loki_proof_json_for_pr
|
|
6426
|
+
# Resolve THIS run's proof.json path from the persisted run_id pointer
|
|
6427
|
+
# (.loki/state/last-proof-id.txt, written by generate_proof_of_run / Slice A2).
|
|
6428
|
+
# Echoes the path when both the pointer and the file exist, else empty. NEVER
|
|
6429
|
+
# uses newest-by-mtime (R-DET-1). Best-effort, always returns 0. Uses the bare
|
|
6430
|
+
# relative .loki to match the other create_session_pr state reads (cwd==TARGET_DIR
|
|
6431
|
+
# at PR time). Returns empty under LOKI_PROVEN_PR=0 (pointer is never written).
|
|
6432
|
+
_loki_proof_json_for_pr() {
|
|
6433
|
+
local id_file=".loki/state/last-proof-id.txt"
|
|
6434
|
+
[ -s "$id_file" ] || { printf '%s' ""; return 0; }
|
|
6435
|
+
local rid=""
|
|
6436
|
+
rid="$(cat "$id_file" 2>/dev/null || true)"
|
|
6437
|
+
[ -n "$rid" ] || { printf '%s' ""; return 0; }
|
|
6438
|
+
local p=".loki/proofs/$rid/proof.json"
|
|
6439
|
+
[ -f "$p" ] || { printf '%s' ""; return 0; }
|
|
6440
|
+
printf '%s' "$p"
|
|
6441
|
+
return 0
|
|
6442
|
+
}
|
|
6443
|
+
|
|
6330
6444
|
create_session_pr() {
|
|
6331
6445
|
# Advise the user how to open a PR for the agent branch. PRINT-ONLY by
|
|
6332
6446
|
# default (no push, no PR). LOKI_AUTO_PR=1 restores the legacy auto behavior.
|
|
@@ -6371,6 +6485,22 @@ create_session_pr() {
|
|
|
6371
6485
|
else
|
|
6372
6486
|
log_info "To open a pull request: git push -u origin ${branch_name}, then open a PR (base: ${base})"
|
|
6373
6487
|
fi
|
|
6488
|
+
# Proven PR (Loop 6): print the Evidence Receipt block AFTER the push/PR
|
|
6489
|
+
# advice so a user opening a manual PR can paste it into the body. This is
|
|
6490
|
+
# the print-PR-body fallback. Default-on; LOKI_PROVEN_PR=0 -> not invoked
|
|
6491
|
+
# (advisory output byte-identical to before). Production callers pass an
|
|
6492
|
+
# empty expected_head_sha: the session commit lands between proof-gen and
|
|
6493
|
+
# this point, so the branch head is structurally offset from the proof's
|
|
6494
|
+
# head and feeding it would false-degrade every legitimate receipt; the
|
|
6495
|
+
# anti-stale guarantee is the run_id pointer (R-DET-1), not a head match.
|
|
6496
|
+
if [ "${LOKI_PROVEN_PR:-1}" != "0" ] && declare -f render_evidence_receipt_md >/dev/null 2>&1; then
|
|
6497
|
+
local _pr_proof=""
|
|
6498
|
+
_pr_proof="$(_loki_proof_json_for_pr 2>/dev/null || true)"
|
|
6499
|
+
if [ -n "$_pr_proof" ]; then
|
|
6500
|
+
printf '\n'
|
|
6501
|
+
render_evidence_receipt_md "$_pr_proof" "" "" || true
|
|
6502
|
+
fi
|
|
6503
|
+
fi
|
|
6374
6504
|
return 0
|
|
6375
6505
|
fi
|
|
6376
6506
|
|
|
@@ -6395,21 +6525,95 @@ create_session_pr() {
|
|
|
6395
6525
|
if [ -n "$existing_pr" ]; then
|
|
6396
6526
|
log_info "PR already exists for branch $branch_name: $existing_pr (skipping create)"
|
|
6397
6527
|
audit_log "PR_EXISTS" "branch=$branch_name,url=$existing_pr"
|
|
6528
|
+
# Proven PR (Loop 6, PO-locked Q1): on idempotent reuse, leave the
|
|
6529
|
+
# existing PR untouched (do not rewrite the body, edit the PR, or
|
|
6530
|
+
# post a comment). Just hint that an Evidence Receipt is available.
|
|
6531
|
+
if [ "${LOKI_PROVEN_PR:-1}" != "0" ]; then
|
|
6532
|
+
local _exist_proof=""
|
|
6533
|
+
_exist_proof="$(_loki_proof_json_for_pr 2>/dev/null || true)"
|
|
6534
|
+
if [ -n "$_exist_proof" ]; then
|
|
6535
|
+
log_info "Evidence Receipt available for this run: loki proof open (run id in .loki/state/last-proof-id.txt)"
|
|
6536
|
+
fi
|
|
6537
|
+
fi
|
|
6398
6538
|
return 0
|
|
6399
6539
|
fi
|
|
6400
|
-
|
|
6401
|
-
|
|
6402
|
-
|
|
6540
|
+
# Build the body into a variable so the Proven PR Evidence Receipt can be
|
|
6541
|
+
# appended (Loop 6). Default-on; LOKI_PROVEN_PR=0 -> body bytes are
|
|
6542
|
+
# byte-identical to the legacy inline body. Empty expected_head_sha by
|
|
6543
|
+
# design (see the advisory-branch note: the session commit offsets the
|
|
6544
|
+
# branch head from the proof head; R-DET-1 run_id pointer is the guard).
|
|
6545
|
+
local _auto_body
|
|
6546
|
+
_auto_body="Automated changes from Loki Mode agent session.
|
|
6403
6547
|
|
|
6404
6548
|
Branch: \`$branch_name\`
|
|
6405
6549
|
Session PID: $$
|
|
6406
|
-
Created: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
6550
|
+
Created: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
6551
|
+
local _auto_proof=""
|
|
6552
|
+
if [ "${LOKI_PROVEN_PR:-1}" != "0" ] && declare -f render_evidence_receipt_md >/dev/null 2>&1; then
|
|
6553
|
+
_auto_proof="$(_loki_proof_json_for_pr 2>/dev/null || true)"
|
|
6554
|
+
if [ -n "$_auto_proof" ]; then
|
|
6555
|
+
local _auto_receipt=""
|
|
6556
|
+
_auto_receipt="$(render_evidence_receipt_md "$_auto_proof" "" "" 2>/dev/null || true)"
|
|
6557
|
+
if [ -n "$_auto_receipt" ]; then
|
|
6558
|
+
_auto_body="${_auto_body}
|
|
6559
|
+
|
|
6560
|
+
${_auto_receipt}"
|
|
6561
|
+
fi
|
|
6562
|
+
fi
|
|
6563
|
+
fi
|
|
6564
|
+
pr_url=$(gh pr create \
|
|
6565
|
+
--title "Loki Mode: Agent session changes ($branch_name)" \
|
|
6566
|
+
--body "$_auto_body" \
|
|
6407
6567
|
--base "$base" \
|
|
6408
6568
|
--head "$branch_name" 2>/dev/null) || true
|
|
6409
6569
|
|
|
6410
6570
|
if [ -n "$pr_url" ]; then
|
|
6411
6571
|
log_info "PR created: $pr_url"
|
|
6412
6572
|
audit_log "PR_CREATED" "branch=$branch_name,url=$pr_url"
|
|
6573
|
+
# Proven PR (Loop 6 / Slice D linkage): persist {run_id, pr_url} next
|
|
6574
|
+
# to the proof so the dashboard proofs panel can show "PR #N:
|
|
6575
|
+
# <headline>". Slice D owns the READ; Slice A owns this WRITE. Atomic
|
|
6576
|
+
# .tmp + mv, python3-guarded for correct JSON escaping, only when a
|
|
6577
|
+
# proof for THIS run exists and a pr_url was returned. Best-effort.
|
|
6578
|
+
if [ -n "$_auto_proof" ] && command -v python3 >/dev/null 2>&1; then
|
|
6579
|
+
local _pr_json_dir
|
|
6580
|
+
_pr_json_dir="$(dirname "$_auto_proof" 2>/dev/null || true)"
|
|
6581
|
+
if [ -n "$_pr_json_dir" ] && [ -d "$_pr_json_dir" ]; then
|
|
6582
|
+
local _pr_run_id
|
|
6583
|
+
_pr_run_id="$(basename "$_pr_json_dir" 2>/dev/null || true)"
|
|
6584
|
+
LOKI_PR_JSON_DIR="$_pr_json_dir" \
|
|
6585
|
+
LOKI_PR_RUN_ID="$_pr_run_id" \
|
|
6586
|
+
LOKI_PR_URL="$pr_url" \
|
|
6587
|
+
python3 - <<'PR_JSON_PY' 2>/dev/null || true
|
|
6588
|
+
import json
|
|
6589
|
+
import os
|
|
6590
|
+
|
|
6591
|
+
d = os.environ.get("LOKI_PR_JSON_DIR", "")
|
|
6592
|
+
run_id = os.environ.get("LOKI_PR_RUN_ID", "")
|
|
6593
|
+
pr_url = os.environ.get("LOKI_PR_URL", "")
|
|
6594
|
+
if d and os.path.isdir(d):
|
|
6595
|
+
path = os.path.join(d, "pr.json")
|
|
6596
|
+
tmp = path + ".tmp"
|
|
6597
|
+
try:
|
|
6598
|
+
with open(tmp, "w") as f:
|
|
6599
|
+
json.dump({"run_id": run_id, "pr_url": pr_url}, f)
|
|
6600
|
+
os.replace(tmp, path)
|
|
6601
|
+
except Exception:
|
|
6602
|
+
try:
|
|
6603
|
+
os.remove(tmp)
|
|
6604
|
+
except Exception:
|
|
6605
|
+
pass
|
|
6606
|
+
PR_JSON_PY
|
|
6607
|
+
fi
|
|
6608
|
+
fi
|
|
6609
|
+
# Proven PR (Loop 6 / Slice B integration): one guarded call to the
|
|
6610
|
+
# optional advisory verified-completion check-run. Posts NOTHING by
|
|
6611
|
+
# default (LOKI_PROVEN_PR_CHECK unset). Slice B owns proof-check.sh;
|
|
6612
|
+
# guarded on the file being sourced (declare -f) so this slice is
|
|
6613
|
+
# correct whether or not B is integrated. Best-effort, never fails PR.
|
|
6614
|
+
if [ "${LOKI_PROVEN_PR_CHECK:-0}" = "1" ] && declare -f post_verified_completion_check >/dev/null 2>&1; then
|
|
6615
|
+
post_verified_completion_check "${_auto_proof:-}" "$pr_url" || true
|
|
6616
|
+
fi
|
|
6413
6617
|
else
|
|
6414
6618
|
log_warn "Failed to create PR - branch pushed to: $branch_name"
|
|
6415
6619
|
fi
|
|
@@ -10446,7 +10650,14 @@ start_dashboard() {
|
|
|
10446
10650
|
# Start the FastAPI dashboard server
|
|
10447
10651
|
# Dashboard module is at project root (parent of autonomy/)
|
|
10448
10652
|
# LOKI_SKILL_DIR tells server.py where to find static files
|
|
10653
|
+
# LOKI_DASHBOARD_AUTOSTARTED=1 marks this as a run-launched (auto-started)
|
|
10654
|
+
# dashboard, distinct from an explicit `loki dashboard start`. The server
|
|
10655
|
+
# uses this so that, after the dashboard Stop button stops the last active
|
|
10656
|
+
# run, it may shut ITSELF down -- but ONLY when it was auto-started, never
|
|
10657
|
+
# when the user started it deliberately to keep monitoring. The explicit
|
|
10658
|
+
# cmd_dashboard_start path never sets this var (M4 fix).
|
|
10449
10659
|
LOKI_TLS_CERT="${LOKI_TLS_CERT:-}" LOKI_TLS_KEY="${LOKI_TLS_KEY:-}" \
|
|
10660
|
+
LOKI_DASHBOARD_AUTOSTARTED=1 \
|
|
10450
10661
|
LOKI_SKILL_DIR="${skill_dir}" PYTHONPATH="${skill_dir}" nohup "$python_cmd" -m dashboard.server > "$log_file" 2>&1 &
|
|
10451
10662
|
DASHBOARD_PID=$!
|
|
10452
10663
|
register_pid "$DASHBOARD_PID" "dashboard" "port=${DASHBOARD_PORT:-57374}"
|
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -3325,12 +3325,14 @@ async def stop_running_project(request: Request, body: RunningProjectStopRequest
|
|
|
3325
3325
|
except OSError:
|
|
3326
3326
|
pass
|
|
3327
3327
|
registry.mark_project_stopped(project_id)
|
|
3328
|
-
|
|
3328
|
+
_resp = {
|
|
3329
3329
|
"success": True,
|
|
3330
3330
|
"project_id": project_id,
|
|
3331
3331
|
"stopped": stop_signaled,
|
|
3332
3332
|
"already_stopped": not stop_signaled,
|
|
3333
3333
|
}
|
|
3334
|
+
_resp.update(_dashboard_teardown_after_project_stop(path))
|
|
3335
|
+
return _resp
|
|
3334
3336
|
|
|
3335
3337
|
# Write the STOP file so the runner's own cleanup STOP-branch fires for a
|
|
3336
3338
|
# clean teardown. Only into the registry-resolved .loki dir.
|
|
@@ -3392,12 +3394,14 @@ async def stop_running_project(request: Request, body: RunningProjectStopRequest
|
|
|
3392
3394
|
|
|
3393
3395
|
registry.mark_project_stopped(project_id)
|
|
3394
3396
|
|
|
3395
|
-
|
|
3397
|
+
_resp = {
|
|
3396
3398
|
"success": True,
|
|
3397
3399
|
"project_id": project_id,
|
|
3398
3400
|
"stopped": stopped,
|
|
3399
3401
|
"already_stopped": False,
|
|
3400
3402
|
}
|
|
3403
|
+
_resp.update(_dashboard_teardown_after_project_stop(path))
|
|
3404
|
+
return _resp
|
|
3401
3405
|
|
|
3402
3406
|
|
|
3403
3407
|
def _recover_spec_source(path: str) -> Optional[_Path]:
|
|
@@ -4057,6 +4061,17 @@ def get_compliance_status(request: Request, report_type: str = Query("soc2", ali
|
|
|
4057
4061
|
# When set, _get_loki_dir() resolves .loki/ relative to this path instead of CWD.
|
|
4058
4062
|
_active_project_dir: Optional[str] = None
|
|
4059
4063
|
|
|
4064
|
+
# M4 (v7.90.1): was this dashboard auto-started by a run (`loki start` ->
|
|
4065
|
+
# run.sh start_dashboard), or started deliberately by the user
|
|
4066
|
+
# (`loki dashboard start`)? Captured ONCE at import from the environment marker
|
|
4067
|
+
# that ONLY the auto-start path sets. Used to decide whether the dashboard may
|
|
4068
|
+
# shut ITSELF down after its Stop button stops the last active run: an
|
|
4069
|
+
# auto-started dashboard self-exits (it came up with the run, so it should go
|
|
4070
|
+
# down with the last run), while a user-started dashboard is left up and only
|
|
4071
|
+
# surfaces a non-destructive notice. Frozen at startup so a later child process
|
|
4072
|
+
# inheriting/clearing the var cannot flip the decision.
|
|
4073
|
+
_DASHBOARD_AUTOSTARTED: bool = os.environ.get("LOKI_DASHBOARD_AUTOSTARTED") == "1"
|
|
4074
|
+
|
|
4060
4075
|
|
|
4061
4076
|
def _get_loki_dir() -> _Path:
|
|
4062
4077
|
"""Get LOKI_DIR, refreshing from env on each call for consistency.
|
|
@@ -4364,6 +4379,126 @@ def _reap_orchestrators_until_clear(project_dir: _Path, expected_cwd: str,
|
|
|
4364
4379
|
return (found_any, not _find_orchestrator_pids_for_dir(project_dir))
|
|
4365
4380
|
|
|
4366
4381
|
|
|
4382
|
+
def _other_runs_alive(exclude_path: Optional[str] = None) -> bool:
|
|
4383
|
+
"""True if ANY registered project OTHER than exclude_path still has a live
|
|
4384
|
+
orchestrator pid. Mirrors run.sh's CLEAR/KEEP check (and cmd_stop's): a
|
|
4385
|
+
project counts as alive only when its recorded pid is still running. The
|
|
4386
|
+
just-stopped project must already be marked stopped in the registry (its pid
|
|
4387
|
+
set to None by mark_project_stopped) before this is called, so it is not
|
|
4388
|
+
self-counted; exclude_path is an extra belt-and-suspenders guard by realpath.
|
|
4389
|
+
Best-effort: any registry/probe error degrades to True (KEEP) so we NEVER
|
|
4390
|
+
tear the dashboard down on uncertain information.
|
|
4391
|
+
"""
|
|
4392
|
+
try:
|
|
4393
|
+
exclude_real = None
|
|
4394
|
+
if exclude_path:
|
|
4395
|
+
try:
|
|
4396
|
+
exclude_real = os.path.realpath(exclude_path)
|
|
4397
|
+
except OSError:
|
|
4398
|
+
exclude_real = os.path.abspath(exclude_path)
|
|
4399
|
+
for p in registry.list_projects(include_inactive=True):
|
|
4400
|
+
path = p.get("path", "")
|
|
4401
|
+
if exclude_real and path:
|
|
4402
|
+
try:
|
|
4403
|
+
if os.path.realpath(path) == exclude_real:
|
|
4404
|
+
continue
|
|
4405
|
+
except OSError:
|
|
4406
|
+
if os.path.abspath(path) == exclude_real:
|
|
4407
|
+
continue
|
|
4408
|
+
pid = p.get("pid")
|
|
4409
|
+
if isinstance(pid, int) and pid > 0 and not _pid_is_gone(pid):
|
|
4410
|
+
return True
|
|
4411
|
+
return False
|
|
4412
|
+
except Exception:
|
|
4413
|
+
# Unknown -> assume something is still running so we never wrongly kill
|
|
4414
|
+
# the dashboard.
|
|
4415
|
+
return True
|
|
4416
|
+
|
|
4417
|
+
|
|
4418
|
+
def _self_shutdown_after_response(delay_s: float = 1.0) -> None:
|
|
4419
|
+
"""Gracefully shut THIS dashboard server down shortly after the current HTTP
|
|
4420
|
+
response has been sent. Used only after the dashboard's own Stop button stops
|
|
4421
|
+
the LAST active run and only when this dashboard was auto-started (M4).
|
|
4422
|
+
|
|
4423
|
+
Why a delayed background thread (not an inline exit): the Stop request is
|
|
4424
|
+
still in flight when this is decided. We must let FastAPI/uvicorn finish
|
|
4425
|
+
writing the response (so the UI gets its JSON) before the process goes away.
|
|
4426
|
+
A short-delay daemon thread sends SIGTERM to our OWN pid, which uvicorn
|
|
4427
|
+
handles as a graceful shutdown (runs the lifespan teardown). This sidesteps
|
|
4428
|
+
the fragile path where the dashboard relied on the orchestrator's cleanup
|
|
4429
|
+
trap to kill it -- a path that a SIGKILL of the orchestrator (after the 5s
|
|
4430
|
+
window) or a uvicorn graceful-shutdown deadlock could bypass, leaving the
|
|
4431
|
+
dashboard lingering (the reported M4 bug). Best-effort; never raises into
|
|
4432
|
+
the request.
|
|
4433
|
+
"""
|
|
4434
|
+
import signal as _signal
|
|
4435
|
+
|
|
4436
|
+
def _kill_self() -> None:
|
|
4437
|
+
try:
|
|
4438
|
+
time.sleep(max(0.0, delay_s))
|
|
4439
|
+
except Exception:
|
|
4440
|
+
pass
|
|
4441
|
+
try:
|
|
4442
|
+
logger.info(
|
|
4443
|
+
"Auto-started dashboard: last active run stopped and no other "
|
|
4444
|
+
"run is alive; shutting self down (pid=%s).", os.getpid())
|
|
4445
|
+
except Exception:
|
|
4446
|
+
pass
|
|
4447
|
+
try:
|
|
4448
|
+
os.kill(os.getpid(), _signal.SIGTERM)
|
|
4449
|
+
except OSError:
|
|
4450
|
+
# Last resort: hard-exit this process only (never touches others).
|
|
4451
|
+
os._exit(0)
|
|
4452
|
+
|
|
4453
|
+
try:
|
|
4454
|
+
threading.Thread(target=_kill_self, name="loki-dash-selfstop",
|
|
4455
|
+
daemon=True).start()
|
|
4456
|
+
except Exception:
|
|
4457
|
+
# If we cannot even spawn the thread, do nothing: a lingering dashboard
|
|
4458
|
+
# is strictly safer than any broader action.
|
|
4459
|
+
pass
|
|
4460
|
+
|
|
4461
|
+
|
|
4462
|
+
def _dashboard_teardown_after_project_stop(stopped_path: Optional[str]) -> dict:
|
|
4463
|
+
"""Decide what happens to THIS dashboard after a per-project Stop, and return
|
|
4464
|
+
fields to merge into the Stop response (M4).
|
|
4465
|
+
|
|
4466
|
+
Rules (conservative; never touches any process but this server's own):
|
|
4467
|
+
- If another registered run is still alive -> KEEP: do nothing, the
|
|
4468
|
+
dashboard stays up because other projects still need it.
|
|
4469
|
+
- If no other run is alive (CLEAR) and this dashboard was AUTO-STARTED by a
|
|
4470
|
+
run -> schedule a graceful self-shutdown after the response is sent. It
|
|
4471
|
+
came up with a run, so it goes down with the last run.
|
|
4472
|
+
- If CLEAR but the dashboard was started DELIBERATELY by the user
|
|
4473
|
+
(`loki dashboard start`) -> do NOT self-kill; return a non-destructive
|
|
4474
|
+
notice so the UI can tell the user how to stop it (loki dashboard stop).
|
|
4475
|
+
|
|
4476
|
+
Returns one of:
|
|
4477
|
+
{"dashboard": "kept"} # other runs alive
|
|
4478
|
+
{"dashboard": "stopping", ...} # auto-started + CLEAR
|
|
4479
|
+
{"dashboard": "idle", "notice": "...", ...} # user-started + CLEAR
|
|
4480
|
+
"""
|
|
4481
|
+
try:
|
|
4482
|
+
if _other_runs_alive(exclude_path=stopped_path):
|
|
4483
|
+
return {"dashboard": "kept"}
|
|
4484
|
+
if _DASHBOARD_AUTOSTARTED:
|
|
4485
|
+
_self_shutdown_after_response()
|
|
4486
|
+
return {
|
|
4487
|
+
"dashboard": "stopping",
|
|
4488
|
+
"dashboard_autostarted": True,
|
|
4489
|
+
}
|
|
4490
|
+
return {
|
|
4491
|
+
"dashboard": "idle",
|
|
4492
|
+
"dashboard_autostarted": False,
|
|
4493
|
+
"notice": ("No active runs. This dashboard was started manually; "
|
|
4494
|
+
"stop it with: loki dashboard stop"),
|
|
4495
|
+
}
|
|
4496
|
+
except Exception:
|
|
4497
|
+
# Any failure in the decision must never break the Stop response, and
|
|
4498
|
+
# must never kill the dashboard on uncertain info.
|
|
4499
|
+
return {"dashboard": "kept"}
|
|
4500
|
+
|
|
4501
|
+
|
|
4367
4502
|
def _pid_is_gone(pid: int) -> bool:
|
|
4368
4503
|
"""True if pid no longer exists OR is a zombie/defunct (effectively dead,
|
|
4369
4504
|
just not yet reaped by its parent). os.kill(pid,0) succeeds on a zombie, so
|
|
@@ -10078,6 +10213,25 @@ def _safe_proof_run_dir(run_id: str) -> _Path:
|
|
|
10078
10213
|
return _Path(target)
|
|
10079
10214
|
|
|
10080
10215
|
|
|
10216
|
+
def _proof_pr_url(run_dir: _Path) -> Optional[str]:
|
|
10217
|
+
"""Return the PR URL linked to this run, or None.
|
|
10218
|
+
|
|
10219
|
+
Reads the optional <run_dir>/pr.json that the run.sh auto-PR path writes at
|
|
10220
|
+
PR-creation time ({"run_id": "...", "pr_url": "..."}). The file is read only
|
|
10221
|
+
inside the already-validated run dir (the caller passes either an iterdir()
|
|
10222
|
+
entry or a _safe_proof_run_dir result -- never a raw run_id), so there is no
|
|
10223
|
+
additional traversal surface. Missing/unreadable/non-dict pr.json or an
|
|
10224
|
+
empty/non-string pr_url -> None, never an error. Most proofs have no PR.
|
|
10225
|
+
"""
|
|
10226
|
+
pr_data = _safe_json_read(run_dir / "pr.json", default=None)
|
|
10227
|
+
if not isinstance(pr_data, dict):
|
|
10228
|
+
return None
|
|
10229
|
+
url = pr_data.get("pr_url")
|
|
10230
|
+
if isinstance(url, str) and url:
|
|
10231
|
+
return url
|
|
10232
|
+
return None
|
|
10233
|
+
|
|
10234
|
+
|
|
10081
10235
|
@app.get("/api/proofs", dependencies=[Depends(auth.require_scope("read"))])
|
|
10082
10236
|
async def list_proofs():
|
|
10083
10237
|
"""List proof-of-run artifacts for the active project's .loki/proofs/."""
|
|
@@ -10096,6 +10250,11 @@ async def list_proofs():
|
|
|
10096
10250
|
data = _safe_json_read(proof_json, default=None)
|
|
10097
10251
|
if not isinstance(data, dict):
|
|
10098
10252
|
continue
|
|
10253
|
+
# Deterministic honesty headline (single source of truth, same access as
|
|
10254
|
+
# proofs_summary's bucketing). Read, never recomputed. The list endpoint
|
|
10255
|
+
# surfaces it so the panel can show the verdict without a second fetch.
|
|
10256
|
+
honesty = data.get("honesty")
|
|
10257
|
+
headline = honesty.get("headline") if isinstance(honesty, dict) else None
|
|
10099
10258
|
items.append({
|
|
10100
10259
|
"run_id": data.get("run_id", entry.name),
|
|
10101
10260
|
"generated_at": data.get("generated_at"),
|
|
@@ -10103,6 +10262,8 @@ async def list_proofs():
|
|
|
10103
10262
|
"cost_usd": (data.get("cost") or {}).get("usd"),
|
|
10104
10263
|
"files_changed": (data.get("files_changed") or {}).get("count"),
|
|
10105
10264
|
"final_verdict": (data.get("council") or {}).get("final_verdict"),
|
|
10265
|
+
"headline": headline,
|
|
10266
|
+
"pr_url": _proof_pr_url(entry),
|
|
10106
10267
|
"has_html": (entry / "index.html").is_file(),
|
|
10107
10268
|
})
|
|
10108
10269
|
# Newest first when generated_at is present.
|
|
@@ -10176,6 +10337,10 @@ async def get_proof(run_id: str):
|
|
|
10176
10337
|
data = _safe_json_read(proof_json, default=None)
|
|
10177
10338
|
if not isinstance(data, dict):
|
|
10178
10339
|
raise HTTPException(status_code=500, detail="proof.json unreadable")
|
|
10340
|
+
# Surface the optional PR linkage alongside the proof. The proof.json itself
|
|
10341
|
+
# already carries honesty.headline; we only add pr_url so the panel can show
|
|
10342
|
+
# "PR #N -> <headline>". Absent pr.json -> pr_url null, never an error.
|
|
10343
|
+
data["pr_url"] = _proof_pr_url(run_dir)
|
|
10179
10344
|
return JSONResponse(content=data)
|
|
10180
10345
|
|
|
10181
10346
|
|