loki-mode 6.16.1 → 6.17.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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/loki +288 -0
- package/autonomy/run.sh +10 -1
- package/autonomy/trigger-schedule.py +367 -0
- package/autonomy/trigger-server.py +309 -0
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: loki-mode
|
|
|
3
3
|
description: Multi-agent autonomous startup system. Triggers on "Loki Mode". Takes PRD to deployed product with minimal human intervention. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v6.
|
|
6
|
+
# Loki Mode v6.17.1
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -267,4 +267,4 @@ The following features are documented in skill modules but not yet fully automat
|
|
|
267
267
|
| Quality gates 3-reviewer system | Implemented (v5.35.0) | 5 specialist reviewers in `skills/quality-gates.md`; execution in run.sh |
|
|
268
268
|
| Benchmarks (HumanEval, SWE-bench) | Infrastructure only | Runner scripts and datasets exist in `benchmarks/`; no published results |
|
|
269
269
|
|
|
270
|
-
**v6.
|
|
270
|
+
**v6.17.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
6.
|
|
1
|
+
6.17.1
|
package/autonomy/loki
CHANGED
|
@@ -433,6 +433,7 @@ show_help() {
|
|
|
433
433
|
echo " worktree [cmd] Parallel worktree management (list|merge|clean|status)"
|
|
434
434
|
echo " agent [cmd] Agent type dispatch (list|info|run|start|review)"
|
|
435
435
|
echo " remote [PRD] Start remote session (connect from phone/browser, Claude Pro/Max)"
|
|
436
|
+
echo " trigger Event-driven autonomous execution (schedules, webhooks)"
|
|
436
437
|
echo " version Show version"
|
|
437
438
|
echo " help Show this help"
|
|
438
439
|
echo ""
|
|
@@ -7725,6 +7726,290 @@ print('Template validated. Lifecycle hooks active.')
|
|
|
7725
7726
|
esac
|
|
7726
7727
|
}
|
|
7727
7728
|
|
|
7729
|
+
# Event-driven trigger management (v6.17.0)
|
|
7730
|
+
cmd_trigger() {
|
|
7731
|
+
local subcmd="${1:-help}"
|
|
7732
|
+
shift || true
|
|
7733
|
+
|
|
7734
|
+
local trigger_server_script
|
|
7735
|
+
trigger_server_script="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/trigger-server.py"
|
|
7736
|
+
local trigger_schedule_script
|
|
7737
|
+
trigger_schedule_script="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/trigger-schedule.py"
|
|
7738
|
+
local pid_file=".loki/triggers/server.pid"
|
|
7739
|
+
|
|
7740
|
+
case "$subcmd" in
|
|
7741
|
+
start)
|
|
7742
|
+
local port=""
|
|
7743
|
+
local secret=""
|
|
7744
|
+
local dry_run_flag=""
|
|
7745
|
+
while [[ $# -gt 0 ]]; do
|
|
7746
|
+
case "$1" in
|
|
7747
|
+
--port) port="$2"; shift 2 ;;
|
|
7748
|
+
--port=*) port="${1#--port=}"; shift ;;
|
|
7749
|
+
--secret) secret="$2"; shift 2 ;;
|
|
7750
|
+
--secret=*) secret="${1#--secret=}"; shift ;;
|
|
7751
|
+
--dry-run) dry_run_flag="--dry-run"; shift ;;
|
|
7752
|
+
*) shift ;;
|
|
7753
|
+
esac
|
|
7754
|
+
done
|
|
7755
|
+
if [[ -f "$pid_file" ]]; then
|
|
7756
|
+
local existing_pid
|
|
7757
|
+
existing_pid=$(cat "$pid_file" 2>/dev/null)
|
|
7758
|
+
if kill -0 "$existing_pid" 2>/dev/null; then
|
|
7759
|
+
echo "Trigger server already running (pid=$existing_pid)"
|
|
7760
|
+
return 0
|
|
7761
|
+
fi
|
|
7762
|
+
fi
|
|
7763
|
+
local args=()
|
|
7764
|
+
[[ -n "$port" ]] && args+=("--port" "$port")
|
|
7765
|
+
[[ -n "$secret" ]] && args+=("--secret" "$secret")
|
|
7766
|
+
[[ -n "$dry_run_flag" ]] && args+=("--dry-run")
|
|
7767
|
+
mkdir -p .loki/triggers
|
|
7768
|
+
nohup python3 "$trigger_server_script" "${args[@]}" \
|
|
7769
|
+
> .loki/triggers/server.log 2>&1 &
|
|
7770
|
+
local server_pid=$!
|
|
7771
|
+
echo "$server_pid" > "$pid_file"
|
|
7772
|
+
sleep 0.5
|
|
7773
|
+
if kill -0 "$server_pid" 2>/dev/null; then
|
|
7774
|
+
echo "Trigger server started (pid=$server_pid)"
|
|
7775
|
+
local p="${port:-7373}"
|
|
7776
|
+
echo "Webhook endpoint: POST http://localhost:$p/webhook"
|
|
7777
|
+
else
|
|
7778
|
+
echo -e "${RED}Trigger server failed to start. Check .loki/triggers/server.log${NC}"
|
|
7779
|
+
return 1
|
|
7780
|
+
fi
|
|
7781
|
+
;;
|
|
7782
|
+
stop)
|
|
7783
|
+
if [[ ! -f "$pid_file" ]]; then
|
|
7784
|
+
echo "Trigger server not running (no pid file)"
|
|
7785
|
+
return 0
|
|
7786
|
+
fi
|
|
7787
|
+
local pid
|
|
7788
|
+
pid=$(cat "$pid_file" 2>/dev/null)
|
|
7789
|
+
if [[ -z "$pid" ]]; then
|
|
7790
|
+
echo "Trigger server not running"
|
|
7791
|
+
rm -f "$pid_file"
|
|
7792
|
+
return 0
|
|
7793
|
+
fi
|
|
7794
|
+
if kill "$pid" 2>/dev/null; then
|
|
7795
|
+
echo "Trigger server stopped (pid=$pid)"
|
|
7796
|
+
rm -f "$pid_file"
|
|
7797
|
+
else
|
|
7798
|
+
echo "Trigger server was not running (pid=$pid)"
|
|
7799
|
+
rm -f "$pid_file"
|
|
7800
|
+
fi
|
|
7801
|
+
;;
|
|
7802
|
+
status)
|
|
7803
|
+
if [[ -f "$pid_file" ]]; then
|
|
7804
|
+
local pid
|
|
7805
|
+
pid=$(cat "$pid_file" 2>/dev/null)
|
|
7806
|
+
if kill -0 "$pid" 2>/dev/null; then
|
|
7807
|
+
echo "Trigger server: running (pid=$pid)"
|
|
7808
|
+
if command -v curl >/dev/null 2>&1; then
|
|
7809
|
+
local config_port
|
|
7810
|
+
config_port=$(python3 -c "
|
|
7811
|
+
import json, pathlib
|
|
7812
|
+
p = pathlib.Path('.loki/triggers/config.json')
|
|
7813
|
+
print(json.loads(p.read_text()).get('port', 7373) if p.exists() else 7373)
|
|
7814
|
+
" 2>/dev/null || echo "7373")
|
|
7815
|
+
curl -sf "http://localhost:$config_port/status" 2>/dev/null | \
|
|
7816
|
+
python3 -m json.tool 2>/dev/null || true
|
|
7817
|
+
fi
|
|
7818
|
+
else
|
|
7819
|
+
echo "Trigger server: stopped (stale pid=$pid)"
|
|
7820
|
+
rm -f "$pid_file"
|
|
7821
|
+
fi
|
|
7822
|
+
else
|
|
7823
|
+
echo "Trigger server: not running"
|
|
7824
|
+
fi
|
|
7825
|
+
local sched_file=".loki/triggers/schedules.json"
|
|
7826
|
+
if [[ -f "$sched_file" ]]; then
|
|
7827
|
+
local count
|
|
7828
|
+
count=$(python3 -c "import json; d=json.load(open('$sched_file')); print(len(d) if isinstance(d,list) else len(d.get('schedules',[])))" 2>/dev/null || echo "?")
|
|
7829
|
+
echo "Schedules configured: $count"
|
|
7830
|
+
else
|
|
7831
|
+
echo "Schedules configured: 0"
|
|
7832
|
+
fi
|
|
7833
|
+
;;
|
|
7834
|
+
list)
|
|
7835
|
+
python3 "$trigger_schedule_script" list
|
|
7836
|
+
;;
|
|
7837
|
+
add)
|
|
7838
|
+
local add_type="${1:-}"
|
|
7839
|
+
shift || true
|
|
7840
|
+
if [[ "$add_type" == "schedule" ]]; then
|
|
7841
|
+
local name="${1:-}"
|
|
7842
|
+
local cron_expr="${2:-}"
|
|
7843
|
+
local action="${3:-}"
|
|
7844
|
+
shift 3 || true
|
|
7845
|
+
if [[ -z "$name" || -z "$cron_expr" || -z "$action" ]]; then
|
|
7846
|
+
echo "Usage: loki trigger add schedule <name> <cron_expr> <action> [args...]"
|
|
7847
|
+
echo ""
|
|
7848
|
+
echo "Example:"
|
|
7849
|
+
echo " loki trigger add schedule daily-review '0 9 * * 1-5' quality-review"
|
|
7850
|
+
echo " loki trigger add schedule auto-issue '*/30 * * * *' run 42"
|
|
7851
|
+
return 1
|
|
7852
|
+
fi
|
|
7853
|
+
python3 "$trigger_schedule_script" add "$name" "$cron_expr" "$action" "$@"
|
|
7854
|
+
else
|
|
7855
|
+
echo "Usage: loki trigger add schedule <name> <cron_expr> <action> [args...]"
|
|
7856
|
+
return 1
|
|
7857
|
+
fi
|
|
7858
|
+
;;
|
|
7859
|
+
remove)
|
|
7860
|
+
local name="${1:-}"
|
|
7861
|
+
if [[ -z "$name" ]]; then
|
|
7862
|
+
echo "Usage: loki trigger remove <name>"
|
|
7863
|
+
return 1
|
|
7864
|
+
fi
|
|
7865
|
+
python3 "$trigger_schedule_script" remove "$name"
|
|
7866
|
+
;;
|
|
7867
|
+
daemon)
|
|
7868
|
+
local dry_run_flag=""
|
|
7869
|
+
[[ "${1:-}" == "--dry-run" ]] && dry_run_flag="--dry-run"
|
|
7870
|
+
python3 "$trigger_schedule_script" daemon $dry_run_flag
|
|
7871
|
+
;;
|
|
7872
|
+
test)
|
|
7873
|
+
local event_type="${1:-issues}"
|
|
7874
|
+
shift || true
|
|
7875
|
+
local repo=""
|
|
7876
|
+
local issue_num=""
|
|
7877
|
+
while [[ $# -gt 0 ]]; do
|
|
7878
|
+
case "$1" in
|
|
7879
|
+
--repo) repo="$2"; shift 2 ;;
|
|
7880
|
+
--repo=*) repo="${1#--repo=}"; shift ;;
|
|
7881
|
+
--issue) issue_num="$2"; shift 2 ;;
|
|
7882
|
+
--issue=*) issue_num="${1#--issue=}"; shift ;;
|
|
7883
|
+
*) shift ;;
|
|
7884
|
+
esac
|
|
7885
|
+
done
|
|
7886
|
+
local config_port
|
|
7887
|
+
config_port=$(python3 -c "
|
|
7888
|
+
import json, pathlib
|
|
7889
|
+
p = pathlib.Path('.loki/triggers/config.json')
|
|
7890
|
+
print(json.loads(p.read_text()).get('port', 7373) if p.exists() else 7373)
|
|
7891
|
+
" 2>/dev/null || echo "7373")
|
|
7892
|
+
|
|
7893
|
+
local payload
|
|
7894
|
+
case "$event_type" in
|
|
7895
|
+
issues)
|
|
7896
|
+
local num="${issue_num:-1}"
|
|
7897
|
+
local r="${repo:-owner/repo}"
|
|
7898
|
+
payload="{\"action\":\"opened\",\"issue\":{\"number\":$num,\"title\":\"Test issue\"},\"repository\":{\"full_name\":\"$r\"}}"
|
|
7899
|
+
;;
|
|
7900
|
+
pull_request)
|
|
7901
|
+
local num="${issue_num:-1}"
|
|
7902
|
+
local r="${repo:-owner/repo}"
|
|
7903
|
+
payload="{\"action\":\"synchronize\",\"pull_request\":{\"number\":$num,\"title\":\"Test PR\"},\"repository\":{\"full_name\":\"$r\"}}"
|
|
7904
|
+
;;
|
|
7905
|
+
workflow_run)
|
|
7906
|
+
local r="${repo:-owner/repo}"
|
|
7907
|
+
payload="{\"action\":\"completed\",\"workflow_run\":{\"name\":\"CI\",\"conclusion\":\"failure\"},\"repository\":{\"full_name\":\"$r\"}}"
|
|
7908
|
+
;;
|
|
7909
|
+
*)
|
|
7910
|
+
echo "Supported event types: issues, pull_request, workflow_run"
|
|
7911
|
+
return 1
|
|
7912
|
+
;;
|
|
7913
|
+
esac
|
|
7914
|
+
|
|
7915
|
+
if command -v curl >/dev/null 2>&1; then
|
|
7916
|
+
echo "Sending test $event_type event to http://localhost:$config_port/webhook"
|
|
7917
|
+
curl -sf -X POST \
|
|
7918
|
+
-H "Content-Type: application/json" \
|
|
7919
|
+
-H "X-GitHub-Event: $event_type" \
|
|
7920
|
+
-d "$payload" \
|
|
7921
|
+
"http://localhost:$config_port/webhook" | python3 -m json.tool 2>/dev/null || \
|
|
7922
|
+
echo "Server not running or request failed. Start with: loki trigger start"
|
|
7923
|
+
else
|
|
7924
|
+
echo "curl not available. Cannot send test event."
|
|
7925
|
+
return 1
|
|
7926
|
+
fi
|
|
7927
|
+
;;
|
|
7928
|
+
logs)
|
|
7929
|
+
local tail_n="50"
|
|
7930
|
+
while [[ $# -gt 0 ]]; do
|
|
7931
|
+
case "$1" in
|
|
7932
|
+
--tail) tail_n="$2"; shift 2 ;;
|
|
7933
|
+
--tail=*) tail_n="${1#--tail=}"; shift ;;
|
|
7934
|
+
*) shift ;;
|
|
7935
|
+
esac
|
|
7936
|
+
done
|
|
7937
|
+
local log_file=".loki/triggers/events.log"
|
|
7938
|
+
if [[ -f "$log_file" ]]; then
|
|
7939
|
+
tail -n "$tail_n" "$log_file" | python3 -c "
|
|
7940
|
+
import sys, json
|
|
7941
|
+
for line in sys.stdin:
|
|
7942
|
+
line = line.strip()
|
|
7943
|
+
if not line:
|
|
7944
|
+
continue
|
|
7945
|
+
try:
|
|
7946
|
+
e = json.loads(line)
|
|
7947
|
+
print('[%s] %-15s %-12s %-8s %s' % (
|
|
7948
|
+
e.get('timestamp','?'),
|
|
7949
|
+
e.get('event','?'),
|
|
7950
|
+
e.get('action','?'),
|
|
7951
|
+
e.get('status','?'),
|
|
7952
|
+
e.get('summary',''),
|
|
7953
|
+
))
|
|
7954
|
+
except Exception:
|
|
7955
|
+
print(line)
|
|
7956
|
+
" 2>/dev/null || cat "$log_file" | tail -n "$tail_n"
|
|
7957
|
+
else
|
|
7958
|
+
echo "No events logged yet. (.loki/triggers/events.log does not exist)"
|
|
7959
|
+
fi
|
|
7960
|
+
local server_log=".loki/triggers/server.log"
|
|
7961
|
+
if [[ -f "$server_log" ]]; then
|
|
7962
|
+
echo ""
|
|
7963
|
+
echo "--- Server log (last $tail_n lines) ---"
|
|
7964
|
+
tail -n "$tail_n" "$server_log"
|
|
7965
|
+
fi
|
|
7966
|
+
;;
|
|
7967
|
+
help|--help|-h)
|
|
7968
|
+
echo -e "${BOLD}loki trigger${NC} - Event-driven autonomous execution"
|
|
7969
|
+
echo ""
|
|
7970
|
+
echo "Usage: loki trigger <subcommand> [options]"
|
|
7971
|
+
echo ""
|
|
7972
|
+
echo "Subcommands:"
|
|
7973
|
+
echo " start [--port PORT] [--secret SECRET] [--dry-run]"
|
|
7974
|
+
echo " Start the webhook receiver server (default port: 7373)"
|
|
7975
|
+
echo " stop Stop the webhook receiver server"
|
|
7976
|
+
echo " status Show server and schedule status"
|
|
7977
|
+
echo " list List configured schedules"
|
|
7978
|
+
echo " add schedule <name> <cron_expr> <action> [args...]"
|
|
7979
|
+
echo " Add a cron-based schedule trigger"
|
|
7980
|
+
echo " remove <name> Remove a schedule trigger"
|
|
7981
|
+
echo " daemon [--dry-run] Run one pass of schedule checks (call from cron)"
|
|
7982
|
+
echo " test <event-type> [--repo owner/repo] [--issue NUM]"
|
|
7983
|
+
echo " Send a test webhook event (event-types: issues, pull_request, workflow_run)"
|
|
7984
|
+
echo " logs [--tail N] Show recent trigger event log (default: last 50)"
|
|
7985
|
+
echo " help Show this help"
|
|
7986
|
+
echo ""
|
|
7987
|
+
echo "Webhook events handled:"
|
|
7988
|
+
echo " issues (opened) -> loki run <issue_number> --pr --detach"
|
|
7989
|
+
echo " pull_request (sync) -> loki run <pr_number> --detach"
|
|
7990
|
+
echo " workflow_run (failure) -> loki run --detach"
|
|
7991
|
+
echo ""
|
|
7992
|
+
echo "Schedule cron format: minute hour day-of-month month day-of-week"
|
|
7993
|
+
echo " Examples:"
|
|
7994
|
+
echo " '0 9 * * 1-5' - 9am Mon-Fri"
|
|
7995
|
+
echo " '*/30 * * * *' - Every 30 minutes"
|
|
7996
|
+
echo " '0 0 * * 0' - Midnight Sunday"
|
|
7997
|
+
echo ""
|
|
7998
|
+
echo "Examples:"
|
|
7999
|
+
echo " loki trigger start --port 7373 --secret mywebhooksecret"
|
|
8000
|
+
echo " loki trigger add schedule daily-review '0 9 * * 1-5' quality-review"
|
|
8001
|
+
echo " loki trigger test issues --repo owner/repo --issue 42"
|
|
8002
|
+
echo " loki trigger logs --tail 20"
|
|
8003
|
+
echo " loki trigger stop"
|
|
8004
|
+
;;
|
|
8005
|
+
*)
|
|
8006
|
+
echo -e "${RED}Unknown trigger command: $subcmd${NC}"
|
|
8007
|
+
echo "Run 'loki trigger help' for usage."
|
|
8008
|
+
return 1
|
|
8009
|
+
;;
|
|
8010
|
+
esac
|
|
8011
|
+
}
|
|
8012
|
+
|
|
7728
8013
|
# Main command dispatcher
|
|
7729
8014
|
main() {
|
|
7730
8015
|
if [ $# -eq 0 ]; then
|
|
@@ -7882,6 +8167,9 @@ main() {
|
|
|
7882
8167
|
remote|rc)
|
|
7883
8168
|
cmd_remote "$@"
|
|
7884
8169
|
;;
|
|
8170
|
+
trigger)
|
|
8171
|
+
cmd_trigger "$@"
|
|
8172
|
+
;;
|
|
7885
8173
|
version|--version|-v)
|
|
7886
8174
|
cmd_version
|
|
7887
8175
|
;;
|
package/autonomy/run.sh
CHANGED
|
@@ -8617,6 +8617,8 @@ if __name__ == "__main__":
|
|
|
8617
8617
|
fi
|
|
8618
8618
|
fi
|
|
8619
8619
|
|
|
8620
|
+
log_step "Post-iteration: running inter-iteration checks..."
|
|
8621
|
+
|
|
8620
8622
|
# App Runner: restart on code changes (v5.45.0)
|
|
8621
8623
|
if [ "${APP_RUNNER_INITIALIZED:-}" = "true" ] && type app_runner_should_restart &>/dev/null; then
|
|
8622
8624
|
if app_runner_should_restart; then
|
|
@@ -8661,10 +8663,12 @@ if __name__ == "__main__":
|
|
|
8661
8663
|
create_checkpoint "iteration-${ITERATION_COUNT} complete" "iteration-${ITERATION_COUNT}"
|
|
8662
8664
|
|
|
8663
8665
|
# Quality gates (v6.10.0 - escalation ladder)
|
|
8666
|
+
log_step "Post-iteration: running quality gates..."
|
|
8664
8667
|
local gate_failures=""
|
|
8665
8668
|
if [ "${LOKI_HARD_GATES:-true}" = "true" ]; then
|
|
8666
8669
|
# Static analysis gate
|
|
8667
8670
|
if [ "${PHASE_STATIC_ANALYSIS:-true}" = "true" ]; then
|
|
8671
|
+
log_info "Quality gate: static analysis..."
|
|
8668
8672
|
if enforce_static_analysis; then
|
|
8669
8673
|
clear_gate_failure "static_analysis"
|
|
8670
8674
|
else
|
|
@@ -8676,6 +8680,7 @@ if __name__ == "__main__":
|
|
|
8676
8680
|
fi
|
|
8677
8681
|
# Test coverage gate
|
|
8678
8682
|
if [ "${PHASE_UNIT_TESTS:-true}" = "true" ]; then
|
|
8683
|
+
log_info "Quality gate: test coverage..."
|
|
8679
8684
|
if enforce_test_coverage; then
|
|
8680
8685
|
clear_gate_failure "test_coverage"
|
|
8681
8686
|
else
|
|
@@ -8687,6 +8692,7 @@ if __name__ == "__main__":
|
|
|
8687
8692
|
fi
|
|
8688
8693
|
# Code review gate (upgraded from advisory, with escalation)
|
|
8689
8694
|
if [ "$PHASE_CODE_REVIEW" = "true" ] && [ "$ITERATION_COUNT" -gt 0 ]; then
|
|
8695
|
+
log_info "Quality gate: code review..."
|
|
8690
8696
|
if run_code_review; then
|
|
8691
8697
|
clear_gate_failure "code_review"
|
|
8692
8698
|
else
|
|
@@ -8717,9 +8723,11 @@ if __name__ == "__main__":
|
|
|
8717
8723
|
fi
|
|
8718
8724
|
else
|
|
8719
8725
|
if [ "$PHASE_CODE_REVIEW" = "true" ] && [ "$ITERATION_COUNT" -gt 0 ]; then
|
|
8726
|
+
log_info "Quality gate: code review (advisory)..."
|
|
8720
8727
|
run_code_review || log_warn "Code review found issues - check .loki/quality/reviews/"
|
|
8721
8728
|
fi
|
|
8722
8729
|
fi
|
|
8730
|
+
log_info "Quality gates complete."
|
|
8723
8731
|
|
|
8724
8732
|
# Automatic episode capture after every RARV iteration (v6.15.0)
|
|
8725
8733
|
# Captures RARV phase, git changes, and iteration context automatically
|
|
@@ -8745,6 +8753,7 @@ if __name__ == "__main__":
|
|
|
8745
8753
|
|
|
8746
8754
|
# Completion Council check (v5.25.0) - multi-agent voting on completion
|
|
8747
8755
|
# Runs before completion promise check since council is more comprehensive
|
|
8756
|
+
log_step "Post-iteration: checking completion council..."
|
|
8748
8757
|
if type council_should_stop &>/dev/null && council_should_stop; then
|
|
8749
8758
|
echo ""
|
|
8750
8759
|
log_header "COMPLETION COUNCIL: PROJECT COMPLETE"
|
|
@@ -8776,7 +8785,7 @@ if __name__ == "__main__":
|
|
|
8776
8785
|
fi
|
|
8777
8786
|
|
|
8778
8787
|
# SUCCESS exit - continue IMMEDIATELY to next iteration (no wait!)
|
|
8779
|
-
|
|
8788
|
+
log_step "Starting next iteration..."
|
|
8780
8789
|
((retry++))
|
|
8781
8790
|
continue # Immediately start next iteration, no exponential backoff
|
|
8782
8791
|
fi
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
trigger-schedule.py - Schedule-based trigger daemon for loki-mode.
|
|
4
|
+
|
|
5
|
+
Reads .loki/triggers/schedules.json and runs loki commands on cron-like schedules.
|
|
6
|
+
Called periodically via `loki trigger daemon`.
|
|
7
|
+
|
|
8
|
+
Schedule entry format:
|
|
9
|
+
{
|
|
10
|
+
"name": "daily-quality-review",
|
|
11
|
+
"cron_expr": "0 9 * * 1-5",
|
|
12
|
+
"action": "quality-review",
|
|
13
|
+
"args": [],
|
|
14
|
+
"enabled": true,
|
|
15
|
+
"last_run": null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
Supported actions:
|
|
19
|
+
run <issue-ref> - Run loki run on an issue/ref
|
|
20
|
+
status - Run loki status
|
|
21
|
+
quality-review - Run loki review
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
python3 autonomy/trigger-schedule.py [--once] [--dry-run]
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_loki_dir():
|
|
38
|
+
"""Return .loki/triggers directory, creating it if needed."""
|
|
39
|
+
loki_dir = Path(".loki") / "triggers"
|
|
40
|
+
loki_dir.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
return loki_dir
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_schedules():
|
|
45
|
+
"""Load schedules from .loki/triggers/schedules.json."""
|
|
46
|
+
schedules_path = get_loki_dir() / "schedules.json"
|
|
47
|
+
if not schedules_path.exists():
|
|
48
|
+
return []
|
|
49
|
+
try:
|
|
50
|
+
with open(schedules_path) as f:
|
|
51
|
+
data = json.load(f)
|
|
52
|
+
if isinstance(data, list):
|
|
53
|
+
return data
|
|
54
|
+
return data.get("schedules", [])
|
|
55
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
56
|
+
logging.error("Failed to load schedules: %s", e)
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def save_schedules(schedules):
|
|
61
|
+
"""Save schedules back to .loki/triggers/schedules.json."""
|
|
62
|
+
schedules_path = get_loki_dir() / "schedules.json"
|
|
63
|
+
with open(schedules_path, "w") as f:
|
|
64
|
+
json.dump(schedules, f, indent=2)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_cron_field(field, min_val, max_val):
|
|
68
|
+
"""
|
|
69
|
+
Parse a single cron field.
|
|
70
|
+
Returns a set of matching integers.
|
|
71
|
+
Supports: * (any), N (exact), N-M (range), */N (step), N,M,... (list)
|
|
72
|
+
"""
|
|
73
|
+
result = set()
|
|
74
|
+
if field == "*":
|
|
75
|
+
return set(range(min_val, max_val + 1))
|
|
76
|
+
|
|
77
|
+
for part in field.split(","):
|
|
78
|
+
part = part.strip()
|
|
79
|
+
if "/" in part:
|
|
80
|
+
range_part, step_str = part.split("/", 1)
|
|
81
|
+
step = int(step_str)
|
|
82
|
+
if range_part == "*":
|
|
83
|
+
start, end = min_val, max_val
|
|
84
|
+
elif "-" in range_part:
|
|
85
|
+
s, e = range_part.split("-", 1)
|
|
86
|
+
start, end = int(s), int(e)
|
|
87
|
+
else:
|
|
88
|
+
start = int(range_part)
|
|
89
|
+
end = max_val
|
|
90
|
+
result.update(range(start, end + 1, step))
|
|
91
|
+
elif "-" in part:
|
|
92
|
+
s, e = part.split("-", 1)
|
|
93
|
+
result.update(range(int(s), int(e) + 1))
|
|
94
|
+
else:
|
|
95
|
+
result.add(int(part))
|
|
96
|
+
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cron_matches(cron_expr, dt):
|
|
101
|
+
"""
|
|
102
|
+
Check if a datetime matches a cron expression.
|
|
103
|
+
Format: minute hour day-of-month month day-of-week
|
|
104
|
+
Day-of-week: 0=Sunday, 1=Monday, ..., 6=Saturday
|
|
105
|
+
"""
|
|
106
|
+
parts = cron_expr.strip().split()
|
|
107
|
+
if len(parts) != 5:
|
|
108
|
+
raise ValueError("Invalid cron expression (expected 5 fields): %s" % cron_expr)
|
|
109
|
+
|
|
110
|
+
minute_field, hour_field, dom_field, month_field, dow_field = parts
|
|
111
|
+
|
|
112
|
+
minutes = parse_cron_field(minute_field, 0, 59)
|
|
113
|
+
hours = parse_cron_field(hour_field, 0, 23)
|
|
114
|
+
doms = parse_cron_field(dom_field, 1, 31)
|
|
115
|
+
months = parse_cron_field(month_field, 1, 12)
|
|
116
|
+
# Python weekday: 0=Monday, ..., 6=Sunday
|
|
117
|
+
# Cron weekday: 0=Sunday, 1=Monday, ..., 6=Saturday
|
|
118
|
+
# Convert python weekday to cron dow
|
|
119
|
+
python_dow = dt.weekday() # 0=Mon
|
|
120
|
+
cron_dow = (python_dow + 1) % 7 # 0=Sun, 1=Mon, ...
|
|
121
|
+
dows = parse_cron_field(dow_field, 0, 6)
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
dt.minute in minutes
|
|
125
|
+
and dt.hour in hours
|
|
126
|
+
and dt.day in doms
|
|
127
|
+
and dt.month in months
|
|
128
|
+
and cron_dow in dows
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def should_run(schedule, now):
|
|
133
|
+
"""
|
|
134
|
+
Determine if a schedule should fire now.
|
|
135
|
+
Compares current minute-resolution timestamp to last_run.
|
|
136
|
+
"""
|
|
137
|
+
if not schedule.get("enabled", True):
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
matches = cron_matches(schedule["cron_expr"], now)
|
|
142
|
+
except (ValueError, KeyError) as e:
|
|
143
|
+
logging.warning("Invalid cron for '%s': %s", schedule.get("name", "?"), e)
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
if not matches:
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
last_run = schedule.get("last_run")
|
|
150
|
+
if last_run:
|
|
151
|
+
try:
|
|
152
|
+
# Check if we already ran this minute
|
|
153
|
+
last_dt = datetime.fromisoformat(last_run.replace("Z", "+00:00"))
|
|
154
|
+
if last_dt.year == now.year and last_dt.month == now.month and \
|
|
155
|
+
last_dt.day == now.day and last_dt.hour == now.hour and \
|
|
156
|
+
last_dt.minute == now.minute:
|
|
157
|
+
return False
|
|
158
|
+
except (ValueError, AttributeError):
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def build_loki_args(schedule):
|
|
165
|
+
"""Build the loki CLI argument list from a schedule entry."""
|
|
166
|
+
action = schedule.get("action", "")
|
|
167
|
+
args = schedule.get("args", [])
|
|
168
|
+
|
|
169
|
+
if action == "status":
|
|
170
|
+
return ["status"]
|
|
171
|
+
elif action == "quality-review":
|
|
172
|
+
return ["review"] + list(args)
|
|
173
|
+
elif action.startswith("run"):
|
|
174
|
+
# action is "run" with args, or "run <issue-ref>"
|
|
175
|
+
extra = list(args)
|
|
176
|
+
if not extra and " " in action:
|
|
177
|
+
# action = "run 123"
|
|
178
|
+
parts = action.split(None, 1)
|
|
179
|
+
extra = [parts[1]]
|
|
180
|
+
return ["run"] + extra
|
|
181
|
+
else:
|
|
182
|
+
# Generic: split action and prepend
|
|
183
|
+
return action.split() + list(args)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def run_schedule(schedule, dry_run=False):
|
|
187
|
+
"""Execute a scheduled loki command."""
|
|
188
|
+
loki_args = build_loki_args(schedule)
|
|
189
|
+
cmd = ["loki"] + loki_args
|
|
190
|
+
name = schedule.get("name", "unnamed")
|
|
191
|
+
|
|
192
|
+
if dry_run:
|
|
193
|
+
logging.info("[DRY-RUN] Schedule '%s' would run: %s", name, " ".join(cmd))
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
logging.info("Schedule '%s' firing: %s", name, " ".join(cmd))
|
|
197
|
+
try:
|
|
198
|
+
proc = subprocess.Popen(
|
|
199
|
+
cmd,
|
|
200
|
+
stdout=subprocess.PIPE,
|
|
201
|
+
stderr=subprocess.PIPE,
|
|
202
|
+
)
|
|
203
|
+
logging.info("Schedule '%s' started pid=%d", name, proc.pid)
|
|
204
|
+
return True
|
|
205
|
+
except (FileNotFoundError, OSError) as e:
|
|
206
|
+
logging.error("Schedule '%s' failed: %s", name, e)
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def log_event(name, action_desc, status):
|
|
211
|
+
"""Log schedule fire to events.log."""
|
|
212
|
+
log_path = get_loki_dir() / "events.log"
|
|
213
|
+
timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
214
|
+
entry = {
|
|
215
|
+
"timestamp": timestamp,
|
|
216
|
+
"event": "schedule",
|
|
217
|
+
"action": action_desc,
|
|
218
|
+
"summary": name,
|
|
219
|
+
"status": status,
|
|
220
|
+
}
|
|
221
|
+
with open(log_path, "a") as f:
|
|
222
|
+
f.write(json.dumps(entry) + "\n")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def run_daemon(dry_run=False, once=False):
|
|
226
|
+
"""
|
|
227
|
+
Check all schedules and fire those that match the current minute.
|
|
228
|
+
If once=True, run one pass and exit (used for cron job invocation).
|
|
229
|
+
"""
|
|
230
|
+
now = datetime.now()
|
|
231
|
+
schedules = load_schedules()
|
|
232
|
+
changed = False
|
|
233
|
+
|
|
234
|
+
for i, schedule in enumerate(schedules):
|
|
235
|
+
if should_run(schedule, now):
|
|
236
|
+
success = run_schedule(schedule, dry_run=dry_run)
|
|
237
|
+
status = "fired" if success else "error"
|
|
238
|
+
log_event(schedule.get("name", "?"), schedule.get("action", "?"), status)
|
|
239
|
+
if not dry_run:
|
|
240
|
+
schedules[i]["last_run"] = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
241
|
+
changed = True
|
|
242
|
+
|
|
243
|
+
if changed:
|
|
244
|
+
save_schedules(schedules)
|
|
245
|
+
|
|
246
|
+
if not schedules:
|
|
247
|
+
logging.debug("No schedules configured.")
|
|
248
|
+
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def list_schedules():
|
|
253
|
+
"""Print all configured schedules."""
|
|
254
|
+
schedules = load_schedules()
|
|
255
|
+
if not schedules:
|
|
256
|
+
print("No schedules configured.")
|
|
257
|
+
print("Add schedules to .loki/triggers/schedules.json")
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
print("Configured schedules:")
|
|
261
|
+
for s in schedules:
|
|
262
|
+
enabled = "enabled" if s.get("enabled", True) else "disabled"
|
|
263
|
+
last_run = s.get("last_run") or "never"
|
|
264
|
+
print(" %-30s %-20s %-12s %s (last: %s)" % (
|
|
265
|
+
s.get("name", "?"),
|
|
266
|
+
s.get("cron_expr", "?"),
|
|
267
|
+
s.get("action", "?"),
|
|
268
|
+
enabled,
|
|
269
|
+
last_run,
|
|
270
|
+
))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def add_schedule(name, cron_expr, action, extra_args, enabled=True):
|
|
274
|
+
"""Add or update a schedule entry."""
|
|
275
|
+
schedules = load_schedules()
|
|
276
|
+
# Check for existing
|
|
277
|
+
for i, s in enumerate(schedules):
|
|
278
|
+
if s.get("name") == name:
|
|
279
|
+
schedules[i] = {
|
|
280
|
+
"name": name,
|
|
281
|
+
"cron_expr": cron_expr,
|
|
282
|
+
"action": action,
|
|
283
|
+
"args": list(extra_args),
|
|
284
|
+
"enabled": enabled,
|
|
285
|
+
"last_run": s.get("last_run"),
|
|
286
|
+
}
|
|
287
|
+
save_schedules(schedules)
|
|
288
|
+
print("Updated schedule: %s" % name)
|
|
289
|
+
return
|
|
290
|
+
schedules.append({
|
|
291
|
+
"name": name,
|
|
292
|
+
"cron_expr": cron_expr,
|
|
293
|
+
"action": action,
|
|
294
|
+
"args": list(extra_args),
|
|
295
|
+
"enabled": enabled,
|
|
296
|
+
"last_run": None,
|
|
297
|
+
})
|
|
298
|
+
save_schedules(schedules)
|
|
299
|
+
print("Added schedule: %s" % name)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def remove_schedule(name):
|
|
303
|
+
"""Remove a schedule by name."""
|
|
304
|
+
schedules = load_schedules()
|
|
305
|
+
before = len(schedules)
|
|
306
|
+
schedules = [s for s in schedules if s.get("name") != name]
|
|
307
|
+
if len(schedules) == before:
|
|
308
|
+
print("Schedule not found: %s" % name)
|
|
309
|
+
return False
|
|
310
|
+
save_schedules(schedules)
|
|
311
|
+
print("Removed schedule: %s" % name)
|
|
312
|
+
return True
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def main():
|
|
316
|
+
parser = argparse.ArgumentParser(
|
|
317
|
+
description="loki-mode schedule-based trigger daemon"
|
|
318
|
+
)
|
|
319
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
320
|
+
|
|
321
|
+
# daemon - run one pass
|
|
322
|
+
daemon_parser = subparsers.add_parser("daemon", help="Run one pass of schedule checks")
|
|
323
|
+
daemon_parser.add_argument("--dry-run", action="store_true", help="Preview without running")
|
|
324
|
+
|
|
325
|
+
# list
|
|
326
|
+
subparsers.add_parser("list", help="List configured schedules")
|
|
327
|
+
|
|
328
|
+
# add
|
|
329
|
+
add_parser = subparsers.add_parser("add", help="Add a schedule")
|
|
330
|
+
add_parser.add_argument("name")
|
|
331
|
+
add_parser.add_argument("cron_expr")
|
|
332
|
+
add_parser.add_argument("action")
|
|
333
|
+
add_parser.add_argument("args", nargs="*")
|
|
334
|
+
add_parser.add_argument("--disabled", action="store_true")
|
|
335
|
+
|
|
336
|
+
# remove
|
|
337
|
+
remove_parser = subparsers.add_parser("remove", help="Remove a schedule")
|
|
338
|
+
remove_parser.add_argument("name")
|
|
339
|
+
|
|
340
|
+
args = parser.parse_args()
|
|
341
|
+
|
|
342
|
+
logging.basicConfig(
|
|
343
|
+
level=logging.INFO,
|
|
344
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
345
|
+
datefmt="%Y-%m-%dT%H:%M:%SZ",
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
if args.command == "daemon" or args.command is None:
|
|
349
|
+
dry_run = getattr(args, "dry_run", False)
|
|
350
|
+
run_daemon(dry_run=dry_run, once=True)
|
|
351
|
+
elif args.command == "list":
|
|
352
|
+
list_schedules()
|
|
353
|
+
elif args.command == "add":
|
|
354
|
+
add_schedule(
|
|
355
|
+
args.name,
|
|
356
|
+
args.cron_expr,
|
|
357
|
+
args.action,
|
|
358
|
+
args.args,
|
|
359
|
+
enabled=not args.disabled,
|
|
360
|
+
)
|
|
361
|
+
elif args.command == "remove":
|
|
362
|
+
success = remove_schedule(args.name)
|
|
363
|
+
sys.exit(0 if success else 1)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
if __name__ == "__main__":
|
|
367
|
+
main()
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
trigger-server.py - GitHub webhook receiver for loki-mode event-driven execution.
|
|
4
|
+
|
|
5
|
+
Listens for GitHub webhook events and automatically runs `loki run` in response.
|
|
6
|
+
Supports signature validation, dry-run mode, and event logging.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python3 autonomy/trigger-server.py [--port PORT] [--secret SECRET] [--dry-run]
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
import http.server
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_loki_dir():
|
|
28
|
+
"""Return .loki/triggers directory, creating it if needed."""
|
|
29
|
+
loki_dir = Path(".loki") / "triggers"
|
|
30
|
+
loki_dir.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
return loki_dir
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_config():
|
|
35
|
+
"""Load trigger config from .loki/triggers/config.json."""
|
|
36
|
+
config_path = get_loki_dir() / "config.json"
|
|
37
|
+
defaults = {
|
|
38
|
+
"port": 7373,
|
|
39
|
+
"secret": "",
|
|
40
|
+
"dry_run": False,
|
|
41
|
+
"enabled_events": ["issues", "pull_request", "workflow_run"],
|
|
42
|
+
}
|
|
43
|
+
if config_path.exists():
|
|
44
|
+
try:
|
|
45
|
+
with open(config_path) as f:
|
|
46
|
+
stored = json.load(f)
|
|
47
|
+
defaults.update(stored)
|
|
48
|
+
except (json.JSONDecodeError, OSError):
|
|
49
|
+
pass
|
|
50
|
+
return defaults
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def save_config(config):
|
|
54
|
+
"""Save trigger config to .loki/triggers/config.json."""
|
|
55
|
+
config_path = get_loki_dir() / "config.json"
|
|
56
|
+
with open(config_path, "w") as f:
|
|
57
|
+
json.dump(config, f, indent=2)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def log_event(event_type, action, payload_summary, status):
|
|
61
|
+
"""Append event to .loki/triggers/events.log."""
|
|
62
|
+
log_path = get_loki_dir() / "events.log"
|
|
63
|
+
timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
64
|
+
entry = {
|
|
65
|
+
"timestamp": timestamp,
|
|
66
|
+
"event": event_type,
|
|
67
|
+
"action": action,
|
|
68
|
+
"summary": payload_summary,
|
|
69
|
+
"status": status,
|
|
70
|
+
}
|
|
71
|
+
with open(log_path, "a") as f:
|
|
72
|
+
f.write(json.dumps(entry) + "\n")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def validate_signature(secret, body, signature_header):
|
|
76
|
+
"""Validate GitHub HMAC-SHA256 webhook signature."""
|
|
77
|
+
if not secret:
|
|
78
|
+
return True # No secret configured - accept all
|
|
79
|
+
if not signature_header:
|
|
80
|
+
return False
|
|
81
|
+
expected = "sha256=" + hmac.new(
|
|
82
|
+
secret.encode("utf-8"), body, hashlib.sha256
|
|
83
|
+
).hexdigest()
|
|
84
|
+
return hmac.compare_digest(expected, signature_header)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def send_notification(message):
|
|
88
|
+
"""Send desktop notification via loki syslog."""
|
|
89
|
+
try:
|
|
90
|
+
subprocess.run(
|
|
91
|
+
["loki", "syslog", message],
|
|
92
|
+
timeout=5,
|
|
93
|
+
capture_output=True,
|
|
94
|
+
)
|
|
95
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run_loki_command(args, dry_run=False):
|
|
100
|
+
"""Run a loki command, or print it if dry_run is True."""
|
|
101
|
+
cmd = ["loki"] + args
|
|
102
|
+
if dry_run:
|
|
103
|
+
logging.info("[DRY-RUN] Would run: %s", " ".join(cmd))
|
|
104
|
+
return True
|
|
105
|
+
logging.info("Running: %s", " ".join(cmd))
|
|
106
|
+
try:
|
|
107
|
+
proc = subprocess.Popen(
|
|
108
|
+
cmd,
|
|
109
|
+
stdout=subprocess.PIPE,
|
|
110
|
+
stderr=subprocess.PIPE,
|
|
111
|
+
)
|
|
112
|
+
# Don't wait - detached execution
|
|
113
|
+
logging.info("Started process pid=%d", proc.pid)
|
|
114
|
+
return True
|
|
115
|
+
except (FileNotFoundError, OSError) as e:
|
|
116
|
+
logging.error("Failed to run %s: %s", " ".join(cmd), e)
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def handle_issues_event(payload, dry_run=False):
|
|
121
|
+
"""Handle issues event: opened -> loki run <issue_number> --pr --detach."""
|
|
122
|
+
action = payload.get("action", "")
|
|
123
|
+
if action != "opened":
|
|
124
|
+
return None, "skipped (action=%s)" % action
|
|
125
|
+
issue = payload.get("issue", {})
|
|
126
|
+
issue_number = issue.get("number")
|
|
127
|
+
repo = payload.get("repository", {})
|
|
128
|
+
repo_full_name = repo.get("full_name", "")
|
|
129
|
+
if not issue_number:
|
|
130
|
+
return None, "skipped (no issue number)"
|
|
131
|
+
args = ["run", str(issue_number), "--pr", "--detach"]
|
|
132
|
+
if repo_full_name:
|
|
133
|
+
args = ["run", "%s#%s" % (repo_full_name, issue_number), "--pr", "--detach"]
|
|
134
|
+
summary = "issue #%s opened in %s" % (issue_number, repo_full_name)
|
|
135
|
+
success = run_loki_command(args, dry_run=dry_run)
|
|
136
|
+
status = "fired" if success else "error"
|
|
137
|
+
if success:
|
|
138
|
+
send_notification("Trigger fired: %s" % summary)
|
|
139
|
+
return summary, status
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def handle_pull_request_event(payload, dry_run=False):
|
|
143
|
+
"""Handle pull_request event: synchronize -> loki run <pr_number> --detach."""
|
|
144
|
+
action = payload.get("action", "")
|
|
145
|
+
if action != "synchronize":
|
|
146
|
+
return None, "skipped (action=%s)" % action
|
|
147
|
+
pr = payload.get("pull_request", {})
|
|
148
|
+
pr_number = pr.get("number")
|
|
149
|
+
repo = payload.get("repository", {})
|
|
150
|
+
repo_full_name = repo.get("full_name", "")
|
|
151
|
+
if not pr_number:
|
|
152
|
+
return None, "skipped (no PR number)"
|
|
153
|
+
args = ["run", str(pr_number), "--detach"]
|
|
154
|
+
if repo_full_name:
|
|
155
|
+
args = ["run", "%s#%s" % (repo_full_name, pr_number), "--detach"]
|
|
156
|
+
summary = "PR #%s synchronized in %s" % (pr_number, repo_full_name)
|
|
157
|
+
success = run_loki_command(args, dry_run=dry_run)
|
|
158
|
+
status = "fired" if success else "error"
|
|
159
|
+
if success:
|
|
160
|
+
send_notification("Trigger fired: %s" % summary)
|
|
161
|
+
return summary, status
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def handle_workflow_run_event(payload, dry_run=False):
|
|
165
|
+
"""Handle workflow_run event: completed+failure -> loki run with context."""
|
|
166
|
+
action = payload.get("action", "")
|
|
167
|
+
if action != "completed":
|
|
168
|
+
return None, "skipped (action=%s)" % action
|
|
169
|
+
wf = payload.get("workflow_run", {})
|
|
170
|
+
conclusion = wf.get("conclusion", "")
|
|
171
|
+
if conclusion != "failure":
|
|
172
|
+
return None, "skipped (conclusion=%s)" % conclusion
|
|
173
|
+
wf_name = wf.get("name", "unknown")
|
|
174
|
+
repo = payload.get("repository", {})
|
|
175
|
+
repo_full_name = repo.get("full_name", "")
|
|
176
|
+
summary = "workflow '%s' failed in %s" % (wf_name, repo_full_name)
|
|
177
|
+
# Run loki run with failure context note
|
|
178
|
+
args = ["run", "--detach"]
|
|
179
|
+
success = run_loki_command(args, dry_run=dry_run)
|
|
180
|
+
status = "fired" if success else "error"
|
|
181
|
+
if success:
|
|
182
|
+
send_notification("Trigger fired: CI failure - %s" % summary)
|
|
183
|
+
return summary, status
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
|
187
|
+
"""HTTP request handler for GitHub webhooks."""
|
|
188
|
+
|
|
189
|
+
dry_run = False
|
|
190
|
+
secret = ""
|
|
191
|
+
|
|
192
|
+
def log_message(self, format, *args):
|
|
193
|
+
logging.info("%s - %s", self.address_string(), format % args)
|
|
194
|
+
|
|
195
|
+
def do_GET(self):
|
|
196
|
+
if self.path == "/health":
|
|
197
|
+
self._send_json(200, {"status": "ok", "service": "loki-trigger-server"})
|
|
198
|
+
elif self.path == "/status":
|
|
199
|
+
config = load_config()
|
|
200
|
+
self._send_json(200, {
|
|
201
|
+
"status": "running",
|
|
202
|
+
"dry_run": self.dry_run,
|
|
203
|
+
"port": config.get("port", 7373),
|
|
204
|
+
"enabled_events": config.get("enabled_events", []),
|
|
205
|
+
})
|
|
206
|
+
else:
|
|
207
|
+
self._send_json(404, {"error": "not found"})
|
|
208
|
+
|
|
209
|
+
def do_POST(self):
|
|
210
|
+
if self.path != "/webhook":
|
|
211
|
+
self._send_json(404, {"error": "not found"})
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
215
|
+
body = self.rfile.read(content_length)
|
|
216
|
+
|
|
217
|
+
event_type = self.headers.get("X-GitHub-Event", "")
|
|
218
|
+
signature = self.headers.get("X-Hub-Signature-256", "")
|
|
219
|
+
|
|
220
|
+
if not validate_signature(self.secret, body, signature):
|
|
221
|
+
logging.warning("Invalid webhook signature")
|
|
222
|
+
self._send_json(401, {"error": "invalid signature"})
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
payload = json.loads(body)
|
|
227
|
+
except json.JSONDecodeError:
|
|
228
|
+
self._send_json(400, {"error": "invalid JSON"})
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
action = payload.get("action", "")
|
|
232
|
+
summary = None
|
|
233
|
+
status = "unhandled"
|
|
234
|
+
|
|
235
|
+
if event_type == "issues":
|
|
236
|
+
summary, status = handle_issues_event(payload, dry_run=self.dry_run)
|
|
237
|
+
elif event_type == "pull_request":
|
|
238
|
+
summary, status = handle_pull_request_event(payload, dry_run=self.dry_run)
|
|
239
|
+
elif event_type == "workflow_run":
|
|
240
|
+
summary, status = handle_workflow_run_event(payload, dry_run=self.dry_run)
|
|
241
|
+
else:
|
|
242
|
+
status = "unsupported event: %s" % event_type
|
|
243
|
+
|
|
244
|
+
log_event(event_type, action, summary or "", status)
|
|
245
|
+
self._send_json(200, {"event": event_type, "action": action, "status": status})
|
|
246
|
+
|
|
247
|
+
def _send_json(self, code, data):
|
|
248
|
+
body = json.dumps(data).encode("utf-8")
|
|
249
|
+
self.send_response(code)
|
|
250
|
+
self.send_header("Content-Type", "application/json")
|
|
251
|
+
self.send_header("Content-Length", str(len(body)))
|
|
252
|
+
self.end_headers()
|
|
253
|
+
self.wfile.write(body)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def write_pid_file():
|
|
257
|
+
"""Write PID to .loki/triggers/server.pid."""
|
|
258
|
+
pid_path = get_loki_dir() / "server.pid"
|
|
259
|
+
with open(pid_path, "w") as f:
|
|
260
|
+
f.write(str(os.getpid()))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def main():
|
|
264
|
+
parser = argparse.ArgumentParser(
|
|
265
|
+
description="loki-mode GitHub webhook trigger server"
|
|
266
|
+
)
|
|
267
|
+
parser.add_argument("--port", type=int, default=None, help="Port to listen on (default: 7373)")
|
|
268
|
+
parser.add_argument("--secret", default=None, help="GitHub webhook secret for HMAC validation")
|
|
269
|
+
parser.add_argument("--dry-run", action="store_true", help="Preview triggers without running loki")
|
|
270
|
+
args = parser.parse_args()
|
|
271
|
+
|
|
272
|
+
logging.basicConfig(
|
|
273
|
+
level=logging.INFO,
|
|
274
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
275
|
+
datefmt="%Y-%m-%dT%H:%M:%SZ",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
config = load_config()
|
|
279
|
+
port = args.port if args.port is not None else config.get("port", 7373)
|
|
280
|
+
secret = args.secret if args.secret is not None else config.get("secret", "")
|
|
281
|
+
dry_run = args.dry_run or config.get("dry_run", False)
|
|
282
|
+
|
|
283
|
+
# Update config with resolved values
|
|
284
|
+
config["port"] = port
|
|
285
|
+
config["secret"] = secret
|
|
286
|
+
config["dry_run"] = dry_run
|
|
287
|
+
save_config(config)
|
|
288
|
+
|
|
289
|
+
WebhookHandler.dry_run = dry_run
|
|
290
|
+
WebhookHandler.secret = secret
|
|
291
|
+
|
|
292
|
+
server = http.server.HTTPServer(("", port), WebhookHandler)
|
|
293
|
+
write_pid_file()
|
|
294
|
+
|
|
295
|
+
mode_label = " [DRY-RUN]" if dry_run else ""
|
|
296
|
+
logging.info("Loki trigger server starting on port %d%s", port, mode_label)
|
|
297
|
+
logging.info("Webhook endpoint: POST http://localhost:%d/webhook", port)
|
|
298
|
+
logging.info("Health check: GET http://localhost:%d/health", port)
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
server.serve_forever()
|
|
302
|
+
except KeyboardInterrupt:
|
|
303
|
+
logging.info("Trigger server stopped.")
|
|
304
|
+
pid_path = get_loki_dir() / "server.pid"
|
|
305
|
+
pid_path.unlink(missing_ok=True)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
if __name__ == "__main__":
|
|
309
|
+
main()
|
package/dashboard/__init__.py
CHANGED
package/docs/INSTALLATION.md
CHANGED
package/mcp/__init__.py
CHANGED